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.
- graphlens_typescript/__init__.py +5 -0
- graphlens_typescript/_adapter.py +304 -0
- graphlens_typescript/_deps.py +117 -0
- graphlens_typescript/_module_resolver.py +114 -0
- graphlens_typescript/_project_detector.py +107 -0
- graphlens_typescript/_visitor.py +1041 -0
- graphlens_typescript-0.2.2.dist-info/METADATA +8 -0
- graphlens_typescript-0.2.2.dist-info/RECORD +10 -0
- graphlens_typescript-0.2.2.dist-info/WHEEL +4 -0
- graphlens_typescript-0.2.2.dist-info/entry_points.txt +3 -0
|
@@ -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)
|