velune-cli 0.9.0__py3-none-any.whl
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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Import graph builder for Python, JavaScript, and TypeScript files.
|
|
2
|
+
|
|
3
|
+
Constructs a directed graph where an edge A → B means "A imports from B".
|
|
4
|
+
Computes structural metrics: fan_in (who imports this?), fan_out (what does this import?).
|
|
5
|
+
|
|
6
|
+
Phase 2a uses simple regex for JS/TS; full tree-sitter parsing in Phase 2b-1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("velune.repository.import_graph")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ImportMetrics:
|
|
22
|
+
"""Metrics for a module/symbol in the import graph."""
|
|
23
|
+
|
|
24
|
+
module_path: str
|
|
25
|
+
fan_in: int = 0 # Count of modules that import this module
|
|
26
|
+
fan_out: int = 0 # Count of modules this module imports
|
|
27
|
+
is_imported_by_tests: bool = False
|
|
28
|
+
importers: set[str] = field(default_factory=set) # Modules that import this
|
|
29
|
+
imports: set[str] = field(default_factory=set) # Modules this imports
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ImportGraphBuilder:
|
|
33
|
+
"""Builds an import dependency graph for a codebase.
|
|
34
|
+
|
|
35
|
+
Extracts import statements from Python, JavaScript, and TypeScript files,
|
|
36
|
+
then computes fan_in/fan_out metrics for each module.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
"""Initialize the import graph builder."""
|
|
41
|
+
self._graph: dict[str, ImportMetrics] = {}
|
|
42
|
+
self._file_extensions = {".py", ".js", ".ts", ".jsx", ".tsx"}
|
|
43
|
+
|
|
44
|
+
def build_from_directory(self, root_path: Path) -> dict[str, ImportMetrics]:
|
|
45
|
+
"""Scan directory recursively and build the import graph.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
root_path:
|
|
50
|
+
Root directory to scan for Python/JS/TS files.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
dict[str, ImportMetrics]:
|
|
55
|
+
Graph mapping module paths to their metrics.
|
|
56
|
+
"""
|
|
57
|
+
self._graph.clear()
|
|
58
|
+
|
|
59
|
+
# First pass: discover all files
|
|
60
|
+
file_paths = self._discover_files(root_path)
|
|
61
|
+
logger.info("Discovered %d source files", len(file_paths))
|
|
62
|
+
|
|
63
|
+
# Second pass: extract imports from each file
|
|
64
|
+
for file_path in file_paths:
|
|
65
|
+
self._extract_imports(file_path, root_path)
|
|
66
|
+
|
|
67
|
+
# Third pass: compute metrics
|
|
68
|
+
self._compute_metrics()
|
|
69
|
+
|
|
70
|
+
logger.info("Built import graph with %d modules", len(self._graph))
|
|
71
|
+
return self._graph
|
|
72
|
+
|
|
73
|
+
def _discover_files(self, root_path: Path) -> list[Path]:
|
|
74
|
+
"""Recursively discover all Python/JS/TS files."""
|
|
75
|
+
files = []
|
|
76
|
+
try:
|
|
77
|
+
for path in root_path.rglob("*"):
|
|
78
|
+
if path.is_file() and path.suffix in self._file_extensions:
|
|
79
|
+
# Skip common exclusions
|
|
80
|
+
if any(
|
|
81
|
+
part in ("node_modules", ".git", "__pycache__", ".venv", "venv")
|
|
82
|
+
for part in path.parts
|
|
83
|
+
):
|
|
84
|
+
continue
|
|
85
|
+
files.append(path)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.warning("Error discovering files in %s: %s", root_path, exc)
|
|
88
|
+
return sorted(files)
|
|
89
|
+
|
|
90
|
+
def _extract_imports(self, file_path: Path, root_path: Path) -> None:
|
|
91
|
+
"""Extract import statements from a single file."""
|
|
92
|
+
try:
|
|
93
|
+
relative_path = file_path.relative_to(root_path).as_posix()
|
|
94
|
+
|
|
95
|
+
# Ensure module is in graph
|
|
96
|
+
if relative_path not in self._graph:
|
|
97
|
+
self._graph[relative_path] = ImportMetrics(module_path=relative_path)
|
|
98
|
+
|
|
99
|
+
if file_path.suffix == ".py":
|
|
100
|
+
self._extract_python_imports(file_path, relative_path)
|
|
101
|
+
elif file_path.suffix in {".js", ".ts", ".jsx", ".tsx"}:
|
|
102
|
+
self._extract_js_imports(file_path, relative_path)
|
|
103
|
+
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
logger.debug("Failed to extract imports from %s: %s", file_path, exc)
|
|
106
|
+
|
|
107
|
+
def _extract_python_imports(self, file_path: Path, relative_path: str) -> None:
|
|
108
|
+
"""Extract imports from a Python file using ast.parse()."""
|
|
109
|
+
try:
|
|
110
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
111
|
+
tree = ast.parse(content)
|
|
112
|
+
|
|
113
|
+
for node in ast.walk(tree):
|
|
114
|
+
if isinstance(node, ast.Import):
|
|
115
|
+
for alias in node.names:
|
|
116
|
+
module_name = alias.name.split(".")[0]
|
|
117
|
+
self._add_import_edge(relative_path, module_name)
|
|
118
|
+
|
|
119
|
+
elif isinstance(node, ast.ImportFrom):
|
|
120
|
+
if node.module:
|
|
121
|
+
module_name = node.module.split(".")[0]
|
|
122
|
+
self._add_import_edge(relative_path, module_name)
|
|
123
|
+
|
|
124
|
+
except SyntaxError:
|
|
125
|
+
logger.debug("Syntax error parsing %s", file_path)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.debug("Error extracting imports from %s: %s", file_path, exc)
|
|
128
|
+
|
|
129
|
+
def _extract_js_imports(self, file_path: Path, relative_path: str) -> None:
|
|
130
|
+
"""Extract imports from JS/TS files using regex (Phase 2a).
|
|
131
|
+
|
|
132
|
+
Phase 2b will use full tree-sitter parsing for better accuracy.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
136
|
+
|
|
137
|
+
# ES6 import: import ... from 'module' or "module"
|
|
138
|
+
es6_pattern = r"import\s+(?:{[^}]*}|[^'\"]*)\s+from\s+['\"]([^'\"]+)['\"]"
|
|
139
|
+
for match in re.finditer(es6_pattern, content):
|
|
140
|
+
module = match.group(1)
|
|
141
|
+
# Normalize: ./foo → foo, ../foo → foo
|
|
142
|
+
module = module.lstrip("./").split("/")[0]
|
|
143
|
+
if module and not module.startswith("."):
|
|
144
|
+
self._add_import_edge(relative_path, module)
|
|
145
|
+
|
|
146
|
+
# CommonJS require: require('module') or require("module")
|
|
147
|
+
require_pattern = r"require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)"
|
|
148
|
+
for match in re.finditer(require_pattern, content):
|
|
149
|
+
module = match.group(1)
|
|
150
|
+
module = module.lstrip("./").split("/")[0]
|
|
151
|
+
if module and not module.startswith("."):
|
|
152
|
+
self._add_import_edge(relative_path, module)
|
|
153
|
+
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logger.debug("Error extracting imports from %s: %s", file_path, exc)
|
|
156
|
+
|
|
157
|
+
def _add_import_edge(self, from_module: str, to_module: str) -> None:
|
|
158
|
+
"""Add an import edge A → B (A imports from B)."""
|
|
159
|
+
if from_module == to_module:
|
|
160
|
+
return # Skip self-imports
|
|
161
|
+
|
|
162
|
+
# Ensure both modules are in graph
|
|
163
|
+
if from_module not in self._graph:
|
|
164
|
+
self._graph[from_module] = ImportMetrics(module_path=from_module)
|
|
165
|
+
if to_module not in self._graph:
|
|
166
|
+
self._graph[to_module] = ImportMetrics(module_path=to_module)
|
|
167
|
+
|
|
168
|
+
# Add edge
|
|
169
|
+
self._graph[from_module].imports.add(to_module)
|
|
170
|
+
self._graph[to_module].importers.add(from_module)
|
|
171
|
+
|
|
172
|
+
def _compute_metrics(self) -> None:
|
|
173
|
+
"""Compute fan_in, fan_out, and test coverage metrics."""
|
|
174
|
+
for metrics in self._graph.values():
|
|
175
|
+
metrics.fan_in = len(metrics.importers)
|
|
176
|
+
metrics.fan_out = len(metrics.imports)
|
|
177
|
+
|
|
178
|
+
# Check if imported by any test file
|
|
179
|
+
metrics.is_imported_by_tests = any(
|
|
180
|
+
"test_" in importer or ".test." in importer for importer in metrics.importers
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def get_metrics(self, module_path: str) -> ImportMetrics | None:
|
|
184
|
+
"""Get metrics for a specific module.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
module_path:
|
|
189
|
+
The relative module path.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
ImportMetrics | None:
|
|
194
|
+
Metrics if module exists, None otherwise.
|
|
195
|
+
"""
|
|
196
|
+
return self._graph.get(module_path)
|
|
197
|
+
|
|
198
|
+
def get_all_metrics(self) -> dict[str, ImportMetrics]:
|
|
199
|
+
"""Return the complete import graph."""
|
|
200
|
+
return self._graph.copy()
|
|
201
|
+
|
|
202
|
+
def get_importers(self, module_path: str) -> set[str]:
|
|
203
|
+
"""Get all modules that import the given module.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
module_path:
|
|
208
|
+
The module to query.
|
|
209
|
+
|
|
210
|
+
Returns
|
|
211
|
+
-------
|
|
212
|
+
set[str]:
|
|
213
|
+
Modules that import the given module.
|
|
214
|
+
"""
|
|
215
|
+
metrics = self._graph.get(module_path)
|
|
216
|
+
return metrics.importers if metrics else set()
|
|
217
|
+
|
|
218
|
+
def get_imports(self, module_path: str) -> set[str]:
|
|
219
|
+
"""Get all modules imported by the given module.
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
module_path:
|
|
224
|
+
The module to query.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
set[str]:
|
|
229
|
+
Modules imported by the given module.
|
|
230
|
+
"""
|
|
231
|
+
metrics = self._graph.get(module_path)
|
|
232
|
+
return metrics.imports if metrics else set()
|
|
233
|
+
|
|
234
|
+
def get_transitive_dependents(self, module_path: str, max_depth: int = 5) -> set[str]:
|
|
235
|
+
"""Get all modules that depend on this module (transitively).
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
module_path:
|
|
240
|
+
The module to query.
|
|
241
|
+
max_depth:
|
|
242
|
+
Maximum traversal depth to avoid cycles.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
set[str]:
|
|
247
|
+
All modules that transitively depend on this module.
|
|
248
|
+
"""
|
|
249
|
+
dependents = set()
|
|
250
|
+
queue = [(module_path, 0)]
|
|
251
|
+
|
|
252
|
+
while queue:
|
|
253
|
+
current, depth = queue.pop(0)
|
|
254
|
+
if depth > max_depth:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
importers = self.get_importers(current)
|
|
258
|
+
for importer in importers:
|
|
259
|
+
if importer not in dependents:
|
|
260
|
+
dependents.add(importer)
|
|
261
|
+
queue.append((importer, depth + 1))
|
|
262
|
+
|
|
263
|
+
return dependents
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Incremental repository indexer that skips unchanged files between sessions.
|
|
2
|
+
|
|
3
|
+
Algorithm
|
|
4
|
+
---------
|
|
5
|
+
1. Load the stored ``IndexState`` (or treat as empty on first run).
|
|
6
|
+
2. Fetch the current git HEAD SHA.
|
|
7
|
+
3. **Fast path**: if HEAD SHA equals the stored SHA *and* the working tree is
|
|
8
|
+
clean (no uncommitted changes), return an empty ``IndexDelta`` immediately —
|
|
9
|
+
no file I/O required.
|
|
10
|
+
4. **Slow path**: walk workspace source files, compute SHA-256 for each, compare
|
|
11
|
+
to the stored hashes, and build the delta (added / updated / removed).
|
|
12
|
+
5. ``apply_delta`` parses only the files in the delta, updates ``IndexState``,
|
|
13
|
+
and saves it to disk.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from velune.repository.index_state import IndexedFile, IndexState
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("velune.repository.incremental_indexer")
|
|
29
|
+
|
|
30
|
+
# Directories always excluded from the walk (on top of .veluneignore)
|
|
31
|
+
_ALWAYS_SKIP = frozenset(
|
|
32
|
+
{
|
|
33
|
+
".git",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
"node_modules",
|
|
36
|
+
".venv",
|
|
37
|
+
"venv",
|
|
38
|
+
"dist",
|
|
39
|
+
"build",
|
|
40
|
+
".velune",
|
|
41
|
+
".mypy_cache",
|
|
42
|
+
".ruff_cache",
|
|
43
|
+
".pytest_cache",
|
|
44
|
+
"*.egg-info",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_CODE_EXTENSIONS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
".py",
|
|
51
|
+
".js",
|
|
52
|
+
".ts",
|
|
53
|
+
".jsx",
|
|
54
|
+
".tsx",
|
|
55
|
+
".go",
|
|
56
|
+
".rs",
|
|
57
|
+
".java",
|
|
58
|
+
".c",
|
|
59
|
+
".cpp",
|
|
60
|
+
".h",
|
|
61
|
+
".cs",
|
|
62
|
+
".php",
|
|
63
|
+
".rb",
|
|
64
|
+
".swift",
|
|
65
|
+
".kt",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class IndexDelta:
|
|
72
|
+
"""Describes which files need to be added, re-parsed, or removed."""
|
|
73
|
+
|
|
74
|
+
to_add: list[str] = field(default_factory=list) # new files not in stored state
|
|
75
|
+
to_update: list[str] = field(default_factory=list) # files whose hash changed
|
|
76
|
+
to_remove: list[str] = field(default_factory=list) # files deleted from disk
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_empty(self) -> bool:
|
|
80
|
+
return not self.to_add and not self.to_update and not self.to_remove
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def total(self) -> int:
|
|
84
|
+
return len(self.to_add) + len(self.to_update) + len(self.to_remove)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class IncrementalIndexer:
|
|
88
|
+
"""Computes and applies file-level deltas to keep ``IndexState`` current."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, workspace_root: Path, state_path: Path) -> None:
|
|
91
|
+
self.workspace_root = workspace_root.resolve()
|
|
92
|
+
self.state_path = state_path
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Public async API
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async def compute_delta(self) -> IndexDelta:
|
|
99
|
+
"""Compare disk state to the stored ``IndexState`` and return the diff.
|
|
100
|
+
|
|
101
|
+
The git HEAD SHA is checked first. If it matches the stored SHA *and*
|
|
102
|
+
the working tree has no uncommitted changes, an empty delta is returned
|
|
103
|
+
immediately — no file reads required.
|
|
104
|
+
"""
|
|
105
|
+
state = IndexState.load(self.state_path)
|
|
106
|
+
git_sha = await asyncio.to_thread(self._get_git_sha)
|
|
107
|
+
|
|
108
|
+
# --- Fast path ---
|
|
109
|
+
if (
|
|
110
|
+
state is not None
|
|
111
|
+
and state.last_commit_sha is not None
|
|
112
|
+
and state.last_commit_sha == git_sha
|
|
113
|
+
):
|
|
114
|
+
clean = await asyncio.to_thread(self._working_tree_is_clean)
|
|
115
|
+
if clean:
|
|
116
|
+
logger.debug("Fast path: git SHA matches and working tree is clean.")
|
|
117
|
+
return IndexDelta()
|
|
118
|
+
|
|
119
|
+
# --- Slow path: walk files and compare hashes ---
|
|
120
|
+
return await asyncio.to_thread(self._compute_file_delta, state)
|
|
121
|
+
|
|
122
|
+
async def apply_delta(self, delta: IndexDelta) -> IndexState:
|
|
123
|
+
"""Parse only the files in *delta*, update stored ``IndexState``, save to disk.
|
|
124
|
+
|
|
125
|
+
Files in ``delta.to_remove`` are dropped from the state.
|
|
126
|
+
Files in ``delta.to_add`` and ``delta.to_update`` are hashed and parsed.
|
|
127
|
+
"""
|
|
128
|
+
state = IndexState.load(self.state_path) or IndexState.empty(str(self.workspace_root))
|
|
129
|
+
|
|
130
|
+
# Remove deleted files
|
|
131
|
+
for rel_path in delta.to_remove:
|
|
132
|
+
state.remove_file(rel_path)
|
|
133
|
+
logger.debug("Removed from index: %s", rel_path)
|
|
134
|
+
|
|
135
|
+
# Parse added and modified files
|
|
136
|
+
now = time.time()
|
|
137
|
+
for rel_path in delta.to_add + delta.to_update:
|
|
138
|
+
full_path = self.workspace_root / rel_path
|
|
139
|
+
if not full_path.exists():
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
content = full_path.read_text(encoding="utf-8", errors="ignore")
|
|
143
|
+
sha = self._hash_file(full_path)
|
|
144
|
+
symbols, language = await asyncio.to_thread(self._parse_file, full_path, content)
|
|
145
|
+
state.update_file(
|
|
146
|
+
IndexedFile(
|
|
147
|
+
path=rel_path,
|
|
148
|
+
content_hash=sha,
|
|
149
|
+
language=language,
|
|
150
|
+
symbol_count=len(symbols),
|
|
151
|
+
indexed_at=now,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
logger.debug("Indexed: %s (%d symbols)", rel_path, len(symbols))
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
logger.debug("Skipped %s during apply_delta: %s", rel_path, exc)
|
|
157
|
+
|
|
158
|
+
# Update metadata and persist
|
|
159
|
+
git_sha = await asyncio.to_thread(self._get_git_sha)
|
|
160
|
+
state.touch(git_sha)
|
|
161
|
+
state.workspace_root = str(self.workspace_root)
|
|
162
|
+
state.save(self.state_path)
|
|
163
|
+
return state
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Synchronous helpers (run in thread pool)
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _get_git_sha(self) -> str | None:
|
|
170
|
+
"""Return the current git HEAD SHA, or None if git is unavailable."""
|
|
171
|
+
try:
|
|
172
|
+
result = subprocess.run(
|
|
173
|
+
["git", "rev-parse", "HEAD"],
|
|
174
|
+
capture_output=True,
|
|
175
|
+
text=True,
|
|
176
|
+
cwd=str(self.workspace_root),
|
|
177
|
+
timeout=5,
|
|
178
|
+
)
|
|
179
|
+
if result.returncode == 0:
|
|
180
|
+
return result.stdout.strip()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def _working_tree_is_clean(self) -> bool:
|
|
186
|
+
"""Return True when there are no staged or unstaged modifications."""
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
["git", "diff", "HEAD", "--name-only"],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
cwd=str(self.workspace_root),
|
|
193
|
+
timeout=5,
|
|
194
|
+
)
|
|
195
|
+
if result.returncode == 0:
|
|
196
|
+
return result.stdout.strip() == ""
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
# If git is unavailable, assume dirty so we do the file scan
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _compute_file_delta(self, state: IndexState | None) -> IndexDelta:
|
|
203
|
+
"""Walk workspace files, compare hashes, and build the delta."""
|
|
204
|
+
stored = state.file_index if state else {}
|
|
205
|
+
|
|
206
|
+
# Discover current source files (reuse scanner for .veluneignore support)
|
|
207
|
+
try:
|
|
208
|
+
from velune.repository.scanner import FilesystemScanner
|
|
209
|
+
|
|
210
|
+
scanner = FilesystemScanner(self.workspace_root)
|
|
211
|
+
current_paths = scanner.scan_code_files()
|
|
212
|
+
except Exception:
|
|
213
|
+
current_paths = list(self._fallback_scan())
|
|
214
|
+
|
|
215
|
+
current: dict[str, Path] = {}
|
|
216
|
+
for p in current_paths:
|
|
217
|
+
try:
|
|
218
|
+
rel = str(p.relative_to(self.workspace_root)).replace("\\", "/")
|
|
219
|
+
current[rel] = p
|
|
220
|
+
except ValueError:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
to_add: list[str] = []
|
|
224
|
+
to_update: list[str] = []
|
|
225
|
+
|
|
226
|
+
for rel_path, abs_path in current.items():
|
|
227
|
+
try:
|
|
228
|
+
sha = self._hash_file(abs_path)
|
|
229
|
+
except Exception:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
stored_entry = stored.get(rel_path)
|
|
233
|
+
if stored_entry is None:
|
|
234
|
+
to_add.append(rel_path)
|
|
235
|
+
elif stored_entry.content_hash != sha:
|
|
236
|
+
to_update.append(rel_path)
|
|
237
|
+
|
|
238
|
+
to_remove = [p for p in stored if p not in current]
|
|
239
|
+
|
|
240
|
+
return IndexDelta(to_add=to_add, to_update=to_update, to_remove=to_remove)
|
|
241
|
+
|
|
242
|
+
def _fallback_scan(self) -> list[Path]:
|
|
243
|
+
"""Minimal walk used when FilesystemScanner is unavailable."""
|
|
244
|
+
results: list[Path] = []
|
|
245
|
+
for path in self.workspace_root.rglob("*"):
|
|
246
|
+
if any(part in _ALWAYS_SKIP for part in path.parts):
|
|
247
|
+
continue
|
|
248
|
+
if path.is_file() and path.suffix.lower() in _CODE_EXTENSIONS:
|
|
249
|
+
results.append(path)
|
|
250
|
+
return results
|
|
251
|
+
|
|
252
|
+
def _parse_file(self, full_path: Path, content: str) -> tuple[list, str]:
|
|
253
|
+
"""Parse *full_path* and return (symbols, language_str)."""
|
|
254
|
+
try:
|
|
255
|
+
from velune.repository.parser import ASTParser
|
|
256
|
+
|
|
257
|
+
parser = ASTParser()
|
|
258
|
+
symbols, _ = parser.parse(full_path, content)
|
|
259
|
+
lang = parser._detect_language(full_path)
|
|
260
|
+
return symbols, lang.value
|
|
261
|
+
except Exception:
|
|
262
|
+
return [], "unknown"
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _hash_file(path: Path) -> str:
|
|
266
|
+
"""SHA-256 of a file's raw bytes, read in 8-KiB chunks."""
|
|
267
|
+
sha = hashlib.sha256()
|
|
268
|
+
with open(path, "rb") as f:
|
|
269
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
270
|
+
sha.update(chunk)
|
|
271
|
+
return sha.hexdigest()
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _hash_content(data: bytes) -> str:
|
|
275
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Persistent index state for incremental repository indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("velune.repository.index_state")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class IndexedFile:
|
|
16
|
+
"""Per-file metadata stored between indexing sessions."""
|
|
17
|
+
|
|
18
|
+
path: str # Relative to workspace root (forward-slash)
|
|
19
|
+
content_hash: str # SHA-256 of file contents at index time
|
|
20
|
+
language: str # Detected language (e.g. "python")
|
|
21
|
+
symbol_count: int # Number of parsed symbols (0 if parsing failed)
|
|
22
|
+
indexed_at: float # Unix timestamp when this entry was written
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class IndexState:
|
|
27
|
+
"""Full workspace index state persisted between Velune sessions.
|
|
28
|
+
|
|
29
|
+
Saved as JSON at ``.velune/index_state.json``. The ``last_commit_sha``
|
|
30
|
+
is used for the primary fast-path check: if git HEAD has not moved since
|
|
31
|
+
the last index run, file scanning can be skipped entirely.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
workspace_root: str
|
|
35
|
+
last_commit_sha: str | None # git HEAD SHA at last full index; None if no git
|
|
36
|
+
last_indexed_at: float # Unix timestamp of the last completed index
|
|
37
|
+
file_index: dict[str, IndexedFile] = field(default_factory=dict) # rel_path → IndexedFile
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Persistence helpers
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def save(self, path: Path) -> None:
|
|
44
|
+
"""Serialize this state to *path* as JSON (creates parent dirs as needed)."""
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
data = {
|
|
47
|
+
"workspace_root": self.workspace_root,
|
|
48
|
+
"last_commit_sha": self.last_commit_sha,
|
|
49
|
+
"last_indexed_at": self.last_indexed_at,
|
|
50
|
+
"file_index": {k: asdict(v) for k, v in self.file_index.items()},
|
|
51
|
+
}
|
|
52
|
+
try:
|
|
53
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
54
|
+
json.dump(data, f, indent=2)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.warning("Could not save index state to %s: %s", path, exc)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def load(cls, path: Path) -> IndexState | None:
|
|
60
|
+
"""Deserialize from *path*. Returns ``None`` if the file is missing or corrupt."""
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
with open(path, encoding="utf-8") as f:
|
|
65
|
+
data = json.load(f)
|
|
66
|
+
file_index = {k: IndexedFile(**v) for k, v in data.get("file_index", {}).items()}
|
|
67
|
+
return cls(
|
|
68
|
+
workspace_root=data["workspace_root"],
|
|
69
|
+
last_commit_sha=data.get("last_commit_sha"),
|
|
70
|
+
last_indexed_at=float(data.get("last_indexed_at", 0.0)),
|
|
71
|
+
file_index=file_index,
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.warning("Could not load index state from %s: %s", path, exc)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def empty(cls, workspace_root: str) -> IndexState:
|
|
79
|
+
"""Return a blank state (first-run placeholder)."""
|
|
80
|
+
return cls(
|
|
81
|
+
workspace_root=workspace_root,
|
|
82
|
+
last_commit_sha=None,
|
|
83
|
+
last_indexed_at=0.0,
|
|
84
|
+
file_index={},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def update_file(self, indexed_file: IndexedFile) -> None:
|
|
88
|
+
self.file_index[indexed_file.path] = indexed_file
|
|
89
|
+
|
|
90
|
+
def remove_file(self, path: str) -> None:
|
|
91
|
+
self.file_index.pop(path, None)
|
|
92
|
+
|
|
93
|
+
def touch(self, commit_sha: str | None) -> None:
|
|
94
|
+
"""Mark the state as freshly indexed at the current time."""
|
|
95
|
+
self.last_commit_sha = commit_sha
|
|
96
|
+
self.last_indexed_at = time.time()
|