node-walk 0.1.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.
- node_walk/__init__.py +3 -0
- node_walk/analysis/__init__.py +12 -0
- node_walk/analysis/base.py +205 -0
- node_walk/analysis/python/__init__.py +10 -0
- node_walk/analysis/python/analyzer.py +44 -0
- node_walk/analysis/python/scope.py +44 -0
- node_walk/analysis/python/visitor.py +559 -0
- node_walk/analysis/python_analyzer.py +14 -0
- node_walk/cli/__init__.py +1 -0
- node_walk/cli/main.py +560 -0
- node_walk/indexer.py +188 -0
- node_walk/ir/__init__.py +39 -0
- node_walk/ir/enums.py +61 -0
- node_walk/ir/models.py +100 -0
- node_walk/query/__init__.py +1 -0
- node_walk/query/engine.py +368 -0
- node_walk/storage/__init__.py +13 -0
- node_walk/storage/base.py +79 -0
- node_walk/storage/repository.py +14 -0
- node_walk/storage/schema.py +130 -0
- node_walk/storage/sqlite_store.py +285 -0
- node_walk-0.1.0.dist-info/METADATA +107 -0
- node_walk-0.1.0.dist-info/RECORD +26 -0
- node_walk-0.1.0.dist-info/WHEEL +4 -0
- node_walk-0.1.0.dist-info/entry_points.txt +4 -0
- node_walk-0.1.0.dist-info/licenses/LICENSE +21 -0
node_walk/indexer.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Indexer — orchestrates file discovery, analysis, storage, and cross-file resolution.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from node_walk.indexer import Indexer
|
|
6
|
+
from node_walk.storage.repository import SQLiteGraphStore
|
|
7
|
+
|
|
8
|
+
store = SQLiteGraphStore(".node_walk/graph.db")
|
|
9
|
+
indexer = Indexer(store)
|
|
10
|
+
indexer.index("./my_repo")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from node_walk.analysis.base import FileDiscovery, LanguageAnalyzer
|
|
19
|
+
from node_walk.analysis.python import PythonAnalyzer
|
|
20
|
+
from node_walk.ir.models import AnalysisResult
|
|
21
|
+
from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus
|
|
22
|
+
from node_walk.storage.base import GraphStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Indexer:
|
|
26
|
+
"""
|
|
27
|
+
Orchestrates a full index run:
|
|
28
|
+
1. Discover files
|
|
29
|
+
2. Analyze each file with the appropriate LanguageAnalyzer
|
|
30
|
+
3. Store all results
|
|
31
|
+
4. Run cross-file resolution pass (fix EXTENDS/IMPLEMENTS/CALLS/IMPORTS)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
store: GraphStore,
|
|
37
|
+
analyzers: list[LanguageAnalyzer] | None = None,
|
|
38
|
+
progress_callback: Callable[[str, int, int], None] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._store = store
|
|
41
|
+
self._analyzers: list[LanguageAnalyzer] = analyzers or [PythonAnalyzer()]
|
|
42
|
+
self._progress = progress_callback # (file_path, current, total)
|
|
43
|
+
|
|
44
|
+
# Build language → analyzer map
|
|
45
|
+
self._lang_map: dict[Language, LanguageAnalyzer] = {}
|
|
46
|
+
for a in self._analyzers:
|
|
47
|
+
for lang in a.supported_languages:
|
|
48
|
+
self._lang_map[lang] = a
|
|
49
|
+
|
|
50
|
+
def index(self, root: str | Path, clear: bool = True) -> IndexStats:
|
|
51
|
+
"""
|
|
52
|
+
Index *root* directory.
|
|
53
|
+
|
|
54
|
+
If *clear* is True (default), wipes the existing graph first
|
|
55
|
+
(full re-index). Set to False for incremental updates (future).
|
|
56
|
+
"""
|
|
57
|
+
root = Path(root).resolve()
|
|
58
|
+
|
|
59
|
+
if clear:
|
|
60
|
+
self._store.clear()
|
|
61
|
+
|
|
62
|
+
# --- 1. Discover files ---
|
|
63
|
+
discovery = FileDiscovery(root, include_languages=list(self._lang_map.keys()))
|
|
64
|
+
file_pairs = discovery.discover()
|
|
65
|
+
total = len(file_pairs)
|
|
66
|
+
|
|
67
|
+
# --- 2. Analyze ---
|
|
68
|
+
results: list[AnalysisResult] = []
|
|
69
|
+
errors: list[str] = []
|
|
70
|
+
|
|
71
|
+
for i, (file_info, source) in enumerate(file_pairs, start=1):
|
|
72
|
+
if self._progress:
|
|
73
|
+
self._progress(file_info.path, i, total)
|
|
74
|
+
|
|
75
|
+
analyzer = self._lang_map.get(file_info.language)
|
|
76
|
+
if not analyzer:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = analyzer.analyze(file_info, source)
|
|
81
|
+
results.append(result)
|
|
82
|
+
except Exception as exc: # noqa: BLE001
|
|
83
|
+
errors.append(f"{file_info.path}: {exc}")
|
|
84
|
+
|
|
85
|
+
# --- 3. Store ---
|
|
86
|
+
self._store.store_results(results)
|
|
87
|
+
|
|
88
|
+
# --- 4. Cross-file resolution ---
|
|
89
|
+
resolved_count = self._resolve_cross_file(results)
|
|
90
|
+
|
|
91
|
+
symbols_total = sum(len(r.symbols) for r in results)
|
|
92
|
+
rels_total = sum(len(r.relationships) for r in results)
|
|
93
|
+
|
|
94
|
+
return IndexStats(
|
|
95
|
+
files_discovered=total,
|
|
96
|
+
files_analyzed=len(results),
|
|
97
|
+
symbols_extracted=symbols_total,
|
|
98
|
+
relationships_extracted=rels_total,
|
|
99
|
+
relationships_resolved=resolved_count,
|
|
100
|
+
errors=errors,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Cross-file resolution
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _resolve_cross_file(self, results: list[AnalysisResult]) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Fix unresolved relationships by matching target_name metadata
|
|
110
|
+
against all known symbols across the entire indexed corpus.
|
|
111
|
+
|
|
112
|
+
Returns the number of newly resolved relationships.
|
|
113
|
+
"""
|
|
114
|
+
# Build a lookup: simple name → [Symbol], qualified_name → Symbol
|
|
115
|
+
name_to_syms: dict[str, list] = {}
|
|
116
|
+
qname_to_sym: dict[str, object] = {}
|
|
117
|
+
|
|
118
|
+
all_symbols = self._store.get_all_symbols()
|
|
119
|
+
for sym in all_symbols:
|
|
120
|
+
name_to_syms.setdefault(sym.name, []).append(sym)
|
|
121
|
+
qname_to_sym[sym.qualified_name] = sym
|
|
122
|
+
|
|
123
|
+
unresolved = self._store.get_all_unresolved_relationships()
|
|
124
|
+
resolved_count = 0
|
|
125
|
+
|
|
126
|
+
for rel in unresolved:
|
|
127
|
+
target_name: str = rel.metadata.get("target_name", "")
|
|
128
|
+
if not target_name:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Try exact qualified name first
|
|
132
|
+
match = qname_to_sym.get(target_name)
|
|
133
|
+
if match is None:
|
|
134
|
+
# Try suffix: e.g. "UserService" in "myapp.services.UserService"
|
|
135
|
+
suffix_matches = [
|
|
136
|
+
s for s in all_symbols
|
|
137
|
+
if s.qualified_name.endswith(f".{target_name}") or s.name == target_name
|
|
138
|
+
]
|
|
139
|
+
if len(suffix_matches) == 1:
|
|
140
|
+
match = suffix_matches[0]
|
|
141
|
+
elif len(suffix_matches) > 1:
|
|
142
|
+
# Multiple matches — pick the most specific (shortest qualified name)
|
|
143
|
+
match = min(suffix_matches, key=lambda s: len(s.qualified_name))
|
|
144
|
+
|
|
145
|
+
if match:
|
|
146
|
+
resolution = (
|
|
147
|
+
ResolutionStatus.RESOLVED
|
|
148
|
+
if qname_to_sym.get(target_name) == match
|
|
149
|
+
else ResolutionStatus.PROBABLE
|
|
150
|
+
)
|
|
151
|
+
self._store.update_relationship(rel.id, match.id, resolution) # type: ignore[arg-type]
|
|
152
|
+
resolved_count += 1
|
|
153
|
+
|
|
154
|
+
return resolved_count
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Stats dataclass
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class IndexStats:
|
|
163
|
+
"""Summary statistics from an index run."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
files_discovered: int,
|
|
168
|
+
files_analyzed: int,
|
|
169
|
+
symbols_extracted: int,
|
|
170
|
+
relationships_extracted: int,
|
|
171
|
+
relationships_resolved: int,
|
|
172
|
+
errors: list[str],
|
|
173
|
+
) -> None:
|
|
174
|
+
self.files_discovered = files_discovered
|
|
175
|
+
self.files_analyzed = files_analyzed
|
|
176
|
+
self.symbols_extracted = symbols_extracted
|
|
177
|
+
self.relationships_extracted = relationships_extracted
|
|
178
|
+
self.relationships_resolved = relationships_resolved
|
|
179
|
+
self.errors = errors
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
return (
|
|
183
|
+
f"IndexStats(files={self.files_analyzed}/{self.files_discovered}, "
|
|
184
|
+
f"symbols={self.symbols_extracted}, "
|
|
185
|
+
f"relationships={self.relationships_extracted}, "
|
|
186
|
+
f"resolved={self.relationships_resolved}, "
|
|
187
|
+
f"errors={len(self.errors)})"
|
|
188
|
+
)
|
node_walk/ir/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node_walk.ir — Code Intermediate Representation.
|
|
3
|
+
|
|
4
|
+
Re-exports everything from enums and models so consumers can do:
|
|
5
|
+
|
|
6
|
+
from node_walk.ir import Symbol, SymbolKind, Relationship
|
|
7
|
+
# or
|
|
8
|
+
from node_walk.ir.models import Symbol
|
|
9
|
+
# or
|
|
10
|
+
from node_walk.ir.enums import SymbolKind
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from node_walk.ir.enums import (
|
|
14
|
+
Language,
|
|
15
|
+
RelationshipType,
|
|
16
|
+
ResolutionStatus,
|
|
17
|
+
SymbolKind,
|
|
18
|
+
)
|
|
19
|
+
from node_walk.ir.models import (
|
|
20
|
+
AnalysisResult,
|
|
21
|
+
FileInfo,
|
|
22
|
+
Relationship,
|
|
23
|
+
SourceLocation,
|
|
24
|
+
Symbol,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# enums
|
|
29
|
+
"Language",
|
|
30
|
+
"RelationshipType",
|
|
31
|
+
"ResolutionStatus",
|
|
32
|
+
"SymbolKind",
|
|
33
|
+
# models
|
|
34
|
+
"AnalysisResult",
|
|
35
|
+
"FileInfo",
|
|
36
|
+
"Relationship",
|
|
37
|
+
"SourceLocation",
|
|
38
|
+
"Symbol",
|
|
39
|
+
]
|
node_walk/ir/enums.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code IR enums — all enumerated types used across the graph model.
|
|
3
|
+
|
|
4
|
+
Kept separate from data models so enum values can be imported
|
|
5
|
+
without pulling in Pydantic (e.g. in lightweight scripts or tests).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SymbolKind(StrEnum):
|
|
14
|
+
"""Canonical kinds of code symbols across all supported languages."""
|
|
15
|
+
|
|
16
|
+
FILE = "FILE"
|
|
17
|
+
MODULE = "MODULE"
|
|
18
|
+
PACKAGE = "PACKAGE"
|
|
19
|
+
CLASS = "CLASS"
|
|
20
|
+
INTERFACE = "INTERFACE" # ABCs and typing.Protocol in Python
|
|
21
|
+
FUNCTION = "FUNCTION"
|
|
22
|
+
METHOD = "METHOD"
|
|
23
|
+
VARIABLE = "VARIABLE"
|
|
24
|
+
CONSTANT = "CONSTANT"
|
|
25
|
+
FIELD = "FIELD" # class-level attribute / instance variable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RelationshipType(StrEnum):
|
|
29
|
+
"""Canonical relationship types between symbols."""
|
|
30
|
+
|
|
31
|
+
# Structural
|
|
32
|
+
CONTAINS = "CONTAINS" # parent → child (module → class, class → method, …)
|
|
33
|
+
|
|
34
|
+
# Dependencies
|
|
35
|
+
IMPORTS = "IMPORTS" # file/module → imported symbol or module
|
|
36
|
+
|
|
37
|
+
# Call graph
|
|
38
|
+
CALLS = "CALLS" # caller → callee
|
|
39
|
+
|
|
40
|
+
# References
|
|
41
|
+
REFERENCES = "REFERENCES" # any name usage that isn't a call
|
|
42
|
+
|
|
43
|
+
# Inheritance / interface
|
|
44
|
+
EXTENDS = "EXTENDS" # subclass → base class
|
|
45
|
+
IMPLEMENTS = "IMPLEMENTS" # concrete class → ABC / Protocol
|
|
46
|
+
OVERRIDES = "OVERRIDES" # overriding method → overridden method
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Language(StrEnum):
|
|
50
|
+
"""Supported programming languages."""
|
|
51
|
+
|
|
52
|
+
PYTHON = "python"
|
|
53
|
+
UNKNOWN = "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResolutionStatus(StrEnum):
|
|
57
|
+
"""How confidently a relationship was resolved."""
|
|
58
|
+
|
|
59
|
+
RESOLVED = "resolved" # statically certain
|
|
60
|
+
PROBABLE = "probable" # high-confidence heuristic
|
|
61
|
+
UNRESOLVED = "unresolved" # could not determine target
|
node_walk/ir/models.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code IR data models — Pydantic v2 models for the semantic graph.
|
|
3
|
+
|
|
4
|
+
All models are immutable (frozen=True). Language analyzers produce
|
|
5
|
+
AnalysisResult objects; the storage and query layers consume them.
|
|
6
|
+
|
|
7
|
+
Enums live in node_walk.ir.enums — import from there if you only
|
|
8
|
+
need enum values without the Pydantic models.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus, SymbolKind
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileInfo(BaseModel):
|
|
22
|
+
"""Represents a source file discovered during indexing."""
|
|
23
|
+
|
|
24
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
25
|
+
path: str # absolute path on disk
|
|
26
|
+
language: Language = Language.UNKNOWN
|
|
27
|
+
content_hash: str = "" # SHA-256 hex; empty until computed
|
|
28
|
+
size_bytes: int = 0
|
|
29
|
+
|
|
30
|
+
model_config = {"frozen": True}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Symbol(BaseModel):
|
|
34
|
+
"""
|
|
35
|
+
A named code entity (class, function, method, variable, …).
|
|
36
|
+
|
|
37
|
+
Every symbol belongs to a file and may have a parent symbol
|
|
38
|
+
(e.g., a method inside a class).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
42
|
+
name: str # simple name: "createUser"
|
|
43
|
+
qualified_name: str # fully-qualified: "pkg.module.Class.method"
|
|
44
|
+
kind: SymbolKind
|
|
45
|
+
language: Language = Language.PYTHON
|
|
46
|
+
file_id: str # FK → FileInfo.id
|
|
47
|
+
start_line: int # 1-indexed
|
|
48
|
+
end_line: int # 1-indexed, inclusive
|
|
49
|
+
signature: str = "" # e.g. "(self, user: User) -> None"
|
|
50
|
+
parent_id: str | None = None # FK → Symbol.id of enclosing scope
|
|
51
|
+
docstring: str = "" # first docstring if present
|
|
52
|
+
is_async: bool = False
|
|
53
|
+
|
|
54
|
+
model_config = {"frozen": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SourceLocation(BaseModel):
|
|
58
|
+
"""A precise location in source code (for relationship call sites)."""
|
|
59
|
+
|
|
60
|
+
file_id: str
|
|
61
|
+
line: int # 1-indexed
|
|
62
|
+
col: int = 0 # 0-indexed column
|
|
63
|
+
|
|
64
|
+
model_config = {"frozen": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Relationship(BaseModel):
|
|
68
|
+
"""
|
|
69
|
+
A directed edge between two symbols in the semantic graph.
|
|
70
|
+
|
|
71
|
+
source → [type] → target
|
|
72
|
+
|
|
73
|
+
The ``resolution`` field captures how confident the analyzer was
|
|
74
|
+
when resolving the target symbol. UNRESOLVED relationships are
|
|
75
|
+
stored with target_id = "" so callers can filter them out.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
79
|
+
source_id: str # FK → Symbol.id
|
|
80
|
+
target_id: str # FK → Symbol.id; "" if unresolved
|
|
81
|
+
type: RelationshipType
|
|
82
|
+
source_location: SourceLocation | None = None # call-site / import-site
|
|
83
|
+
resolution: ResolutionStatus = ResolutionStatus.RESOLVED
|
|
84
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
model_config = {"frozen": True}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AnalysisResult(BaseModel):
|
|
90
|
+
"""
|
|
91
|
+
Complete output from analyzing a single file.
|
|
92
|
+
|
|
93
|
+
Produced by a LanguageAnalyzer and consumed by the storage layer.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
file: FileInfo
|
|
97
|
+
symbols: list[Symbol] = Field(default_factory=list)
|
|
98
|
+
relationships: list[Relationship] = Field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
model_config = {"frozen": True}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Query engine — semantic graph navigation."""
|