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 ADDED
@@ -0,0 +1,3 @@
1
+ """CodeGraph — Semantic code intelligence for humans and LLMs."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,10 @@
1
+ """
2
+ analysis.python — Python language adapter package.
3
+
4
+ Canonical import:
5
+ from node_walk.analysis.python import PythonAnalyzer
6
+ """
7
+
8
+ from node_walk.analysis.python.analyzer import PythonAnalyzer
9
+
10
+ __all__ = ["PythonAnalyzer"]
@@ -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)