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/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node_walk.analysis — language analysis layer.
|
|
3
|
+
|
|
4
|
+
Canonical imports:
|
|
5
|
+
from node_walk.analysis.base import LanguageAnalyzer, FileDiscovery
|
|
6
|
+
from node_walk.analysis.python import PythonAnalyzer
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from node_walk.analysis.base import FileDiscovery, LanguageAnalyzer
|
|
10
|
+
from node_walk.analysis.python import PythonAnalyzer
|
|
11
|
+
|
|
12
|
+
__all__ = ["FileDiscovery", "LanguageAnalyzer", "PythonAnalyzer"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Language analysis base — adapter interface and file discovery.
|
|
3
|
+
|
|
4
|
+
All language-specific analyzers must implement LanguageAnalyzer.
|
|
5
|
+
FileDiscovery handles walking a repository and routing files to
|
|
6
|
+
the correct analyzer based on extension / content sniffing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from node_walk.ir.models import AnalysisResult, FileInfo, Language
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# File extension → language mapping
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
_EXTENSION_MAP: dict[str, Language] = {
|
|
23
|
+
".py": Language.PYTHON,
|
|
24
|
+
".pyi": Language.PYTHON,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Directories that are never useful to index
|
|
28
|
+
_SKIP_DIRS: frozenset[str] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
".git",
|
|
31
|
+
".hg",
|
|
32
|
+
".svn",
|
|
33
|
+
"__pycache__",
|
|
34
|
+
".mypy_cache",
|
|
35
|
+
".pytest_cache",
|
|
36
|
+
".ruff_cache",
|
|
37
|
+
".tox",
|
|
38
|
+
"node_modules",
|
|
39
|
+
".venv",
|
|
40
|
+
"venv",
|
|
41
|
+
"env",
|
|
42
|
+
".env",
|
|
43
|
+
"dist",
|
|
44
|
+
"build",
|
|
45
|
+
".node_walk",
|
|
46
|
+
".eggs",
|
|
47
|
+
"*.egg-info",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Files that are never useful to index
|
|
52
|
+
_SKIP_FILE_PATTERNS: frozenset[str] = frozenset(
|
|
53
|
+
{
|
|
54
|
+
".DS_Store",
|
|
55
|
+
"Thumbs.db",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _should_skip_dir(name: str) -> bool:
|
|
61
|
+
return name in _SKIP_DIRS or name.endswith(".egg-info")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _compute_hash(path: Path) -> str:
|
|
65
|
+
"""Return SHA-256 hex digest of a file's contents."""
|
|
66
|
+
h = hashlib.sha256()
|
|
67
|
+
try:
|
|
68
|
+
with path.open("rb") as f:
|
|
69
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
70
|
+
h.update(chunk)
|
|
71
|
+
except OSError:
|
|
72
|
+
return ""
|
|
73
|
+
return h.hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Language analyzer interface
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LanguageAnalyzer(ABC):
|
|
82
|
+
"""
|
|
83
|
+
Abstract base for language-specific analyzers.
|
|
84
|
+
|
|
85
|
+
Each implementation parses source files for one language and
|
|
86
|
+
returns an AnalysisResult containing the discovered symbols and
|
|
87
|
+
relationships (expressed as Code IR objects).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def supported_languages(self) -> list[Language]:
|
|
93
|
+
"""Languages this analyzer handles."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def analyze(self, file_info: FileInfo, source: str) -> AnalysisResult:
|
|
98
|
+
"""
|
|
99
|
+
Parse *source* (the full text of *file_info.path*) and return
|
|
100
|
+
an AnalysisResult with all extracted symbols and relationships.
|
|
101
|
+
|
|
102
|
+
The analyzer must NOT perform I/O; the caller reads the file and
|
|
103
|
+
passes the text in. This makes analyzers trivially testable.
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# File discovery
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FileDiscovery:
|
|
114
|
+
"""
|
|
115
|
+
Walks a repository root, detects file languages, and produces FileInfo
|
|
116
|
+
objects ready for analysis.
|
|
117
|
+
|
|
118
|
+
Does not perform analysis itself — that is delegated to analyzers.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, root: str | Path, include_languages: list[Language] | None = None):
|
|
122
|
+
self.root = Path(root).resolve()
|
|
123
|
+
self._include = set(include_languages) if include_languages else None
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Public API
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def discover(self) -> list[tuple[FileInfo, str]]:
|
|
130
|
+
"""
|
|
131
|
+
Walk the repository and return a list of (FileInfo, source_text)
|
|
132
|
+
tuples for every file that should be analyzed.
|
|
133
|
+
|
|
134
|
+
Skips binary files and directories in the skip list.
|
|
135
|
+
Returns files in a deterministic order (sorted by path).
|
|
136
|
+
"""
|
|
137
|
+
results: list[tuple[FileInfo, str]] = []
|
|
138
|
+
|
|
139
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
140
|
+
# Prune directories in-place so os.walk skips them
|
|
141
|
+
dirnames[:] = sorted(
|
|
142
|
+
d for d in dirnames if not _should_skip_dir(d)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for filename in sorted(filenames):
|
|
146
|
+
if filename in _SKIP_FILE_PATTERNS:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
full_path = Path(dirpath) / filename
|
|
150
|
+
language = self._detect_language(full_path)
|
|
151
|
+
|
|
152
|
+
if language == Language.UNKNOWN:
|
|
153
|
+
continue
|
|
154
|
+
if self._include and language not in self._include:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
source = self._read_safe(full_path)
|
|
158
|
+
if source is None:
|
|
159
|
+
continue # binary or unreadable
|
|
160
|
+
|
|
161
|
+
rel_path = str(full_path.relative_to(self.root))
|
|
162
|
+
file_info = FileInfo(
|
|
163
|
+
path=str(full_path),
|
|
164
|
+
language=language,
|
|
165
|
+
content_hash=_compute_hash(full_path),
|
|
166
|
+
size_bytes=full_path.stat().st_size,
|
|
167
|
+
)
|
|
168
|
+
results.append((file_info, source))
|
|
169
|
+
|
|
170
|
+
return results
|
|
171
|
+
|
|
172
|
+
def discover_file(self, path: str | Path) -> tuple[FileInfo, str] | None:
|
|
173
|
+
"""Discover a single file. Returns None if unsupported or unreadable."""
|
|
174
|
+
full_path = Path(path).resolve()
|
|
175
|
+
language = self._detect_language(full_path)
|
|
176
|
+
if language == Language.UNKNOWN:
|
|
177
|
+
return None
|
|
178
|
+
source = self._read_safe(full_path)
|
|
179
|
+
if source is None:
|
|
180
|
+
return None
|
|
181
|
+
file_info = FileInfo(
|
|
182
|
+
path=str(full_path),
|
|
183
|
+
language=language,
|
|
184
|
+
content_hash=_compute_hash(full_path),
|
|
185
|
+
size_bytes=full_path.stat().st_size,
|
|
186
|
+
)
|
|
187
|
+
return file_info, source
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Helpers
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _detect_language(path: Path) -> Language:
|
|
195
|
+
suffix = path.suffix.lower()
|
|
196
|
+
return _EXTENSION_MAP.get(suffix, Language.UNKNOWN)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _read_safe(path: Path) -> str | None:
|
|
200
|
+
"""Read a text file. Returns None if it appears binary or cannot be read."""
|
|
201
|
+
try:
|
|
202
|
+
text = path.read_text(encoding="utf-8", errors="strict")
|
|
203
|
+
return text
|
|
204
|
+
except (UnicodeDecodeError, OSError):
|
|
205
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PythonAnalyzer — thin adapter that wires Tree-sitter parsing to the visitor.
|
|
3
|
+
|
|
4
|
+
This module intentionally contains very little logic. Its job is to:
|
|
5
|
+
1. Parse source text into a Tree-sitter syntax tree.
|
|
6
|
+
2. Hand the tree to SymbolCollector.
|
|
7
|
+
3. Return the collected AnalysisResult.
|
|
8
|
+
|
|
9
|
+
All symbol extraction and relationship logic lives in visitor.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from node_walk.analysis.base import LanguageAnalyzer
|
|
15
|
+
from node_walk.analysis.python.visitor import SymbolCollector, _PARSER
|
|
16
|
+
from node_walk.ir.enums import Language
|
|
17
|
+
from node_walk.ir.models import AnalysisResult, FileInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PythonAnalyzer(LanguageAnalyzer):
|
|
21
|
+
"""
|
|
22
|
+
Tree-sitter-based Python language adapter.
|
|
23
|
+
|
|
24
|
+
Thread-safe: the parser is a module-level singleton and parse()
|
|
25
|
+
returns a new tree each call.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def supported_languages(self) -> list[Language]:
|
|
30
|
+
return [Language.PYTHON]
|
|
31
|
+
|
|
32
|
+
def analyze(self, file_info: FileInfo, source: str) -> AnalysisResult:
|
|
33
|
+
"""Parse *source* and return all extracted symbols and relationships."""
|
|
34
|
+
source_bytes = source.encode("utf-8")
|
|
35
|
+
tree = _PARSER.parse(source_bytes)
|
|
36
|
+
|
|
37
|
+
collector = SymbolCollector(file_info, source_bytes)
|
|
38
|
+
collector.visit(tree.root_node)
|
|
39
|
+
|
|
40
|
+
return AnalysisResult(
|
|
41
|
+
file=file_info,
|
|
42
|
+
symbols=collector.symbols,
|
|
43
|
+
relationships=collector.relationships,
|
|
44
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scope stack — tracks symbol nesting during AST traversal.
|
|
3
|
+
|
|
4
|
+
Kept separate from the visitor so the visitor module stays focused
|
|
5
|
+
on AST logic, not bookkeeping.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from node_walk.ir.models import Symbol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Scope:
|
|
14
|
+
"""
|
|
15
|
+
Tracks the current nesting of symbols during AST traversal.
|
|
16
|
+
|
|
17
|
+
The scope stack mirrors the Python scope rules: module → class →
|
|
18
|
+
method/function → nested function. Push on entry, pop on exit.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._stack: list[Symbol] = []
|
|
23
|
+
|
|
24
|
+
def push(self, sym: Symbol) -> None:
|
|
25
|
+
self._stack.append(sym)
|
|
26
|
+
|
|
27
|
+
def pop(self) -> Symbol | None:
|
|
28
|
+
return self._stack.pop() if self._stack else None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def current(self) -> Symbol | None:
|
|
32
|
+
"""The innermost enclosing symbol, or None at module top-level."""
|
|
33
|
+
return self._stack[-1] if self._stack else None
|
|
34
|
+
|
|
35
|
+
def qualified_prefix(self, module_qname: str) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Build the dotted qualified-name prefix for a new symbol.
|
|
38
|
+
|
|
39
|
+
Combines the module qualified name with all symbols currently
|
|
40
|
+
on the stack, e.g. "myapp.services.UserService".
|
|
41
|
+
"""
|
|
42
|
+
parts = [module_qname] if module_qname else []
|
|
43
|
+
parts.extend(s.name for s in self._stack)
|
|
44
|
+
return ".".join(parts)
|