repomap-cli 1.0.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.
repomap/resolver.py ADDED
@@ -0,0 +1,906 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Repo Map Resolver — Import and Alias Resolution Layer
4
+ =======================================================
5
+ 负责 import 路径解析、alias 映射、re-export 追踪。
6
+
7
+ 支持:
8
+ - tsconfig.json / jsconfig.json paths 和 baseUrl
9
+ - package.json exports 字段
10
+ - Vite / Webpack 等 bundler 的 alias 配置
11
+ - CommonJS require/module.exports
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ from collections import defaultdict
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any
23
+
24
+ from .parser import EXT_TO_LANG
25
+ from . import JSImportBinding, PathAliasRule, ProjectImportConfig, RepoGraph
26
+
27
+ logger = logging.getLogger("repomap")
28
+
29
+ MAX_EXPORT_RESOLVE_DEPTH = 3
30
+ CALLABLE_KINDS = {"function", "method", "anonymous_function"}
31
+
32
+ # Bundler config file names
33
+ BUNDLER_CONFIGS = {
34
+ "vite": ["vite.config.js", "vite.config.ts", "vite.config.mjs"],
35
+ "webpack": ["webpack.config.js", "webpack.config.ts"],
36
+ "rollup": ["rollup.config.js", "rollup.config.mjs"],
37
+ "esbuild": [], # esbuild usually configured in package.json or build scripts
38
+ "turbopack": [], # Next.js 13+, uses next.config.js
39
+ }
40
+
41
+ SKIP_DIR_NAMES = {
42
+ ".cache",
43
+ ".git",
44
+ ".hg",
45
+ ".idea",
46
+ ".mypy_cache",
47
+ ".next",
48
+ ".nox",
49
+ ".nuxt",
50
+ ".parcel-cache",
51
+ ".pnpm-store",
52
+ ".pytest_cache",
53
+ ".ruff_cache",
54
+ ".svelte-kit",
55
+ ".tox",
56
+ ".turbo",
57
+ ".venv",
58
+ ".vscode",
59
+ ".yarn",
60
+ "__pypackages__",
61
+ "__pycache__",
62
+ "build",
63
+ "coverage",
64
+ "dist",
65
+ "env",
66
+ "ENV",
67
+ "node_modules",
68
+ "site-packages",
69
+ "target",
70
+ "venv",
71
+ }
72
+
73
+
74
+ class PackageJsonExports:
75
+ """解析 package.json exports 字段,支持条件导出和子路径模式。"""
76
+
77
+ def __init__(self, exports: Any) -> None:
78
+ self.raw = exports
79
+ self.mappings: dict[str, str] = {} # subpath -> resolved path
80
+ self._parse_exports(exports)
81
+
82
+ def _parse_exports(self, exports: Any, base_path: str = ".", is_nested_condition: bool = False) -> None:
83
+ """解析 exports 字段的各种形式。
84
+
85
+ Args:
86
+ exports: package.json 中的 exports 字段值
87
+ base_path: 当前子路径(用于子路径映射)
88
+ is_nested_condition: 是否处于嵌套条件导出中
89
+ """
90
+ if isinstance(exports, str):
91
+ # 简写形式: "exports": "./dist/index.js"
92
+ self.mappings[base_path] = exports
93
+ elif isinstance(exports, dict):
94
+ # 检查是否有条件导出 (import/require/default/types 等)
95
+ condition_keys = {"import", "require", "default", "types", "node", "browser", "deno"}
96
+ has_conditions = bool(set(exports.keys()) & condition_keys)
97
+
98
+ if has_conditions:
99
+ # 选择优先的条件: import > default > require > types
100
+ selected_target = None
101
+ for key in ("import", "default", "require", "types"):
102
+ if key in exports:
103
+ selected_target = exports[key]
104
+ break
105
+
106
+ if selected_target is None:
107
+ # 使用第一个非子路径的可用条件
108
+ for key, target in exports.items():
109
+ if not key.startswith("."):
110
+ selected_target = target
111
+ break
112
+
113
+ # 处理选中的目标
114
+ if isinstance(selected_target, str):
115
+ self.mappings[base_path] = selected_target
116
+ elif isinstance(selected_target, dict):
117
+ # 嵌套条件导出,base_path 保持不变
118
+ self._parse_exports(selected_target, base_path, is_nested_condition=True)
119
+ else:
120
+ # 子路径映射形式
121
+ for key, target in exports.items():
122
+ subpath = f"{base_path}/{key}" if base_path != "." else key
123
+ if isinstance(target, str):
124
+ self.mappings[subpath] = target
125
+ elif isinstance(target, dict):
126
+ # 子路径映射中的值可能是条件导出字典
127
+ self._parse_exports(target, subpath, is_nested_condition=False)
128
+
129
+ def resolve(self, import_path: str) -> str | None:
130
+ """
131
+ 解析 import 路径到实际文件路径。
132
+ import_path 应该是包内路径,如 "#utils" 或 "./helpers"
133
+ """
134
+ # 直接匹配
135
+ if import_path in self.mappings:
136
+ return self.mappings[import_path]
137
+
138
+ # 处理子路径通配符模式,如 "./features/*": "./dist/features/*.js"
139
+ for pattern, target in self.mappings.items():
140
+ if "*" in pattern:
141
+ prefix = pattern.split("*")[0]
142
+ if import_path.startswith(prefix):
143
+ suffix = import_path[len(prefix):]
144
+ if "*" in target:
145
+ return target.replace("*", suffix, 1)
146
+ # 目标没有通配符,追加到目录
147
+ if target.endswith("/"):
148
+ return f"{target}{suffix}"
149
+
150
+ return None
151
+
152
+
153
+ class BundlerAliasConfig:
154
+ """解析各种 bundler 的 alias 配置。
155
+
156
+ NOTE: 当前实现仅作为占位符,实际 bundler alias 解析依赖于:
157
+ 1. tsconfig.json / jsconfig.json 的 paths 配置(由 ImportResolver 主逻辑处理)
158
+ 2. 项目特定的构建配置通常应由用户通过其他方式提供
159
+
160
+ 正则解析 JS 配置文件极易出错,已移除。如需支持特定 bundler,
161
+ 建议通过显式的配置文件映射而非解析代码实现。
162
+ """
163
+
164
+ def __init__(self, project_root: Path) -> None:
165
+ self.project_root = project_root
166
+ self.aliases: dict[str, str] = {} # alias -> resolved path
167
+
168
+ def resolve(self, import_path: str) -> str | None:
169
+ """解析 bundler alias 到实际路径。"""
170
+ for alias, target in self.aliases.items():
171
+ if import_path == alias:
172
+ return target
173
+ if import_path.startswith(f"{alias}/"):
174
+ suffix = import_path[len(alias) + 1:]
175
+ return f"{target}/{suffix}" if not target.endswith("/") else f"{target}{suffix}"
176
+ return None
177
+
178
+
179
+ class ImportResolver:
180
+ """
181
+ Import 解析器:处理各种 import 路径解析场景。
182
+ """
183
+
184
+ def __init__(self, project_root: Path, graph: RepoGraph) -> None:
185
+ self.project_root = project_root
186
+ self.graph = graph
187
+ self.import_configs: list[ProjectImportConfig] = []
188
+ self.bundler_aliases = BundlerAliasConfig(project_root)
189
+ self.package_exports: dict[str, PackageJsonExports] = {} # package name -> exports
190
+ self.package_export_roots: dict[str, PurePosixPath] = {} # package name -> project-relative package root
191
+ self._package_imports: dict[str, str] = {} # #pattern -> resolved target
192
+
193
+ # 构建文件索引: stem -> [files]
194
+ self._file_map: dict[str, list[str]] = defaultdict(list)
195
+ # name -> [sym_id]
196
+ self._name_idx: dict[str, list[str]] = defaultdict(list)
197
+ # sym_id -> file
198
+ self._sym_file: dict[str, str] = {}
199
+
200
+ # 导入解析缓存:(source_file, imp) -> target_files,避免同一模块被反复解析
201
+ self._resolve_cache: dict[tuple[str, str], list[str]] = {}
202
+
203
+ self._load_import_configs()
204
+ self._load_package_json_exports()
205
+ self._detect_vite_alias()
206
+
207
+ def _load_import_configs(self) -> None:
208
+ """加载所有 tsconfig.json / jsconfig.json 配置。"""
209
+ configs: list[ProjectImportConfig] = []
210
+ for config_path in self._discover_import_config_paths():
211
+ data = self._load_jsonc_with_extends(config_path, set())
212
+ compiler_options = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
213
+ base_url = self._resolve_config_relative_path(config_path, compiler_options.get("baseUrl"))
214
+ alias_rules: list[PathAliasRule] = []
215
+ raw_paths = compiler_options.get("paths", {})
216
+ if isinstance(raw_paths, dict):
217
+ for alias_pattern, targets in raw_paths.items():
218
+ if not isinstance(alias_pattern, str) or not isinstance(targets, list):
219
+ continue
220
+ resolved_targets = tuple(
221
+ target
222
+ for raw_target in targets
223
+ if isinstance(raw_target, str)
224
+ for target in [self._resolve_config_relative_path(config_path, raw_target, base_url)]
225
+ if target is not None
226
+ )
227
+ if resolved_targets:
228
+ alias_rules.append(
229
+ PathAliasRule(alias_pattern=alias_pattern, target_patterns=resolved_targets)
230
+ )
231
+ try:
232
+ config_dir = config_path.parent.relative_to(self.project_root).as_posix()
233
+ except ValueError:
234
+ continue
235
+ configs.append(
236
+ ProjectImportConfig(
237
+ config_path=str(config_path.relative_to(self.project_root)),
238
+ config_dir=config_dir or ".",
239
+ base_url=base_url,
240
+ alias_rules=alias_rules,
241
+ )
242
+ )
243
+ configs.sort(
244
+ key=lambda config: (
245
+ -self._path_depth(config.config_dir),
246
+ config.config_path or "",
247
+ )
248
+ )
249
+ self.import_configs = configs
250
+
251
+ def _load_package_json_exports(self) -> None:
252
+ """扫描 package.json 并解析 exports 字段。"""
253
+ package_json_path = self.project_root / "package.json"
254
+ if package_json_path.exists():
255
+ try:
256
+ data = json.loads(package_json_path.read_text(encoding="utf-8"))
257
+ if isinstance(data, dict) and "exports" in data:
258
+ self._register_package_exports(".", data["exports"], PurePosixPath("."))
259
+ package_name = data.get("name")
260
+ if isinstance(package_name, str) and package_name:
261
+ self._register_package_exports(package_name, data["exports"], PurePosixPath("."))
262
+ # 解析 imports 字段(Node.js # 私有导入)
263
+ if isinstance(data, dict) and "imports" in data:
264
+ self._parse_package_imports(data["imports"])
265
+ except Exception as e:
266
+ logger.debug(f"Failed to parse package.json: {e}")
267
+
268
+ # 解析子包的 package.json(monorepo 场景),跳过依赖和构建目录,避免读取海量无关 package。
269
+ for root, dir_names, file_names in os.walk(self.project_root):
270
+ dir_names[:] = [name for name in dir_names if name not in SKIP_DIR_NAMES]
271
+ if "package.json" not in file_names:
272
+ continue
273
+ sub_package_path = Path(root) / "package.json"
274
+ if sub_package_path == package_json_path:
275
+ continue
276
+ try:
277
+ sub_data = json.loads(sub_package_path.read_text(encoding="utf-8"))
278
+ if not isinstance(sub_data, dict) or "exports" not in sub_data:
279
+ continue
280
+ rel_path = sub_package_path.parent.relative_to(self.project_root)
281
+ package_root = PurePosixPath(rel_path.as_posix())
282
+ path_package_name = f"./{rel_path.as_posix()}"
283
+ self._register_package_exports(path_package_name, sub_data["exports"], package_root)
284
+ package_name = sub_data.get("name")
285
+ if isinstance(package_name, str) and package_name:
286
+ self._register_package_exports(package_name, sub_data["exports"], package_root)
287
+ except ValueError:
288
+ continue
289
+ except Exception as e:
290
+ logger.debug(f"Failed to parse {sub_package_path}: {e}")
291
+
292
+ def _detect_vite_alias(self) -> None:
293
+ """检测 Vite 默认 alias: ~/ → src/。"""
294
+ for cfg_name in ("vite.config.ts", "vite.config.js", "vite.config.mjs"):
295
+ if (self.project_root / cfg_name).exists():
296
+ if "~" not in self.bundler_aliases.aliases:
297
+ self.bundler_aliases.aliases["~"] = "src"
298
+ break
299
+
300
+ def _parse_package_imports(self, imports: Any) -> None:
301
+ """解析 package.json imports 字段(# 私有导入映射)。"""
302
+ if not isinstance(imports, dict):
303
+ return
304
+ for pattern, target in imports.items():
305
+ resolved = None
306
+ if isinstance(target, str):
307
+ resolved = target
308
+ elif isinstance(target, dict):
309
+ for key in ("import", "default", "require", "node", "browser"):
310
+ if key in target and isinstance(target[key], str):
311
+ resolved = target[key]
312
+ break
313
+ if resolved and isinstance(pattern, str):
314
+ self._package_imports[pattern] = resolved
315
+
316
+ def _register_package_exports(self, package_name: str, exports: Any, package_root: PurePosixPath) -> None:
317
+ self.package_exports[package_name] = PackageJsonExports(exports)
318
+ self.package_export_roots[package_name] = package_root
319
+
320
+ def build_indices(self) -> None:
321
+ """构建文件和符号索引,用于快速解析。"""
322
+ self._file_map.clear()
323
+ self._name_idx.clear()
324
+ self._sym_file.clear()
325
+
326
+ for file in sorted(self.graph.file_symbols):
327
+ self._file_map[Path(file).stem].append(file)
328
+
329
+ for sid, sym in self.graph.symbols.items():
330
+ self._name_idx[sym.name].append(sid)
331
+ self._sym_file[sid] = sym.file
332
+
333
+ for symbol_ids in self._name_idx.values():
334
+ symbol_ids.sort(key=lambda symbol_id: (
335
+ self._sym_file[symbol_id],
336
+ self.graph.symbols[symbol_id].line,
337
+ self.graph.symbols[symbol_id].name,
338
+ ))
339
+
340
+ def resolve_import_targets(self, source_file: str, imp: str) -> list[str]:
341
+ """解析 import 路径到目标文件列表。"""
342
+ # 非相对路径走缓存,避免同一模块被不同文件重复解析
343
+ cache_key = (source_file, imp)
344
+ if cached := self._resolve_cache.get(cache_key):
345
+ return cached
346
+
347
+ result: list[str]
348
+ # 处理 Node.js # 私有导入
349
+ if imp.startswith("#") and self._package_imports:
350
+ target = self._package_imports.get(imp)
351
+ if target:
352
+ result = self._resolve_package_export_target(".", target)
353
+ if result:
354
+ self._resolve_cache[cache_key] = result
355
+ return result
356
+
357
+ if imp.startswith("."):
358
+ result = self._resolve_relative(source_file, imp)
359
+ else:
360
+ # 尝试 Java 点号导入 (com.example.Foo → com/example/Foo.java)
361
+ source_ext = Path(source_file).suffix.lower()
362
+ if source_ext == ".java" and "." in imp and not imp.startswith("."):
363
+ java_modules = [part for part in imp.split(".") if part]
364
+ if java_modules:
365
+ java_path = PurePosixPath(*java_modules)
366
+ java_matches = self._candidate_files_for_base_path(java_path)
367
+ if java_matches:
368
+ self._resolve_cache[cache_key] = java_matches
369
+ return java_matches
370
+
371
+ # 尝试 bundler alias 解析
372
+ bundler_match = self.bundler_aliases.resolve(imp)
373
+ if bundler_match:
374
+ result = self._resolve_relative(source_file, bundler_match)
375
+ else:
376
+ # 尝试 tsconfig/jsconfig alias/baseUrl 解析
377
+ alias_matches = self._resolve_alias_or_baseurl_targets(source_file, imp)
378
+ if alias_matches:
379
+ result = alias_matches
380
+ else:
381
+ # 尝试 package.json exports 解析(用于自引用或子包)
382
+ pkg_result = None
383
+ for package_name, exports in self.package_exports.items():
384
+ export_subpath = self._package_export_subpath(package_name, imp)
385
+ if export_subpath is None:
386
+ continue
387
+ resolved = exports.resolve(export_subpath)
388
+ if resolved:
389
+ pkg_result = self._resolve_package_export_target(package_name, resolved)
390
+ break
391
+ if pkg_result is not None:
392
+ result = pkg_result
393
+ else:
394
+ python_matches = self._resolve_python_dotted_import(source_file, imp)
395
+ if python_matches:
396
+ result = python_matches
397
+ else:
398
+ # 最后尝试模块名匹配(优先同语言)
399
+ module_key = Path(imp).stem or imp.split(".")[-1]
400
+ matches = list(self._file_map.get(module_key, []))
401
+ if not matches:
402
+ result = []
403
+ else:
404
+ # 优先匹配同扩展名的文件(语言隔离)
405
+ source_ext = Path(source_file).suffix.lower()
406
+ same_lang_matches = [f for f in matches if f.lower().endswith(source_ext)]
407
+ if same_lang_matches:
408
+ result = same_lang_matches
409
+ else:
410
+ # 次优:匹配相同语言组的文件
411
+ source_lang = EXT_TO_LANG.get(source_ext)
412
+ if source_lang:
413
+ same_group_matches = [f for f in matches if EXT_TO_LANG.get(Path(f).suffix.lower()) == source_lang]
414
+ if same_group_matches:
415
+ result = same_group_matches
416
+ else:
417
+ # 兜底:返回所有匹配(但限制数量避免爆炸)
418
+ result = matches[:3]
419
+ else:
420
+ result = matches[:3]
421
+
422
+ # 缓存非相对路径的解析结果(相对路径取决于源文件位置,不宜缓存)
423
+ if not imp.startswith("."):
424
+ self._resolve_cache[cache_key] = result
425
+ return result
426
+
427
+ def _package_export_subpath(self, package_name: str, imp: str) -> str | None:
428
+ if package_name == ".":
429
+ return imp if imp.startswith("#") else None
430
+ if imp == package_name:
431
+ return "."
432
+ prefix = package_name + "/"
433
+ if imp.startswith(prefix):
434
+ return "./" + imp[len(prefix):]
435
+ return None
436
+
437
+ def _resolve_package_export_target(self, package_name: str, target: str) -> list[str]:
438
+ package_root = self.package_export_roots.get(package_name, PurePosixPath("."))
439
+ target_path = PurePosixPath(target)
440
+ if target_path.is_absolute():
441
+ return []
442
+ normalized = self._normalize_posix_path(package_root / target_path)
443
+ if normalized is None:
444
+ return []
445
+ return self._candidate_files_for_base_path(normalized)
446
+
447
+ def _resolve_relative(self, source_file: str, imp: str) -> list[str]:
448
+ resolved = self._resolve_relative_base(source_file, imp)
449
+ if resolved is None:
450
+ return []
451
+ return self._candidate_files_for_base_path(resolved)
452
+
453
+ def _candidate_files_for_base_path(self, resolved: PurePosixPath) -> list[str]:
454
+ matches = []
455
+ resolved_str = str(resolved)
456
+ if resolved_str in self.graph.file_symbols:
457
+ matches.append(resolved_str)
458
+ if resolved.suffix.lower() in EXT_TO_LANG:
459
+ return sorted(set(matches))
460
+ for ext in EXT_TO_LANG:
461
+ direct = resolved_str + ext
462
+ index_file = str(resolved / f"index{ext}")
463
+ if direct in self.graph.file_symbols:
464
+ matches.append(direct)
465
+ if index_file in self.graph.file_symbols:
466
+ matches.append(index_file)
467
+ init_file = str(resolved / "__init__.py")
468
+ if init_file in self.graph.file_symbols:
469
+ matches.append(init_file)
470
+ # Python namespace package: 无 __init__.py 时也尝试匹配目录下 .py 文件
471
+ elif resolved_str not in self.graph.file_symbols:
472
+ ns_prefix = resolved_str + "/"
473
+ ns_matches = [
474
+ f for f in self.graph.file_symbols
475
+ if f.startswith(ns_prefix) and f.endswith(".py")
476
+ ]
477
+ matches.extend(ns_matches[:5])
478
+ return sorted(set(matches))
479
+
480
+ def _resolve_python_dotted_import(self, source_file: str, imp: str) -> list[str]:
481
+ if EXT_TO_LANG.get(Path(source_file).suffix.lower()) != "python" or "." not in imp:
482
+ return []
483
+ module_path = PurePosixPath(*[part for part in imp.split(".") if part])
484
+ if str(module_path) in ("", "."):
485
+ return []
486
+ return self._candidate_files_for_base_path(module_path)
487
+
488
+ def _resolve_alias_or_baseurl_targets(self, source_file: str, imp: str) -> list[str]:
489
+ for config in self._candidate_import_configs_for_file(source_file):
490
+ alias_matches: list[str] = []
491
+ for rule in config.alias_rules:
492
+ wildcard_value = self._match_alias_pattern(rule.alias_pattern, imp)
493
+ if wildcard_value is None:
494
+ continue
495
+ for target_pattern in rule.target_patterns:
496
+ target_base = self._apply_alias_target(target_pattern, wildcard_value)
497
+ if target_base is None:
498
+ continue
499
+ alias_matches.extend(self._candidate_files_for_base_path(PurePosixPath(target_base)))
500
+ if alias_matches:
501
+ return sorted(set(alias_matches))
502
+ if config.base_url:
503
+ base_path = self._normalize_posix_path(PurePosixPath(config.base_url, imp))
504
+ base_matches = self._candidate_files_for_base_path(base_path)
505
+ if base_matches:
506
+ return base_matches
507
+ return []
508
+
509
+ def _candidate_import_configs_for_file(self, source_file: str) -> list[ProjectImportConfig]:
510
+ source_parent = self._normalize_posix_path(PurePosixPath(source_file).parent)
511
+ if source_parent is None:
512
+ return []
513
+ ranked: list[tuple[int, ProjectImportConfig]] = []
514
+ for config in self.import_configs:
515
+ config_dir = self._normalize_posix_path(PurePosixPath(config.config_dir or "."))
516
+ if config_dir is not None and self._is_subpath(source_parent, config_dir):
517
+ ranked.append((self._path_depth(config.config_dir), config))
518
+ ranked.sort(key=lambda item: (-item[0], item[1].config_path or ""))
519
+ return [config for _, config in ranked]
520
+
521
+ @staticmethod
522
+ def _match_alias_pattern(alias_pattern: str, import_path: str) -> str | None:
523
+ if "*" not in alias_pattern:
524
+ return "" if alias_pattern == import_path else None
525
+ prefix, suffix = alias_pattern.split("*", 1)
526
+ if not import_path.startswith(prefix) or not import_path.endswith(suffix):
527
+ return None
528
+ return import_path[len(prefix):len(import_path) - len(suffix) if suffix else None]
529
+
530
+ @staticmethod
531
+ def _apply_alias_target(target_pattern: str, wildcard_value: str) -> str | None:
532
+ if "*" in target_pattern:
533
+ return target_pattern.replace("*", wildcard_value, 1)
534
+ if wildcard_value:
535
+ return None
536
+ return target_pattern
537
+
538
+ def _resolve_relative_base(self, source_file: str, imp: str) -> PurePosixPath | None:
539
+ source_parent = PurePosixPath(source_file).parent
540
+ if "/" in imp:
541
+ return self._normalize_posix_path(PurePosixPath(source_parent, imp))
542
+
543
+ leading_dots = len(imp) - len(imp.lstrip("."))
544
+ remainder = imp.lstrip(".").replace(".", "/")
545
+ base = source_parent
546
+ for _ in range(max(leading_dots - 1, 0)):
547
+ base = base.parent
548
+ if remainder:
549
+ base = PurePosixPath(base, remainder)
550
+ return self._normalize_posix_path(base)
551
+
552
+ @staticmethod
553
+ def _normalize_posix_path(path: PurePosixPath) -> PurePosixPath | None:
554
+ normalized_parts: list[str] = []
555
+ for part in path.parts:
556
+ if part in ("", "."):
557
+ continue
558
+ if part == "..":
559
+ if not normalized_parts:
560
+ return None
561
+ normalized_parts.pop()
562
+ continue
563
+ normalized_parts.append(part)
564
+ if not normalized_parts:
565
+ return PurePosixPath(".")
566
+ return PurePosixPath(*normalized_parts)
567
+
568
+ @staticmethod
569
+ def _path_depth(path_value: str | None) -> int:
570
+ if not path_value or path_value == ".":
571
+ return 0
572
+ return len([part for part in PurePosixPath(path_value).parts if part not in ("", ".")])
573
+
574
+ @staticmethod
575
+ def _is_subpath(path: PurePosixPath, maybe_parent: PurePosixPath) -> bool:
576
+ parent_parts = tuple(part for part in maybe_parent.parts if part not in ("", "."))
577
+ path_parts = tuple(part for part in path.parts if part not in ("", "."))
578
+ if not parent_parts:
579
+ return True
580
+ if len(parent_parts) > len(path_parts):
581
+ return False
582
+ return path_parts[: len(parent_parts)] == parent_parts
583
+
584
+ def resolve_calling_symbol(self, file: str, call_line: int) -> str | None:
585
+ """确定指定行所在的符号(调用者)。"""
586
+ symbol_ids = self.graph.file_symbols.get(file, [])
587
+ containing = [
588
+ self.graph.symbols[symbol_id]
589
+ for symbol_id in symbol_ids
590
+ if symbol_id in self.graph.symbols
591
+ and self.graph.symbols[symbol_id].line <= call_line <= max(
592
+ self.graph.symbols[symbol_id].end_line,
593
+ self.graph.symbols[symbol_id].line,
594
+ )
595
+ ]
596
+ if not containing:
597
+ return None
598
+ containing.sort(
599
+ key=lambda symbol: (
600
+ max(symbol.end_line, symbol.line) - symbol.line,
601
+ -symbol.line,
602
+ symbol.col,
603
+ symbol.name,
604
+ )
605
+ )
606
+ return containing[0].id
607
+
608
+ def resolve_import_binding_targets(
609
+ self,
610
+ file: str,
611
+ binding: JSImportBinding,
612
+ ) -> set[str]:
613
+ """解析 import binding 到目标符号。"""
614
+ if binding.imported_name == "*":
615
+ return set()
616
+ target_files = self.resolve_import_targets(file, binding.module)
617
+ if not target_files:
618
+ return set()
619
+
620
+ resolved_ids: set[str] = set()
621
+ for target_file in target_files:
622
+ resolved_ids.update(
623
+ self._resolve_exported_symbols(
624
+ file=target_file,
625
+ export_name=binding.imported_name,
626
+ depth=0,
627
+ visited=set(),
628
+ )
629
+ )
630
+
631
+ if resolved_ids:
632
+ return resolved_ids
633
+
634
+ if binding.imported_name != "default":
635
+ direct_candidates = [
636
+ symbol_id
637
+ for symbol_id in self._name_idx.get(binding.imported_name, [])
638
+ if self._sym_file[symbol_id] in target_files
639
+ ]
640
+ if direct_candidates:
641
+ return set(direct_candidates)
642
+ return set()
643
+
644
+ def _resolve_exported_symbols(
645
+ self,
646
+ file: str,
647
+ export_name: str,
648
+ depth: int,
649
+ visited: set[tuple[str, str]],
650
+ ) -> set[str]:
651
+ """递归解析 re-export,追踪到实际定义的符号。"""
652
+ if depth >= MAX_EXPORT_RESOLVE_DEPTH:
653
+ return set()
654
+ visit_key = (file, export_name)
655
+ if visit_key in visited:
656
+ return set()
657
+ next_visited = set(visited)
658
+ next_visited.add(visit_key)
659
+
660
+ bindings = self.graph.file_exports.get(file, [])
661
+ resolved_ids: set[str] = set()
662
+
663
+ for binding in bindings:
664
+ if binding.kind == "wildcard" and binding.module:
665
+ target_files = self.resolve_import_targets(file, binding.module)
666
+ for target_file in target_files:
667
+ resolved_ids.update(
668
+ self._resolve_exported_symbols(
669
+ file=target_file,
670
+ export_name=export_name,
671
+ depth=depth + 1,
672
+ visited=next_visited,
673
+ )
674
+ )
675
+ continue
676
+
677
+ if binding.exported_name != export_name:
678
+ continue
679
+
680
+ if binding.module is None and binding.source_name:
681
+ resolved_ids.update(
682
+ symbol_id
683
+ for symbol_id in self._name_idx.get(binding.source_name, [])
684
+ if self._sym_file[symbol_id] == file
685
+ )
686
+ continue
687
+
688
+ if binding.module and binding.source_name:
689
+ target_files = self.resolve_import_targets(file, binding.module)
690
+ for target_file in target_files:
691
+ resolved_ids.update(
692
+ self._resolve_exported_symbols(
693
+ file=target_file,
694
+ export_name=binding.source_name,
695
+ depth=depth + 1,
696
+ visited=next_visited,
697
+ )
698
+ )
699
+
700
+ if resolved_ids:
701
+ return resolved_ids
702
+
703
+ return {
704
+ symbol_id
705
+ for symbol_id in self._name_idx.get(export_name, [])
706
+ if self._sym_file[symbol_id] == file and self.graph.symbols[symbol_id].visibility == "exported"
707
+ }
708
+
709
+ def resolve_call_target(
710
+ self,
711
+ file: str,
712
+ call_name: str,
713
+ call_line: int,
714
+ call_kind: str,
715
+ import_targets_by_file: dict[str, set[str]],
716
+ import_symbol_targets_by_file: dict[str, dict[str, set[str]]],
717
+ ) -> str | None:
718
+ """解析函数调用到目标符号。"""
719
+ candidates = [
720
+ symbol_id
721
+ for symbol_id in self._name_idx.get(call_name, [])
722
+ if self.graph.symbols[symbol_id].kind in CALLABLE_KINDS
723
+ ]
724
+ if not candidates:
725
+ return None
726
+
727
+ imported_targets = [
728
+ symbol_id
729
+ for symbol_id in import_symbol_targets_by_file.get(file, {}).get(call_name, set())
730
+ if self.graph.symbols[symbol_id].kind in CALLABLE_KINDS
731
+ ]
732
+ if imported_targets:
733
+ if len(imported_targets) == 1:
734
+ return imported_targets[0]
735
+ return self._pick_best_target(imported_targets, file, call_line)
736
+
737
+ imported_files = import_targets_by_file.get(file, set())
738
+ imported = [symbol_id for symbol_id in candidates if self._sym_file[symbol_id] in imported_files]
739
+ if imported:
740
+ return self._pick_best_target(imported, file, call_line)
741
+
742
+ if call_kind == "member":
743
+ # 成员调用 (obj.method()):仅在同文件匹配 method 类型的符号,避免误绑到全局函数
744
+ same_file_methods = [
745
+ sid for sid in candidates
746
+ if self._sym_file[sid] == file
747
+ and self.graph.symbols[sid].kind == "method"
748
+ ]
749
+ if same_file_methods:
750
+ return self._pick_best_target(same_file_methods, file, call_line)
751
+ return None
752
+
753
+ if any(binding.local_name == call_name for binding in self.graph.file_import_bindings.get(file, [])):
754
+ return None
755
+
756
+ same_file = [symbol_id for symbol_id in candidates if self._sym_file[symbol_id] == file]
757
+ if same_file:
758
+ return self._pick_best_target(same_file, file, call_line)
759
+
760
+ if len(candidates) == 1:
761
+ return candidates[0]
762
+
763
+ exported = [
764
+ symbol_id
765
+ for symbol_id in candidates
766
+ if self.graph.symbols[symbol_id].visibility == "exported"
767
+ ]
768
+ if len(exported) == 1:
769
+ return exported[0]
770
+ return None
771
+
772
+ def _pick_best_target(self, candidates: list[str], file: str, call_line: int) -> str:
773
+ ordered = sorted(
774
+ candidates,
775
+ key=lambda symbol_id: (
776
+ 0 if self.graph.symbols[symbol_id].file == file else 1,
777
+ abs(self.graph.symbols[symbol_id].line - call_line),
778
+ 0 if self.graph.symbols[symbol_id].visibility == "exported" else 1,
779
+ self.graph.symbols[symbol_id].file,
780
+ self.graph.symbols[symbol_id].line,
781
+ self.graph.symbols[symbol_id].name,
782
+ ),
783
+ )
784
+ return ordered[0]
785
+
786
+ def _discover_import_config_paths(self) -> list[Path]:
787
+ found: list[Path] = []
788
+ for root, dir_names, file_names in os.walk(self.project_root):
789
+ dir_names[:] = [name for name in dir_names if name not in SKIP_DIR_NAMES]
790
+ for filename in ("tsconfig.json", "jsconfig.json"):
791
+ if filename in file_names:
792
+ found.append(Path(root) / filename)
793
+ return sorted(found)
794
+
795
+ def _load_jsonc_with_extends(self, config_path: Path, visited: set[Path]) -> dict[str, Any]:
796
+ if config_path in visited or not config_path.exists():
797
+ return {}
798
+ next_visited = set(visited)
799
+ next_visited.add(config_path)
800
+ try:
801
+ raw_data = json.loads(self._strip_jsonc(config_path.read_text(encoding="utf-8")))
802
+ except (OSError, json.JSONDecodeError):
803
+ return {}
804
+ if not isinstance(raw_data, dict):
805
+ return {}
806
+
807
+ extends_value = raw_data.get("extends")
808
+ if not isinstance(extends_value, str):
809
+ return raw_data
810
+ base_config_path = self._resolve_extends_path(config_path, extends_value)
811
+ if base_config_path is None:
812
+ return raw_data
813
+ base_data = self._load_jsonc_with_extends(base_config_path, next_visited)
814
+ return self._merge_dicts(base_data, raw_data)
815
+
816
+ def _resolve_extends_path(self, config_path: Path, extends_value: str) -> Path | None:
817
+ candidate = Path(extends_value)
818
+ if not candidate.suffix:
819
+ candidate = candidate.with_suffix(".json")
820
+ if candidate.is_absolute():
821
+ resolved = candidate
822
+ else:
823
+ if not extends_value.startswith("."):
824
+ return None
825
+ resolved = (config_path.parent / candidate).resolve()
826
+ try:
827
+ resolved.relative_to(self.project_root)
828
+ except ValueError:
829
+ return None
830
+ return resolved
831
+
832
+ def _resolve_config_relative_path(self, config_path: Path, value: Any, base_url: str | None = None) -> str | None:
833
+ if not isinstance(value, str) or not value.strip():
834
+ return None
835
+ value_path = Path(value)
836
+ if base_url and not value.startswith(".") and not value_path.is_absolute():
837
+ resolved = (self.project_root / base_url / value).resolve()
838
+ else:
839
+ resolved = (config_path.parent / value).resolve()
840
+ try:
841
+ return resolved.relative_to(self.project_root).as_posix()
842
+ except ValueError:
843
+ return None
844
+
845
+ @staticmethod
846
+ def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
847
+ merged = dict(base)
848
+ for key, value in override.items():
849
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
850
+ merged[key] = ImportResolver._merge_dicts(merged[key], value)
851
+ else:
852
+ merged[key] = value
853
+ return merged
854
+
855
+ @staticmethod
856
+ def _strip_jsonc(text: str) -> str:
857
+ result: list[str] = []
858
+ in_string = False
859
+ string_delimiter = ""
860
+ escape = False
861
+ in_line_comment = False
862
+ in_block_comment = False
863
+ i = 0
864
+ while i < len(text):
865
+ char = text[i]
866
+ next_char = text[i + 1] if i + 1 < len(text) else ""
867
+ if in_line_comment:
868
+ if char == "\n":
869
+ in_line_comment = False
870
+ result.append(char)
871
+ i += 1
872
+ continue
873
+ if in_block_comment:
874
+ if char == "*" and next_char == "/":
875
+ in_block_comment = False
876
+ i += 2
877
+ continue
878
+ i += 1
879
+ continue
880
+ if in_string:
881
+ result.append(char)
882
+ if escape:
883
+ escape = False
884
+ elif char == "\\":
885
+ escape = True
886
+ elif char == string_delimiter:
887
+ in_string = False
888
+ i += 1
889
+ continue
890
+ if char == "/" and next_char == "/":
891
+ in_line_comment = True
892
+ i += 2
893
+ continue
894
+ if char == "/" and next_char == "*":
895
+ in_block_comment = True
896
+ i += 2
897
+ continue
898
+ if char in {'"', "'"}:
899
+ in_string = True
900
+ string_delimiter = char
901
+ result.append(char)
902
+ i += 1
903
+ continue
904
+ result.append(char)
905
+ i += 1
906
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(result))