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/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
+ )
@@ -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."""