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/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())