devcouncil 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -30
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +49 -30
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +960 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +303 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,1776 @@
|
|
|
1
|
+
"""Config-declared entry roots and structural exemptions for file-level liveness.
|
|
2
|
+
|
|
3
|
+
Single source of truth shared by ``dev map`` liveness fields and the
|
|
4
|
+
``unwired_file`` / ``dead_symbol`` verification gates so they never disagree on
|
|
5
|
+
what counts as "wired by convention/config".
|
|
6
|
+
|
|
7
|
+
Also hosts comment strippers and wiring-decorator exemptions used by both the
|
|
8
|
+
map's ``dead_symbol_candidates`` and the verify ``dead_symbol`` gate.
|
|
9
|
+
|
|
10
|
+
Never raises on malformed config — degrades to empty/False.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from devcouncil.indexing.walk import IGNORED_DIR_NAMES, should_skip_path
|
|
26
|
+
from devcouncil.utils.json_persist import read_model_json, write_model_json
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Python + JS/TS always. Go is file-level (all package members). Rust is included
|
|
31
|
+
# only when tree-sitter edges are available (see is_liveness_code_file).
|
|
32
|
+
_LIVENESS_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go"}
|
|
33
|
+
_RUST_LIVENESS_EXT = ".rs"
|
|
34
|
+
_TEST_DIR_NAMES = {"tests", "test", "__tests__", "spec"}
|
|
35
|
+
_SCRIPT_DIR_NAMES = {"scripts", "bin", "benchmarks"}
|
|
36
|
+
ALLOW_UNWIRED = "devcouncil: allow-unwired"
|
|
37
|
+
_IMPORTLIB_RE = re.compile(
|
|
38
|
+
r"""(?:importlib(?:\.import_module)?|__import__)\s*\(\s*['"]([^'"]+)['"]"""
|
|
39
|
+
)
|
|
40
|
+
_DYNAMIC_IMPORT_RE = re.compile(r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
41
|
+
# Vite/webpack ``new Worker(new URL("./x", import.meta.url))`` (and bare URL).
|
|
42
|
+
_WORKER_URL_RE = re.compile(
|
|
43
|
+
r"""(?:new\s+(?:Worker|SharedWorker)\s*\(\s*)?"""
|
|
44
|
+
r"""new\s+URL\s*\(\s*['"]([^'"]+)['"]\s*,\s*import\.meta\.url"""
|
|
45
|
+
)
|
|
46
|
+
# ``python -m pkg.mod`` and argv forms like ``"-m", "pkg.mod"``.
|
|
47
|
+
_PYTHON_DASH_M_RE = re.compile(
|
|
48
|
+
r"""(?:^|[^\w-])(?:-m|--module)(?:\s+|\s*,\s*)['"]([A-Za-z_][\w.]*)['"]"""
|
|
49
|
+
r"""|['"](?:-m|--module)['"]\s*,\s*['"]([A-Za-z_][\w.]*)['"]"""
|
|
50
|
+
)
|
|
51
|
+
# ``importlib.resources.files("pkg.sub")`` package-resource loads.
|
|
52
|
+
_PACKAGE_RESOURCES_RE = re.compile(
|
|
53
|
+
r"""(?:resources\.)?files\s*\(\s*['"]([A-Za-z_][\w.]*)['"]"""
|
|
54
|
+
)
|
|
55
|
+
# Bundled asset basenames referenced as string constants (plugins, images, …).
|
|
56
|
+
_BUNDLED_ASSET_RE = re.compile(
|
|
57
|
+
r"""['"]([A-Za-z_][\w.-]*\.(?:mjs|cjs|js|css|svg|png|jpe?g|webp|html))['"]"""
|
|
58
|
+
)
|
|
59
|
+
_HATCH_CUSTOM_HOOK_RE = re.compile(
|
|
60
|
+
r"""(?ms)^\[tool\.hatch\.build\.hooks\.custom\]\s*$.*?^path\s*=\s*['"]([^'"]+)['"]"""
|
|
61
|
+
)
|
|
62
|
+
_CODE_CONFIG_SUFFIXES = {
|
|
63
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
64
|
+
".toml", ".json", ".yaml", ".yml", ".cfg", ".ini",
|
|
65
|
+
}
|
|
66
|
+
_ROUTE_DIR_HINTS = (
|
|
67
|
+
"app/",
|
|
68
|
+
"pages/",
|
|
69
|
+
"routes/",
|
|
70
|
+
"src/app/",
|
|
71
|
+
"src/pages/",
|
|
72
|
+
"src/routes/",
|
|
73
|
+
"app/routes/",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Decorators that themselves constitute wiring (framework registration).
|
|
77
|
+
_WIRING_DECORATOR_HINTS = (
|
|
78
|
+
"app.", "router.", "typer.", "click.", "pytest.", "celery.",
|
|
79
|
+
"flask", "fastapi", "command", "route", "task", "fixture",
|
|
80
|
+
"register", "hookimpl", "hookable",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Bumped when dead-symbol / token-scan semantics change so ratchet baselines
|
|
84
|
+
# skip stale symbol diffs instead of firing false stranded_code regressions.
|
|
85
|
+
LIVENESS_SCAN_VERSION = 3
|
|
86
|
+
|
|
87
|
+
_VENDOR_DIR_NAMES = frozenset({"vendor", "vendored", "node_modules"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _norm(path: str) -> str:
|
|
91
|
+
"""Normalize to posix and strip leading ``./`` only (never ``lstrip('./')``)."""
|
|
92
|
+
s = str(path).replace("\\", "/")
|
|
93
|
+
while s.startswith("./"):
|
|
94
|
+
s = s[2:]
|
|
95
|
+
return s
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_test_path(path: str) -> bool:
|
|
99
|
+
"""True when path looks like a test file by common conventions."""
|
|
100
|
+
norm = _norm(path).lower()
|
|
101
|
+
name = norm.rsplit("/", 1)[-1]
|
|
102
|
+
parts = norm.split("/")
|
|
103
|
+
in_test_dir = any(p in _TEST_DIR_NAMES for p in parts[:-1])
|
|
104
|
+
looks_like_test = (
|
|
105
|
+
name.startswith("test_")
|
|
106
|
+
or name == "conftest.py"
|
|
107
|
+
or any(
|
|
108
|
+
name.endswith(suffix)
|
|
109
|
+
for suffix in (
|
|
110
|
+
"_test.py",
|
|
111
|
+
"_test.go",
|
|
112
|
+
".test.js",
|
|
113
|
+
".test.ts",
|
|
114
|
+
".test.jsx",
|
|
115
|
+
".test.tsx",
|
|
116
|
+
".spec.js",
|
|
117
|
+
".spec.ts",
|
|
118
|
+
".spec.jsx",
|
|
119
|
+
".spec.tsx",
|
|
120
|
+
"_spec.rb",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return looks_like_test or (in_test_dir and not name.startswith("."))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_liveness_code_file(path: str) -> bool:
|
|
128
|
+
"""True for languages with reliable file-level import edges.
|
|
129
|
+
|
|
130
|
+
Go is included (file-level package member edges). Rust is included only when
|
|
131
|
+
the optional tree-sitter layer can emit ``mod``/``use`` edges; without it,
|
|
132
|
+
Rust files would all look unwired.
|
|
133
|
+
"""
|
|
134
|
+
suffix = Path(_norm(path)).suffix.lower()
|
|
135
|
+
if suffix in _LIVENESS_EXTS:
|
|
136
|
+
return True
|
|
137
|
+
if suffix == _RUST_LIVENESS_EXT:
|
|
138
|
+
try:
|
|
139
|
+
from devcouncil.indexing.ts_imports import tree_sitter_available
|
|
140
|
+
|
|
141
|
+
return tree_sitter_available()
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_private_symbol(name: str) -> bool:
|
|
148
|
+
"""True for underscore-prefixed names skipped by dead-symbol detection."""
|
|
149
|
+
return bool(name) and name.startswith("_")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_dunder_symbol(name: str) -> bool:
|
|
153
|
+
"""True for ``__dunder__`` names (methods exempt from dead-code reports)."""
|
|
154
|
+
return bool(name) and len(name) >= 4 and name.startswith("__") and name.endswith("__")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_vendored_path(path: str) -> bool:
|
|
158
|
+
"""True when ``path`` is a vendored/minified bundle, not first-class source.
|
|
159
|
+
|
|
160
|
+
Matches ``vendor`` / ``vendored`` / ``node_modules`` path segments and
|
|
161
|
+
``.min.js`` / ``.min.css`` basenames — same convention
|
|
162
|
+
:func:`structural_exemptions` already encodes for file-level liveness.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
norm = _norm(path)
|
|
166
|
+
name = Path(norm).name
|
|
167
|
+
parts = norm.lower().split("/")
|
|
168
|
+
if any(p in _VENDOR_DIR_NAMES for p in parts):
|
|
169
|
+
return True
|
|
170
|
+
if name.endswith(".min.js") or name.endswith(".min.css"):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
except Exception:
|
|
174
|
+
logger.debug("is_vendored_path failed for %s", path, exc_info=True)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Dynamic getattr(x, "name") keys in :func:`build_dynamic_import_index`.
|
|
179
|
+
GETATTR_INDEX_PREFIX = "getattr:"
|
|
180
|
+
|
|
181
|
+
_GETATTR_NAME_RE = re.compile(
|
|
182
|
+
r"""getattr\s*\(\s*[^,]+,\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# JS/TS export forms shared by map token-scan and verify dead_symbol gate.
|
|
186
|
+
_JS_EXPORT_DECL_RE = re.compile(
|
|
187
|
+
r"(?m)^\s*export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
188
|
+
)
|
|
189
|
+
_JS_EXPORT_LIST_RE = re.compile(
|
|
190
|
+
r"(?m)^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
191
|
+
r"|^\s*export\s+default\s+([A-Za-z_][A-Za-z0-9_]*)\s*;"
|
|
192
|
+
r"|^\s*export\s*\{([^}]+)\}"
|
|
193
|
+
r"|^\s*export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['\"][^'\"]+['\"]"
|
|
194
|
+
r"|^\s*export\s+\*\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s*['\"][^'\"]+['\"]"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_python_all_exports(source: str) -> Set[str]:
|
|
199
|
+
"""Return names listed in a module-level ``__all__`` assignment (best-effort)."""
|
|
200
|
+
out: Set[str] = set()
|
|
201
|
+
try:
|
|
202
|
+
tree = ast.parse(source)
|
|
203
|
+
except (SyntaxError, ValueError):
|
|
204
|
+
return out
|
|
205
|
+
for node in tree.body:
|
|
206
|
+
if not isinstance(node, ast.Assign):
|
|
207
|
+
continue
|
|
208
|
+
for t in node.targets:
|
|
209
|
+
if isinstance(t, ast.Name) and t.id == "__all__":
|
|
210
|
+
if isinstance(node.value, (ast.List, ast.Tuple)):
|
|
211
|
+
for elt in node.value.elts:
|
|
212
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
213
|
+
out.add(elt.value)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_python_reexport_names(path: str, source: str) -> Set[str]:
|
|
218
|
+
"""Names re-exported by a barrel ``__init__.py`` or listed in ``__all__``.
|
|
219
|
+
|
|
220
|
+
Non-init modules do not treat every ``from x import y`` as a re-export — only
|
|
221
|
+
names that also appear in ``__all__``.
|
|
222
|
+
"""
|
|
223
|
+
all_names = parse_python_all_exports(source)
|
|
224
|
+
is_init = path.replace("\\", "/").endswith("__init__.py")
|
|
225
|
+
out: Set[str] = set()
|
|
226
|
+
try:
|
|
227
|
+
tree = ast.parse(source)
|
|
228
|
+
except (SyntaxError, ValueError):
|
|
229
|
+
return out
|
|
230
|
+
for stmt in tree.body:
|
|
231
|
+
if not isinstance(stmt, ast.ImportFrom):
|
|
232
|
+
continue
|
|
233
|
+
for alias in stmt.names:
|
|
234
|
+
if not alias.name or alias.name == "*":
|
|
235
|
+
continue
|
|
236
|
+
local = alias.asname or alias.name
|
|
237
|
+
if is_init or local in all_names:
|
|
238
|
+
out.add(local)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def iter_js_export_symbols(source: str) -> List[tuple[int, str]]:
|
|
243
|
+
"""Yield ``(line, name)`` for JS/TS export forms (decl, list, default, re-export)."""
|
|
244
|
+
found: List[tuple[int, str]] = []
|
|
245
|
+
seen: Set[tuple[int, str]] = set()
|
|
246
|
+
|
|
247
|
+
def _add(line: int, name: str) -> None:
|
|
248
|
+
if not name or is_private_symbol(name):
|
|
249
|
+
return
|
|
250
|
+
key = (line, name)
|
|
251
|
+
if key in seen:
|
|
252
|
+
return
|
|
253
|
+
seen.add(key)
|
|
254
|
+
found.append((line, name))
|
|
255
|
+
|
|
256
|
+
for m in _JS_EXPORT_DECL_RE.finditer(source):
|
|
257
|
+
line = source[: m.start()].count("\n") + 1
|
|
258
|
+
_add(line, m.group(1))
|
|
259
|
+
|
|
260
|
+
for m in _JS_EXPORT_LIST_RE.finditer(source):
|
|
261
|
+
line = source[: m.start()].count("\n") + 1
|
|
262
|
+
if m.group(1):
|
|
263
|
+
_add(line, m.group(1))
|
|
264
|
+
if m.group(2):
|
|
265
|
+
_add(line, m.group(2))
|
|
266
|
+
for group in (m.group(3), m.group(4)):
|
|
267
|
+
if not group:
|
|
268
|
+
continue
|
|
269
|
+
for part in group.split(","):
|
|
270
|
+
part = part.strip()
|
|
271
|
+
if not part or part == "type":
|
|
272
|
+
continue
|
|
273
|
+
# `Foo as Bar` / `type Foo` / `default as X`
|
|
274
|
+
part = re.sub(r"^type\s+", "", part)
|
|
275
|
+
if " as " in part:
|
|
276
|
+
part = part.split(" as ")[-1].strip()
|
|
277
|
+
if part == "default":
|
|
278
|
+
continue
|
|
279
|
+
name = part.split(":", 1)[0].strip()
|
|
280
|
+
_add(line, name)
|
|
281
|
+
if m.group(5):
|
|
282
|
+
_add(line, m.group(5))
|
|
283
|
+
return found
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def strip_py_comments(text: str) -> str:
|
|
287
|
+
"""Blank ``#`` comments in place — preserve newlines/line count, never renumber.
|
|
288
|
+
|
|
289
|
+
Dead-symbol detection indexes tokens from cleaned text while definition spans
|
|
290
|
+
come from ``ast.parse`` on the raw source; dropping lines would skew them.
|
|
291
|
+
"""
|
|
292
|
+
ends_nl = text.endswith("\n")
|
|
293
|
+
lines = []
|
|
294
|
+
for line in text.splitlines():
|
|
295
|
+
if "#" not in line:
|
|
296
|
+
lines.append(line)
|
|
297
|
+
continue
|
|
298
|
+
stripped = line.lstrip()
|
|
299
|
+
if stripped.startswith("#"):
|
|
300
|
+
lines.append("")
|
|
301
|
+
continue
|
|
302
|
+
in_str = False
|
|
303
|
+
quote = ""
|
|
304
|
+
buf: List[str] = []
|
|
305
|
+
i = 0
|
|
306
|
+
while i < len(line):
|
|
307
|
+
ch = line[i]
|
|
308
|
+
if in_str:
|
|
309
|
+
buf.append(ch)
|
|
310
|
+
if ch == quote and (i == 0 or line[i - 1] != "\\"):
|
|
311
|
+
in_str = False
|
|
312
|
+
i += 1
|
|
313
|
+
continue
|
|
314
|
+
if ch in ("'", '"'):
|
|
315
|
+
in_str = True
|
|
316
|
+
quote = ch
|
|
317
|
+
buf.append(ch)
|
|
318
|
+
i += 1
|
|
319
|
+
continue
|
|
320
|
+
if ch == "#":
|
|
321
|
+
break
|
|
322
|
+
buf.append(ch)
|
|
323
|
+
i += 1
|
|
324
|
+
lines.append("".join(buf))
|
|
325
|
+
out = "\n".join(lines)
|
|
326
|
+
return out + ("\n" if ends_nl and lines else "")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _blank_js_block_comment(match: re.Match[str]) -> str:
|
|
330
|
+
"""Replace block-comment body with spaces, keeping every newline."""
|
|
331
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def strip_js_comments(text: str) -> str:
|
|
335
|
+
"""Blank ``/* */`` and ``//`` comments in place — preserve newlines/line count."""
|
|
336
|
+
ends_nl = text.endswith("\n")
|
|
337
|
+
text = re.sub(r"/\*.*?\*/", _blank_js_block_comment, text, flags=re.DOTALL)
|
|
338
|
+
lines = []
|
|
339
|
+
for line in text.splitlines():
|
|
340
|
+
if "//" not in line:
|
|
341
|
+
lines.append(line)
|
|
342
|
+
continue
|
|
343
|
+
in_str = False
|
|
344
|
+
quote = ""
|
|
345
|
+
buf: List[str] = []
|
|
346
|
+
i = 0
|
|
347
|
+
while i < len(line):
|
|
348
|
+
ch = line[i]
|
|
349
|
+
if in_str:
|
|
350
|
+
buf.append(ch)
|
|
351
|
+
if ch == quote and (i == 0 or line[i - 1] != "\\"):
|
|
352
|
+
in_str = False
|
|
353
|
+
i += 1
|
|
354
|
+
continue
|
|
355
|
+
if ch in ("'", '"', "`"):
|
|
356
|
+
in_str = True
|
|
357
|
+
quote = ch
|
|
358
|
+
buf.append(ch)
|
|
359
|
+
i += 1
|
|
360
|
+
continue
|
|
361
|
+
if ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
|
|
362
|
+
break
|
|
363
|
+
buf.append(ch)
|
|
364
|
+
i += 1
|
|
365
|
+
lines.append("".join(buf))
|
|
366
|
+
out = "\n".join(lines)
|
|
367
|
+
return out + ("\n" if ends_nl and lines else "")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def strip_string_literals(text: str) -> str:
|
|
371
|
+
"""Blank string literal bodies in place — preserve newlines/line count.
|
|
372
|
+
|
|
373
|
+
Dead-symbol token scans index identifiers from cleaned text; leaving
|
|
374
|
+
``\"cost_by_task\"`` dict keys (etc.) intact falsely clears real dead
|
|
375
|
+
symbols. Dynamic getattr/importlib strings are indexed separately by
|
|
376
|
+
:func:`build_dynamic_import_index` on the raw source before stripping.
|
|
377
|
+
"""
|
|
378
|
+
if not text:
|
|
379
|
+
return text
|
|
380
|
+
ends_nl = text.endswith("\n")
|
|
381
|
+
out: List[str] = []
|
|
382
|
+
i = 0
|
|
383
|
+
n = len(text)
|
|
384
|
+
while i < n:
|
|
385
|
+
ch = text[i]
|
|
386
|
+
if ch in ("'", '"', "`"):
|
|
387
|
+
quote = ch
|
|
388
|
+
triple = (
|
|
389
|
+
quote in ("'", '"')
|
|
390
|
+
and i + 2 < n
|
|
391
|
+
and text[i + 1] == quote
|
|
392
|
+
and text[i + 2] == quote
|
|
393
|
+
)
|
|
394
|
+
if triple:
|
|
395
|
+
out.extend((quote, quote, quote))
|
|
396
|
+
i += 3
|
|
397
|
+
while i < n:
|
|
398
|
+
if (
|
|
399
|
+
text[i] == quote
|
|
400
|
+
and i + 2 < n
|
|
401
|
+
and text[i + 1] == quote
|
|
402
|
+
and text[i + 2] == quote
|
|
403
|
+
):
|
|
404
|
+
out.extend((quote, quote, quote))
|
|
405
|
+
i += 3
|
|
406
|
+
break
|
|
407
|
+
out.append("\n" if text[i] == "\n" else " ")
|
|
408
|
+
i += 1
|
|
409
|
+
continue
|
|
410
|
+
out.append(quote)
|
|
411
|
+
i += 1
|
|
412
|
+
while i < n:
|
|
413
|
+
c = text[i]
|
|
414
|
+
if c == "\\" and i + 1 < n:
|
|
415
|
+
out.append(" ")
|
|
416
|
+
i += 2
|
|
417
|
+
continue
|
|
418
|
+
if c == quote:
|
|
419
|
+
out.append(quote)
|
|
420
|
+
i += 1
|
|
421
|
+
break
|
|
422
|
+
if c == "\n":
|
|
423
|
+
out.append("\n")
|
|
424
|
+
i += 1
|
|
425
|
+
if quote != "`":
|
|
426
|
+
break
|
|
427
|
+
continue
|
|
428
|
+
out.append(" ")
|
|
429
|
+
i += 1
|
|
430
|
+
continue
|
|
431
|
+
out.append(ch)
|
|
432
|
+
i += 1
|
|
433
|
+
result = "".join(out)
|
|
434
|
+
if ends_nl and not result.endswith("\n"):
|
|
435
|
+
result += "\n"
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def decorator_names(node: ast.AST) -> List[str]:
|
|
440
|
+
"""Unparse decorator expressions on a function/class AST node."""
|
|
441
|
+
out: List[str] = []
|
|
442
|
+
for dec in getattr(node, "decorator_list", []) or []:
|
|
443
|
+
try:
|
|
444
|
+
out.append(ast.unparse(dec))
|
|
445
|
+
except Exception:
|
|
446
|
+
if isinstance(dec, ast.Name):
|
|
447
|
+
out.append(dec.id)
|
|
448
|
+
elif isinstance(dec, ast.Attribute):
|
|
449
|
+
out.append(dec.attr)
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def is_wiring_decorated(decorators: List[str]) -> bool:
|
|
454
|
+
"""True when any decorator looks like framework registration (route/cli/fixture).
|
|
455
|
+
|
|
456
|
+
Dotted hints (``app.``, ``router.``, …) match as prefixes; bare hints
|
|
457
|
+
(``route``, ``task``, ``register``, …) match whole identifier segments only.
|
|
458
|
+
The old substring-over-joined-string check over-matched (e.g. ``multitask``,
|
|
459
|
+
``preregister``) and hid real dead code behind unrelated decorators.
|
|
460
|
+
"""
|
|
461
|
+
for dec in decorators:
|
|
462
|
+
base = dec.split("(", 1)[0].strip().lower()
|
|
463
|
+
if not base:
|
|
464
|
+
continue
|
|
465
|
+
segments = [s for s in re.split(r"[.\s]+", base) if s]
|
|
466
|
+
for hint in _WIRING_DECORATOR_HINTS:
|
|
467
|
+
if hint.endswith("."):
|
|
468
|
+
if base.startswith(hint):
|
|
469
|
+
return True
|
|
470
|
+
elif hint in segments:
|
|
471
|
+
return True
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def structural_exemptions(path: str) -> bool:
|
|
476
|
+
"""True when ``path`` is wired by convention and should not be flagged unwired.
|
|
477
|
+
|
|
478
|
+
Shared by map candidates and verify gates. Basename-only exemptions like
|
|
479
|
+
``main.py`` are intentionally NOT included — real entry points clear via
|
|
480
|
+
:func:`entry_roots`.
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
norm = _norm(path)
|
|
484
|
+
name = Path(norm).name
|
|
485
|
+
lower = norm.lower()
|
|
486
|
+
parts = lower.split("/")
|
|
487
|
+
suffix = Path(norm).suffix.lower()
|
|
488
|
+
|
|
489
|
+
if name in {"__main__.py", "conftest.py", "manage.py"}:
|
|
490
|
+
return True
|
|
491
|
+
if name.endswith(".d.ts"):
|
|
492
|
+
return True
|
|
493
|
+
if ".stories." in name or name.endswith((".stories.ts", ".stories.tsx", ".stories.js", ".stories.jsx")):
|
|
494
|
+
return True
|
|
495
|
+
if is_test_path(norm):
|
|
496
|
+
return True
|
|
497
|
+
# Vendored JS/CSS bundles are loaded as package resources, not imported.
|
|
498
|
+
if is_vendored_path(norm):
|
|
499
|
+
return True
|
|
500
|
+
if any(p in _SCRIPT_DIR_NAMES for p in parts[:-1]):
|
|
501
|
+
return True
|
|
502
|
+
# Migrations / alembic version modules.
|
|
503
|
+
if "migrations" in parts or "alembic" in parts:
|
|
504
|
+
if suffix == ".py":
|
|
505
|
+
return True
|
|
506
|
+
# Next/Remix/app-router style route files.
|
|
507
|
+
if any(lower.startswith(hint) or f"/{hint}" in f"/{lower}" for hint in _ROUTE_DIR_HINTS):
|
|
508
|
+
route_names = {
|
|
509
|
+
"page.tsx", "page.ts", "page.jsx", "page.js",
|
|
510
|
+
"layout.tsx", "layout.ts", "layout.jsx", "layout.js",
|
|
511
|
+
"route.ts", "route.js", "route.tsx", "route.jsx",
|
|
512
|
+
"loading.tsx", "error.tsx", "not-found.tsx",
|
|
513
|
+
"middleware.ts", "middleware.js",
|
|
514
|
+
"+page.svelte", "+layout.svelte", "+page.ts", "+layout.ts",
|
|
515
|
+
"index.tsx", "index.ts", "index.jsx", "index.js",
|
|
516
|
+
}
|
|
517
|
+
if name in route_names or name.startswith("route.") or name.startswith("+"):
|
|
518
|
+
return True
|
|
519
|
+
# Cargo build scripts are invoked by rustc, not imported by app code.
|
|
520
|
+
if name == "build.rs":
|
|
521
|
+
return True
|
|
522
|
+
# Bundler / test-runner configs are tooling entrypoints, not product modules.
|
|
523
|
+
# Exempt from unwired/unreachable — do NOT seed BFS from them.
|
|
524
|
+
if _TOOLING_CONFIG_RE.search(norm):
|
|
525
|
+
return True
|
|
526
|
+
if ".config." in name and suffix in {
|
|
527
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
|
|
528
|
+
}:
|
|
529
|
+
return True
|
|
530
|
+
return False
|
|
531
|
+
except Exception:
|
|
532
|
+
logger.debug("structural_exemptions failed for %s", path, exc_info=True)
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _read_text(root: Path, rel: str) -> str:
|
|
537
|
+
try:
|
|
538
|
+
return (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
539
|
+
except OSError:
|
|
540
|
+
return ""
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _pyproject_script_targets(root: Path, file_set: Set[str]) -> Set[str]:
|
|
544
|
+
"""Resolve pyproject [project.scripts]/entry-points/gui-scripts module paths."""
|
|
545
|
+
text = _read_text(root, "pyproject.toml")
|
|
546
|
+
if not text:
|
|
547
|
+
return set()
|
|
548
|
+
found: Set[str] = set()
|
|
549
|
+
try:
|
|
550
|
+
# Prefer tomllib when available; fall back to regex for exotic envs.
|
|
551
|
+
try:
|
|
552
|
+
import tomllib
|
|
553
|
+
except ImportError: # pragma: no cover
|
|
554
|
+
import tomli as tomllib # type: ignore
|
|
555
|
+
|
|
556
|
+
data = tomllib.loads(text)
|
|
557
|
+
project = data.get("project") or {}
|
|
558
|
+
entry_maps = []
|
|
559
|
+
for key in ("scripts", "gui-scripts"):
|
|
560
|
+
val = project.get(key)
|
|
561
|
+
if isinstance(val, dict):
|
|
562
|
+
entry_maps.append(val)
|
|
563
|
+
eps = project.get("entry-points") or {}
|
|
564
|
+
if isinstance(eps, dict):
|
|
565
|
+
for group in eps.values():
|
|
566
|
+
if isinstance(group, dict):
|
|
567
|
+
entry_maps.append(group)
|
|
568
|
+
# pytest plugins often live under tool.pytest.ini_options / pytest.ini
|
|
569
|
+
tool = data.get("tool") or {}
|
|
570
|
+
pytest_cfg = tool.get("pytest") or {}
|
|
571
|
+
ini = pytest_cfg.get("ini_options") or {}
|
|
572
|
+
plugins = ini.get("pytest_plugins") or ini.get("plugins")
|
|
573
|
+
if isinstance(plugins, list):
|
|
574
|
+
for plug in plugins:
|
|
575
|
+
if isinstance(plug, str):
|
|
576
|
+
_add_module_file(plug.split(":")[0].strip(), file_set, found)
|
|
577
|
+
elif isinstance(plugins, str):
|
|
578
|
+
for plug in re.split(r"[\s,]+", plugins):
|
|
579
|
+
if plug:
|
|
580
|
+
_add_module_file(plug.split(":")[0].strip(), file_set, found)
|
|
581
|
+
|
|
582
|
+
for mapping in entry_maps:
|
|
583
|
+
for target in mapping.values():
|
|
584
|
+
if not isinstance(target, str):
|
|
585
|
+
continue
|
|
586
|
+
mod = target.split(":")[0].strip()
|
|
587
|
+
_add_module_file(mod, file_set, found)
|
|
588
|
+
except Exception:
|
|
589
|
+
# Regex fallback for scripts = { name = "pkg.mod:fn" }
|
|
590
|
+
for m in re.finditer(
|
|
591
|
+
r"""['"]([A-Za-z_][\w.]*)\s*:\s*[A-Za-z_]\w*['"]""",
|
|
592
|
+
text,
|
|
593
|
+
):
|
|
594
|
+
_add_module_file(m.group(1), file_set, found)
|
|
595
|
+
return found
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _add_module_file(module: str, file_set: Set[str], out: Set[str]) -> None:
|
|
599
|
+
if not module or module.startswith("."):
|
|
600
|
+
return
|
|
601
|
+
parts = module.replace(".", "/")
|
|
602
|
+
candidates = [
|
|
603
|
+
f"{parts}.py",
|
|
604
|
+
f"{parts}/__init__.py",
|
|
605
|
+
f"src/{parts}.py",
|
|
606
|
+
f"src/{parts}/__init__.py",
|
|
607
|
+
]
|
|
608
|
+
for cand in candidates:
|
|
609
|
+
if cand in file_set:
|
|
610
|
+
out.add(cand)
|
|
611
|
+
return
|
|
612
|
+
# Soft match: any file whose path ends with the module path (sorted for determinism).
|
|
613
|
+
suffix = f"/{parts}.py"
|
|
614
|
+
suffix_init = f"/{parts}/__init__.py"
|
|
615
|
+
for f in sorted(file_set):
|
|
616
|
+
if f.endswith(suffix) or f.endswith(suffix_init) or f == f"{parts}.py":
|
|
617
|
+
out.add(f)
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# Workspace manifests scanned for entry targets (sorted; bounded for determinism
|
|
622
|
+
# and to keep monorepos with generated packages from ballooning map time).
|
|
623
|
+
_PACKAGE_MANIFEST_CAP = 200
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _package_json_entry_targets(root: Path, file_set: Set[str]) -> Set[str]:
|
|
627
|
+
"""Entry targets from the root ``package.json`` and workspace manifests.
|
|
628
|
+
|
|
629
|
+
Monorepos often have no root ``main``/``bin``/``exports`` at all — the real
|
|
630
|
+
entries live in ``frontend/package.json`` etc., so every tracked manifest is
|
|
631
|
+
scanned (capped) with its targets resolved relative to the manifest's dir.
|
|
632
|
+
"""
|
|
633
|
+
manifests = sorted(
|
|
634
|
+
p for p in file_set if p.rsplit("/", 1)[-1] == "package.json"
|
|
635
|
+
)[:_PACKAGE_MANIFEST_CAP]
|
|
636
|
+
if not manifests and (root / "package.json").is_file():
|
|
637
|
+
manifests = ["package.json"]
|
|
638
|
+
found: Set[str] = set()
|
|
639
|
+
for manifest in manifests:
|
|
640
|
+
_package_json_entry_targets_for(root, manifest, file_set, found)
|
|
641
|
+
return found
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _package_json_entry_targets_for(
|
|
645
|
+
root: Path, manifest: str, file_set: Set[str], found: Set[str]
|
|
646
|
+
) -> None:
|
|
647
|
+
text = _read_text(root, manifest)
|
|
648
|
+
if not text:
|
|
649
|
+
return
|
|
650
|
+
try:
|
|
651
|
+
data = json.loads(text)
|
|
652
|
+
except Exception:
|
|
653
|
+
return
|
|
654
|
+
prefix = manifest[: -len("package.json")] # "" at root, "frontend/" nested
|
|
655
|
+
|
|
656
|
+
def _add(candidate: object) -> None:
|
|
657
|
+
if not isinstance(candidate, str):
|
|
658
|
+
return
|
|
659
|
+
rel = _norm(f"{prefix}{_norm(candidate)}")
|
|
660
|
+
if rel in file_set:
|
|
661
|
+
found.add(rel)
|
|
662
|
+
return
|
|
663
|
+
# Strip leading ./ and try common extensions.
|
|
664
|
+
base = rel
|
|
665
|
+
for ext in ("", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
|
|
666
|
+
cand = f"{base}{ext}" if ext and not base.endswith(ext) else base
|
|
667
|
+
if cand in file_set:
|
|
668
|
+
found.add(cand)
|
|
669
|
+
return
|
|
670
|
+
idx = f"{base}/index{ext}" if ext else f"{base}/index.js"
|
|
671
|
+
if idx in file_set:
|
|
672
|
+
found.add(idx)
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
for key in ("main", "module", "browser", "types", "typings"):
|
|
676
|
+
_add(data.get(key))
|
|
677
|
+
bin_val = data.get("bin")
|
|
678
|
+
if isinstance(bin_val, str):
|
|
679
|
+
_add(bin_val)
|
|
680
|
+
elif isinstance(bin_val, dict):
|
|
681
|
+
for v in bin_val.values():
|
|
682
|
+
_add(v)
|
|
683
|
+
exports = data.get("exports")
|
|
684
|
+
if isinstance(exports, str):
|
|
685
|
+
_add(exports)
|
|
686
|
+
elif isinstance(exports, dict):
|
|
687
|
+
for v in exports.values():
|
|
688
|
+
if isinstance(v, str):
|
|
689
|
+
_add(v)
|
|
690
|
+
elif isinstance(v, dict):
|
|
691
|
+
for nested in v.values():
|
|
692
|
+
if isinstance(nested, str):
|
|
693
|
+
_add(nested)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# Convention-based mains. JS/TS and Rust paths are specific enough to seed by
|
|
697
|
+
# name; ``main.go`` and Python service mains collide with helpers often enough
|
|
698
|
+
# that a bounded content sniff gates them.
|
|
699
|
+
_JS_MAIN_SEED_RE = re.compile(
|
|
700
|
+
r"(?:^|/)"
|
|
701
|
+
r"(?:"
|
|
702
|
+
r"src/(?:index|main)"
|
|
703
|
+
r"|App"
|
|
704
|
+
r"|(?:server|api|backend|worker|functions|lambda)/index"
|
|
705
|
+
r")"
|
|
706
|
+
r"\.(?:ts|tsx|js|jsx|mjs)$"
|
|
707
|
+
)
|
|
708
|
+
_RUST_MAIN_SEED_RE = re.compile(r"(?:^|/)src/(?:main\.rs|lib\.rs|bin/[^/]+\.rs)$")
|
|
709
|
+
_TOOLING_CONFIG_RE = re.compile(
|
|
710
|
+
r"(?:^|/)"
|
|
711
|
+
r"(?:"
|
|
712
|
+
r"vite\.config\.[cm]?[jt]s"
|
|
713
|
+
r"|vitest\.config\.[cm]?[jt]s"
|
|
714
|
+
r"|webpack\.config\.[cm]?[jt]s"
|
|
715
|
+
r"|rollup\.config\.[cm]?[jt]s"
|
|
716
|
+
r"|esbuild\.config\.[cm]?[jt]s"
|
|
717
|
+
r"|next\.config\.[cm]?[jt]s"
|
|
718
|
+
r"|astro\.config\.[cm]?[jt]s"
|
|
719
|
+
r"|nuxt\.config\.[cm]?[jt]s"
|
|
720
|
+
r"|playwright\.config\.[cm]?[jt]s"
|
|
721
|
+
r"|tailwind\.config\.[cm]?[jt]s"
|
|
722
|
+
r"|postcss\.config\.[cm]?[jt]s"
|
|
723
|
+
r"|eslint\.config\.[cm]?[jt]s"
|
|
724
|
+
r")"
|
|
725
|
+
r"$"
|
|
726
|
+
)
|
|
727
|
+
_PY_MAIN_SEED_NAMES = {"main.py", "app.py", "wsgi.py", "asgi.py"}
|
|
728
|
+
_C_MAIN_SEED_SUFFIXES = (".c", ".cc", ".cpp", ".cxx")
|
|
729
|
+
_C_MAIN_RE = re.compile(r"\b(?:int|void)\s+main\s*\(")
|
|
730
|
+
# "__main__" covers both quote styles of the run guard.
|
|
731
|
+
_PY_MAIN_SEED_MARKERS = ("__main__", "FastAPI(", "Flask(", "uvicorn.run(")
|
|
732
|
+
_MAIN_SEED_SNIFF_CAP = 512
|
|
733
|
+
_JS_RESOLVE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
734
|
+
_JS_SUFFIXES = frozenset(_JS_RESOLVE_EXTS)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _normalize_rel_path(target: str) -> str:
|
|
738
|
+
parts: List[str] = []
|
|
739
|
+
for comp in target.replace("\\", "/").split("/"):
|
|
740
|
+
if comp in ("", "."):
|
|
741
|
+
continue
|
|
742
|
+
if comp == "..":
|
|
743
|
+
if parts:
|
|
744
|
+
parts.pop()
|
|
745
|
+
continue
|
|
746
|
+
parts.append(comp)
|
|
747
|
+
return "/".join(parts)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _probe_js_path(norm: str, file_set: Set[str]) -> Optional[str]:
|
|
751
|
+
"""Resolve extensionless / ``.js``-suffixed specs against ``file_set``."""
|
|
752
|
+
if not norm:
|
|
753
|
+
return None
|
|
754
|
+
candidates = [norm]
|
|
755
|
+
candidates += [f"{norm}{ext}" for ext in _JS_RESOLVE_EXTS]
|
|
756
|
+
candidates += [f"{norm}/index{ext}" for ext in _JS_RESOLVE_EXTS]
|
|
757
|
+
suffix = Path(norm).suffix.lower()
|
|
758
|
+
if suffix in _JS_SUFFIXES:
|
|
759
|
+
stem = norm[: -len(suffix)]
|
|
760
|
+
candidates += [f"{stem}{ext}" for ext in _JS_RESOLVE_EXTS]
|
|
761
|
+
candidates += [f"{stem}/index{ext}" for ext in _JS_RESOLVE_EXTS]
|
|
762
|
+
for cand in candidates:
|
|
763
|
+
if cand in file_set:
|
|
764
|
+
return cand
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _expand_roots_via_dynamic_imports(
|
|
769
|
+
root: Path, file_set: Set[str], seeds: Set[str]
|
|
770
|
+
) -> Set[str]:
|
|
771
|
+
"""One-BFS expansion: dynamic ``import(...)`` / worker URL targets become roots.
|
|
772
|
+
|
|
773
|
+
Covers Vite/CRA ``main.tsx → import('./App')``, alias ``import('@/x')``, and
|
|
774
|
+
``new Worker(new URL('./worker.ts', import.meta.url))`` without treating every
|
|
775
|
+
dynamic import in the repo as a seed.
|
|
776
|
+
"""
|
|
777
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
778
|
+
|
|
779
|
+
mapper = RepoMapper(root)
|
|
780
|
+
mapper._last_file_set = file_set
|
|
781
|
+
expanded = set(seeds)
|
|
782
|
+
queue = list(seeds)
|
|
783
|
+
seen = set(seeds)
|
|
784
|
+
while queue:
|
|
785
|
+
cur = queue.pop()
|
|
786
|
+
text = _read_text(root, cur)
|
|
787
|
+
if not text:
|
|
788
|
+
continue
|
|
789
|
+
specs: List[str] = [match.group(1) for match in _DYNAMIC_IMPORT_RE.finditer(text)]
|
|
790
|
+
specs.extend(match.group(1) for match in _WORKER_URL_RE.finditer(text))
|
|
791
|
+
for spec in specs:
|
|
792
|
+
if not spec:
|
|
793
|
+
continue
|
|
794
|
+
hit: Optional[str] = None
|
|
795
|
+
if spec.startswith("."):
|
|
796
|
+
try:
|
|
797
|
+
joined = (Path(cur).parent / spec).as_posix()
|
|
798
|
+
except Exception:
|
|
799
|
+
continue
|
|
800
|
+
hit = _probe_js_path(_normalize_rel_path(joined), file_set)
|
|
801
|
+
else:
|
|
802
|
+
try:
|
|
803
|
+
hit = mapper._resolve_js_spec(cur, spec, file_set)
|
|
804
|
+
except Exception:
|
|
805
|
+
hit = None
|
|
806
|
+
if hit and hit not in seen:
|
|
807
|
+
seen.add(hit)
|
|
808
|
+
expanded.add(hit)
|
|
809
|
+
queue.append(hit)
|
|
810
|
+
return expanded
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _conventional_main_seeds(root: Path, file_set: Set[str]) -> Set[str]:
|
|
814
|
+
"""Language-convention entry mains: Go/Rust/C/C++ binaries, Python service
|
|
815
|
+
mains, JS/TS ``src/index``/``src/main``/``App``/service ``index`` modules,
|
|
816
|
+
and Rust ``main.rs`` / ``lib.rs``.
|
|
817
|
+
|
|
818
|
+
Tooling configs (Vite/PostCSS/…) are structural exemptions only — never BFS
|
|
819
|
+
seeds (they do not import product modules).
|
|
820
|
+
"""
|
|
821
|
+
found: Set[str] = set()
|
|
822
|
+
sniffed = 0
|
|
823
|
+
for f in sorted(file_set):
|
|
824
|
+
if _JS_MAIN_SEED_RE.search(f) or _RUST_MAIN_SEED_RE.search(f):
|
|
825
|
+
found.add(f)
|
|
826
|
+
continue
|
|
827
|
+
name = f.rsplit("/", 1)[-1]
|
|
828
|
+
if name == "main.go":
|
|
829
|
+
if sniffed >= _MAIN_SEED_SNIFF_CAP:
|
|
830
|
+
continue
|
|
831
|
+
sniffed += 1
|
|
832
|
+
text = _read_text(root, f)
|
|
833
|
+
if "package main" in text and "func main(" in text:
|
|
834
|
+
found.add(f)
|
|
835
|
+
elif name in _PY_MAIN_SEED_NAMES:
|
|
836
|
+
if sniffed >= _MAIN_SEED_SNIFF_CAP:
|
|
837
|
+
continue
|
|
838
|
+
sniffed += 1
|
|
839
|
+
text = _read_text(root, f)
|
|
840
|
+
if any(marker in text for marker in _PY_MAIN_SEED_MARKERS):
|
|
841
|
+
found.add(f)
|
|
842
|
+
elif name.endswith(_C_MAIN_SEED_SUFFIXES):
|
|
843
|
+
# C/C++ binaries: a file defining main() is a conventional entry,
|
|
844
|
+
# same as ``package main`` in Go.
|
|
845
|
+
if sniffed >= _MAIN_SEED_SNIFF_CAP:
|
|
846
|
+
continue
|
|
847
|
+
sniffed += 1
|
|
848
|
+
if _C_MAIN_RE.search(_read_text(root, f)):
|
|
849
|
+
found.add(f)
|
|
850
|
+
return found
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _config_declared_entry_roots(root: Path, file_set: Set[str]) -> Set[str]:
|
|
854
|
+
"""Paths from ``indexing.entry_roots`` that exist in the tracked file set."""
|
|
855
|
+
declared: list = []
|
|
856
|
+
try:
|
|
857
|
+
from devcouncil.app.config import load_config
|
|
858
|
+
|
|
859
|
+
cfg = load_config(root)
|
|
860
|
+
raw_declared = getattr(cfg.indexing, "entry_roots", None)
|
|
861
|
+
if isinstance(raw_declared, list):
|
|
862
|
+
declared = raw_declared
|
|
863
|
+
except Exception:
|
|
864
|
+
logger.debug("config entry_roots load failed", exc_info=True)
|
|
865
|
+
if not declared:
|
|
866
|
+
try:
|
|
867
|
+
import yaml
|
|
868
|
+
|
|
869
|
+
cfg_path = root / ".devcouncil" / "config.yaml"
|
|
870
|
+
if cfg_path.is_file():
|
|
871
|
+
payload = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
|
872
|
+
indexing = payload.get("indexing")
|
|
873
|
+
if isinstance(indexing, dict):
|
|
874
|
+
yaml_roots = indexing.get("entry_roots")
|
|
875
|
+
if isinstance(yaml_roots, list):
|
|
876
|
+
declared = yaml_roots
|
|
877
|
+
except Exception:
|
|
878
|
+
logger.debug("yaml entry_roots load failed", exc_info=True)
|
|
879
|
+
return {_norm(str(p)) for p in declared if p and _norm(str(p)) in file_set}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def entry_roots(
|
|
883
|
+
root: Path,
|
|
884
|
+
files: Iterable[str],
|
|
885
|
+
*,
|
|
886
|
+
production_only: bool = False,
|
|
887
|
+
) -> list[str]:
|
|
888
|
+
"""Config-declared + small convention set used as BFS reachability seeds.
|
|
889
|
+
|
|
890
|
+
Seeds are pyproject targets, package.json targets (root + workspace
|
|
891
|
+
manifests), language-convention mains (``main.go`` binaries, Python service
|
|
892
|
+
mains, JS/TS ``src/index``/``src/main``/``App``/service indexes, Rust
|
|
893
|
+
``src/main.rs``/``lib.rs``), relative dynamic-import expansions from those
|
|
894
|
+
seeds, plus ``__main__.py`` / ``manage.py``. Structural exemptions (routes,
|
|
895
|
+
migrations, scripts, stories, tests, tooling configs, ``build.rs``) remain a
|
|
896
|
+
skip-list for unwired/unreachable — they are NOT BFS seeds (that diluted
|
|
897
|
+
reachability and, with caps, could truncate real config entries).
|
|
898
|
+
|
|
899
|
+
When ``production_only`` is True, test-file seeds are excluded so reachability
|
|
900
|
+
means "reachable from production code".
|
|
901
|
+
|
|
902
|
+
Never raises. Returns a sorted list of repo-relative posix paths.
|
|
903
|
+
"""
|
|
904
|
+
try:
|
|
905
|
+
file_set = {_norm(f) for f in files}
|
|
906
|
+
roots: Set[str] = set()
|
|
907
|
+
roots |= _config_declared_entry_roots(root, file_set)
|
|
908
|
+
roots |= _pyproject_script_targets(root, file_set)
|
|
909
|
+
roots |= _package_json_entry_targets(root, file_set)
|
|
910
|
+
|
|
911
|
+
for f in _conventional_main_seeds(root, file_set):
|
|
912
|
+
if production_only and is_test_path(f):
|
|
913
|
+
continue
|
|
914
|
+
roots.add(f)
|
|
915
|
+
|
|
916
|
+
for f in file_set:
|
|
917
|
+
if production_only and is_test_path(f):
|
|
918
|
+
continue
|
|
919
|
+
name = Path(f).name
|
|
920
|
+
if name in {"__main__.py", "manage.py"}:
|
|
921
|
+
roots.add(f)
|
|
922
|
+
|
|
923
|
+
roots = _expand_roots_via_dynamic_imports(root, file_set, roots)
|
|
924
|
+
if production_only:
|
|
925
|
+
roots = {r for r in roots if not is_test_path(r)}
|
|
926
|
+
|
|
927
|
+
return sorted(roots)
|
|
928
|
+
except Exception:
|
|
929
|
+
logger.debug("entry_roots failed", exc_info=True)
|
|
930
|
+
return []
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def entry_point_symbols(root: Path, files: Iterable[str]) -> Set[str]:
|
|
934
|
+
"""Return ``path::attr`` keys for pyproject ``module:attr`` script targets.
|
|
935
|
+
|
|
936
|
+
Used by graph dead-code so CLI entry functions (e.g. ``pkg.b:main``) are not
|
|
937
|
+
flagged merely because nothing in-repo calls them.
|
|
938
|
+
"""
|
|
939
|
+
out: Set[str] = set()
|
|
940
|
+
try:
|
|
941
|
+
file_set = {_norm(f) for f in files}
|
|
942
|
+
text = _read_text(root, "pyproject.toml")
|
|
943
|
+
if not text:
|
|
944
|
+
return out
|
|
945
|
+
try:
|
|
946
|
+
import tomllib
|
|
947
|
+
except ImportError: # pragma: no cover
|
|
948
|
+
import tomli as tomllib # type: ignore
|
|
949
|
+
|
|
950
|
+
data = tomllib.loads(text)
|
|
951
|
+
project = data.get("project") or {}
|
|
952
|
+
entry_maps: list = []
|
|
953
|
+
for key in ("scripts", "gui-scripts"):
|
|
954
|
+
val = project.get(key)
|
|
955
|
+
if isinstance(val, dict):
|
|
956
|
+
entry_maps.append(val)
|
|
957
|
+
eps = project.get("entry-points") or {}
|
|
958
|
+
if isinstance(eps, dict):
|
|
959
|
+
for group in eps.values():
|
|
960
|
+
if isinstance(group, dict):
|
|
961
|
+
entry_maps.append(group)
|
|
962
|
+
for mapping in entry_maps:
|
|
963
|
+
for target in mapping.values():
|
|
964
|
+
if not isinstance(target, str) or ":" not in target:
|
|
965
|
+
continue
|
|
966
|
+
mod, _, attr = target.partition(":")
|
|
967
|
+
mod, attr = mod.strip(), attr.strip()
|
|
968
|
+
if not mod or not attr:
|
|
969
|
+
continue
|
|
970
|
+
found: Set[str] = set()
|
|
971
|
+
_add_module_file(mod, file_set, found)
|
|
972
|
+
for path in found:
|
|
973
|
+
out.add(f"{path}::{attr}")
|
|
974
|
+
except Exception:
|
|
975
|
+
logger.debug("entry_point_symbols failed", exc_info=True)
|
|
976
|
+
return out
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
_SHORT_STEM_MAX = 12
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def module_tokens_for(path: str) -> Set[str]:
|
|
983
|
+
"""Tokens that could appear in an importlib/dynamic string for ``path``.
|
|
984
|
+
|
|
985
|
+
Omits bare short stems (``config``, ``utils``) that over-match via suffix
|
|
986
|
+
checks against unrelated modules like ``other.config``.
|
|
987
|
+
"""
|
|
988
|
+
norm = _norm(path)
|
|
989
|
+
stem = Path(norm).stem
|
|
990
|
+
no_ext = norm.rsplit(".", 1)[0] if "." in Path(norm).name else norm
|
|
991
|
+
dotted = no_ext.replace("/", ".")
|
|
992
|
+
if dotted.startswith("src."):
|
|
993
|
+
dotted = dotted[4:]
|
|
994
|
+
tokens = {no_ext, dotted, norm}
|
|
995
|
+
if Path(norm).name == "__init__.py":
|
|
996
|
+
pkg = Path(norm).parent.as_posix().replace("/", ".")
|
|
997
|
+
if pkg.startswith("src."):
|
|
998
|
+
pkg = pkg[4:]
|
|
999
|
+
tokens.add(pkg)
|
|
1000
|
+
tokens.add(Path(norm).parent.as_posix())
|
|
1001
|
+
# Bare stem only when long enough to be specific, or path is top-level.
|
|
1002
|
+
if "/" not in no_ext and len(stem) >= _SHORT_STEM_MAX:
|
|
1003
|
+
tokens.add(stem)
|
|
1004
|
+
elif "/" in no_ext and len(stem) >= _SHORT_STEM_MAX:
|
|
1005
|
+
# Still skip short stems; path/dotted forms above are enough.
|
|
1006
|
+
pass
|
|
1007
|
+
# Bundled non-Python assets are often referenced by basename only.
|
|
1008
|
+
name = Path(norm).name
|
|
1009
|
+
if Path(norm).suffix.lower() in {
|
|
1010
|
+
".mjs",
|
|
1011
|
+
".cjs",
|
|
1012
|
+
".js",
|
|
1013
|
+
".css",
|
|
1014
|
+
".svg",
|
|
1015
|
+
".png",
|
|
1016
|
+
".jpg",
|
|
1017
|
+
".jpeg",
|
|
1018
|
+
".webp",
|
|
1019
|
+
".html",
|
|
1020
|
+
}:
|
|
1021
|
+
tokens.add(name)
|
|
1022
|
+
return {t for t in tokens if t}
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _module_forms(value: str) -> Set[str]:
|
|
1026
|
+
"""Comparable dotted + slash forms (extensions stripped) for boundary matching."""
|
|
1027
|
+
v = _norm(value)
|
|
1028
|
+
forms = {v, v.replace("/", "."), v.replace(".", "/")}
|
|
1029
|
+
for ext in (".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
|
|
1030
|
+
if v.endswith(ext):
|
|
1031
|
+
base = v[: -len(ext)]
|
|
1032
|
+
forms.add(base)
|
|
1033
|
+
forms.add(base.replace("/", "."))
|
|
1034
|
+
forms.add(base.replace(".", "/"))
|
|
1035
|
+
break
|
|
1036
|
+
return {f for f in forms if f}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def import_spec_matches(spec: str, tokens: Set[str]) -> bool:
|
|
1040
|
+
"""True when an import string matches ``tokens`` on a module/path boundary."""
|
|
1041
|
+
if not spec or not tokens:
|
|
1042
|
+
return False
|
|
1043
|
+
spec_forms = _module_forms(spec)
|
|
1044
|
+
for t in tokens:
|
|
1045
|
+
if spec_forms & _module_forms(t):
|
|
1046
|
+
return True
|
|
1047
|
+
return False
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def has_allow_unwired(project_root: Path, path: str) -> bool:
|
|
1051
|
+
"""True when ``path`` contains the ``devcouncil: allow-unwired`` marker."""
|
|
1052
|
+
try:
|
|
1053
|
+
text = (project_root / path).read_text(encoding="utf-8", errors="replace")
|
|
1054
|
+
except OSError:
|
|
1055
|
+
return False
|
|
1056
|
+
return ALLOW_UNWIRED in text
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def dynamic_import_keys(path: str, source: str) -> Set[str]:
|
|
1060
|
+
"""Return normalized dynamic-import and ``getattr`` keys for one file."""
|
|
1061
|
+
norm = _norm(path)
|
|
1062
|
+
suffix = Path(norm).suffix.lower()
|
|
1063
|
+
if suffix not in _CODE_CONFIG_SUFFIXES:
|
|
1064
|
+
return set()
|
|
1065
|
+
specs: List[str] = [match.group(1) for match in _IMPORTLIB_RE.finditer(source)]
|
|
1066
|
+
for match in _DYNAMIC_IMPORT_RE.finditer(source):
|
|
1067
|
+
spec = match.group(1)
|
|
1068
|
+
if not spec:
|
|
1069
|
+
continue
|
|
1070
|
+
if spec.startswith("."):
|
|
1071
|
+
# Relative dynamic import — resolve against this file so
|
|
1072
|
+
# ``import('./App')`` clears ``App.tsx`` via reference_cleared.
|
|
1073
|
+
try:
|
|
1074
|
+
joined = (Path(norm).parent / spec).as_posix()
|
|
1075
|
+
except Exception:
|
|
1076
|
+
continue
|
|
1077
|
+
resolved = _normalize_rel_path(joined)
|
|
1078
|
+
hit_stem = resolved
|
|
1079
|
+
for ext in _JS_RESOLVE_EXTS:
|
|
1080
|
+
if resolved.endswith(ext):
|
|
1081
|
+
hit_stem = resolved[: -len(ext)]
|
|
1082
|
+
break
|
|
1083
|
+
specs.append(resolved)
|
|
1084
|
+
specs.append(hit_stem)
|
|
1085
|
+
else:
|
|
1086
|
+
specs.append(spec)
|
|
1087
|
+
for match in _WORKER_URL_RE.finditer(source):
|
|
1088
|
+
spec = match.group(1)
|
|
1089
|
+
if not spec:
|
|
1090
|
+
continue
|
|
1091
|
+
if spec.startswith("."):
|
|
1092
|
+
try:
|
|
1093
|
+
joined = (Path(norm).parent / spec).as_posix()
|
|
1094
|
+
except Exception:
|
|
1095
|
+
continue
|
|
1096
|
+
resolved = _normalize_rel_path(joined)
|
|
1097
|
+
hit_stem = resolved
|
|
1098
|
+
for ext in _JS_RESOLVE_EXTS:
|
|
1099
|
+
if resolved.endswith(ext):
|
|
1100
|
+
hit_stem = resolved[: -len(ext)]
|
|
1101
|
+
break
|
|
1102
|
+
specs.append(resolved)
|
|
1103
|
+
specs.append(hit_stem)
|
|
1104
|
+
else:
|
|
1105
|
+
specs.append(spec)
|
|
1106
|
+
for match in _PYTHON_DASH_M_RE.finditer(source):
|
|
1107
|
+
spec = match.group(1) or match.group(2)
|
|
1108
|
+
if spec:
|
|
1109
|
+
specs.append(spec)
|
|
1110
|
+
specs.extend(_PACKAGE_RESOURCES_RE.findall(source))
|
|
1111
|
+
specs.extend(_BUNDLED_ASSET_RE.findall(source))
|
|
1112
|
+
if suffix == ".toml":
|
|
1113
|
+
specs.extend(
|
|
1114
|
+
(Path(norm).parent / match.group(1)).with_suffix("").as_posix()
|
|
1115
|
+
for match in _HATCH_CUSTOM_HOOK_RE.finditer(source)
|
|
1116
|
+
)
|
|
1117
|
+
keys = {form for spec in specs for form in _module_forms(spec)}
|
|
1118
|
+
keys.update(
|
|
1119
|
+
f"{GETATTR_INDEX_PREFIX}{match.group(1)}"
|
|
1120
|
+
for match in _GETATTR_NAME_RE.finditer(source)
|
|
1121
|
+
if match.group(1)
|
|
1122
|
+
)
|
|
1123
|
+
return keys
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def build_dynamic_import_index(
|
|
1127
|
+
project_root: Path,
|
|
1128
|
+
git_files: Optional[List[str]] = None,
|
|
1129
|
+
) -> dict[str, Set[str]]:
|
|
1130
|
+
"""One shared scan: normalized module form → non-test files that reference it.
|
|
1131
|
+
|
|
1132
|
+
Call once per liveness/verify pass; O(repo files) instead of O(candidates × files).
|
|
1133
|
+
"""
|
|
1134
|
+
index: dict[str, Set[str]] = {}
|
|
1135
|
+
try:
|
|
1136
|
+
if git_files is None:
|
|
1137
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
1138
|
+
|
|
1139
|
+
try:
|
|
1140
|
+
candidates = RepoMapper(project_root).get_git_files()
|
|
1141
|
+
except Exception:
|
|
1142
|
+
candidates = []
|
|
1143
|
+
else:
|
|
1144
|
+
candidates = list(git_files)
|
|
1145
|
+
|
|
1146
|
+
for rel in candidates:
|
|
1147
|
+
norm = _norm(rel)
|
|
1148
|
+
if is_test_path(norm):
|
|
1149
|
+
continue
|
|
1150
|
+
path = project_root / norm
|
|
1151
|
+
if not path.is_file():
|
|
1152
|
+
continue
|
|
1153
|
+
if path.suffix.lower() not in _CODE_CONFIG_SUFFIXES:
|
|
1154
|
+
continue
|
|
1155
|
+
try:
|
|
1156
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1157
|
+
except OSError:
|
|
1158
|
+
continue
|
|
1159
|
+
for key in dynamic_import_keys(norm, text):
|
|
1160
|
+
index.setdefault(key, set()).add(norm)
|
|
1161
|
+
except Exception:
|
|
1162
|
+
logger.debug("build_dynamic_import_index failed", exc_info=True)
|
|
1163
|
+
return index
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def reference_cleared(
|
|
1167
|
+
project_root: Path,
|
|
1168
|
+
target: str,
|
|
1169
|
+
*,
|
|
1170
|
+
skip_files: Optional[Set[str]] = None,
|
|
1171
|
+
git_files: Optional[List[str]] = None,
|
|
1172
|
+
dynamic_index: Optional[dict[str, Set[str]]] = None,
|
|
1173
|
+
) -> bool:
|
|
1174
|
+
"""True when a non-test file holds an import-shaped string reference to ``target``.
|
|
1175
|
+
|
|
1176
|
+
Prefer a prebuilt ``dynamic_index`` (from :func:`build_dynamic_import_index`) so
|
|
1177
|
+
a liveness pass pays one repo scan. Falls back to a targeted scan when omitted.
|
|
1178
|
+
|
|
1179
|
+
Scans only non-test code/config files so a dynamic import in a test does not
|
|
1180
|
+
clear unwired (parity with the static-import rule).
|
|
1181
|
+
"""
|
|
1182
|
+
tokens = module_tokens_for(target)
|
|
1183
|
+
if not tokens:
|
|
1184
|
+
return False
|
|
1185
|
+
skip = {_norm(p) for p in (skip_files or set())}
|
|
1186
|
+
target_n = _norm(target)
|
|
1187
|
+
token_forms: Set[str] = set()
|
|
1188
|
+
for t in tokens:
|
|
1189
|
+
token_forms |= _module_forms(t)
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
if dynamic_index is not None:
|
|
1193
|
+
for form in token_forms:
|
|
1194
|
+
for ref in dynamic_index.get(form, ()):
|
|
1195
|
+
if ref in skip or ref == target_n or is_test_path(ref):
|
|
1196
|
+
continue
|
|
1197
|
+
return True
|
|
1198
|
+
return False
|
|
1199
|
+
|
|
1200
|
+
if git_files is None:
|
|
1201
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
1202
|
+
|
|
1203
|
+
try:
|
|
1204
|
+
candidates = RepoMapper(project_root).get_git_files()
|
|
1205
|
+
except Exception:
|
|
1206
|
+
candidates = []
|
|
1207
|
+
else:
|
|
1208
|
+
candidates = list(git_files)
|
|
1209
|
+
for rel in candidates:
|
|
1210
|
+
norm = _norm(rel)
|
|
1211
|
+
if norm in skip or norm == target_n:
|
|
1212
|
+
continue
|
|
1213
|
+
if is_test_path(norm):
|
|
1214
|
+
continue
|
|
1215
|
+
path = project_root / norm
|
|
1216
|
+
if not path.is_file():
|
|
1217
|
+
continue
|
|
1218
|
+
if path.suffix.lower() not in _CODE_CONFIG_SUFFIXES:
|
|
1219
|
+
continue
|
|
1220
|
+
try:
|
|
1221
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1222
|
+
except OSError:
|
|
1223
|
+
continue
|
|
1224
|
+
for m in _IMPORTLIB_RE.finditer(text):
|
|
1225
|
+
if import_spec_matches(m.group(1), tokens):
|
|
1226
|
+
return True
|
|
1227
|
+
for m in _DYNAMIC_IMPORT_RE.finditer(text):
|
|
1228
|
+
spec = m.group(1)
|
|
1229
|
+
if not spec:
|
|
1230
|
+
continue
|
|
1231
|
+
if spec.startswith("."):
|
|
1232
|
+
try:
|
|
1233
|
+
joined = (Path(norm).parent / spec).as_posix()
|
|
1234
|
+
except Exception:
|
|
1235
|
+
continue
|
|
1236
|
+
resolved = _normalize_rel_path(joined)
|
|
1237
|
+
if import_spec_matches(resolved, tokens):
|
|
1238
|
+
return True
|
|
1239
|
+
# Extensionless stem match (./App → App.tsx tokens)
|
|
1240
|
+
stem = resolved
|
|
1241
|
+
for ext in _JS_RESOLVE_EXTS:
|
|
1242
|
+
if resolved.endswith(ext):
|
|
1243
|
+
stem = resolved[: -len(ext)]
|
|
1244
|
+
break
|
|
1245
|
+
if import_spec_matches(stem, tokens):
|
|
1246
|
+
return True
|
|
1247
|
+
continue
|
|
1248
|
+
if import_spec_matches(spec, tokens):
|
|
1249
|
+
return True
|
|
1250
|
+
for m in _PYTHON_DASH_M_RE.finditer(text):
|
|
1251
|
+
spec = m.group(1) or m.group(2)
|
|
1252
|
+
if spec and import_spec_matches(spec, tokens):
|
|
1253
|
+
return True
|
|
1254
|
+
for spec in _PACKAGE_RESOURCES_RE.findall(text):
|
|
1255
|
+
if import_spec_matches(spec, tokens):
|
|
1256
|
+
return True
|
|
1257
|
+
for spec in _BUNDLED_ASSET_RE.findall(text):
|
|
1258
|
+
if import_spec_matches(spec, tokens):
|
|
1259
|
+
return True
|
|
1260
|
+
except Exception:
|
|
1261
|
+
logger.debug("reference scan failed for %s", target, exc_info=True)
|
|
1262
|
+
return False
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
# ---------------------------------------------------------------------------
|
|
1266
|
+
# Advisory corpus index (docs / PDF / image side graph)
|
|
1267
|
+
# ---------------------------------------------------------------------------
|
|
1268
|
+
# Separate from the deterministic code graph — never wired into verify gates.
|
|
1269
|
+
# Artifacts: ``.devcouncil/corpus/graph.json`` (+ optional ``graph.html``).
|
|
1270
|
+
|
|
1271
|
+
CorpusNodeKind = Literal[
|
|
1272
|
+
"document",
|
|
1273
|
+
"section",
|
|
1274
|
+
"concept",
|
|
1275
|
+
"link",
|
|
1276
|
+
"code_ref",
|
|
1277
|
+
"pdf",
|
|
1278
|
+
"pdf_page",
|
|
1279
|
+
"image",
|
|
1280
|
+
]
|
|
1281
|
+
CorpusEdgeKind = Literal[
|
|
1282
|
+
"contains",
|
|
1283
|
+
"links_to",
|
|
1284
|
+
"references",
|
|
1285
|
+
"cites",
|
|
1286
|
+
"parent_of",
|
|
1287
|
+
"mentions",
|
|
1288
|
+
]
|
|
1289
|
+
|
|
1290
|
+
_CORPUS_TEXT_EXTS = frozenset({".md", ".markdown", ".txt", ".rst"})
|
|
1291
|
+
_CORPUS_PDF_EXTS = frozenset({".pdf"})
|
|
1292
|
+
_CORPUS_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"})
|
|
1293
|
+
_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
|
1294
|
+
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
1295
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
|
1296
|
+
_CODE_PATH_RE = re.compile(
|
|
1297
|
+
r"`([^`]+)`|(?:^|\s)((?:src|docs|tests)/[\w./-]+\.(?:py|ts|tsx|js|md|rst|yaml|yml))(?:\s|$)"
|
|
1298
|
+
)
|
|
1299
|
+
_RST_HEADING_RE = re.compile(
|
|
1300
|
+
r"^(?P<title>.+)\n(?P<uline>[=\-`:~^_*+#]+)\s*$",
|
|
1301
|
+
re.MULTILINE,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
class CorpusNode(BaseModel):
|
|
1306
|
+
id: str
|
|
1307
|
+
kind: CorpusNodeKind
|
|
1308
|
+
label: str
|
|
1309
|
+
path: Optional[str] = None
|
|
1310
|
+
content: Optional[str] = None
|
|
1311
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
class CorpusEdge(BaseModel):
|
|
1315
|
+
id: str
|
|
1316
|
+
source: str
|
|
1317
|
+
target: str
|
|
1318
|
+
kind: CorpusEdgeKind
|
|
1319
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
class CorpusGraph(BaseModel):
|
|
1323
|
+
version: int = 1
|
|
1324
|
+
advisory: bool = True
|
|
1325
|
+
built_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
1326
|
+
source_roots: List[str] = Field(default_factory=list)
|
|
1327
|
+
nodes: List[CorpusNode] = Field(default_factory=list)
|
|
1328
|
+
edges: List[CorpusEdge] = Field(default_factory=list)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
class CorpusSettings(BaseModel):
|
|
1332
|
+
enabled: bool = True
|
|
1333
|
+
paths: List[str] = Field(default_factory=lambda: ["docs", "README.md"])
|
|
1334
|
+
llm_enrichment: bool = False
|
|
1335
|
+
vision_captions: bool = False
|
|
1336
|
+
write_html: bool = False
|
|
1337
|
+
auto_refresh_on_verify: bool = True
|
|
1338
|
+
extensions: List[str] = Field(
|
|
1339
|
+
default_factory=lambda: sorted(
|
|
1340
|
+
_CORPUS_TEXT_EXTS | _CORPUS_PDF_EXTS | _CORPUS_IMAGE_EXTS
|
|
1341
|
+
)
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def corpus_dir(project_root: Path) -> Path:
|
|
1346
|
+
return project_root / ".devcouncil" / "corpus"
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def corpus_graph_path(project_root: Path) -> Path:
|
|
1350
|
+
return corpus_dir(project_root) / "graph.json"
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def corpus_html_path(project_root: Path) -> Path:
|
|
1354
|
+
return corpus_dir(project_root) / "graph.html"
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def load_corpus_settings(project_root: Path) -> CorpusSettings:
|
|
1358
|
+
merged: dict = {}
|
|
1359
|
+
try:
|
|
1360
|
+
from devcouncil.app.config import load_config
|
|
1361
|
+
|
|
1362
|
+
merged.update(load_config(project_root).indexing.corpus.model_dump())
|
|
1363
|
+
except FileNotFoundError:
|
|
1364
|
+
pass
|
|
1365
|
+
return CorpusSettings.model_validate(merged)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def load_corpus_graph(project_root: Path) -> Optional[CorpusGraph]:
|
|
1369
|
+
path = corpus_graph_path(project_root)
|
|
1370
|
+
if not path.is_file():
|
|
1371
|
+
return None
|
|
1372
|
+
return read_model_json(path, CorpusGraph)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def write_corpus_graph(project_root: Path, graph: CorpusGraph) -> Path:
|
|
1376
|
+
out = corpus_graph_path(project_root)
|
|
1377
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1378
|
+
write_model_json(out, graph)
|
|
1379
|
+
return out
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _slug_id(prefix: str, label: str) -> str:
|
|
1383
|
+
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or "node"
|
|
1384
|
+
return f"{prefix}:{slug}"
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _edge_id(source: str, target: str, kind: str) -> str:
|
|
1388
|
+
return f"{source}->{kind}->{target}"
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _iter_corpus_files(
|
|
1392
|
+
project_root: Path,
|
|
1393
|
+
roots: List[str],
|
|
1394
|
+
extensions: List[str],
|
|
1395
|
+
) -> Iterator[Path]:
|
|
1396
|
+
ext_set = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in extensions}
|
|
1397
|
+
root = project_root.resolve()
|
|
1398
|
+
for rel in roots:
|
|
1399
|
+
target = (root / rel).resolve()
|
|
1400
|
+
if not str(target).startswith(str(root)):
|
|
1401
|
+
continue
|
|
1402
|
+
if target.is_file():
|
|
1403
|
+
if target.suffix.lower() in ext_set:
|
|
1404
|
+
yield target
|
|
1405
|
+
continue
|
|
1406
|
+
if not target.is_dir():
|
|
1407
|
+
continue
|
|
1408
|
+
for dirpath, dirnames, filenames in target.walk(on_error=lambda _: None):
|
|
1409
|
+
dirnames[:] = [n for n in dirnames if n not in IGNORED_DIR_NAMES]
|
|
1410
|
+
for name in filenames:
|
|
1411
|
+
file_path = dirpath / name
|
|
1412
|
+
if file_path.suffix.lower() not in ext_set:
|
|
1413
|
+
continue
|
|
1414
|
+
rel_path = file_path.relative_to(root)
|
|
1415
|
+
if should_skip_path(rel_path):
|
|
1416
|
+
continue
|
|
1417
|
+
yield file_path
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _extract_text_doc(
|
|
1421
|
+
rel: str,
|
|
1422
|
+
text: str,
|
|
1423
|
+
*,
|
|
1424
|
+
suffix: str,
|
|
1425
|
+
) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
|
|
1426
|
+
doc_id = f"doc:{rel}"
|
|
1427
|
+
nodes: List[CorpusNode] = [
|
|
1428
|
+
CorpusNode(id=doc_id, kind="document", label=rel, path=rel, content=text[:8000])
|
|
1429
|
+
]
|
|
1430
|
+
edges: List[CorpusEdge] = []
|
|
1431
|
+
parent_stack: List[Tuple[int, str]] = [(0, doc_id)]
|
|
1432
|
+
|
|
1433
|
+
if suffix in _CORPUS_TEXT_EXTS and suffix != ".rst":
|
|
1434
|
+
for match in _MD_HEADING_RE.finditer(text):
|
|
1435
|
+
level = len(match.group(1))
|
|
1436
|
+
title = match.group(2).strip()
|
|
1437
|
+
sec_id = f"section:{rel}:{level}:{title[:48]}"
|
|
1438
|
+
nodes.append(
|
|
1439
|
+
CorpusNode(
|
|
1440
|
+
id=sec_id,
|
|
1441
|
+
kind="section",
|
|
1442
|
+
label=title,
|
|
1443
|
+
path=rel,
|
|
1444
|
+
metadata={"level": level},
|
|
1445
|
+
)
|
|
1446
|
+
)
|
|
1447
|
+
while parent_stack and parent_stack[-1][0] >= level:
|
|
1448
|
+
parent_stack.pop()
|
|
1449
|
+
parent_id = parent_stack[-1][1] if parent_stack else doc_id
|
|
1450
|
+
edges.append(
|
|
1451
|
+
CorpusEdge(
|
|
1452
|
+
id=_edge_id(parent_id, sec_id, "contains"),
|
|
1453
|
+
source=parent_id,
|
|
1454
|
+
target=sec_id,
|
|
1455
|
+
kind="contains",
|
|
1456
|
+
)
|
|
1457
|
+
)
|
|
1458
|
+
parent_stack.append((level, sec_id))
|
|
1459
|
+
|
|
1460
|
+
for match in _MD_LINK_RE.finditer(text):
|
|
1461
|
+
label, href = match.group(1).strip(), match.group(2).strip()
|
|
1462
|
+
link_id = _slug_id(f"link:{rel}", label)
|
|
1463
|
+
nodes.append(
|
|
1464
|
+
CorpusNode(
|
|
1465
|
+
id=link_id,
|
|
1466
|
+
kind="link",
|
|
1467
|
+
label=label,
|
|
1468
|
+
path=rel,
|
|
1469
|
+
metadata={"href": href},
|
|
1470
|
+
)
|
|
1471
|
+
)
|
|
1472
|
+
edges.append(
|
|
1473
|
+
CorpusEdge(
|
|
1474
|
+
id=_edge_id(doc_id, link_id, "mentions"),
|
|
1475
|
+
source=doc_id,
|
|
1476
|
+
target=link_id,
|
|
1477
|
+
kind="mentions",
|
|
1478
|
+
)
|
|
1479
|
+
)
|
|
1480
|
+
if href and not href.startswith(("http://", "https://", "#", "mailto:")):
|
|
1481
|
+
target = href.split("#", 1)[0].lstrip("./")
|
|
1482
|
+
edges.append(
|
|
1483
|
+
CorpusEdge(
|
|
1484
|
+
id=_edge_id(link_id, f"doc:{target}", "links_to"),
|
|
1485
|
+
source=link_id,
|
|
1486
|
+
target=f"doc:{target}",
|
|
1487
|
+
kind="links_to",
|
|
1488
|
+
)
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
for match in _WIKILINK_RE.finditer(text):
|
|
1492
|
+
target = match.group(1).strip()
|
|
1493
|
+
label = (match.group(2) or target).strip()
|
|
1494
|
+
link_id = _slug_id(f"wiki:{rel}", target)
|
|
1495
|
+
nodes.append(
|
|
1496
|
+
CorpusNode(id=link_id, kind="link", label=label, path=rel, metadata={"wiki": target})
|
|
1497
|
+
)
|
|
1498
|
+
edges.append(
|
|
1499
|
+
CorpusEdge(
|
|
1500
|
+
id=_edge_id(doc_id, link_id, "mentions"),
|
|
1501
|
+
source=doc_id,
|
|
1502
|
+
target=link_id,
|
|
1503
|
+
kind="mentions",
|
|
1504
|
+
)
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
if suffix == ".rst":
|
|
1508
|
+
for match in _RST_HEADING_RE.finditer(text):
|
|
1509
|
+
title = match.group("title").strip()
|
|
1510
|
+
sec_id = f"section:{rel}:rst:{title[:48]}"
|
|
1511
|
+
nodes.append(CorpusNode(id=sec_id, kind="section", label=title, path=rel))
|
|
1512
|
+
edges.append(
|
|
1513
|
+
CorpusEdge(
|
|
1514
|
+
id=_edge_id(doc_id, sec_id, "contains"),
|
|
1515
|
+
source=doc_id,
|
|
1516
|
+
target=sec_id,
|
|
1517
|
+
kind="contains",
|
|
1518
|
+
)
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
for match in _CODE_PATH_RE.finditer(text):
|
|
1522
|
+
code_path = (match.group(1) or match.group(2) or "").strip()
|
|
1523
|
+
if not code_path or "/" not in code_path:
|
|
1524
|
+
continue
|
|
1525
|
+
ref_id = f"code:{code_path}"
|
|
1526
|
+
nodes.append(
|
|
1527
|
+
CorpusNode(id=ref_id, kind="code_ref", label=code_path, path=rel, metadata={"ref": code_path})
|
|
1528
|
+
)
|
|
1529
|
+
edges.append(
|
|
1530
|
+
CorpusEdge(
|
|
1531
|
+
id=_edge_id(doc_id, ref_id, "references"),
|
|
1532
|
+
source=doc_id,
|
|
1533
|
+
target=ref_id,
|
|
1534
|
+
kind="references",
|
|
1535
|
+
)
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
return nodes, edges
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _extract_pdf(rel: str, file_path: Path) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
|
|
1542
|
+
doc_id = f"pdf:{rel}"
|
|
1543
|
+
nodes: List[CorpusNode] = [
|
|
1544
|
+
CorpusNode(id=doc_id, kind="pdf", label=rel, path=rel, metadata={"pages": 0})
|
|
1545
|
+
]
|
|
1546
|
+
edges: List[CorpusEdge] = []
|
|
1547
|
+
try:
|
|
1548
|
+
from pypdf import PdfReader
|
|
1549
|
+
except ImportError:
|
|
1550
|
+
logger.debug("pypdf not installed; PDF %s indexed as metadata-only", rel)
|
|
1551
|
+
return nodes, edges
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
reader = PdfReader(str(file_path))
|
|
1555
|
+
nodes[0].metadata["pages"] = len(reader.pages)
|
|
1556
|
+
for idx, page in enumerate(reader.pages[:200]):
|
|
1557
|
+
try:
|
|
1558
|
+
page_text = page.extract_text() or ""
|
|
1559
|
+
except Exception:
|
|
1560
|
+
page_text = ""
|
|
1561
|
+
page_id = f"pdf-page:{rel}:{idx + 1}"
|
|
1562
|
+
nodes.append(
|
|
1563
|
+
CorpusNode(
|
|
1564
|
+
id=page_id,
|
|
1565
|
+
kind="pdf_page",
|
|
1566
|
+
label=f"{rel} p.{idx + 1}",
|
|
1567
|
+
path=rel,
|
|
1568
|
+
content=page_text[:4000],
|
|
1569
|
+
metadata={"page": idx + 1},
|
|
1570
|
+
)
|
|
1571
|
+
)
|
|
1572
|
+
edges.append(
|
|
1573
|
+
CorpusEdge(
|
|
1574
|
+
id=_edge_id(doc_id, page_id, "contains"),
|
|
1575
|
+
source=doc_id,
|
|
1576
|
+
target=page_id,
|
|
1577
|
+
kind="contains",
|
|
1578
|
+
)
|
|
1579
|
+
)
|
|
1580
|
+
for match in _MD_LINK_RE.finditer(page_text):
|
|
1581
|
+
href = match.group(2).strip()
|
|
1582
|
+
if href.startswith(("http://", "https://")):
|
|
1583
|
+
cite_id = _slug_id(f"cite:{rel}:{idx}", href)
|
|
1584
|
+
nodes.append(
|
|
1585
|
+
CorpusNode(
|
|
1586
|
+
id=cite_id,
|
|
1587
|
+
kind="link",
|
|
1588
|
+
label=match.group(1).strip(),
|
|
1589
|
+
path=rel,
|
|
1590
|
+
metadata={"href": href},
|
|
1591
|
+
)
|
|
1592
|
+
)
|
|
1593
|
+
edges.append(
|
|
1594
|
+
CorpusEdge(
|
|
1595
|
+
id=_edge_id(page_id, cite_id, "cites"),
|
|
1596
|
+
source=page_id,
|
|
1597
|
+
target=cite_id,
|
|
1598
|
+
kind="cites",
|
|
1599
|
+
)
|
|
1600
|
+
)
|
|
1601
|
+
except Exception:
|
|
1602
|
+
logger.debug("PDF extract failed for %s", rel, exc_info=True)
|
|
1603
|
+
return nodes, edges
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _extract_image(
|
|
1607
|
+
rel: str,
|
|
1608
|
+
file_path: Path,
|
|
1609
|
+
*,
|
|
1610
|
+
vision_captions: bool,
|
|
1611
|
+
project_root: Path,
|
|
1612
|
+
) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
|
|
1613
|
+
img_id = f"image:{rel}"
|
|
1614
|
+
stat = file_path.stat()
|
|
1615
|
+
meta: Dict[str, Any] = {
|
|
1616
|
+
"size_bytes": stat.st_size,
|
|
1617
|
+
"suffix": file_path.suffix.lower(),
|
|
1618
|
+
}
|
|
1619
|
+
caption: Optional[str] = None
|
|
1620
|
+
if vision_captions:
|
|
1621
|
+
try:
|
|
1622
|
+
from devcouncil.app.config import load_config
|
|
1623
|
+
|
|
1624
|
+
cfg = load_config(project_root)
|
|
1625
|
+
if cfg.models.roles:
|
|
1626
|
+
caption = _optional_vision_caption(project_root, file_path)
|
|
1627
|
+
except Exception:
|
|
1628
|
+
logger.debug("vision caption skipped for %s", rel, exc_info=True)
|
|
1629
|
+
if caption:
|
|
1630
|
+
meta["caption"] = caption
|
|
1631
|
+
return (
|
|
1632
|
+
[CorpusNode(id=img_id, kind="image", label=rel, path=rel, content=caption, metadata=meta)],
|
|
1633
|
+
[],
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _optional_vision_caption(project_root: Path, file_path: Path) -> Optional[str]:
|
|
1638
|
+
"""Best-effort caption when a vision-capable model is configured (opt-in)."""
|
|
1639
|
+
# ModelRouter does not yet expose a standardized multimodal request API.
|
|
1640
|
+
# Keep the opt-in deterministic and advisory until that contract exists.
|
|
1641
|
+
return None
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _optional_llm_enrich(project_root: Path, graph: CorpusGraph) -> CorpusGraph:
|
|
1645
|
+
settings = load_corpus_settings(project_root)
|
|
1646
|
+
if not settings.llm_enrichment:
|
|
1647
|
+
return graph
|
|
1648
|
+
try:
|
|
1649
|
+
from devcouncil.app.config import load_config
|
|
1650
|
+
|
|
1651
|
+
cfg = load_config(project_root)
|
|
1652
|
+
if not cfg.models.roles:
|
|
1653
|
+
return graph
|
|
1654
|
+
except Exception:
|
|
1655
|
+
return graph
|
|
1656
|
+
# Placeholder: deterministic graph is authoritative; LLM enrichment is optional.
|
|
1657
|
+
return graph
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def build_corpus(
|
|
1661
|
+
project_root: Path,
|
|
1662
|
+
*,
|
|
1663
|
+
path: Optional[str] = None,
|
|
1664
|
+
) -> CorpusGraph:
|
|
1665
|
+
"""Build the advisory corpus graph under ``.devcouncil/corpus/``."""
|
|
1666
|
+
settings = load_corpus_settings(project_root)
|
|
1667
|
+
roots = [path] if path else list(settings.paths)
|
|
1668
|
+
nodes: List[CorpusNode] = []
|
|
1669
|
+
edges: List[CorpusEdge] = []
|
|
1670
|
+
seen_nodes: set[str] = set()
|
|
1671
|
+
seen_edges: set[str] = set()
|
|
1672
|
+
|
|
1673
|
+
for file_path in _iter_corpus_files(project_root, roots, settings.extensions):
|
|
1674
|
+
rel = file_path.relative_to(project_root.resolve()).as_posix()
|
|
1675
|
+
suffix = file_path.suffix.lower()
|
|
1676
|
+
if suffix in _CORPUS_TEXT_EXTS:
|
|
1677
|
+
try:
|
|
1678
|
+
text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
1679
|
+
except OSError:
|
|
1680
|
+
continue
|
|
1681
|
+
n, e = _extract_text_doc(rel, text, suffix=suffix)
|
|
1682
|
+
elif suffix in _CORPUS_PDF_EXTS:
|
|
1683
|
+
n, e = _extract_pdf(rel, file_path)
|
|
1684
|
+
elif suffix in _CORPUS_IMAGE_EXTS:
|
|
1685
|
+
n, e = _extract_image(
|
|
1686
|
+
rel,
|
|
1687
|
+
file_path,
|
|
1688
|
+
vision_captions=settings.vision_captions,
|
|
1689
|
+
project_root=project_root,
|
|
1690
|
+
)
|
|
1691
|
+
else:
|
|
1692
|
+
continue
|
|
1693
|
+
for node in n:
|
|
1694
|
+
if node.id not in seen_nodes:
|
|
1695
|
+
seen_nodes.add(node.id)
|
|
1696
|
+
nodes.append(node)
|
|
1697
|
+
for edge in e:
|
|
1698
|
+
if edge.id not in seen_edges:
|
|
1699
|
+
seen_edges.add(edge.id)
|
|
1700
|
+
edges.append(edge)
|
|
1701
|
+
|
|
1702
|
+
graph = CorpusGraph(source_roots=roots, nodes=nodes, edges=edges)
|
|
1703
|
+
graph = _optional_llm_enrich(project_root, graph)
|
|
1704
|
+
write_corpus_graph(project_root, graph)
|
|
1705
|
+
if settings.write_html:
|
|
1706
|
+
write_corpus_html(project_root, graph)
|
|
1707
|
+
return graph
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def write_corpus_html(project_root: Path, graph: CorpusGraph) -> Path:
|
|
1711
|
+
"""Self-contained advisory corpus listing (not the code-graph visualizer)."""
|
|
1712
|
+
rows = []
|
|
1713
|
+
for node in sorted(graph.nodes, key=lambda n: (n.kind, n.label)):
|
|
1714
|
+
rows.append(
|
|
1715
|
+
f"<tr><td>{node.kind}</td><td>{node.label}</td><td>{node.path or ''}</td></tr>"
|
|
1716
|
+
)
|
|
1717
|
+
html = (
|
|
1718
|
+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
1719
|
+
"<title>DevCouncil Corpus Index</title>"
|
|
1720
|
+
"<style>body{font-family:system-ui;margin:1.5rem}"
|
|
1721
|
+
"table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4rem}"
|
|
1722
|
+
"</style></head><body>"
|
|
1723
|
+
"<h1>DevCouncil Corpus Index (advisory)</h1>"
|
|
1724
|
+
f"<p>Built {graph.built_at} — {len(graph.nodes)} nodes, {len(graph.edges)} edges</p>"
|
|
1725
|
+
"<table><thead><tr><th>Kind</th><th>Label</th><th>Path</th></tr></thead><tbody>"
|
|
1726
|
+
+ "".join(rows)
|
|
1727
|
+
+ "</tbody></table></body></html>"
|
|
1728
|
+
)
|
|
1729
|
+
out = corpus_html_path(project_root)
|
|
1730
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1731
|
+
out.write_text(html, encoding="utf-8")
|
|
1732
|
+
return out
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def query_corpus(project_root: Path, query: str, *, limit: int = 20) -> Dict[str, Any]:
|
|
1736
|
+
graph = load_corpus_graph(project_root)
|
|
1737
|
+
if graph is None:
|
|
1738
|
+
return {"error": "No corpus graph; run `dev corpus build` first.", "matches": []}
|
|
1739
|
+
needle = query.strip().lower()
|
|
1740
|
+
if not needle:
|
|
1741
|
+
return {"error": "Empty query.", "matches": []}
|
|
1742
|
+
scored: List[Tuple[int, CorpusNode]] = []
|
|
1743
|
+
for node in graph.nodes:
|
|
1744
|
+
hay = " ".join(
|
|
1745
|
+
filter(None, [node.label, node.content or "", str(node.metadata)])
|
|
1746
|
+
).lower()
|
|
1747
|
+
if needle in hay:
|
|
1748
|
+
scored.append((hay.count(needle), node))
|
|
1749
|
+
scored.sort(key=lambda item: (-item[0], item[1].label))
|
|
1750
|
+
matches = [
|
|
1751
|
+
{
|
|
1752
|
+
"id": node.id,
|
|
1753
|
+
"kind": node.kind,
|
|
1754
|
+
"label": node.label,
|
|
1755
|
+
"path": node.path,
|
|
1756
|
+
"score": score,
|
|
1757
|
+
}
|
|
1758
|
+
for score, node in scored[:limit]
|
|
1759
|
+
]
|
|
1760
|
+
return {"query": query, "matches": matches, "count": len(matches)}
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def corpus_status(project_root: Path) -> Dict[str, Any]:
|
|
1764
|
+
settings = load_corpus_settings(project_root)
|
|
1765
|
+
path = corpus_graph_path(project_root)
|
|
1766
|
+
graph = load_corpus_graph(project_root)
|
|
1767
|
+
return {
|
|
1768
|
+
"enabled": settings.enabled,
|
|
1769
|
+
"graph_path": str(path.relative_to(project_root.resolve())) if path.is_file() else None,
|
|
1770
|
+
"built_at": graph.built_at if graph else None,
|
|
1771
|
+
"node_count": len(graph.nodes) if graph else 0,
|
|
1772
|
+
"edge_count": len(graph.edges) if graph else 0,
|
|
1773
|
+
"source_roots": graph.source_roots if graph else settings.paths,
|
|
1774
|
+
"advisory": True,
|
|
1775
|
+
"verify_gates": False,
|
|
1776
|
+
}
|