repograph-codeintel 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.
- repograph/__init__.py +3 -0
- repograph/__main__.py +3 -0
- repograph/agent_setup.py +224 -0
- repograph/cli.py +476 -0
- repograph/config.py +145 -0
- repograph/extract.py +616 -0
- repograph/impact.py +558 -0
- repograph/indexer.py +184 -0
- repograph/mcp_server.py +130 -0
- repograph/query.py +485 -0
- repograph/resolve.py +755 -0
- repograph/scanner.py +107 -0
- repograph/store.py +287 -0
- repograph/test_gate.py +169 -0
- repograph_codeintel-0.1.0.dist-info/METADATA +295 -0
- repograph_codeintel-0.1.0.dist-info/RECORD +20 -0
- repograph_codeintel-0.1.0.dist-info/WHEEL +5 -0
- repograph_codeintel-0.1.0.dist-info/entry_points.txt +2 -0
- repograph_codeintel-0.1.0.dist-info/licenses/LICENSE +202 -0
- repograph_codeintel-0.1.0.dist-info/top_level.txt +1 -0
repograph/resolve.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""Static import resolution for Python, TypeScript and JavaScript.
|
|
2
|
+
|
|
3
|
+
Resolution is file-level and evidence-producing: each result records how the
|
|
4
|
+
target was found and how certain we are. Dynamic imports, reflection and
|
|
5
|
+
runtime wiring are *not* resolved; they are surfaced as blind spots instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import posixpath
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from .extract import Import
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ts_scope_patterns(data: dict) -> list[str]:
|
|
21
|
+
"""`include`/`files` of a tsconfig, i.e. the files the config applies to.
|
|
22
|
+
|
|
23
|
+
A root tsconfig commonly declares `include: ["src/**/*"]`; its `paths`
|
|
24
|
+
aliases do not apply to a sibling example app that is compiled by its own
|
|
25
|
+
config (or by none). Applying them anyway produced a false internal edge in
|
|
26
|
+
zustand's examples.
|
|
27
|
+
"""
|
|
28
|
+
out: list[str] = []
|
|
29
|
+
for key in ("include", "files"):
|
|
30
|
+
value = data.get(key)
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
out.append(value)
|
|
33
|
+
elif isinstance(value, list):
|
|
34
|
+
out.extend(v for v in value if isinstance(v, str))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _glob_match(pattern: str, path: str) -> bool:
|
|
39
|
+
"""Match a tsconfig include pattern against a repo-relative path."""
|
|
40
|
+
pat = pattern[2:] if pattern.startswith("./") else pattern
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
i = 0
|
|
43
|
+
while i < len(pat):
|
|
44
|
+
if pat.startswith("**/", i):
|
|
45
|
+
out.append("(?:.*/)?")
|
|
46
|
+
i += 3
|
|
47
|
+
elif pat.startswith("**", i):
|
|
48
|
+
out.append(".*")
|
|
49
|
+
i += 2
|
|
50
|
+
elif pat[i] == "*":
|
|
51
|
+
out.append("[^/]*")
|
|
52
|
+
i += 1
|
|
53
|
+
elif pat[i] == "?":
|
|
54
|
+
out.append("[^/]")
|
|
55
|
+
i += 1
|
|
56
|
+
else:
|
|
57
|
+
out.append(re.escape(pat[i]))
|
|
58
|
+
i += 1
|
|
59
|
+
rx = "".join(out)
|
|
60
|
+
# A bare directory (`src`) covers everything below it.
|
|
61
|
+
return re.fullmatch(rx, path) is not None or re.fullmatch(rx + "/.*", path) is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ResolvedImport:
|
|
66
|
+
raw: str
|
|
67
|
+
module: str
|
|
68
|
+
line: int
|
|
69
|
+
target: str | None # repo-relative path of resolved file
|
|
70
|
+
confidence: str # confirmed | inferred | unresolved
|
|
71
|
+
method: str
|
|
72
|
+
kind: str = "internal" # internal | external | unresolved
|
|
73
|
+
names: list[tuple[str, str]] = field(default_factory=list)
|
|
74
|
+
note: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
PY_EXT = ".py"
|
|
78
|
+
TS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts")
|
|
79
|
+
PY_STDLIB = set(sys.stdlib_module_names) | {"__future__"}
|
|
80
|
+
PY_PROJECT_MANIFESTS = ("pyproject.toml", "setup.py", "setup.cfg")
|
|
81
|
+
# Directories walked for project manifests and package.json files. Depth is a
|
|
82
|
+
# deliberate bound: deeper fixture packages tend to be named after real
|
|
83
|
+
# dependencies, and registering those globally would hijack real imports.
|
|
84
|
+
MANIFEST_MAX_DEPTH = 3
|
|
85
|
+
PACKAGE_MAX_DEPTH = 4
|
|
86
|
+
WALK_SKIP = ("node_modules", ".git", ".venv", "venv", "dist", "build",
|
|
87
|
+
"coverage", "__pycache__", ".tox", ".mypy_cache")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _strip_json_comments(text: str) -> str:
|
|
91
|
+
"""Remove // and /* */ comments without touching string contents.
|
|
92
|
+
|
|
93
|
+
A regex over the raw text corrupts glob patterns: ``"@vue/*"`` contains
|
|
94
|
+
``/*`` and was truncated up to the ``*/`` inside ``./packages/*/src``,
|
|
95
|
+
silently discarding tsconfig path aliases.
|
|
96
|
+
"""
|
|
97
|
+
out: list[str] = []
|
|
98
|
+
i, n = 0, len(text)
|
|
99
|
+
in_string = escaped = False
|
|
100
|
+
while i < n:
|
|
101
|
+
ch = text[i]
|
|
102
|
+
if in_string:
|
|
103
|
+
out.append(ch)
|
|
104
|
+
if escaped:
|
|
105
|
+
escaped = False
|
|
106
|
+
elif ch == "\\":
|
|
107
|
+
escaped = True
|
|
108
|
+
elif ch == '"':
|
|
109
|
+
in_string = False
|
|
110
|
+
i += 1
|
|
111
|
+
continue
|
|
112
|
+
if ch == '"':
|
|
113
|
+
in_string = True
|
|
114
|
+
out.append(ch)
|
|
115
|
+
i += 1
|
|
116
|
+
continue
|
|
117
|
+
if ch == "/" and i + 1 < n and text[i + 1] == "/":
|
|
118
|
+
end = text.find("\n", i)
|
|
119
|
+
i = n if end < 0 else end
|
|
120
|
+
continue
|
|
121
|
+
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
|
122
|
+
end = text.find("*/", i + 2)
|
|
123
|
+
i = n if end < 0 else end + 2
|
|
124
|
+
continue
|
|
125
|
+
out.append(ch)
|
|
126
|
+
i += 1
|
|
127
|
+
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Resolver:
|
|
131
|
+
def __init__(self, root: Path, files: list[tuple[str, str]]) -> None:
|
|
132
|
+
self.root = root
|
|
133
|
+
self.files = {rel for rel, _ in files}
|
|
134
|
+
self.lang = dict(files)
|
|
135
|
+
self.py_modules: dict[str, str | None] = {}
|
|
136
|
+
# Names reachable only via a project root or a conftest sys.path
|
|
137
|
+
# mutation. They are runtime configuration, so they stay `inferred`.
|
|
138
|
+
self.py_alt_modules: dict[str, tuple[str | None, str, str]] = {}
|
|
139
|
+
self.ts_configs: list[dict] = []
|
|
140
|
+
self.packages: dict[str, str] = {}
|
|
141
|
+
self.package_dirs: dict[str, str] = {}
|
|
142
|
+
self.package_imports: dict[str, dict] = {}
|
|
143
|
+
# Every directory holding a package.json. Used to tell a package's own
|
|
144
|
+
# self-reference from a nested app importing the same name from the
|
|
145
|
+
# registry (measured zustand defect: examples pin `zustand ^5`).
|
|
146
|
+
self.pkg_dirs: set[str] = set()
|
|
147
|
+
# Package dirs that declare workspaces. Bare-name resolution may only
|
|
148
|
+
# cross package boundaries inside one of these.
|
|
149
|
+
self.workspace_roots: set[str] = set()
|
|
150
|
+
self._dirs = {str(PurePosixPath(p).parent) for p in self.files}
|
|
151
|
+
self._build_python_modules()
|
|
152
|
+
self._build_python_roots()
|
|
153
|
+
self._build_ts_configs()
|
|
154
|
+
self._build_packages()
|
|
155
|
+
|
|
156
|
+
# -- index construction -------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _build_python_modules(self) -> None:
|
|
159
|
+
for rel, lang in self.lang.items():
|
|
160
|
+
if lang != "python":
|
|
161
|
+
continue
|
|
162
|
+
parts = rel.split("/")
|
|
163
|
+
stem = parts[-1].rsplit(".", 1)[0]
|
|
164
|
+
for alias in self._python_aliases(parts, stem):
|
|
165
|
+
self.py_modules[alias] = None if alias in self.py_modules else rel
|
|
166
|
+
if stem == "__init__":
|
|
167
|
+
pkg = ".".join(parts[:-1])
|
|
168
|
+
if pkg:
|
|
169
|
+
self.py_modules.setdefault(pkg, rel)
|
|
170
|
+
# `import pkg` must bind the package, not `pkg.__init__`.
|
|
171
|
+
if parts[0] in ("src", "lib", "python") and len(parts) > 2:
|
|
172
|
+
self.py_modules.setdefault(".".join(parts[1:-1]), rel)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _python_aliases(parts: list[str], stem: str) -> list[str]:
|
|
176
|
+
aliases = []
|
|
177
|
+
# Module name relative to the repository root.
|
|
178
|
+
rel_mod = ".".join(parts[:-1] + [stem])
|
|
179
|
+
aliases.append(rel_mod)
|
|
180
|
+
# Module name relative to an `src`/`lib`/`python` source root.
|
|
181
|
+
if parts and parts[0] in ("src", "lib", "python") and len(parts) > 1:
|
|
182
|
+
aliases.append(".".join(parts[1:-1] + [stem]))
|
|
183
|
+
# Module name relative to the top-most regular package.
|
|
184
|
+
dirs = parts[:-1]
|
|
185
|
+
start = None
|
|
186
|
+
for i in range(len(dirs), 0, -1):
|
|
187
|
+
if (Path(*dirs[:i]) / "__init__.py").is_file():
|
|
188
|
+
start = i - 1
|
|
189
|
+
break
|
|
190
|
+
if start is not None:
|
|
191
|
+
aliases.append(".".join(parts[start:-1] + [stem]))
|
|
192
|
+
return list(dict.fromkeys(aliases))
|
|
193
|
+
|
|
194
|
+
def _build_python_roots(self) -> None:
|
|
195
|
+
"""Register names that are importable only through a sys.path root.
|
|
196
|
+
|
|
197
|
+
Two measured, evidence-bearing cases in Flask: example projects import
|
|
198
|
+
their own top-level package (``flaskr``), and a conftest fixture calls
|
|
199
|
+
``syspath_prepend(tests/test_apps)``. Neither is importable from the
|
|
200
|
+
repository root, so the root-relative alias map cannot see them.
|
|
201
|
+
Names are registered only when unambiguous across the whole repo.
|
|
202
|
+
"""
|
|
203
|
+
import os
|
|
204
|
+
|
|
205
|
+
roots: list[tuple[str, str, str]] = []
|
|
206
|
+
|
|
207
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
208
|
+
dirnames[:] = [d for d in dirnames if d not in WALK_SKIP]
|
|
209
|
+
rel_dir = Path(dirpath).relative_to(self.root).as_posix()
|
|
210
|
+
depth = 0 if rel_dir == "." else rel_dir.count("/") + 1
|
|
211
|
+
if depth >= MANIFEST_MAX_DEPTH:
|
|
212
|
+
dirnames[:] = []
|
|
213
|
+
if not any(m in filenames for m in PY_PROJECT_MANIFESTS):
|
|
214
|
+
continue
|
|
215
|
+
base = "" if rel_dir == "." else rel_dir
|
|
216
|
+
src = posixpath.join(base, "src") if base else "src"
|
|
217
|
+
if any(p.startswith(src + "/") and p.endswith(PY_EXT) for p in self.files):
|
|
218
|
+
roots.append((src, "python-project-root",
|
|
219
|
+
f"src layout of the Python project at {base or '.'}"))
|
|
220
|
+
else:
|
|
221
|
+
roots.append((base or ".", "python-project-root",
|
|
222
|
+
f"Python project root {base or '.'}"))
|
|
223
|
+
|
|
224
|
+
for rel, lang in self.lang.items():
|
|
225
|
+
if lang != "python" or rel.rsplit("/", 1)[-1] != "conftest.py":
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
text = (self.root / rel).read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
except OSError:
|
|
230
|
+
continue
|
|
231
|
+
for line in text.splitlines():
|
|
232
|
+
if "syspath_prepend(" not in line and "sys.path.insert(" not in line:
|
|
233
|
+
continue
|
|
234
|
+
for name in re.findall(r'"([^"\n]+)"|\'([^\'\n]+)\'', line):
|
|
235
|
+
literal = name[0] or name[1]
|
|
236
|
+
cand = posixpath.normpath(posixpath.join(posixpath.dirname(rel), literal))
|
|
237
|
+
if cand.startswith("../") or not any(
|
|
238
|
+
p.startswith(cand + "/") for p in self.files):
|
|
239
|
+
continue
|
|
240
|
+
roots.append((cand, "python-syspath-root",
|
|
241
|
+
f"conftest {rel} prepends {cand} to sys.path"))
|
|
242
|
+
|
|
243
|
+
for root, method, note in dict.fromkeys(roots):
|
|
244
|
+
prefix = "" if root == "." else root + "/"
|
|
245
|
+
for rel, lang in self.lang.items():
|
|
246
|
+
if lang != "python" or (prefix and not rel.startswith(prefix)):
|
|
247
|
+
continue
|
|
248
|
+
parts = (rel[len(prefix):] if prefix else rel).split("/")
|
|
249
|
+
stem = parts[-1][:-len(PY_EXT)]
|
|
250
|
+
dotted = ".".join(parts[:-1] if stem == "__init__" else parts[:-1] + [stem])
|
|
251
|
+
if not dotted:
|
|
252
|
+
continue
|
|
253
|
+
if dotted in self.py_modules:
|
|
254
|
+
continue # a root-relative alias already resolves this name
|
|
255
|
+
current = self.py_alt_modules.get(dotted)
|
|
256
|
+
if current is None:
|
|
257
|
+
self.py_alt_modules[dotted] = (rel, method, note)
|
|
258
|
+
elif current[0] != rel:
|
|
259
|
+
self.py_alt_modules[dotted] = (
|
|
260
|
+
None, "ambiguous-python-root",
|
|
261
|
+
"the same import name is defined by more than one sys.path root",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _build_ts_configs(self) -> None:
|
|
265
|
+
import os
|
|
266
|
+
|
|
267
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
268
|
+
dirnames[:] = [d for d in dirnames if d not in
|
|
269
|
+
("node_modules", ".git", ".venv", "dist", "build", "coverage")]
|
|
270
|
+
rel_dir = Path(dirpath).relative_to(self.root).as_posix()
|
|
271
|
+
depth = 0 if rel_dir == "." else rel_dir.count("/") + 1
|
|
272
|
+
if depth > 3:
|
|
273
|
+
dirnames[:] = []
|
|
274
|
+
continue
|
|
275
|
+
if "tsconfig.json" not in filenames:
|
|
276
|
+
continue
|
|
277
|
+
cfg = Path(dirpath, "tsconfig.json")
|
|
278
|
+
try:
|
|
279
|
+
data = json.loads(_strip_json_comments(cfg.read_text(encoding="utf-8", errors="replace")))
|
|
280
|
+
except (OSError, ValueError):
|
|
281
|
+
continue
|
|
282
|
+
opts = data.get("compilerOptions", {})
|
|
283
|
+
if not isinstance(opts, dict):
|
|
284
|
+
continue
|
|
285
|
+
paths = opts.get("paths", {})
|
|
286
|
+
entries = []
|
|
287
|
+
if isinstance(paths, dict):
|
|
288
|
+
for pattern, targets in paths.items():
|
|
289
|
+
if isinstance(targets, list):
|
|
290
|
+
entries.append((pattern, [str(t) for t in targets]))
|
|
291
|
+
self.ts_configs.append({
|
|
292
|
+
"dir": "" if rel_dir == "." else rel_dir,
|
|
293
|
+
"baseurl": str(opts.get("baseUrl", ".")),
|
|
294
|
+
"paths": entries,
|
|
295
|
+
"scope": _ts_scope_patterns(data),
|
|
296
|
+
})
|
|
297
|
+
# Most specific config (deepest directory) wins.
|
|
298
|
+
self.ts_configs.sort(key=lambda c: len(c["dir"]), reverse=True)
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _ts_covers(cfg: dict, rel: str) -> bool:
|
|
302
|
+
"""Whether a tsconfig's `include`/`files` covers this file.
|
|
303
|
+
|
|
304
|
+
No include/files means the project's default include (everything under
|
|
305
|
+
the config directory), so aliases keep applying.
|
|
306
|
+
"""
|
|
307
|
+
scope = cfg.get("scope") or []
|
|
308
|
+
if not scope:
|
|
309
|
+
return True
|
|
310
|
+
sub = rel[len(cfg["dir"]) + 1:] if cfg["dir"] else rel
|
|
311
|
+
return any(_glob_match(pattern, sub) for pattern in scope)
|
|
312
|
+
|
|
313
|
+
def _nearest_pkg_dir(self, rel: str) -> str:
|
|
314
|
+
d = posixpath.dirname(rel)
|
|
315
|
+
while True:
|
|
316
|
+
if d in self.pkg_dirs:
|
|
317
|
+
return d
|
|
318
|
+
if not d:
|
|
319
|
+
return ""
|
|
320
|
+
d = posixpath.dirname(d)
|
|
321
|
+
|
|
322
|
+
def _package_visible_from(self, rel: str, name: str) -> bool:
|
|
323
|
+
"""Can `rel` see this package's source as `name`?
|
|
324
|
+
|
|
325
|
+
True for a package importing itself, and across package boundaries only
|
|
326
|
+
when the repository declares workspaces (npm/yarn `workspaces` or a
|
|
327
|
+
pnpm workspace file). Nested demo apps that list the registry package in
|
|
328
|
+
their own `dependencies` are otherwise a separate install.
|
|
329
|
+
"""
|
|
330
|
+
target = self.package_dirs.get(name)
|
|
331
|
+
if target is None:
|
|
332
|
+
return False
|
|
333
|
+
importer = self._nearest_pkg_dir(rel)
|
|
334
|
+
if importer == target:
|
|
335
|
+
return True
|
|
336
|
+
for root in self.workspace_roots:
|
|
337
|
+
if root == "" or importer == root or importer.startswith(root + "/"):
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _condition_target(value, prefer_types: bool = False) -> str | None:
|
|
343
|
+
"""First usable target in an exports/imports condition object.
|
|
344
|
+
|
|
345
|
+
``types`` is tried last for value imports: for change impact we want
|
|
346
|
+
the file that runs. Whole-module ``import type`` resolves the ``types``
|
|
347
|
+
condition instead, which the audit showed is a different file.
|
|
348
|
+
"""
|
|
349
|
+
if isinstance(value, str):
|
|
350
|
+
return value
|
|
351
|
+
if isinstance(value, dict):
|
|
352
|
+
order = (("types", "source", "import", "module", "default", "require")
|
|
353
|
+
if prefer_types else
|
|
354
|
+
("source", "import", "module", "default", "require", "types"))
|
|
355
|
+
for key in order:
|
|
356
|
+
if key in value:
|
|
357
|
+
found = Resolver._condition_target(value[key], prefer_types)
|
|
358
|
+
if found:
|
|
359
|
+
return found
|
|
360
|
+
for item in value.values():
|
|
361
|
+
found = Resolver._condition_target(item, prefer_types)
|
|
362
|
+
if found:
|
|
363
|
+
return found
|
|
364
|
+
if isinstance(value, list):
|
|
365
|
+
for item in value:
|
|
366
|
+
found = Resolver._condition_target(item, prefer_types)
|
|
367
|
+
if found:
|
|
368
|
+
return found
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def _entry_source_candidates(self, base: str, entry: str) -> list[str]:
|
|
372
|
+
"""Concrete files an entry field may denote, preferring source.
|
|
373
|
+
|
|
374
|
+
Published entries point at build output (``dist/node/index.js``) that
|
|
375
|
+
is absent from a source checkout; the file developers change is
|
|
376
|
+
``src/node/index.ts``. Generalising this also makes a package
|
|
377
|
+
self-reference (``import "vite"`` inside Vite) resolvable.
|
|
378
|
+
"""
|
|
379
|
+
cand = posixpath.normpath(posixpath.join(base, entry))
|
|
380
|
+
cands = [cand]
|
|
381
|
+
rel = posixpath.relpath(cand, base or ".")
|
|
382
|
+
if rel.startswith("dist/"):
|
|
383
|
+
stem = rel[len("dist/"):].rsplit(".", 1)[0]
|
|
384
|
+
for ext in TS_EXTS:
|
|
385
|
+
cands.append(posixpath.normpath(
|
|
386
|
+
posixpath.join(base, "src", stem + ext)))
|
|
387
|
+
return cands
|
|
388
|
+
|
|
389
|
+
def _build_packages(self) -> None:
|
|
390
|
+
import os
|
|
391
|
+
|
|
392
|
+
# pnpm declares workspaces in pnpm-workspace.yaml, but the file also
|
|
393
|
+
# exists for settings only (zustand). Without a `packages:` key there
|
|
394
|
+
# are no workspace members, so nested apps stay separate installs.
|
|
395
|
+
for name in ("pnpm-workspace.yaml", "pnpm-workspace.yml"):
|
|
396
|
+
ws = self.root / name
|
|
397
|
+
if ws.is_file():
|
|
398
|
+
try:
|
|
399
|
+
text = ws.read_text(encoding="utf-8", errors="replace")
|
|
400
|
+
except OSError:
|
|
401
|
+
continue
|
|
402
|
+
if re.search(r"^\s*packages\s*:", text, re.MULTILINE):
|
|
403
|
+
self.workspace_roots.add("")
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
407
|
+
dirnames[:] = [d for d in dirnames if d not in WALK_SKIP]
|
|
408
|
+
rel_dir = Path(dirpath).relative_to(self.root).as_posix()
|
|
409
|
+
depth = 0 if rel_dir == "." else rel_dir.count("/") + 1
|
|
410
|
+
if depth >= PACKAGE_MAX_DEPTH:
|
|
411
|
+
dirnames[:] = []
|
|
412
|
+
if "package.json" not in filenames:
|
|
413
|
+
continue
|
|
414
|
+
pkg_json = Path(dirpath, "package.json")
|
|
415
|
+
rel = pkg_json.relative_to(self.root).as_posix()
|
|
416
|
+
try:
|
|
417
|
+
data = json.loads(_strip_json_comments(pkg_json.read_text(encoding="utf-8", errors="replace")))
|
|
418
|
+
except (OSError, ValueError):
|
|
419
|
+
continue
|
|
420
|
+
base = posixpath.dirname(rel)
|
|
421
|
+
self.pkg_dirs.add(base)
|
|
422
|
+
if data.get("workspaces"):
|
|
423
|
+
self.workspace_roots.add(base)
|
|
424
|
+
imports = data.get("imports")
|
|
425
|
+
if isinstance(imports, dict) and imports:
|
|
426
|
+
self.package_imports[base] = imports
|
|
427
|
+
name = data.get("name")
|
|
428
|
+
if not isinstance(name, str) or not name:
|
|
429
|
+
continue
|
|
430
|
+
self.package_dirs[name] = base
|
|
431
|
+
exports = data.get("exports")
|
|
432
|
+
if isinstance(exports, str):
|
|
433
|
+
main_export = exports
|
|
434
|
+
elif isinstance(exports, dict):
|
|
435
|
+
main_export = self._condition_target(exports.get("."))
|
|
436
|
+
else:
|
|
437
|
+
main_export = None
|
|
438
|
+
entries = [data.get("source"), data.get("module"), data.get("main"),
|
|
439
|
+
main_export, "src/index.ts", "src/index.tsx", "src/index.js",
|
|
440
|
+
"index.ts", "index.js"]
|
|
441
|
+
resolved = None
|
|
442
|
+
for entry in entries:
|
|
443
|
+
if not isinstance(entry, str) or not entry:
|
|
444
|
+
continue
|
|
445
|
+
hit = next((c for c in self._entry_source_candidates(base, entry)
|
|
446
|
+
if c in self.files), None)
|
|
447
|
+
if hit:
|
|
448
|
+
resolved = hit
|
|
449
|
+
break
|
|
450
|
+
# If the published entry is build output, prefer the conventional
|
|
451
|
+
# source entry over a stale top-level shim.
|
|
452
|
+
published = f"{data.get('module') or ''} {data.get('main') or ''} {main_export or ''}"
|
|
453
|
+
if resolved and "dist/" in published:
|
|
454
|
+
for entry in ("src/index.ts", "src/index.tsx", "src/index.js"):
|
|
455
|
+
cand = posixpath.normpath(posixpath.join(base, entry))
|
|
456
|
+
if cand in self.files:
|
|
457
|
+
resolved = cand
|
|
458
|
+
break
|
|
459
|
+
if resolved:
|
|
460
|
+
self.packages[name] = resolved
|
|
461
|
+
|
|
462
|
+
# -- Python -------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def _py_lookup(self, dotted: str) -> tuple[str | None, str, str, str]:
|
|
465
|
+
# Only exact module paths. A suffix match such as ``types`` ->
|
|
466
|
+
# ``fastapi.types`` is not how Python absolute imports bind, and it
|
|
467
|
+
# produced false edges to same-named repository files (stdlib
|
|
468
|
+
# collisions). Source-root aliases are already registered exactly.
|
|
469
|
+
target = self.py_modules.get(dotted)
|
|
470
|
+
if target:
|
|
471
|
+
return target, "confirmed", "python-absolute-import", ""
|
|
472
|
+
alt = self.py_alt_modules.get(dotted)
|
|
473
|
+
if alt is None:
|
|
474
|
+
return None, "unresolved", "python-absolute-import", ""
|
|
475
|
+
target, method, note = alt
|
|
476
|
+
if target is None:
|
|
477
|
+
return None, "unresolved", method, note
|
|
478
|
+
return target, "inferred", method, note
|
|
479
|
+
|
|
480
|
+
def _package_of(self, rel: str) -> list[str]:
|
|
481
|
+
parts = rel.split("/")[:-1]
|
|
482
|
+
return parts
|
|
483
|
+
|
|
484
|
+
def _resolve_python(self, rel: str, imp: Import) -> list[ResolvedImport]:
|
|
485
|
+
out: list[ResolvedImport] = []
|
|
486
|
+
method = "python-relative-import" if imp.level else "python-absolute-import"
|
|
487
|
+
|
|
488
|
+
def file_for_parts(parts: list[str]) -> str | None:
|
|
489
|
+
base = "/".join(parts)
|
|
490
|
+
if not base:
|
|
491
|
+
return None
|
|
492
|
+
for cand in (base + ".py", base + "/__init__.py", base + ".pyi"):
|
|
493
|
+
if cand in self.files:
|
|
494
|
+
return cand
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
if imp.level:
|
|
498
|
+
pkg = self._package_of(rel)
|
|
499
|
+
drop = imp.level - 1
|
|
500
|
+
if drop > len(pkg):
|
|
501
|
+
return [ResolvedImport(imp.raw, imp.module, imp.line, None, "unresolved",
|
|
502
|
+
method, "unresolved", imp.names,
|
|
503
|
+
"relative import escapes the repository")]
|
|
504
|
+
base_parts = (pkg[:-drop] if drop else pkg) + (imp.module.split(".") if imp.module else [])
|
|
505
|
+
base_target = file_for_parts(base_parts)
|
|
506
|
+
base_conf = "confirmed"
|
|
507
|
+
base_note = ""
|
|
508
|
+
else:
|
|
509
|
+
base_parts = imp.module.split(".") if imp.module else []
|
|
510
|
+
base = ".".join(base_parts)
|
|
511
|
+
top = base_parts[0] if base_parts else ""
|
|
512
|
+
# An absolute import of a stdlib name is external unless this
|
|
513
|
+
# repository defines that exact top-level module (shadowing).
|
|
514
|
+
if top in PY_STDLIB and top not in self.py_modules:
|
|
515
|
+
return [ResolvedImport(imp.raw, imp.module, imp.line, None, "external",
|
|
516
|
+
method, "external", imp.names,
|
|
517
|
+
"standard library (absolute import)")]
|
|
518
|
+
base_target, base_conf, lookup_method, base_note = (
|
|
519
|
+
self._py_lookup(base) if base else (None, "unresolved", method, ""))
|
|
520
|
+
method = lookup_method
|
|
521
|
+
|
|
522
|
+
# `from pkg import submodule` where `submodule` is a module file.
|
|
523
|
+
name_edges: list[ResolvedImport] = []
|
|
524
|
+
resolved_names: set[str] = set()
|
|
525
|
+
for imported, local in imp.names:
|
|
526
|
+
if imported in ("*", ""):
|
|
527
|
+
continue
|
|
528
|
+
if imp.level:
|
|
529
|
+
target = file_for_parts(base_parts + imported.split("."))
|
|
530
|
+
conf, sub_method, note = "confirmed", method, ""
|
|
531
|
+
else:
|
|
532
|
+
dotted = ".".join([*base_parts, imported])
|
|
533
|
+
target, conf, sub_method, note = self._py_lookup(dotted)
|
|
534
|
+
if target:
|
|
535
|
+
resolved_names.add(imported)
|
|
536
|
+
if target != base_target:
|
|
537
|
+
name_edges.append(ResolvedImport(
|
|
538
|
+
imp.raw, f"{imp.module}.{imported}", imp.line, target,
|
|
539
|
+
conf, sub_method, "internal", [(imported, local)], note))
|
|
540
|
+
|
|
541
|
+
# Emit the package `__init__` edge only when the import names the
|
|
542
|
+
# package itself, or when a name is a package attribute rather than a
|
|
543
|
+
# submodule (`from . import VERSION`). Otherwise `from . import mod`
|
|
544
|
+
# produced a duplicate edge that labeling scored as a wrong target.
|
|
545
|
+
meaningful = [n for n, _ in imp.names if n not in ("*", "")]
|
|
546
|
+
if base_target and (imp.module or not meaningful
|
|
547
|
+
or any(n not in resolved_names for n in meaningful)):
|
|
548
|
+
out.append(ResolvedImport(imp.raw, imp.module, imp.line, base_target, base_conf,
|
|
549
|
+
method, "internal", imp.names, base_note))
|
|
550
|
+
out.extend(name_edges)
|
|
551
|
+
|
|
552
|
+
if out:
|
|
553
|
+
return out
|
|
554
|
+
top = base_parts[0] if base_parts else ""
|
|
555
|
+
if not imp.level and (top in PY_STDLIB or top and not self._looks_internal(top)):
|
|
556
|
+
return [ResolvedImport(imp.raw, imp.module, imp.line, None, "external", method,
|
|
557
|
+
"external", imp.names, "third-party or standard library")]
|
|
558
|
+
if not imp.level and top:
|
|
559
|
+
return [ResolvedImport(imp.raw, imp.module, imp.line, None, "unresolved", method,
|
|
560
|
+
"unresolved", imp.names,
|
|
561
|
+
base_note or "top-level name matches repository content but module was not resolved")]
|
|
562
|
+
return [ResolvedImport(imp.raw, imp.module, imp.line, None, "unresolved", method,
|
|
563
|
+
"unresolved", imp.names)]
|
|
564
|
+
|
|
565
|
+
def _looks_internal(self, top: str) -> bool:
|
|
566
|
+
if top in self._dirs or f"{top}.py" in self.files:
|
|
567
|
+
return True
|
|
568
|
+
return any(top in p.split("/") for p in self.files)
|
|
569
|
+
|
|
570
|
+
# -- TypeScript / JavaScript -------------------------------------------
|
|
571
|
+
|
|
572
|
+
def _ts_candidates(self, base: str) -> list[str]:
|
|
573
|
+
base = posixpath.normpath(base)
|
|
574
|
+
if base.startswith("../") or base == "..":
|
|
575
|
+
return []
|
|
576
|
+
cands = [base] if base in self.files else []
|
|
577
|
+
if not cands:
|
|
578
|
+
for ext in (*TS_EXTS, ".d.ts"):
|
|
579
|
+
if base + ext in self.files:
|
|
580
|
+
cands.append(base + ext)
|
|
581
|
+
break
|
|
582
|
+
if not cands:
|
|
583
|
+
for ext in (*TS_EXTS, ".d.ts"):
|
|
584
|
+
idx = posixpath.normpath(posixpath.join(base, "index" + ext))
|
|
585
|
+
if idx in self.files:
|
|
586
|
+
cands.append(idx)
|
|
587
|
+
break
|
|
588
|
+
if not cands and base.endswith((".js", ".jsx", ".mjs", ".cjs")):
|
|
589
|
+
# ESM style: `./x.js` refers to `./x.ts` in TypeScript source.
|
|
590
|
+
stem = base.rsplit(".", 1)[0]
|
|
591
|
+
for ext in (".ts", ".tsx", ".mts", ".cts", ".d.ts"):
|
|
592
|
+
if stem + ext in self.files:
|
|
593
|
+
cands.append(stem + ext)
|
|
594
|
+
break
|
|
595
|
+
return cands
|
|
596
|
+
|
|
597
|
+
def _resolve_package_import(self, rel: str, spec: str, imp: Import) -> list[ResolvedImport]:
|
|
598
|
+
"""Resolve a Node ``#private`` specifier from the nearest ``imports`` map.
|
|
599
|
+
|
|
600
|
+
``imports`` maps are package-local and use mandatory ``#`` prefixes;
|
|
601
|
+
they are ordinary static configuration, but the mapping lives in JSON
|
|
602
|
+
outside the module, so the edge is reported as ``inferred``.
|
|
603
|
+
"""
|
|
604
|
+
dirs = sorted(self.package_dirs.values(), key=len, reverse=True)
|
|
605
|
+
pkg_dir = next((d for d in dirs if d == "" or rel.startswith(d + "/")), None)
|
|
606
|
+
mapping = self.package_imports.get(pkg_dir) if pkg_dir is not None else None
|
|
607
|
+
type_only = bool(re.match(r"\s*(?:import|export)\s+type\b", imp.raw))
|
|
608
|
+
target = None
|
|
609
|
+
if mapping:
|
|
610
|
+
if spec in mapping:
|
|
611
|
+
target = self._condition_target(mapping[spec], type_only)
|
|
612
|
+
else:
|
|
613
|
+
for key, value in mapping.items():
|
|
614
|
+
if key.count("*") != 1:
|
|
615
|
+
continue
|
|
616
|
+
prefix, suffix = key.split("*", 1)
|
|
617
|
+
if spec.startswith(prefix) and (not suffix or spec.endswith(suffix)):
|
|
618
|
+
middle = spec[len(prefix): len(spec) - len(suffix) if suffix else None]
|
|
619
|
+
declared = self._condition_target(value, type_only)
|
|
620
|
+
if declared:
|
|
621
|
+
target = declared.replace("*", middle)
|
|
622
|
+
break
|
|
623
|
+
if target:
|
|
624
|
+
base = posixpath.normpath(posixpath.join(pkg_dir or ".", target))
|
|
625
|
+
found = self._ts_candidates(base)
|
|
626
|
+
if found:
|
|
627
|
+
return [ResolvedImport(
|
|
628
|
+
imp.raw, spec, imp.line, found[0], "inferred", "package-imports-map",
|
|
629
|
+
"internal", imp.names,
|
|
630
|
+
f"resolved by the imports map of {pkg_dir or '.'}/package.json")]
|
|
631
|
+
return [ResolvedImport(
|
|
632
|
+
imp.raw, spec, imp.line, None, "unresolved", "package-imports-map",
|
|
633
|
+
"unresolved", imp.names,
|
|
634
|
+
"private # import is not declared by the nearest package.json imports map")]
|
|
635
|
+
|
|
636
|
+
def _resolve_ts(self, rel: str, imp: Import) -> list[ResolvedImport]:
|
|
637
|
+
spec = imp.module
|
|
638
|
+
method = "typescript-relative-import"
|
|
639
|
+
targets: list[str] = []
|
|
640
|
+
|
|
641
|
+
if spec.startswith(".") or spec.startswith("/"):
|
|
642
|
+
base = posixpath.join(posixpath.dirname(rel), spec) if spec.startswith(".") else spec.lstrip("/")
|
|
643
|
+
targets = self._ts_candidates(base)
|
|
644
|
+
if not targets:
|
|
645
|
+
return [ResolvedImport(imp.raw, spec, imp.line, None, "unresolved",
|
|
646
|
+
"typescript-relative-import", "unresolved", imp.names,
|
|
647
|
+
"relative path not found (generated, missing, or out of index)")]
|
|
648
|
+
else:
|
|
649
|
+
if spec.startswith("#"):
|
|
650
|
+
return self._resolve_package_import(rel, spec, imp)
|
|
651
|
+
cfg = next(
|
|
652
|
+
(c for c in self.ts_configs
|
|
653
|
+
if (c["dir"] == "" or rel == c["dir"] or rel.startswith(c["dir"] + "/"))
|
|
654
|
+
and self._ts_covers(c, rel)),
|
|
655
|
+
{"dir": "", "baseurl": ".", "paths": []},
|
|
656
|
+
)
|
|
657
|
+
baseurl_dir = posixpath.normpath(posixpath.join(cfg["dir"], cfg["baseurl"]))
|
|
658
|
+
for pattern, subs in cfg["paths"]:
|
|
659
|
+
if "*" in pattern:
|
|
660
|
+
prefix, suffix = pattern.split("*", 1)
|
|
661
|
+
if spec.startswith(prefix) and spec.endswith(suffix):
|
|
662
|
+
middle = spec[len(prefix): len(spec) - len(suffix) if suffix else None]
|
|
663
|
+
for sub in subs:
|
|
664
|
+
cand = sub.replace("*", middle)
|
|
665
|
+
base = posixpath.normpath(posixpath.join(baseurl_dir, cand))
|
|
666
|
+
found = self._ts_candidates(base)
|
|
667
|
+
if found:
|
|
668
|
+
targets = found
|
|
669
|
+
method = "tsconfig-path-alias"
|
|
670
|
+
break
|
|
671
|
+
elif spec == pattern:
|
|
672
|
+
for sub in subs:
|
|
673
|
+
base = posixpath.normpath(posixpath.join(baseurl_dir, sub))
|
|
674
|
+
found = self._ts_candidates(base)
|
|
675
|
+
if found:
|
|
676
|
+
targets = found
|
|
677
|
+
method = "tsconfig-path-alias"
|
|
678
|
+
break
|
|
679
|
+
if targets:
|
|
680
|
+
break
|
|
681
|
+
if not targets:
|
|
682
|
+
if spec.startswith("@"):
|
|
683
|
+
parts = spec.split("/")
|
|
684
|
+
pkg_name = "/".join(parts[:2])
|
|
685
|
+
subpath = "/".join(parts[2:])
|
|
686
|
+
else:
|
|
687
|
+
parts = spec.split("/")
|
|
688
|
+
pkg_name = parts[0]
|
|
689
|
+
subpath = "/".join(parts[1:])
|
|
690
|
+
pkg_dir = self.package_dirs.get(pkg_name)
|
|
691
|
+
visible = pkg_dir is not None and self._package_visible_from(rel, pkg_name)
|
|
692
|
+
if visible and subpath:
|
|
693
|
+
for probe in (posixpath.join(pkg_dir, subpath),
|
|
694
|
+
posixpath.join(pkg_dir, "src", subpath)):
|
|
695
|
+
found = self._ts_candidates(posixpath.normpath(probe))
|
|
696
|
+
if found:
|
|
697
|
+
targets = found
|
|
698
|
+
method = "workspace-package"
|
|
699
|
+
break
|
|
700
|
+
elif visible and self.packages.get(pkg_name):
|
|
701
|
+
targets = [self.packages[pkg_name]]
|
|
702
|
+
method = "workspace-package"
|
|
703
|
+
elif pkg_dir is not None:
|
|
704
|
+
# A nested app that pins the registry package, in a repo
|
|
705
|
+
# without workspace linking. Not an edge into this source.
|
|
706
|
+
return [ResolvedImport(imp.raw, spec, imp.line, None, "external",
|
|
707
|
+
"bare-specifier", "external", imp.names,
|
|
708
|
+
f"package {pkg_name!r} is not visible from "
|
|
709
|
+
f"{self._nearest_pkg_dir(rel) or '.'}")]
|
|
710
|
+
if not targets:
|
|
711
|
+
return [ResolvedImport(imp.raw, spec, imp.line, None, "external",
|
|
712
|
+
"bare-specifier", "external", imp.names,
|
|
713
|
+
"third-party package or Node builtin")]
|
|
714
|
+
|
|
715
|
+
conf = "confirmed" if method == "typescript-relative-import" else "inferred"
|
|
716
|
+
return [ResolvedImport(imp.raw, spec, imp.line, targets[0], conf, method,
|
|
717
|
+
"internal", imp.names)]
|
|
718
|
+
|
|
719
|
+
# -- public -------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
def resolve(self, rel: str, imports: list[Import]) -> list[ResolvedImport]:
|
|
722
|
+
lang = self.lang.get(rel, "")
|
|
723
|
+
out: list[ResolvedImport] = []
|
|
724
|
+
for imp in imports:
|
|
725
|
+
if imp.kind == "dynamic":
|
|
726
|
+
if not imp.module:
|
|
727
|
+
out.append(ResolvedImport(imp.raw, "", imp.line, None, "unresolved",
|
|
728
|
+
"dynamic-import", "dynamic", imp.names,
|
|
729
|
+
"dynamic import target is not a string literal"))
|
|
730
|
+
continue
|
|
731
|
+
if lang == "python":
|
|
732
|
+
sub = self._resolve_python(rel, imp)
|
|
733
|
+
elif lang in ("typescript", "tsx", "javascript", "jsx"):
|
|
734
|
+
sub = self._resolve_ts(rel, imp)
|
|
735
|
+
else:
|
|
736
|
+
sub = []
|
|
737
|
+
for r in sub:
|
|
738
|
+
r.kind = "dynamic"
|
|
739
|
+
r.method = "dynamic-import"
|
|
740
|
+
r.note = "dynamic import; target may change at runtime"
|
|
741
|
+
r.confidence = "inferred" if r.target else "unresolved"
|
|
742
|
+
out.extend(sub)
|
|
743
|
+
continue
|
|
744
|
+
if lang == "python":
|
|
745
|
+
out.extend(self._resolve_python(rel, imp))
|
|
746
|
+
elif lang in ("typescript", "tsx", "javascript", "jsx"):
|
|
747
|
+
out.extend(self._resolve_ts(rel, imp))
|
|
748
|
+
# Deduplicate by (target, line, module); keep the strongest confidence.
|
|
749
|
+
rank = {"confirmed": 0, "inferred": 1, "external": 2, "unresolved": 3}
|
|
750
|
+
best: dict[tuple, ResolvedImport] = {}
|
|
751
|
+
for r in out:
|
|
752
|
+
key = (r.target, r.line, r.module)
|
|
753
|
+
if key not in best or rank[r.confidence] < rank[best[key].confidence]:
|
|
754
|
+
best[key] = r
|
|
755
|
+
return list(best.values())
|