diff --git a/codegraph/graph/builder.py b/codegraph/graph/builder.py index 4a5c563..aab182b 100644 --- a/codegraph/graph/builder.py +++ b/codegraph/graph/builder.py @@ -160,7 +160,7 @@ def build(self, incremental: bool = True) -> BuildStats: # Best-effort cross-file resolution of unresolved CALLS/IMPORTS edges. try: from codegraph.resolve import resolve_unresolved_edges - rstats = resolve_unresolved_edges(self._store) + rstats = resolve_unresolved_edges(self._store, self._repo_root) self._store.set_meta( "last_resolve", f"{rstats.resolved}/{rstats.inspected} resolved", diff --git a/codegraph/resolve/calls.py b/codegraph/resolve/calls.py index 2d4c92b..343393a 100644 --- a/codegraph/resolve/calls.py +++ b/codegraph/resolve/calls.py @@ -9,6 +9,8 @@ * bare ``foo`` inside a module/function -> function/class in the same module * ``mod.foo`` -> resolved through the module's IMPORTS edges * fully-qualified name -> exact qualname match +* tsconfig/jsconfig path aliases: ``@/lib/db`` -> ``src.lib.db`` +* index-file fallback: ``models`` -> ``models.index`` when direct match fails * otherwise: unique tail/qualname match across the whole graph Anything that cannot be resolved unambiguously is left as ``unresolved::*`` so @@ -18,9 +20,11 @@ from collections import defaultdict from dataclasses import dataclass +from pathlib import Path from codegraph.graph.schema import Edge, EdgeKind, Node, NodeKind from codegraph.graph.store_sqlite import SQLiteGraphStore +from codegraph.resolve.tsconfig_paths import TsPathMapping, load_ts_path_mapping, rewrite_alias _REFERENCE_KINDS: frozenset[EdgeKind] = frozenset( { @@ -333,6 +337,24 @@ def _resolve_target( if mod is not None: return mod + # 5b. Index-file fallback: ``./models`` often means ``models/index.ts``. + # When the module qualname has no direct match, retry with ``.index`` + # appended before falling through to the expensive suffix scan. + index_target = target + ".index" + mod_index = index.module_by_qualname.get(index_target) + if mod_index is not None: + return mod_index + cands_index = index.by_qualname.get(index_target, []) + if len(cands_index) == 1: + return cands_index[0] + # Also retry the same-module variant with the index suffix. Like every + # other heuristic, accept only an unambiguous single match. + if src_module is not None: + candidate_index_q = f"{src_module.qualname}.{target}.index" + cands = index.by_qualname.get(candidate_index_q, []) + if len(cands) == 1: + return cands[0] + # 6. Tail match: any qualname ending with .target -- accept only if unique. suffix_matches: list[Node] = [] for qn, nodes in index.by_qualname.items(): @@ -351,12 +373,19 @@ def _resolve_target( def _build_import_bindings( - edges: list[Edge], index: _Index + edges: list[Edge], + index: _Index, + ts_mapping: TsPathMapping | None = None, + repo_root: Path | None = None, ) -> dict[str, dict[str, str]]: """For each module node id, map import alias -> imported module qualname. Currently we only know the textual target_name (e.g. "models" or "./utils"), so the alias is the leaf segment. + + When *ts_mapping* is provided, tsconfig path aliases (e.g. ``@/lib/db``) + are rewritten to their dotted qualname equivalents before the usual + normalization step. """ bindings: dict[str, dict[str, str]] = defaultdict(dict) for edge in edges: @@ -368,21 +397,52 @@ def _build_import_bindings( target = edge.metadata.get("target_name") if not isinstance(target, str) or not target: continue + + imported_name_raw = edge.metadata.get("imported_name") + + # tsconfig alias rewrite: operate on the module portion of target_name. + # The TS parser emits target_name like "@/lib/db.db" (module + "." + # + imported_name). We extract and rewrite the module part, then + # reassemble before the usual normalization so the full qualname + # (e.g. "src.lib.db.db") is preserved for the binding. + rewritten_target = target + if ts_mapping is not None and not ts_mapping.is_empty() \ + and repo_root is not None: + # Extract module portion: strip trailing ".ImportedName" suffix. + module_part = target + if ( + isinstance(imported_name_raw, str) + and imported_name_raw + and target.endswith("." + imported_name_raw) + ): + module_part = target[: -(len(imported_name_raw) + 1)] + rewritten_mod = rewrite_alias(module_part, ts_mapping, repo_root) + if rewritten_mod is not None: + # Re-attach the imported name to the rewritten module path. + if ( + isinstance(imported_name_raw, str) + and imported_name_raw + and target.endswith("." + imported_name_raw) + ): + rewritten_target = rewritten_mod + "." + imported_name_raw + else: + rewritten_target = rewritten_mod + # Python parser may already produce absolute dotted qualnames for # relative imports (e.g. "pkg.models.Foo"). Only strip leading "./" # and "../" path noise, not bare leading dots that may be part of # a dotted qualname. - normalized = target.replace("\\", "/") + normalized = rewritten_target.replace("\\", "/") while normalized.startswith("./") or normalized.startswith("../"): normalized = normalized[2:] if normalized.startswith("./") \ else normalized[3:] normalized = normalized.replace("/", ".") if not normalized: continue - imported_name = edge.metadata.get("imported_name") - if isinstance(imported_name, str) and imported_name: + + if isinstance(imported_name_raw, str) and imported_name_raw: # Bind the alias used in the source file -> full qualname. - bindings[src_node.id][imported_name] = normalized + bindings[src_node.id][imported_name_raw] = normalized bindings[src_node.id][normalized] = normalized else: leaf = normalized.rsplit(".", 1)[-1] @@ -391,12 +451,24 @@ def _build_import_bindings( return bindings -def resolve_unresolved_edges(store: SQLiteGraphStore) -> ResolveStats: - """Rewrite ``unresolved::*`` edges in-place, returning summary stats.""" +def resolve_unresolved_edges( + store: SQLiteGraphStore, + repo_root: Path | None = None, +) -> ResolveStats: + """Rewrite ``unresolved::*`` edges in-place, returning summary stats. + + When *repo_root* is provided, the resolver loads tsconfig.json / + jsconfig.json from that directory to apply TypeScript path-alias rewrites + (e.g. ``@/lib/db`` → ``src.lib.db``). + """ + ts_mapping: TsPathMapping | None = None + if repo_root is not None: + ts_mapping = load_ts_path_mapping(repo_root) + nodes = list(store.iter_nodes()) edges = list(store.iter_edges()) index = _Index(nodes) - bindings = _build_import_bindings(edges, index) + bindings = _build_import_bindings(edges, index, ts_mapping, repo_root) stats = ResolveStats() new_edges: list[Edge] = [] diff --git a/codegraph/resolve/tsconfig_paths.py b/codegraph/resolve/tsconfig_paths.py new file mode 100644 index 0000000..7cc9706 --- /dev/null +++ b/codegraph/resolve/tsconfig_paths.py @@ -0,0 +1,173 @@ +"""Tolerant tsconfig.json / jsconfig.json path-alias loader. + +Reads ``compilerOptions.paths`` and ``compilerOptions.baseUrl`` from the +nearest ``tsconfig.json`` or ``jsconfig.json`` found at *repo_root*. + +The parser is intentionally lenient: +- strips ``//`` and ``/* ... */`` comments before handing the text to + ``json.loads`` (tsconfig allows both; the JSON spec does not); +- strips trailing commas before closing ``]`` or ``}`` tokens. + +No new dependency is introduced — only stdlib. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +# --------------------------------------------------------------------------- +# Tolerant JSON parser helpers +# --------------------------------------------------------------------------- + +# Remove // line comments (not inside strings — good enough for tsconfig). +_LINE_COMMENT_RE = re.compile(r'(?m)//[^\n]*') +# Remove /* ... */ block comments. +_BLOCK_COMMENT_RE = re.compile(r'/\*.*?\*/', re.DOTALL) +# Remove trailing commas before ] or }. +_TRAILING_COMMA_RE = re.compile(r',\s*(?=[}\]])') + + +def _tolerant_loads(text: str) -> object: + """Parse JSON with comments and trailing commas stripped.""" + text = _BLOCK_COMMENT_RE.sub('', text) + text = _LINE_COMMENT_RE.sub('', text) + text = _TRAILING_COMMA_RE.sub('', text) + return json.loads(text) + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class TsPathMapping: + """Resolved path mapping table from tsconfig/jsconfig.""" + + # base_url is the absolute path of compilerOptions.baseUrl (if set). + base_url: Path | None = None + + # paths maps a pattern key (e.g. "@/*") to a list of replacement patterns + # (e.g. ["./src/*"]). The raw strings from compilerOptions.paths. + paths: dict[str, list[str]] = field(default_factory=dict) + + def is_empty(self) -> bool: + return not self.paths and self.base_url is None + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + +_CONFIG_NAMES = ("tsconfig.json", "jsconfig.json") + + +def load_ts_path_mapping(repo_root: Path) -> TsPathMapping: + """Return the TsPathMapping for *repo_root*, or an empty one if absent. + + Searches for ``tsconfig.json`` then ``jsconfig.json`` directly inside + *repo_root* (not recursively). Silently returns empty on any parse + error so as not to break the build pass on malformed configs. + """ + for name in _CONFIG_NAMES: + config_path = repo_root / name + if not config_path.is_file(): + continue + try: + data = _tolerant_loads(config_path.read_text(encoding="utf-8")) + except Exception: + continue + if not isinstance(data, dict): + continue + compiler_opts = data.get("compilerOptions") + if not isinstance(compiler_opts, dict): + return TsPathMapping() + + base_url: Path | None = None + raw_base = compiler_opts.get("baseUrl") + if isinstance(raw_base, str) and raw_base: + candidate = (repo_root / raw_base).resolve() + if candidate.is_dir(): + base_url = candidate + + raw_paths = compiler_opts.get("paths") + paths: dict[str, list[str]] = {} + if isinstance(raw_paths, dict): + for key, val in raw_paths.items(): + if not isinstance(key, str): + continue + if isinstance(val, list): + cleaned = [v for v in val if isinstance(v, str)] + if cleaned: + paths[key] = cleaned + + return TsPathMapping(base_url=base_url, paths=paths) + return TsPathMapping() + + +# --------------------------------------------------------------------------- +# Alias rewriting +# --------------------------------------------------------------------------- + +def rewrite_alias( + import_target: str, + mapping: TsPathMapping, + repo_root: Path, +) -> str | None: + """Rewrite *import_target* using the tsconfig path mapping. + + Returns the rewritten dotted-qualname string if a mapping matches, or + ``None`` if no alias applies. + + E.g.:: + + "@/lib/db" → "src.lib.db" (when @/* -> ["./src/*"]) + "@/lib/db" → None (when no mapping present) + + The returned string uses dots (``src.lib.db``) so it integrates + directly with the existing resolver's dotted-qualname lookup. + """ + if mapping.is_empty(): + return None + + for pattern_key, replacements in mapping.paths.items(): + if not replacements: + continue + replacement = replacements[0] # tsconfig resolves first match + + if pattern_key.endswith("/*"): + prefix = pattern_key[:-2] # e.g. "@" + if import_target.startswith(prefix + "/"): + suffix = import_target[len(prefix) + 1:] # after the "/" + # Build the resolved path: replacement is e.g. "./src/*" + repl_base = replacement[:-2] if replacement.endswith("/*") \ + else replacement + # Strip leading "./" or "../" relative prefix + repl_base = repl_base.lstrip(".") + repl_base = repl_base.lstrip("/") + # Combine: "src" + "/" + "lib/db" -> "src/lib/db" + combined = (repl_base.rstrip("/") + "/" + suffix).lstrip("/") + return combined.replace("/", ".") + else: + # Exact match (no wildcard) + if import_target == pattern_key: + repl = replacement + repl = repl.lstrip("./") + return repl.replace("/", ".") + + # baseUrl fallback: bare non-relative non-alias imports resolve relative + # to baseUrl directory (e.g. "lib/db" -> baseUrl/lib/db). + if mapping.base_url is not None and not import_target.startswith(".") \ + and not import_target.startswith("@"): + # Only attempt if we haven't matched an alias above. + # Convert to dotted qualname relative to repo_root. + try: + rel = mapping.base_url.relative_to(repo_root) + prefix = str(rel).replace("/", ".") + dotted = import_target.replace("/", ".") + return f"{prefix}.{dotted}" if prefix else dotted + except ValueError: + pass + + return None diff --git a/tests/fixtures/ts_index_file/src/models/index.ts b/tests/fixtures/ts_index_file/src/models/index.ts new file mode 100644 index 0000000..07529d0 --- /dev/null +++ b/tests/fixtures/ts_index_file/src/models/index.ts @@ -0,0 +1,7 @@ +export class User { + constructor(public name: string) {} + + greet(): string { + return `Hi, I am ${this.name}`; + } +} diff --git a/tests/fixtures/ts_index_file/src/service.ts b/tests/fixtures/ts_index_file/src/service.ts new file mode 100644 index 0000000..893f262 --- /dev/null +++ b/tests/fixtures/ts_index_file/src/service.ts @@ -0,0 +1,5 @@ +import { User } from "./models"; + +export function createUser(name: string): User { + return new User(name); +} diff --git a/tests/fixtures/ts_jsconfig/jsconfig.json b/tests/fixtures/ts_jsconfig/jsconfig.json new file mode 100644 index 0000000..caf6d79 --- /dev/null +++ b/tests/fixtures/ts_jsconfig/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "#/*": ["./src/*"] + } + } +} diff --git a/tests/fixtures/ts_jsconfig/main.ts b/tests/fixtures/ts_jsconfig/main.ts new file mode 100644 index 0000000..2a364e3 --- /dev/null +++ b/tests/fixtures/ts_jsconfig/main.ts @@ -0,0 +1,5 @@ +import { greet } from "#/utils/helper"; + +export function run(): void { + greet("world"); +} diff --git a/tests/fixtures/ts_jsconfig/src/utils/helper.ts b/tests/fixtures/ts_jsconfig/src/utils/helper.ts new file mode 100644 index 0000000..ae9dfa6 --- /dev/null +++ b/tests/fixtures/ts_jsconfig/src/utils/helper.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}`; +} diff --git a/tests/fixtures/ts_path_aliases/src/app.ts b/tests/fixtures/ts_path_aliases/src/app.ts new file mode 100644 index 0000000..839f55c --- /dev/null +++ b/tests/fixtures/ts_path_aliases/src/app.ts @@ -0,0 +1,5 @@ +import { db } from "@/lib/db"; + +export function main(): void { + db(); +} diff --git a/tests/fixtures/ts_path_aliases/src/lib/db.ts b/tests/fixtures/ts_path_aliases/src/lib/db.ts new file mode 100644 index 0000000..99d5848 --- /dev/null +++ b/tests/fixtures/ts_path_aliases/src/lib/db.ts @@ -0,0 +1,7 @@ +export function db(): void { + // database helper +} + +export function connect(): boolean { + return true; +} diff --git a/tests/fixtures/ts_path_aliases/tsconfig.json b/tests/fixtures/ts_path_aliases/tsconfig.json new file mode 100644 index 0000000..2636639 --- /dev/null +++ b/tests/fixtures/ts_path_aliases/tsconfig.json @@ -0,0 +1,10 @@ +{ + // TypeScript path alias configuration + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + "strict": true, // trailing comma is intentional (tests tolerant parse) + } +} diff --git a/tests/test_resolve_ts_paths.py b/tests/test_resolve_ts_paths.py new file mode 100644 index 0000000..3368da3 --- /dev/null +++ b/tests/test_resolve_ts_paths.py @@ -0,0 +1,351 @@ +"""Tests for TypeScript path-alias and index-file resolution. + +Fixtures live under ``tests/fixtures/ts_path_aliases``, ``ts_jsconfig``, +and ``ts_index_file``. Each is built end-to-end via ``GraphBuilder`` so +the full resolver pipeline (including tsconfig loading) runs. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +from codegraph.graph.builder import GraphBuilder +from codegraph.graph.schema import EdgeKind, NodeKind +from codegraph.graph.store_sqlite import SQLiteGraphStore +from codegraph.resolve.tsconfig_paths import ( + TsPathMapping, + load_ts_path_mapping, + rewrite_alias, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _build(tmp_path: Path, fixture: str) -> tuple[Path, SQLiteGraphStore]: + """Copy *fixture* to a tmp repo and run a full build; return (repo, store).""" + repo = tmp_path / "repo" + shutil.copytree(FIXTURES / fixture, repo) + store = SQLiteGraphStore(tmp_path / "graph.db") + GraphBuilder(repo, store).build(incremental=False) + return repo, store + + +def _count_unresolved(store: SQLiteGraphStore, kind: EdgeKind = EdgeKind.CALLS) -> int: + return sum( + 1 for e in store.iter_edges(kind=kind) + if e.dst.startswith("unresolved::") + ) + + +# --------------------------------------------------------------------------- +# Unit tests for tsconfig loader +# --------------------------------------------------------------------------- + +def test_tolerant_parse_comments_and_trailing_commas(tmp_path: Path) -> None: + """load_ts_path_mapping must handle // comments and trailing commas.""" + cfg = tmp_path / "tsconfig.json" + cfg.write_text( + '{\n' + ' // this is a comment\n' + ' "compilerOptions": {\n' + ' "baseUrl": ".",\n' + ' /* block comment */\n' + ' "paths": {\n' + ' "@/*": ["./src/*"],\n' # trailing comma + ' },\n' # trailing comma + ' "strict": true,\n' # trailing comma + ' },\n' # trailing comma + '}\n', + encoding="utf-8", + ) + mapping = load_ts_path_mapping(tmp_path) + assert "@/*" in mapping.paths + assert mapping.paths["@/*"] == ["./src/*"] + + +def test_jsconfig_loaded_when_no_tsconfig(tmp_path: Path) -> None: + """load_ts_path_mapping falls back to jsconfig.json.""" + cfg = tmp_path / "jsconfig.json" + cfg.write_text( + '{"compilerOptions": {"paths": {"#/*": ["./lib/*"]}}}', + encoding="utf-8", + ) + mapping = load_ts_path_mapping(tmp_path) + assert "#/*" in mapping.paths + + +def test_tsconfig_preferred_over_jsconfig(tmp_path: Path) -> None: + """tsconfig.json is loaded before jsconfig.json.""" + (tmp_path / "tsconfig.json").write_text( + '{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}', + encoding="utf-8", + ) + (tmp_path / "jsconfig.json").write_text( + '{"compilerOptions": {"paths": {"@/*": ["./other/*"]}}}', + encoding="utf-8", + ) + mapping = load_ts_path_mapping(tmp_path) + assert mapping.paths["@/*"] == ["./src/*"] + + +def test_no_config_returns_empty_mapping(tmp_path: Path) -> None: + """Returns empty TsPathMapping when no tsconfig/jsconfig present.""" + mapping = load_ts_path_mapping(tmp_path) + assert mapping.is_empty() + + +def test_rewrite_alias_basic() -> None: + """rewrite_alias converts '@/lib/db' -> 'src.lib.db' for @/* -> ./src/*.""" + mapping = TsPathMapping(paths={"@/*": ["./src/*"]}) + result = rewrite_alias("@/lib/db", mapping, Path("/repo")) + assert result == "src.lib.db", result + + +def test_rewrite_alias_no_match_returns_none() -> None: + """rewrite_alias returns None when target doesn't match any pattern.""" + mapping = TsPathMapping(paths={"@/*": ["./src/*"]}) + result = rewrite_alias("./relative/path", mapping, Path("/repo")) + assert result is None + + +def test_rewrite_alias_empty_mapping_returns_none() -> None: + """rewrite_alias returns None for an empty mapping.""" + mapping = TsPathMapping() + result = rewrite_alias("@/anything", mapping, Path("/repo")) + assert result is None + + +# --------------------------------------------------------------------------- +# Integration: tsconfig path aliases (tsconfig.json with @/* -> ./src/*) +# --------------------------------------------------------------------------- + +def test_tsconfig_alias_resolves_import_binding(tmp_path: Path) -> None: + """'import { db } from \"@/lib/db\"' must bind db -> src.lib.db.db.""" + from codegraph.resolve.calls import _build_import_bindings, _Index + from codegraph.resolve.tsconfig_paths import load_ts_path_mapping + + _repo, store = _build(tmp_path, "ts_path_aliases") + repo = tmp_path / "repo" + + nodes = list(store.iter_nodes()) + edges = list(store.iter_edges()) + index = _Index(nodes) + ts_mapping = load_ts_path_mapping(repo) + bindings = _build_import_bindings(edges, index, ts_mapping, repo) + + # Find the app module + app_module = next( + (n for n in nodes + if n.kind == NodeKind.MODULE and n.qualname.endswith("app")), + None, + ) + assert app_module is not None, "expected a module qualname ending with 'app'" + + module_bindings = bindings.get(app_module.id, {}) + assert "db" in module_bindings, ( + f"expected 'db' bound via @/lib/db alias; got {list(module_bindings)}" + ) + bound = module_bindings["db"] + assert "lib" in bound and "db" in bound, ( + f"binding should reference 'lib' and 'db', got {bound!r}" + ) + + +def test_tsconfig_alias_calls_edge_resolves(tmp_path: Path) -> None: + """'db()' in app.ts must emit a resolved CALLS edge to src/lib/db.db.""" + _repo, store = _build(tmp_path, "ts_path_aliases") + + # Find the db function node + db_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".db") or n.qualname == "db" + ] + assert db_funcs, "expected db function node in src/lib/db.ts" + db_node = db_funcs[0] + + # Find main function in app.ts + main_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".main") or n.qualname == "main" + ] + assert main_funcs, "expected main function in src/app.ts" + main_node = main_funcs[0] + + # There should be a resolved CALLS edge from main to db + calls = [ + e for e in store.iter_edges(src=main_node.id, kind=EdgeKind.CALLS) + if e.dst == db_node.id + ] + assert calls, ( + f"expected CALLS edge from main -> db (resolved via @/lib/db alias); " + f"main={main_node.qualname!r}, db={db_node.qualname!r}, " + f"unresolved_calls={_count_unresolved(store)}" + ) + + +def test_tsconfig_alias_no_unresolved_for_in_repo_symbol( + tmp_path: Path, +) -> None: + """After alias resolution, no CALLS edge from main should be unresolved.""" + _repo, store = _build(tmp_path, "ts_path_aliases") + + main_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".main") or n.qualname == "main" + ] + assert main_funcs + main_node = main_funcs[0] + + unresolved = [ + e for e in store.iter_edges(src=main_node.id, kind=EdgeKind.CALLS) + if e.dst.startswith("unresolved::") + ] + assert not unresolved, ( + f"expected no unresolved CALLS from main; got {[e.dst for e in unresolved]}" + ) + + +# --------------------------------------------------------------------------- +# Integration: jsconfig.json variant +# --------------------------------------------------------------------------- + +def test_jsconfig_alias_resolves(tmp_path: Path) -> None: + """jsconfig.json with '#/*' path alias resolves greet() call.""" + _repo, store = _build(tmp_path, "ts_jsconfig") + + greet_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".greet") or n.qualname == "greet" + ] + assert greet_funcs, "expected greet function in src/utils/helper.ts" + greet_node = greet_funcs[0] + + run_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".run") or n.qualname == "run" + ] + assert run_funcs, "expected run function in main.ts" + run_node = run_funcs[0] + + calls = [ + e for e in store.iter_edges(src=run_node.id, kind=EdgeKind.CALLS) + if e.dst == greet_node.id + ] + assert calls, ( + f"expected CALLS edge from run -> greet via jsconfig #/* alias; " + f"run={run_node.qualname!r}, greet={greet_node.qualname!r}" + ) + + +# --------------------------------------------------------------------------- +# Integration: index-file resolution (./models -> models/index.ts) +# --------------------------------------------------------------------------- + +def test_index_file_import_resolves_user_class(tmp_path: Path) -> None: + """'import { User } from \"./models\"' resolves through models/index.ts. + + The TS parser emits an IMPORTS edge with target_name='./models.User'. + The resolver must follow the index-file fallback to rewrite this to + the resolved node src.models.index.User. + """ + _repo, store = _build(tmp_path, "ts_index_file") + + user_classes = [ + n for n in store.iter_nodes(kind=NodeKind.CLASS) + if n.qualname.endswith(".User") or n.qualname == "User" + ] + assert user_classes, "expected User class in src/models/index.ts" + user_node = user_classes[0] + + # The service module should have an IMPORTS edge pointing to User + service_mod = next( + (n for n in store.iter_nodes(kind=NodeKind.MODULE) + if n.qualname.endswith("service")), + None, + ) + assert service_mod is not None, "expected service module" + + # The IMPORTS edge from service to User should be resolved (not unresolved::) + imports = [ + e for e in store.iter_edges(src=service_mod.id, kind=EdgeKind.IMPORTS) + if e.dst == user_node.id + ] + assert imports, ( + f"expected IMPORTS edge from service -> User resolved via models/index.ts; " + f"User={user_node.qualname!r}; " + f"all imports: {[(e.dst, e.metadata) for e in store.iter_edges(src=service_mod.id, kind=EdgeKind.IMPORTS)]}" + ) + + +def test_index_file_import_binding_includes_user(tmp_path: Path) -> None: + """_build_import_bindings must bind 'User' from './models' -> models.index.User.""" + from codegraph.resolve.calls import _build_import_bindings, _Index + + _repo, store = _build(tmp_path, "ts_index_file") + + nodes = list(store.iter_nodes()) + edges = list(store.iter_edges()) + index = _Index(nodes) + bindings = _build_import_bindings(edges, index, None, None) + + # Find the service module + service_mod = next( + (n for n in nodes + if n.kind == NodeKind.MODULE and n.qualname.endswith("service")), + None, + ) + assert service_mod is not None, "expected service module" + + module_bindings = bindings.get(service_mod.id, {}) + # User should be bound to models.index.User or similar + assert "User" in module_bindings, ( + f"expected 'User' in bindings for service module; got {list(module_bindings)}" + ) + + +# --------------------------------------------------------------------------- +# Regression: no tsconfig -> behavior unchanged +# --------------------------------------------------------------------------- + +def test_no_tsconfig_regression(tmp_path: Path) -> None: + """Repos without tsconfig.json behave exactly as before.""" + repo = tmp_path / "repo" + repo.mkdir() + # Plain TS without any tsconfig + (repo / "utils.ts").write_text( + "export function add(a: number, b: number): number { return a + b; }\n" + ) + (repo / "main.ts").write_text( + "import { add } from './utils';\n" + "export function main(): void { add(1, 2); }\n" + ) + store = SQLiteGraphStore(tmp_path / "graph.db") + GraphBuilder(repo, store).build(incremental=False) + + # add function should resolve + add_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".add") or n.qualname == "add" + ] + assert add_funcs, "expected add function in utils.ts" + add_node = add_funcs[0] + + main_funcs = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".main") or n.qualname == "main" + ] + assert main_funcs, "expected main function in main.ts" + main_node = main_funcs[0] + + calls = [ + e for e in store.iter_edges(src=main_node.id, kind=EdgeKind.CALLS) + if e.dst == add_node.id + ] + assert calls, ( + f"expected CALLS edge from main -> add (no tsconfig); " + f"main={main_node.qualname!r}, add={add_node.qualname!r}" + )