graphlens-typescript 0.2.2__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.
@@ -0,0 +1,5 @@
1
+ """graphlens_typescript — TypeScript language adapter for graphlens."""
2
+
3
+ from graphlens_typescript._adapter import TypescriptAdapter
4
+
5
+ __all__ = ["TypescriptAdapter"]
@@ -0,0 +1,304 @@
1
+ """TypescriptAdapter — orchestrates TypeScript project analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from graphlens import (
9
+ GraphLens,
10
+ LanguageAdapter,
11
+ Node,
12
+ NodeKind,
13
+ Relation,
14
+ RelationKind,
15
+ )
16
+ from graphlens.utils import make_node_id
17
+
18
+ from graphlens_typescript._deps import (
19
+ TYPESCRIPT_DEFAULT_DEP_PARSERS,
20
+ get_stdlib_names,
21
+ )
22
+ from graphlens_typescript._module_resolver import (
23
+ file_to_qualified_name,
24
+ find_source_roots,
25
+ )
26
+ from graphlens_typescript._project_detector import (
27
+ detect_project_name,
28
+ find_typescript_roots,
29
+ is_typescript_project,
30
+ )
31
+ from graphlens_typescript._visitor import (
32
+ ImportClassifier,
33
+ TypescriptASTVisitor,
34
+ VisitorContext,
35
+ parse_typescript,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from pathlib import Path
40
+
41
+ from graphlens.contracts import DependencyFileParser
42
+
43
+ logger = logging.getLogger("graphlens_typescript")
44
+
45
+ _STDLIB = get_stdlib_names()
46
+
47
+ # Declaration files contain only type information — skip them during analysis
48
+ _DECLARATION_SUFFIXES: tuple[str, ...] = (".d.ts", ".d.mts", ".d.cts")
49
+
50
+
51
+ class TypescriptAdapter(LanguageAdapter):
52
+ """Language adapter for TypeScript projects."""
53
+
54
+ def __init__(
55
+ self,
56
+ dep_parsers: list[DependencyFileParser] | None = None,
57
+ ) -> None:
58
+ """
59
+ Initialize the TypeScript adapter.
60
+
61
+ Args:
62
+ dep_parsers: parsers used to extract third-party dependency
63
+ names from manifest files. Pass a custom list to support
64
+ non-standard package managers.
65
+ Defaults to ``TYPESCRIPT_DEFAULT_DEP_PARSERS``.
66
+
67
+ """
68
+ self._dep_parsers = (
69
+ dep_parsers
70
+ if dep_parsers is not None
71
+ else TYPESCRIPT_DEFAULT_DEP_PARSERS
72
+ )
73
+
74
+ def language(self) -> str:
75
+ return "typescript"
76
+
77
+ def file_extensions(self) -> set[str]:
78
+ return {".ts", ".tsx", ".mts", ".cts"}
79
+
80
+ def can_handle(self, project_root: Path) -> bool:
81
+ return is_typescript_project(project_root)
82
+
83
+ def collect_files(self, project_root: Path) -> list[Path]:
84
+ """
85
+ Collect TypeScript source files, excluding declaration files.
86
+
87
+ Declaration files (``.d.ts``, ``.d.mts``, ``.d.cts``) contain only
88
+ type information and no implementation — they are skipped.
89
+ """
90
+ files = super().collect_files(project_root)
91
+ return [
92
+ f for f in files
93
+ if not any(str(f).endswith(suf) for suf in _DECLARATION_SUFFIXES)
94
+ ]
95
+
96
+ def analyze(
97
+ self,
98
+ project_root: Path,
99
+ files: list[Path] | None = None,
100
+ ) -> GraphLens:
101
+ graph = GraphLens()
102
+
103
+ if files is not None:
104
+ _analyze_root(
105
+ graph,
106
+ project_root,
107
+ project_root,
108
+ files,
109
+ self._dep_parsers,
110
+ )
111
+ else:
112
+ for lang_root in find_typescript_roots(project_root):
113
+ root_files = self.collect_files(lang_root)
114
+ _analyze_root(
115
+ graph,
116
+ project_root,
117
+ lang_root,
118
+ root_files,
119
+ self._dep_parsers,
120
+ )
121
+
122
+ return graph
123
+
124
+
125
+ def _analyze_root(
126
+ graph: GraphLens,
127
+ project_root: Path,
128
+ lang_root: Path,
129
+ files: list[Path],
130
+ dep_parsers: list[DependencyFileParser],
131
+ ) -> None:
132
+ """Analyze one TypeScript project root and populate graph in-place."""
133
+ project_name = detect_project_name(lang_root)
134
+ source_roots = find_source_roots(lang_root, files)
135
+
136
+ # Pre-pass: collect internal top-level names from file paths (no parsing)
137
+ internal_tops: set[str] = set()
138
+ for f in files:
139
+ sr = _find_source_root_for(f, source_roots) or source_roots[0]
140
+ try:
141
+ qname = file_to_qualified_name(f, sr)
142
+ internal_tops.add(qname.split(".")[0])
143
+ except ValueError:
144
+ pass
145
+
146
+ # Parse dependency manifests
147
+ third_party: set[str] = set()
148
+ for parser in dep_parsers:
149
+ if parser.can_parse(lang_root):
150
+ third_party.update(parser.parse(lang_root))
151
+
152
+ classifier = ImportClassifier(
153
+ stdlib=_STDLIB,
154
+ third_party=frozenset(third_party),
155
+ internal=frozenset(internal_tops),
156
+ )
157
+
158
+ project_id = make_node_id(
159
+ project_name, project_name, NodeKind.PROJECT.value
160
+ )
161
+ if project_id not in graph.nodes:
162
+ graph.add_node(
163
+ Node(
164
+ id=project_id,
165
+ kind=NodeKind.PROJECT,
166
+ qualified_name=project_name,
167
+ name=project_name,
168
+ )
169
+ )
170
+
171
+ modules: dict[str, str] = {}
172
+
173
+ for file in files:
174
+ source_root = (
175
+ _find_source_root_for(file, source_roots) or source_roots[0]
176
+ )
177
+
178
+ try:
179
+ module_qname = file_to_qualified_name(file, source_root)
180
+ except ValueError:
181
+ logger.warning(
182
+ "Cannot compute qualified name for %s, skipping", file
183
+ )
184
+ continue
185
+
186
+ _ensure_module_chain(graph, project_name, module_qname, modules)
187
+
188
+ try:
189
+ relative_path = str(file.relative_to(project_root))
190
+ except ValueError:
191
+ relative_path = str(file.relative_to(lang_root))
192
+
193
+ file_id = make_node_id(
194
+ project_name, relative_path, NodeKind.FILE.value
195
+ )
196
+ if file_id not in graph.nodes:
197
+ graph.add_node(
198
+ Node(
199
+ id=file_id,
200
+ kind=NodeKind.FILE,
201
+ qualified_name=relative_path,
202
+ name=file.name,
203
+ file_path=relative_path,
204
+ )
205
+ )
206
+ leaf_module_id = modules[module_qname]
207
+ graph.add_relation(
208
+ Relation(
209
+ source_id=leaf_module_id,
210
+ target_id=file_id,
211
+ kind=RelationKind.CONTAINS,
212
+ )
213
+ )
214
+
215
+ try:
216
+ source_bytes = file.read_bytes()
217
+ except OSError as e:
218
+ logger.warning("Cannot read %s: %s — skipping", file, e)
219
+ continue
220
+
221
+ is_tsx = file.suffix.lower() == ".tsx"
222
+ tree = parse_typescript(source_bytes, tsx=is_tsx)
223
+ if tree.root_node.has_error:
224
+ logger.warning(
225
+ "Parse errors in %s — continuing with partial results",
226
+ file,
227
+ )
228
+
229
+ ctx = VisitorContext(
230
+ project_name=project_name,
231
+ file_path=file,
232
+ file_relative_path=relative_path,
233
+ source_root=source_root,
234
+ module_qualified_name=module_qname,
235
+ modules=modules,
236
+ )
237
+ visitor = TypescriptASTVisitor(
238
+ ctx, graph, file_id, source_bytes, classifier
239
+ )
240
+ visitor.visit(tree.root_node)
241
+
242
+ # PROJECT --CONTAINS--> top-level modules
243
+ top_level = {qn: mid for qn, mid in modules.items() if "." not in qn}
244
+ for module_id in top_level.values():
245
+ graph.add_relation(
246
+ Relation(
247
+ source_id=project_id,
248
+ target_id=module_id,
249
+ kind=RelationKind.CONTAINS,
250
+ )
251
+ )
252
+
253
+
254
+ def _find_source_root_for(file: Path, source_roots: list[Path]) -> Path | None:
255
+ for root in source_roots:
256
+ try:
257
+ file.relative_to(root)
258
+ return root
259
+ except ValueError:
260
+ continue
261
+ return None
262
+
263
+
264
+ def _ensure_module_chain(
265
+ graph: GraphLens,
266
+ project_name: str,
267
+ module_qname: str,
268
+ modules: dict[str, str],
269
+ ) -> str:
270
+ """
271
+ Ensure MODULE nodes exist for the full chain a.b.c.
272
+
273
+ Returns the node ID of the leaf module.
274
+ Creates CONTAINS relations between parent and child modules.
275
+ """
276
+ parts = module_qname.split(".")
277
+ parent_id: str | None = None
278
+
279
+ for i in range(1, len(parts) + 1):
280
+ qname = ".".join(parts[:i])
281
+ if qname not in modules:
282
+ node_id = make_node_id(project_name, qname, NodeKind.MODULE.value)
283
+ graph.add_node(
284
+ Node(
285
+ id=node_id,
286
+ kind=NodeKind.MODULE,
287
+ qualified_name=qname,
288
+ name=parts[i - 1],
289
+ )
290
+ )
291
+ modules[qname] = node_id
292
+
293
+ if parent_id is not None:
294
+ graph.add_relation(
295
+ Relation(
296
+ source_id=parent_id,
297
+ target_id=node_id,
298
+ kind=RelationKind.CONTAINS,
299
+ )
300
+ )
301
+
302
+ parent_id = modules[qname]
303
+
304
+ return modules[module_qname]
@@ -0,0 +1,117 @@
1
+ """Dependency file parsers for TypeScript / Node.js projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from graphlens.contracts import DependencyFileParser, normalize_pkg_name
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Package.json parser
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ class PackageJsonParser(DependencyFileParser):
19
+ """
20
+ Reads declared dependencies from ``package.json``.
21
+
22
+ Includes ``dependencies``, ``devDependencies``, ``peerDependencies``,
23
+ and ``optionalDependencies`` so that test-only and peer imports are
24
+ classified as ``third_party`` rather than ``unknown``.
25
+ """
26
+
27
+ def can_parse(self, project_root: Path) -> bool:
28
+ return (project_root / "package.json").exists()
29
+
30
+ def parse(self, project_root: Path) -> frozenset[str]:
31
+ path = project_root / "package.json"
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except Exception:
35
+ return frozenset()
36
+
37
+ names: set[str] = set()
38
+ for section in (
39
+ "dependencies",
40
+ "devDependencies",
41
+ "peerDependencies",
42
+ "optionalDependencies",
43
+ ):
44
+ for dep in data.get(section, {}):
45
+ n = normalize_pkg_name(dep)
46
+ if n:
47
+ names.add(n)
48
+ return frozenset(names)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Default parser list
53
+ # ---------------------------------------------------------------------------
54
+
55
+ TYPESCRIPT_DEFAULT_DEP_PARSERS: list[DependencyFileParser] = [
56
+ PackageJsonParser(),
57
+ ]
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Node.js stdlib / built-in module names
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def get_stdlib_names() -> frozenset[str]:
65
+ """
66
+ Return top-level module names that ship with Node.js.
67
+
68
+ These are the importable names callers use, without the ``node:``
69
+ scheme prefix (e.g. ``"fs"``, not ``"node:fs"``). The adapter strips
70
+ ``node:`` prefixes from import paths before calling
71
+ ``ImportClassifier.classify()``, so plain names are sufficient.
72
+ """
73
+ return frozenset({
74
+ # Core Node.js built-in modules (stable API)
75
+ "assert",
76
+ "async_hooks",
77
+ "buffer",
78
+ "child_process",
79
+ "cluster",
80
+ "console",
81
+ "constants",
82
+ "crypto",
83
+ "dgram",
84
+ "diagnostics_channel",
85
+ "dns",
86
+ "domain",
87
+ "events",
88
+ "fs",
89
+ "http",
90
+ "http2",
91
+ "https",
92
+ "inspector",
93
+ "module",
94
+ "net",
95
+ "os",
96
+ "path",
97
+ "perf_hooks",
98
+ "process",
99
+ "punycode",
100
+ "querystring",
101
+ "readline",
102
+ "repl",
103
+ "stream",
104
+ "string_decoder",
105
+ "sys",
106
+ "timers",
107
+ "tls",
108
+ "trace_events",
109
+ "tty",
110
+ "url",
111
+ "util",
112
+ "v8",
113
+ "vm",
114
+ "wasi",
115
+ "worker_threads",
116
+ "zlib",
117
+ })
@@ -0,0 +1,114 @@
1
+ """Module qualified name resolution and source root detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ # Extensions to strip when converting file path to module name
8
+ _TS_EXTENSIONS: frozenset[str] = frozenset({
9
+ ".ts", ".tsx", ".mts", ".cts",
10
+ })
11
+
12
+ # Files that represent the package root (like __init__.py in Python)
13
+ _INDEX_STEMS: frozenset[str] = frozenset({"index"})
14
+
15
+
16
+ def find_source_roots(project_root: Path, files: list[Path]) -> list[Path]:
17
+ """
18
+ Detect TypeScript source roots.
19
+
20
+ Prefers a ``src/`` sub-directory when source files live there.
21
+ Falls back to ``project_root``.
22
+ """
23
+ src = project_root / "src"
24
+ if (
25
+ src.is_dir()
26
+ and files
27
+ and any(f.is_relative_to(src) for f in files)
28
+ ):
29
+ return [src]
30
+ return [project_root]
31
+
32
+
33
+ def file_to_qualified_name(file_path: Path, source_root: Path) -> str:
34
+ """
35
+ Convert a TypeScript file path to a dotted module qualified name.
36
+
37
+ Examples:
38
+ src/mypackage/index.ts -> ``"mypackage"``
39
+ src/mypackage/utils.ts -> ``"mypackage.utils"``
40
+ src/mypackage/ui.tsx -> ``"mypackage.ui"``
41
+
42
+ Declaration files (.d.ts) follow the same mapping — they are filtered
43
+ out at the adapter level, but the resolver handles them correctly.
44
+
45
+ """
46
+ relative = file_path.relative_to(source_root)
47
+ parts = list(relative.parts)
48
+
49
+ # Strip TypeScript extension from last segment
50
+ last = Path(parts[-1])
51
+ # Handle compound extensions like .d.ts, .d.mts
52
+ if last.suffix in _TS_EXTENSIONS:
53
+ stem = last.stem
54
+ # Strip inner .d suffix for declaration files (e.g. foo.d → foo)
55
+ if stem.endswith(".d"):
56
+ stem = stem[:-2]
57
+ parts[-1] = stem
58
+ else:
59
+ parts[-1] = last.stem
60
+
61
+ # Drop index files (they represent the package itself, like __init__.py)
62
+ if parts and parts[-1] in _INDEX_STEMS:
63
+ parts = parts[:-1]
64
+
65
+ if not parts:
66
+ return source_root.name
67
+
68
+ return ".".join(parts)
69
+
70
+
71
+ def resolve_relative_import(
72
+ current_module_qname: str,
73
+ import_path: str,
74
+ ) -> str:
75
+ """
76
+ Resolve a TypeScript relative import path to an absolute qualified name.
77
+
78
+ Args:
79
+ current_module_qname: dotted name of the module that contains the
80
+ import statement, e.g. ``"mypackage.core"``.
81
+ import_path: raw import path string (already stripped of quotes),
82
+ e.g. ``"./utils"``, ``"../shared"``, ``"."``.
83
+
84
+ Examples:
85
+ resolve_relative_import("mypackage.core", "./utils")
86
+ -> "mypackage.utils"
87
+ resolve_relative_import("mypackage.core", "../shared")
88
+ -> "shared"
89
+ resolve_relative_import("mypackage.core", ".")
90
+ -> "mypackage"
91
+
92
+ """
93
+ current_parts = current_module_qname.split(".")
94
+ # Start at the directory containing the current file (drop module name)
95
+ base_parts: list[str] = (
96
+ current_parts[:-1] if len(current_parts) > 1 else []
97
+ )
98
+
99
+ for segment in import_path.replace("\\", "/").split("/"):
100
+ if segment in ("", "."):
101
+ pass # stay at current level
102
+ elif segment == "..":
103
+ base_parts = base_parts[:-1] if base_parts else []
104
+ else:
105
+ # Strip file extensions if present in the import path
106
+ stem = segment.split(".")[0] if "." in segment else segment
107
+ if stem and stem not in _INDEX_STEMS:
108
+ base_parts = [*base_parts, stem]
109
+ # For "index" imports stay at the current package level
110
+
111
+ if not base_parts:
112
+ # Went above root — return the top-level part of the original qname
113
+ return current_parts[0]
114
+ return ".".join(base_parts)
@@ -0,0 +1,107 @@
1
+ """TypeScript project detection: marker files and project name extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ TYPESCRIPT_MARKERS: tuple[str, ...] = (
13
+ "package.json",
14
+ "tsconfig.json",
15
+ )
16
+
17
+ _EXCLUDED_DIRS: frozenset[str] = frozenset({
18
+ ".venv", "venv", "__pycache__", ".git",
19
+ "dist", "build", ".eggs", "node_modules",
20
+ "out", "coverage", ".next", ".nuxt",
21
+ })
22
+
23
+ _NAME_NORMALIZE_RE = re.compile(r"[^a-z0-9_]")
24
+
25
+
26
+ def is_typescript_project(project_root: Path) -> bool:
27
+ """
28
+ Return True if the directory looks like a TypeScript project.
29
+
30
+ Detection order:
31
+ 1. TypeScript-specific marker files (package.json, tsconfig.json)
32
+ 2. Fallback: any .ts or .tsx file exists anywhere under project_root
33
+ """
34
+ if _has_typescript_markers(project_root):
35
+ return True
36
+ return any(
37
+ project_root.rglob("*.ts")
38
+ ) or any(project_root.rglob("*.tsx"))
39
+
40
+
41
+ def find_typescript_roots(search_root: Path) -> list[Path]:
42
+ """
43
+ Find TypeScript project roots within search_root (monorepo support).
44
+
45
+ Returns [search_root] if search_root itself has markers.
46
+ Otherwise walks subdirectories for marker files and returns distinct roots.
47
+ Falls back to [search_root] if nothing found.
48
+ """
49
+ if _has_typescript_markers(search_root):
50
+ return [search_root]
51
+
52
+ roots: list[Path] = []
53
+ for marker in TYPESCRIPT_MARKERS:
54
+ for marker_file in sorted(search_root.rglob(marker)):
55
+ rel_parts = marker_file.relative_to(search_root).parts
56
+ if _EXCLUDED_DIRS & set(rel_parts):
57
+ continue
58
+ candidate = marker_file.parent
59
+ if any(
60
+ candidate == r or candidate.is_relative_to(r)
61
+ for r in roots
62
+ ):
63
+ continue
64
+ roots.append(candidate)
65
+
66
+ return sorted(roots) if roots else [search_root]
67
+
68
+
69
+ def detect_project_name(project_root: Path) -> str:
70
+ """
71
+ Extract the project name from manifest or fall back to directory name.
72
+
73
+ Resolution order:
74
+ 1. package.json "name" field (hyphens → underscores, lowercased)
75
+ 2. project_root directory name
76
+ """
77
+ package_json = project_root / "package.json"
78
+ if package_json.exists():
79
+ try:
80
+ data = json.loads(package_json.read_text(encoding="utf-8"))
81
+ raw = data.get("name", "")
82
+ if raw:
83
+ # Strip npm scope (e.g. "@scope/pkg" → "pkg")
84
+ if raw.startswith("@") and "/" in raw:
85
+ raw = raw.split("/", 1)[1]
86
+ # Normalize: lowercase, non-alnum → underscore
87
+ name = _NAME_NORMALIZE_RE.sub("_", raw.lower()).strip("_")
88
+ if name:
89
+ return name
90
+ except (
91
+ json.JSONDecodeError,
92
+ OSError,
93
+ KeyError,
94
+ TypeError,
95
+ AttributeError,
96
+ ):
97
+ pass
98
+ return _normalize_name(project_root.name)
99
+
100
+
101
+ def _normalize_name(name: str) -> str:
102
+ """Normalize a directory name to a valid Python identifier."""
103
+ return _NAME_NORMALIZE_RE.sub("_", name.lower()).strip("_") or name
104
+
105
+
106
+ def _has_typescript_markers(directory: Path) -> bool:
107
+ return any((directory / m).exists() for m in TYPESCRIPT_MARKERS)