Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codegraph/graph/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 80 additions & 8 deletions codegraph/resolve/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
{
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand All @@ -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]
Expand All @@ -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] = []
Expand Down
173 changes: 173 additions & 0 deletions codegraph/resolve/tsconfig_paths.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions tests/fixtures/ts_index_file/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class User {
constructor(public name: string) {}

greet(): string {
return `Hi, I am ${this.name}`;
}
}
5 changes: 5 additions & 0 deletions tests/fixtures/ts_index_file/src/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { User } from "./models";

export function createUser(name: string): User {
return new User(name);
}
7 changes: 7 additions & 0 deletions tests/fixtures/ts_jsconfig/jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"#/*": ["./src/*"]
}
}
}
5 changes: 5 additions & 0 deletions tests/fixtures/ts_jsconfig/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { greet } from "#/utils/helper";

export function run(): void {
greet("world");
}
3 changes: 3 additions & 0 deletions tests/fixtures/ts_jsconfig/src/utils/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function greet(name: string): string {
return `Hello, ${name}`;
}
5 changes: 5 additions & 0 deletions tests/fixtures/ts_path_aliases/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { db } from "@/lib/db";

export function main(): void {
db();
}
7 changes: 7 additions & 0 deletions tests/fixtures/ts_path_aliases/src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function db(): void {
// database helper
}

export function connect(): boolean {
return true;
}
10 changes: 10 additions & 0 deletions tests/fixtures/ts_path_aliases/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
// TypeScript path alias configuration
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
"strict": true, // trailing comma is intentional (tests tolerant parse)
}
}
Loading
Loading