diff --git a/codegraph/codegraph/framework.py b/codegraph/codegraph/framework.py index 9cb610b..a2d0186 100644 --- a/codegraph/codegraph/framework.py +++ b/codegraph/codegraph/framework.py @@ -38,6 +38,10 @@ class FrameworkType(Enum): SVELTEKIT = "sveltekit" NESTJS = "nestjs" ODOO = "odoo" + FASTAPI = "fastapi" + FLASK = "flask" + DJANGO = "django" + FASTIFY = "fastify" UNKNOWN = "unknown" @@ -54,6 +58,10 @@ class FrameworkType(Enum): FrameworkType.SVELTEKIT: "SvelteKit", FrameworkType.NESTJS: "NestJS", FrameworkType.ODOO: "Odoo", + FrameworkType.FASTAPI: "FastAPI", + FrameworkType.FLASK: "Flask", + FrameworkType.DJANGO: "Django", + FrameworkType.FASTIFY: "Fastify", FrameworkType.UNKNOWN: "Unknown", } @@ -128,6 +136,42 @@ class FrameworkDetector: r"_name\s*=\s*['\"]\w+\.\w+['\"]", ], }, + FrameworkType.FASTAPI: { + "files": [], + "dependencies": ["fastapi", "uvicorn"], + "patterns": [ + r"@app\.(get|post|put|delete|patch)\s*\(", + r"from\s+fastapi\s+import", + r"APIRouter", + ], + }, + FrameworkType.FLASK: { + "files": ["wsgi.py"], + "dependencies": ["flask", "Flask"], + "patterns": [ + r"@app\.route\s*\(", + r"from\s+flask\s+import", + r"Flask\s*\(", + ], + }, + FrameworkType.DJANGO: { + "files": ["manage.py", "urls.py", "wsgi.py", "asgi.py"], + "dependencies": ["django", "Django"], + "patterns": [ + r"from\s+django", + r"urlpatterns\s*=", + r"INSTALLED_APPS", + ], + }, + FrameworkType.FASTIFY: { + "files": [], + "dependencies": ["fastify"], + "patterns": [ + r"fastify\.(get|post|put|delete|patch)\s*\(", + r"from\s+['\"]fastify['\"]", + r"import\s+.*Fastify", + ], + }, } STYLING_INDICATORS = { @@ -191,6 +235,7 @@ def __init__(self, project_path: Path) -> None: self._files_cache: Optional[list[Path]] = None self._workspace_deps_cache: Optional[set[str]] = None self._workspace_pjs_cache: Optional[list[dict]] = None + self._python_deps_cache: Optional[set[str]] = None # ── monorepo walk-up ──────────────────────────────────────────────── @@ -400,6 +445,61 @@ def _detect_package_manager(self) -> Optional[str]: return "bun" return None + # ── Python dependency reading ──────────────────────────────────────── + + @property + def _python_dependencies(self) -> set[str]: + """Merged dep names from ``pyproject.toml``, ``setup.py``, and ``requirements.txt``.""" + if self._python_deps_cache is not None: + return self._python_deps_cache + + deps: set[str] = set() + + # pyproject.toml — [project.dependencies] + pyproject = self.project_path / "pyproject.toml" + if pyproject.exists(): + try: + try: + import tomllib # Python 3.11+ + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + with open(pyproject, "rb") as f: + data = tomllib.load(f) + for dep_str in data.get("project", {}).get("dependencies", []): + # Strip version specifiers: "fastapi>=0.100" → "fastapi" + name = re.split(r"[><=!~;\[\s]", dep_str, maxsplit=1)[0].strip() + if name: + deps.add(name.lower()) + # Also check optional deps + for group_deps in data.get("project", {}).get("optional-dependencies", {}).values(): + for dep_str in group_deps: + name = re.split(r"[><=!~;\[\s]", dep_str, maxsplit=1)[0].strip() + if name: + deps.add(name.lower()) + except Exception: + pass + + # requirements.txt + for req_name in ("requirements.txt", "requirements-dev.txt", "requirements-test.txt"): + req_file = self.project_path / req_name + if req_file.exists(): + try: + for line in req_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + name = re.split(r"[><=!~;\[\s]", line, maxsplit=1)[0].strip() + if name: + deps.add(name.lower()) + except OSError: + pass + + self._python_deps_cache = deps + return deps + + def _check_python_dependency(self, dep: str) -> bool: + return dep.lower() in self._python_dependencies + # ── Odoo short-circuit ────────────────────────────────────────────── def _has_odoo_signature(self) -> bool: @@ -441,16 +541,21 @@ def detect(self) -> FrameworkInfo: confidence=0.95, ) + _PYTHON_FRAMEWORKS = {FrameworkType.FASTAPI, FrameworkType.FLASK, FrameworkType.DJANGO} + scores: dict[FrameworkType, float] = {ft: 0.0 for ft in FrameworkType} - code_extensions = (".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte") + code_extensions = (".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".py") for framework, indicators in self.FRAMEWORK_INDICATORS.items(): for file_indicator in indicators["files"]: if self._check_file_exists(file_indicator): scores[framework] += 30.0 for dep in indicators["dependencies"]: + # Check both JS (package.json) and Python (pyproject.toml/requirements.txt) if self._check_dependency(dep): scores[framework] += 25.0 + elif framework in _PYTHON_FRAMEWORKS and self._check_python_dependency(dep): + scores[framework] += 25.0 for pattern in indicators["patterns"]: if self._check_pattern_in_files(pattern, code_extensions): scores[framework] += 15.0 @@ -478,6 +583,8 @@ def detect(self) -> FrameworkInfo: version = self._get_dependency_version("svelte") elif best_framework == FrameworkType.NESTJS: version = self._get_dependency_version("@nestjs/core") + elif best_framework == FrameworkType.FASTIFY: + version = self._get_dependency_version("fastify") return FrameworkInfo( framework=best_framework, diff --git a/codegraph/codegraph/parser.py b/codegraph/codegraph/parser.py index e9b3fac..0fe9fad 100644 --- a/codegraph/codegraph/parser.py +++ b/codegraph/codegraph/parser.py @@ -602,6 +602,24 @@ def _extract_http_method_from_callee(self, fn: Node) -> str: return name return "" + # Fastify/Express-style server objects that register routes + _FASTIFY_OBJECTS = {"fastify", "server", "app", "instance", "fastifyInstance"} + + def _is_fastify_route(self, fn: Node) -> bool: + """Check if a call is a Fastify/Express route registration: ``fastify.get(...)``.""" + if fn.type != "member_expression": + return False + obj = fn.child_by_field_name("object") + prop = fn.child_by_field_name("property") + if obj is None or prop is None: + return False + prop_name = self._text(prop) + obj_name = self._text(obj) + return ( + prop_name in ("get", "post", "put", "patch", "delete", "head", "options") + and obj_name in self._FASTIFY_OBJECTS + ) + def _is_emit_call(self, fn: Node) -> bool: if fn.type != "member_expression": return False @@ -976,6 +994,31 @@ def _scan_function_body(self, body: Node, fn: FunctionNode) -> None: http_m = self._extract_http_method_from_callee(callee) self.result.rest_calls.append((fn.name, http_m, first_str)) + # Fastify route registration: fastify.get('/path', handler) + if self._is_fastify_route(callee): + args = d.child_by_field_name("arguments") + first_str = self._first_string_arg(args) + if first_str and first_str.startswith("/"): + # Extract method directly from the property name (already + # validated by _is_fastify_route) — avoids the + # _extract_http_method_from_callee gap for HEAD/OPTIONS. + prop = callee.child_by_field_name("property") + http_m = self._text(prop).upper() if prop else "GET" + ep = EndpointNode( + method=http_m, + path=first_str, + controller_class=f"file:{self.result.file.path}", + file=self.result.file.path, + handler=fn.name, + ) + self.result.endpoints.append(ep) + self.result.edges.append( + Edge(kind=EXPOSES, src_id=f"file:{self.result.file.path}", dst_id=ep.id) + ) + self.result.edges.append( + Edge(kind=HANDLES, src_id=fn.id, dst_id=ep.id) + ) + # ConfigService.get('X') if self._is_config_get(callee): args = d.child_by_field_name("arguments") diff --git a/codegraph/codegraph/py_parser.py b/codegraph/codegraph/py_parser.py index 93e18bd..61a5400 100644 --- a/codegraph/codegraph/py_parser.py +++ b/codegraph/codegraph/py_parser.py @@ -48,12 +48,17 @@ from .schema import ( ClassNode, + ColumnNode, DECORATED_BY, DEFINES_CLASS, DEFINES_FUNC, Edge, + EndpointNode, + EXPOSES, FileNode, FunctionNode, + HANDLES, + HAS_COLUMN, HAS_METHOD, ImportSpec, MethodNode, @@ -61,6 +66,22 @@ ) +# ── Python route decorator mapping ─────────────────────────────────── + +# Maps canonical decorator base names (without "()" suffix) to HTTP methods. +# None means the method must be extracted from a `methods=` kwarg (Flask-style). +_PY_ROUTE_DECORATORS: dict[str, Optional[str]] = {} +for _obj in ("app", "router"): + for _method, _verb in [("get", "GET"), ("post", "POST"), ("put", "PUT"), + ("delete", "DELETE"), ("patch", "PATCH"), + ("head", "HEAD"), ("options", "OPTIONS")]: + _PY_ROUTE_DECORATORS[f"{_obj}.{_method}"] = _verb + +# Flask-style route() — method extracted from `methods=` kwarg, default GET +for _obj in ("app", "bp", "blueprint"): + _PY_ROUTE_DECORATORS[f"{_obj}.route"] = None + + def _descend(root): """Iterative depth-first descent over a tree-sitter subtree. @@ -308,16 +329,24 @@ def _handle_class(self, node, decorators) -> None: dst_id=cls.id, )) - # Base classes → class_extends name-refs + is_abstract detection + # Base classes → class_extends name-refs + is_abstract + ORM entity detection + base_names: list[str] = [] superclasses = self._child_by_field(node, "superclasses") if superclasses is not None: for c in superclasses.children: if c.type in ("identifier", "attribute", "dotted_name"): base_name = self._text(c).split(".")[-1] + base_names.append(base_name) if base_name in ("ABC", "ABCMeta"): cls.is_abstract = True self.result.class_extends.append((name, base_name)) + # Detect ORM entities: SQLAlchemy (Base, DeclarativeBase, db.Model) + # and Django (models.Model, Model) + _ORM_BASES = {"Base", "DeclarativeBase", "Model"} + if any(b in _ORM_BASES for b in base_names): + cls.is_entity = True + # Class-level decorators for dec in decorators: dname = self._decorator_name(dec) @@ -328,10 +357,12 @@ def _handle_class(self, node, decorators) -> None: dst_id=f"dec:{dname}", )) - # Walk body for methods + # Walk body for methods + ORM columns body = self._child_by_field(node, "body") if body is not None: self._walk_class_body(body, cls) + if cls.is_entity: + self._scan_orm_columns(body, cls) def _walk_class_body(self, body, cls: ClassNode) -> None: for child in body.children: @@ -353,6 +384,142 @@ def _walk_class_body(self, body, cls: ClassNode) -> None: # Nested class — treat as a top-level class for simplicity. self._handle_class(target, decorators=decorators) + # ── ORM column detection ─────────────────────────────────────────── + + # Column-producing call names (SQLAlchemy + Django) + _COLUMN_CALLS = { + "Column", "mapped_column", + # Django model fields + "CharField", "IntegerField", "FloatField", "BooleanField", + "TextField", "DateField", "DateTimeField", "TimeField", + "DecimalField", "EmailField", "URLField", "UUIDField", + "SlugField", "FileField", "ImageField", "JSONField", + "BigIntegerField", "SmallIntegerField", "PositiveIntegerField", + "PositiveSmallIntegerField", "BinaryField", "DurationField", + "AutoField", "BigAutoField", "SmallAutoField", + } + + # Relationship/ForeignKey calls → result.relations + _RELATION_CALLS = {"relationship", "ForeignKey"} + + def _scan_orm_columns(self, body, cls: ClassNode) -> None: + """Scan a class body for ORM column and relationship assignments.""" + for child in body.children: + # Assignments are wrapped in expression_statement → assignment + target = child + if target.type == "expression_statement": + for c in target.children: + if c.type == "assignment": + target = c + break + if target.type == "assignment": + self._check_column_assignment(target, cls) + + def _check_column_assignment(self, node, cls: ClassNode) -> None: + """Check if a statement is a column/relationship assignment.""" + # Find assignment: look for "=" with LHS identifier and RHS call + # Tree-sitter shapes: + # assignment: left=identifier, right=call + # assignment: left=identifier, type=..., right=call (annotated) + + lhs = self._child_by_field(node, "left") + rhs = self._child_by_field(node, "right") + + if lhs is None or rhs is None: + return + + # Get the column name from LHS + col_name = None + if lhs.type == "identifier": + col_name = self._text(lhs) + elif lhs.type == "pattern_list": + return # tuple unpacking, not a column + + if not col_name or col_name.startswith("_"): + # Skip __tablename__, __table_args__, etc. — but extract tablename + if col_name == "__tablename__" and rhs.type == "string": + cls.table_name = self._strip_quotes(self._text(rhs)) + return + + # Get the call name from RHS + if rhs.type != "call": + return + + fn = self._child_by_field(rhs, "function") + if fn is None: + return + + # Get the function name (handles `Column(...)`, `models.CharField(...)`) + call_name = self._text(fn).split(".")[-1] + + if call_name in self._COLUMN_CALLS: + col_type = self._extract_column_type(rhs) + # Django fields: the type is the field class name itself (CharField, etc.) + if not col_type and call_name not in ("Column", "mapped_column"): + col_type = call_name.replace("Field", "") + col = ColumnNode(entity_id=cls.id, name=col_name, type=col_type) + self.result.columns.append(col) + self.result.edges.append(Edge( + kind=HAS_COLUMN, + src_id=cls.id, + dst_id=f"col:{cls.id}#{col_name}", + )) + # Scan Column args for nested ForeignKey/relationship calls + self._scan_nested_relations(rhs, cls, col_name) + elif call_name in self._RELATION_CALLS: + # relationship("Address") or ForeignKey("address.id") + target = self._call_first_string_arg(rhs) + if target: + # Strip table references: "address.id" → "address" + target = target.split(".")[0] + self.result.relations.append((cls.name, call_name, col_name, target)) + + def _scan_nested_relations(self, call_node, cls: ClassNode, col_name: str) -> None: + """Scan a Column() call's arguments for nested ForeignKey/relationship calls.""" + args = self._child_by_field(call_node, "arguments") + if args is None: + return + for arg in args.children: + if arg.type == "call": + fn = self._child_by_field(arg, "function") + if fn is None: + continue + nested_name = self._text(fn).split(".")[-1] + if nested_name in self._RELATION_CALLS: + target = self._call_first_string_arg(arg) + if target: + target = target.split(".")[0] + self.result.relations.append((cls.name, nested_name, col_name, target)) + + def _extract_column_type(self, call_node) -> str: + """Extract the type from a Column/mapped_column/models.*Field call.""" + args = self._child_by_field(call_node, "arguments") + if args is None: + return "" + for arg in args.children: + if arg.type in ("(", ")", ","): + continue + if arg.type == "keyword_argument": + continue + # First positional arg is typically the type + return self._text(arg).split("(")[0] # Column(String(50)) → "String" + return "" + + def _call_first_string_arg(self, call_node) -> Optional[str]: + """Extract the first string literal argument from a call.""" + args = self._child_by_field(call_node, "arguments") + if args is None: + return None + for arg in args.children: + if arg.type == "string": + return self._strip_quotes(self._text(arg)) + if arg.type in ("(", ")", ","): + continue + if arg.type == "keyword_argument": + continue + break + return None + # ── methods ─────────────────────────────────────────────────────── def _handle_method(self, node, cls: ClassNode, decorators) -> None: @@ -393,7 +560,8 @@ def _handle_method(self, node, cls: ClassNode, decorators) -> None: dst_id=method.id, )) - # Method decorators + # Method decorators + route endpoint detection + http_dec = None # (http_method, path) if a route decorator is found for dec in decorators: dname = self._decorator_name(dec) if dname: @@ -402,6 +570,29 @@ def _handle_method(self, node, cls: ClassNode, decorators) -> None: src_id=method.id, dst_id=f"dec:{dname}", )) + # Check if this decorator is a route decorator + base = dname.rstrip("()") + if base in _PY_ROUTE_DECORATORS: + verb = _PY_ROUTE_DECORATORS[base] + path = self._decorator_first_string_arg(dec) or "/" + if verb is None: + # Flask-style: extract method from `methods=` kwarg + verb = self._decorator_methods_kwarg(dec) or "GET" + http_dec = (verb, path) + + if http_dec: + http_method, path = http_dec + full_path = self._join_paths(cls.base_path, path) + ep = EndpointNode( + method=http_method, + path=full_path, + controller_class=cls.id, + file=self.result.file.path, + handler=name, + ) + self.result.endpoints.append(ep) + self.result.edges.append(Edge(kind=EXPOSES, src_id=cls.id, dst_id=ep.id)) + self.result.edges.append(Edge(kind=HANDLES, src_id=method.id, dst_id=ep.id)) # Method call graph (Phase 4 input — consumed by resolver) body = self._child_by_field(node, "body") @@ -519,7 +710,8 @@ def _handle_function(self, node, decorators) -> None: dst_id=fn.id, )) - # Function decorators + # Function decorators + route endpoint detection + http_dec = None for dec in decorators: dname = self._decorator_name(dec) if dname: @@ -528,6 +720,26 @@ def _handle_function(self, node, decorators) -> None: src_id=fn.id, dst_id=f"dec:{dname}", )) + base = dname.rstrip("()") + if base in _PY_ROUTE_DECORATORS: + verb = _PY_ROUTE_DECORATORS[base] + path = self._decorator_first_string_arg(dec) or "/" + if verb is None: + verb = self._decorator_methods_kwarg(dec) or "GET" + http_dec = (verb, path) + + if http_dec: + http_method, path = http_dec + ep = EndpointNode( + method=http_method, + path=path, + controller_class=self.result.file.id, + file=self.result.file.path, + handler=name, + ) + self.result.endpoints.append(ep) + self.result.edges.append(Edge(kind=EXPOSES, src_id=self.result.file.id, dst_id=ep.id)) + self.result.edges.append(Edge(kind=HANDLES, src_id=fn.id, dst_id=ep.id)) # ── signature + docstring extraction ────────────────────────────── @@ -625,6 +837,68 @@ def _param_to_dict(self, node) -> Optional[dict]: return entry return None + # ── decorator argument extraction ──────────────────────────────── + + def _decorator_first_string_arg(self, dec) -> Optional[str]: + """Extract the first positional string literal from a decorator call. + + ``@app.get("/users")`` → ``"/users"`` + ``@app.route("/items", methods=["POST"])`` → ``"/items"`` + """ + for c in dec.children: + if c.type == "call": + args = self._child_by_field(c, "arguments") + if args is None: + continue + for arg in args.children: + if arg.type == "string": + return self._strip_quotes(self._text(arg)) + if arg.type == "concatenated_string": + # f"..." or "a" "b" — just take the raw text + return self._strip_quotes(self._text(arg)) + if arg.type in ("keyword_argument",): + continue # skip kwargs, look for positional + if arg.type in ("(", ")", ","): + continue + break # first non-string positional → give up + return None + + def _decorator_methods_kwarg(self, dec) -> Optional[str]: + """Extract the first HTTP method from a ``methods=[...]`` kwarg. + + ``@app.route("/x", methods=["POST", "PUT"])`` → ``"POST"`` + """ + for c in dec.children: + if c.type == "call": + args = self._child_by_field(c, "arguments") + if args is None: + continue + for arg in args.children: + if arg.type == "keyword_argument": + key = self._child_by_field(arg, "name") + if key and self._text(key) == "methods": + value = self._child_by_field(arg, "value") + if value and value.type == "list": + for item in value.children: + if item.type == "string": + return self._strip_quotes(self._text(item)) + return None + + @staticmethod + def _strip_quotes(s: str) -> str: + """Remove surrounding quotes from a string literal.""" + for q in ('"""', "'''", '"', "'"): + if s.startswith(q) and s.endswith(q): + return s[len(q):-len(q)] + return s + + @staticmethod + def _join_paths(base: str, sub: str) -> str: + """Join a base path and a sub path, normalising slashes.""" + if not base: + return sub + return f"{base.rstrip('/')}/{sub.lstrip('/')}" + # ── decorator naming ────────────────────────────────────────────── def _decorator_name(self, dec) -> Optional[str]: diff --git a/codegraph/codegraph/resolver.py b/codegraph/codegraph/resolver.py index 85a1ff8..5f12980 100644 --- a/codegraph/codegraph/resolver.py +++ b/codegraph/codegraph/resolver.py @@ -36,6 +36,9 @@ _EXT_CANDIDATES = ["", ".ts", ".tsx", ".d.ts", "/index.ts", "/index.tsx", "/index.d.ts"] _TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])") +# NodeNext-style imports use .js extensions even when the source is .ts on disk. +_JS_TO_TS_REMAP = {".js": ".ts", ".jsx": ".tsx", ".mjs": ".mts", ".cjs": ".cts"} + # ── tsconfig JSONC ─────────────────────────────────────────── @@ -198,13 +201,16 @@ def __init__(self, repo_root: Path, packages: list[PackageConfig]) -> None: def set_path_index(self, path_index: PathIndex) -> None: self._path_index = path_index - # Precompute alias → [(alias_prefix, absolute_target_dir)] for quick scan + # Precompute alias → [(alias_prefix, absolute_target_dir)] for quick scan. + # Keyed by str(pkg.root) (not pkg.name) so two packages with the same + # basename (e.g. apps/frontend/src and apps/backend/src) get distinct buckets. self._alias_cache = {} for pkg in self.packages: + pkg_key = str(pkg.root) for alias, targets in pkg.aliases.items(): - self._alias_cache.setdefault(pkg.name, []) + self._alias_cache.setdefault(pkg_key, []) for t in targets: - self._alias_cache[pkg.name].append((alias, t)) + self._alias_cache[pkg_key].append((alias, t)) def resolve(self, importer_rel: str, specifier: str) -> Optional[str]: if self._path_index is None: @@ -226,25 +232,71 @@ def resolve(self, importer_rel: str, specifier: str) -> Optional[str]: base_rel = str(target.relative_to(self.repo_root)).replace("\\", "/") except ValueError: return None - return self._path_index.try_resolve(base_rel) + hit = self._path_index.try_resolve(base_rel) + if hit: + return hit + # NodeNext: remap .js → .ts when the literal path doesn't exist + return self._try_js_remap(base_rel) # Absolute from repo root — rare if spec.startswith("/"): return self._path_index.try_resolve(spec.lstrip("/")) - # Alias lookup + # Alias lookup — try importer's own package first, then fall through + importer_pkg = self._package_for_file(importer_rel) + if importer_pkg and importer_pkg in self._alias_cache: + hit = self._try_aliases(spec, self._alias_cache[importer_pkg]) + if hit: + return hit for pkg_name, alias_pairs in self._alias_cache.items(): - for alias, target_dir in alias_pairs: - if spec.startswith(alias): - rest = spec[len(alias):] - candidate = (target_dir / rest).resolve() if rest else target_dir - try: - base_rel = str(candidate.relative_to(self.repo_root)).replace("\\", "/") - except ValueError: - continue - hit = self._path_index.try_resolve(base_rel) - if hit: - return hit + if pkg_name == importer_pkg: + continue # already tried + hit = self._try_aliases(spec, alias_pairs) + if hit: + return hit + return None + + def _try_aliases(self, spec: str, alias_pairs: list[tuple[str, Path]]) -> Optional[str]: + """Try resolving *spec* against a list of (alias_prefix, target_dir) pairs.""" + if self._path_index is None: + return None + for alias, target_dir in alias_pairs: + if spec.startswith(alias): + rest = spec[len(alias):] + candidate = (target_dir / rest).resolve() if rest else target_dir + try: + base_rel = str(candidate.relative_to(self.repo_root)).replace("\\", "/") + except ValueError: + continue + hit = self._path_index.try_resolve(base_rel) + if hit: + return hit + # NodeNext: remap .js → .ts for aliased imports too + hit = self._try_js_remap(base_rel) + if hit: + return hit + return None + + def _package_for_file(self, rel: str) -> Optional[str]: + """Return the cache key (``str(pkg.root)``) for the package that contains *rel*.""" + abs_path = (self.repo_root / rel).resolve() + for pkg in self.packages: + try: + abs_path.relative_to(pkg.root) + return str(pkg.root) + except ValueError: + continue + return None + + def _try_js_remap(self, base_rel: str) -> Optional[str]: + """Remap NodeNext .js/.jsx/.mjs/.cjs extensions to .ts/.tsx/.mts/.cts.""" + if self._path_index is None: + return None + for js_ext, ts_ext in _JS_TO_TS_REMAP.items(): + if base_rel.endswith(js_ext): + remapped = base_rel[: -len(js_ext)] + ts_ext + if remapped in self._path_index.files: + return remapped return None # ── Python resolution ───────────────────────────────────────────── @@ -290,13 +342,19 @@ def _resolve_python(self, importer_rel: str, spec: str) -> Optional[str]: return self._resolve_python_module(base, remainder) # Absolute intra-package import: strip the top-level name. + # Try the importer's own package first to avoid basename collisions + # when multiple Python packages share the same directory name. first = spec.split(".")[0] - for pkg in self.packages: - if pkg.language != "py": - continue - if pkg.name == first: - remainder = ".".join(spec.split(".")[1:]) - return self._resolve_python_module(pkg.root, remainder) + remainder = ".".join(spec.split(".")[1:]) + importer_root = self._package_for_file(importer_rel) + candidates = sorted( + (pkg for pkg in self.packages if pkg.language == "py" and pkg.name == first), + key=lambda p: (str(p.root) != importer_root), # own package sorts first + ) + for pkg in candidates: + hit = self._resolve_python_module(pkg.root, remainder) + if hit: + return hit # External — the caller emits IMPORTS_EXTERNAL. return None diff --git a/codegraph/pyproject.toml b/codegraph/pyproject.toml index 1a4350c..78f53d3 100644 --- a/codegraph/pyproject.toml +++ b/codegraph/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "cognitx-codegraph" -version = "0.2.0" -description = "Code knowledge graph for Claude Code & AI coding agents — index TypeScript, NestJS, React into Neo4j and query architecture in Cypher" +version = "0.1.0" +description = "Code knowledge graph for Claude Code & AI coding agents — index TypeScript, Python, NestJS, FastAPI, React into Neo4j and query architecture in Cypher. Note: v0.2.0 is deprecated, use 0.1.x." readme = "README.md" requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/codegraph/tests/test_py_framework.py b/codegraph/tests/test_py_framework.py new file mode 100644 index 0000000..6391318 --- /dev/null +++ b/codegraph/tests/test_py_framework.py @@ -0,0 +1,230 @@ +"""Tests for Python + Fastify framework detection in :class:`codegraph.framework.FrameworkDetector`. + +Builds synthetic package directories in ``tmp_path`` with characteristic +files / dependency declarations / code patterns and verifies that +``FrameworkDetector.detect()`` returns the correct ``FrameworkType``. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from codegraph.framework import FrameworkDetector, FrameworkType + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _write(root: Path, rel: str, content: str = "") -> None: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + + +# ═══════════════════════════════════════════════════════════════════ +# FastAPI +# ═══════════════════════════════════════════════════════════════════ + + +class TestFastAPI: + + def test_pyproject_dependency(self, tmp_path: Path): + """FastAPI detected via pyproject.toml dependency.""" + _write(tmp_path, "pyproject.toml", """\ +[project] +name = "myapi" +dependencies = ["fastapi>=0.100", "uvicorn"] +""") + _write(tmp_path, "main.py", """\ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/") +def root(): + return {"msg": "hi"} +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTAPI + assert info.confidence >= 0.5 + + def test_requirements_txt(self, tmp_path: Path): + """FastAPI detected via requirements.txt.""" + _write(tmp_path, "requirements.txt", "fastapi==0.104.1\nuvicorn\npydantic\n") + _write(tmp_path, "app.py", "from fastapi import FastAPI\napp = FastAPI()\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTAPI + + def test_code_pattern_only(self, tmp_path: Path): + """FastAPI detected from code patterns alone (no dependency file).""" + _write(tmp_path, "main.py", """\ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/users") +def list_users(): + return [] + +@app.post("/users") +def create_user(): + return {} +""") + info = FrameworkDetector(tmp_path).detect() + # Two pattern matches (from fastapi import + @app.get) = 30pts, above threshold + assert info.framework == FrameworkType.FASTAPI + assert info.confidence >= 0.25 + + +# ═══════════════════════════════════════════════════════════════════ +# Flask +# ═══════════════════════════════════════════════════════════════════ + + +class TestFlask: + + def test_requirements_and_pattern(self, tmp_path: Path): + """Flask detected via requirements.txt + code pattern.""" + _write(tmp_path, "requirements.txt", "flask==3.0\ngunicorn\n") + _write(tmp_path, "app.py", """\ +from flask import Flask +app = Flask(__name__) + +@app.route("/hello") +def hello(): + return "Hello!" +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FLASK + assert info.confidence >= 0.5 + + def test_wsgi_file(self, tmp_path: Path): + """Flask detected via wsgi.py marker file + pattern.""" + _write(tmp_path, "wsgi.py", "from app import app\n") + _write(tmp_path, "app.py", "from flask import Flask\napp = Flask(__name__)\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FLASK + + +# ═══════════════════════════════════════════════════════════════════ +# Django +# ═══════════════════════════════════════════════════════════════════ + + +class TestDjango: + + def test_manage_py_and_settings(self, tmp_path: Path): + """Django detected via manage.py + settings patterns.""" + _write(tmp_path, "manage.py", "#!/usr/bin/env python\nimport django\n") + _write(tmp_path, "mysite/settings.py", """\ +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', +] +""") + _write(tmp_path, "mysite/urls.py", """\ +from django.urls import path +urlpatterns = [ + path('admin/', admin.site.urls), +] +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.DJANGO + assert info.confidence >= 0.5 + + def test_pyproject_dependency(self, tmp_path: Path): + """Django detected via pyproject.toml dependency.""" + _write(tmp_path, "pyproject.toml", """\ +[project] +name = "mysite" +dependencies = ["django>=4.2"] +""") + _write(tmp_path, "manage.py", "import django\n") + _write(tmp_path, "app/views.py", "from django.views import View\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.DJANGO + + +# ═══════════════════════════════════════════════════════════════════ +# Fastify (TS/JS) +# ═══════════════════════════════════════════════════════════════════ + + +class TestFastify: + + def test_package_json_dependency(self, tmp_path: Path): + """Fastify detected via package.json dependency.""" + _write(tmp_path, "package.json", '{"dependencies": {"fastify": "^4.0.0"}}') + _write(tmp_path, "src/app.ts", """\ +import Fastify from 'fastify'; +const fastify = Fastify(); +fastify.get('/health', async () => ({ status: 'ok' })); +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTIFY + assert info.confidence >= 0.5 + + def test_code_pattern(self, tmp_path: Path): + """Fastify detected from code patterns.""" + _write(tmp_path, "package.json", '{"dependencies": {"fastify": "^4.0.0"}}') + _write(tmp_path, "src/routes.ts", """\ +import Fastify from 'fastify'; +fastify.get('/users', getUsers); +fastify.post('/users', createUser); +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTIFY + + +# ═══════════════════════════════════════════════════════════════════ +# Edge cases +# ═══════════════════════════════════════════════════════════════════ + + +class TestEdgeCases: + + def test_no_framework(self, tmp_path: Path): + """Pure Python library with no web framework → UNKNOWN.""" + _write(tmp_path, "pyproject.toml", """\ +[project] +name = "mylib" +dependencies = ["requests", "pydantic"] +""") + _write(tmp_path, "mylib.py", "import requests\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.UNKNOWN + + def test_python_only_no_package_json(self, tmp_path: Path): + """Python-only repo with no package.json still detects framework.""" + _write(tmp_path, "requirements.txt", "fastapi\nuvicorn\n") + _write(tmp_path, "main.py", "from fastapi import FastAPI\napp = FastAPI()\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTAPI + + def test_optional_deps_detected(self, tmp_path: Path): + """Dependencies in [project.optional-dependencies] are also checked.""" + _write(tmp_path, "pyproject.toml", """\ +[project] +name = "myapi" +dependencies = ["pydantic"] + +[project.optional-dependencies] +web = ["fastapi", "uvicorn"] +""") + _write(tmp_path, "main.py", "from fastapi import FastAPI\n") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTAPI + + def test_mixed_fastapi_sqlalchemy(self, tmp_path: Path): + """FastAPI + SQLAlchemy — FastAPI should win (higher score from route patterns).""" + _write(tmp_path, "requirements.txt", "fastapi\nsqlalchemy\nuvicorn\n") + _write(tmp_path, "main.py", """\ +from fastapi import FastAPI +from sqlalchemy import create_engine +app = FastAPI() + +@app.get("/items") +def get_items(): + return [] +""") + info = FrameworkDetector(tmp_path).detect() + assert info.framework == FrameworkType.FASTAPI diff --git a/codegraph/tests/test_py_parser_endpoints.py b/codegraph/tests/test_py_parser_endpoints.py new file mode 100644 index 0000000..51ef7a3 --- /dev/null +++ b/codegraph/tests/test_py_parser_endpoints.py @@ -0,0 +1,353 @@ +"""Tests for Python endpoint + ORM column emission in :mod:`codegraph.py_parser`. + +Covers FastAPI / Flask route decorators → ``EndpointNode`` + edges, +and SQLAlchemy / Django ORM → ``ColumnNode`` + ``HAS_COLUMN`` edges. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from codegraph.py_parser import PyParser +from codegraph.schema import EXPOSES, HANDLES, HAS_COLUMN + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _parse(tmp_path: Path, code: str, filename: str = "app.py"): + """Write code to a temp file, parse it, return the ParseResult.""" + f = tmp_path / filename + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(code) + parser = PyParser() + return parser.parse_file(f, filename, "myapp", is_test=False) + + +# ═══════════════════════════════════════════════════════════════════ +# FastAPI endpoints +# ═══════════════════════════════════════════════════════════════════ + + +class TestFastAPIEndpoints: + + def test_function_endpoint(self, tmp_path: Path): + """``@app.get("/users")`` on a top-level function → EndpointNode.""" + result = _parse(tmp_path, """\ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/users") +def get_users(): + return [] +""") + assert len(result.endpoints) == 1 + ep = result.endpoints[0] + assert ep.method == "GET" + assert ep.path == "/users" + assert ep.handler == "get_users" + + def test_multiple_endpoints(self, tmp_path: Path): + """Multiple route decorators → multiple EndpointNode objects.""" + result = _parse(tmp_path, """\ +from fastapi import FastAPI +app = FastAPI() + +@app.get("/items") +def list_items(): + return [] + +@app.post("/items") +def create_item(): + return {} + +@app.delete("/items/{id}") +def delete_item(): + pass +""") + assert len(result.endpoints) == 3 + methods = {ep.method for ep in result.endpoints} + assert methods == {"GET", "POST", "DELETE"} + + def test_router_decorator(self, tmp_path: Path): + """``@router.post("/users")`` also detected.""" + result = _parse(tmp_path, """\ +from fastapi import APIRouter +router = APIRouter() + +@router.post("/users") +def create_user(): + return {} +""") + assert len(result.endpoints) == 1 + assert result.endpoints[0].method == "POST" + assert result.endpoints[0].path == "/users" + + def test_endpoint_edges(self, tmp_path: Path): + """Each endpoint produces EXPOSES + HANDLES edges.""" + result = _parse(tmp_path, """\ +@app.get("/health") +def health(): + return {"ok": True} +""") + assert len(result.endpoints) == 1 + exposes = [e for e in result.edges if e.kind == EXPOSES] + handles = [e for e in result.edges if e.kind == HANDLES] + assert len(exposes) == 1 + assert len(handles) == 1 + # HANDLES links function → endpoint + assert "health" in handles[0].src_id + assert "health" in handles[0].dst_id + + def test_class_method_endpoint(self, tmp_path: Path): + """Route decorator on a class method → EndpointNode with class as controller.""" + result = _parse(tmp_path, """\ +class UserController: + @app.get("/users") + def list_users(self): + return [] +""") + assert len(result.endpoints) == 1 + ep = result.endpoints[0] + assert ep.method == "GET" + assert "UserController" in ep.controller_class + + +# ═══════════════════════════════════════════════════════════════════ +# Flask endpoints +# ═══════════════════════════════════════════════════════════════════ + + +class TestFlaskEndpoints: + + def test_route_with_methods_kwarg(self, tmp_path: Path): + """``@app.route("/items", methods=["POST"])`` → POST endpoint.""" + result = _parse(tmp_path, """\ +from flask import Flask +app = Flask(__name__) + +@app.route("/items", methods=["POST"]) +def create_item(): + return {} +""") + assert len(result.endpoints) == 1 + assert result.endpoints[0].method == "POST" + assert result.endpoints[0].path == "/items" + + def test_route_default_get(self, tmp_path: Path): + """``@app.route("/")`` without methods kwarg → defaults to GET.""" + result = _parse(tmp_path, """\ +@app.route("/") +def index(): + return "Hello" +""") + assert len(result.endpoints) == 1 + assert result.endpoints[0].method == "GET" + + def test_blueprint_route(self, tmp_path: Path): + """``@bp.route("/users")`` on a blueprint.""" + result = _parse(tmp_path, """\ +@bp.route("/users", methods=["GET"]) +def list_users(): + return [] +""") + assert len(result.endpoints) == 1 + assert result.endpoints[0].method == "GET" + + +# ═══════════════════════════════════════════════════════════════════ +# SQLAlchemy ORM +# ═══════════════════════════════════════════════════════════════════ + + +class TestSQLAlchemyORM: + + def test_entity_detection(self, tmp_path: Path): + """Class extending ``Base`` → ``is_entity=True``.""" + result = _parse(tmp_path, """\ +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + name = Column(String) +""", "models.py") + user_cls = [c for c in result.classes if c.name == "User"][0] + assert user_cls.is_entity is True + assert user_cls.table_name == "users" + + def test_column_nodes(self, tmp_path: Path): + """Column assignments → ColumnNode objects.""" + result = _parse(tmp_path, """\ +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + name = Column(String(50)) + email = Column(String) +""", "models.py") + assert len(result.columns) == 3 + col_names = {c.name for c in result.columns} + assert col_names == {"id", "name", "email"} + # Type extraction + id_col = [c for c in result.columns if c.name == "id"][0] + assert id_col.type == "Integer" + + def test_has_column_edges(self, tmp_path: Path): + """Each column produces a HAS_COLUMN edge.""" + result = _parse(tmp_path, """\ +class User(Base): + id = Column(Integer) + name = Column(String) +""", "models.py") + hc_edges = [e for e in result.edges if e.kind == HAS_COLUMN] + assert len(hc_edges) == 2 + + def test_relationship(self, tmp_path: Path): + """``relationship("Address")`` → entry in result.relations.""" + result = _parse(tmp_path, """\ +class User(Base): + id = Column(Integer) + addresses = relationship("Address") +""", "models.py") + assert len(result.relations) == 1 + cls_name, rel_type, field, target = result.relations[0] + assert cls_name == "User" + assert rel_type == "relationship" + assert field == "addresses" + assert target == "Address" + + def test_foreign_key(self, tmp_path: Path): + """``ForeignKey("users.id")`` → relation entry.""" + result = _parse(tmp_path, """\ +class Address(Base): + user_id = Column(Integer, ForeignKey("users.id")) +""", "models.py") + assert len(result.relations) == 1 + _, rel_type, field, target = result.relations[0] + assert rel_type == "ForeignKey" + assert target == "users" + + def test_declarative_base(self, tmp_path: Path): + """Class extending ``DeclarativeBase`` is also detected as entity.""" + result = _parse(tmp_path, """\ +class Base(DeclarativeBase): + pass + +class Item(Base): + id = Column(Integer) +""", "models.py") + item_cls = [c for c in result.classes if c.name == "Item"][0] + assert item_cls.is_entity is True + + +# ═══════════════════════════════════════════════════════════════════ +# Django ORM +# ═══════════════════════════════════════════════════════════════════ + + +class TestDjangoORM: + + def test_django_model_entity(self, tmp_path: Path): + """Class extending ``models.Model`` → ``is_entity=True``.""" + result = _parse(tmp_path, """\ +from django.db import models + +class Article(models.Model): + title = models.CharField(max_length=200) + body = models.TextField() +""", "models.py") + article = [c for c in result.classes if c.name == "Article"][0] + assert article.is_entity is True + + def test_django_column_types(self, tmp_path: Path): + """Django model fields → ColumnNode with type derived from field name.""" + result = _parse(tmp_path, """\ +class Article(models.Model): + title = models.CharField(max_length=200) + count = models.IntegerField() + active = models.BooleanField() +""", "models.py") + assert len(result.columns) == 3 + types = {c.name: c.type for c in result.columns} + assert types["title"] == "Char" + assert types["count"] == "Integer" + assert types["active"] == "Boolean" + + def test_django_foreign_key(self, tmp_path: Path): + """``models.ForeignKey("auth.User")`` → relation entry.""" + result = _parse(tmp_path, """\ +class Comment(models.Model): + author = models.ForeignKey("auth.User") +""", "models.py") + assert len(result.relations) == 1 + assert result.relations[0][3] == "auth" + + +# ═══════════════════════════════════════════════════════════════════ +# Integration: FastAPI + SQLAlchemy golden path +# ═══════════════════════════════════════════════════════════════════ + + +class TestIntegration: + + def test_fastapi_sqlalchemy_app(self, tmp_path: Path): + """Realistic FastAPI app with SQLAlchemy models — full ParseResult check.""" + result = _parse(tmp_path, """\ +from fastapi import FastAPI, APIRouter +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +app = FastAPI() +router = APIRouter() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + name = Column(String(100)) + email = Column(String) + posts = relationship("Post") + +class Post(Base): + __tablename__ = "posts" + id = Column(Integer, primary_key=True) + title = Column(String) + user_id = Column(Integer, ForeignKey("users.id")) + +@router.get("/users") +def list_users(): + return [] + +@router.post("/users") +def create_user(): + return {} + +@router.get("/posts") +def list_posts(): + return [] +""", "main.py") + + # Endpoints + assert len(result.endpoints) == 3 + methods = sorted(ep.method for ep in result.endpoints) + assert methods == ["GET", "GET", "POST"] + + # ORM entities + user = [c for c in result.classes if c.name == "User"][0] + post = [c for c in result.classes if c.name == "Post"][0] + assert user.is_entity is True + assert user.table_name == "users" + assert post.is_entity is True + assert post.table_name == "posts" + + # Columns: 3 for User + 3 for Post = 6 + assert len(result.columns) == 6 + + # Relations: 1 relationship + 1 ForeignKey + assert len(result.relations) == 2 + + # Edge counts + exposes = [e for e in result.edges if e.kind == EXPOSES] + handles = [e for e in result.edges if e.kind == HANDLES] + has_col = [e for e in result.edges if e.kind == HAS_COLUMN] + assert len(exposes) == 3 + assert len(handles) == 3 + assert len(has_col) == 6 diff --git a/codegraph/tests/test_resolver_bugs.py b/codegraph/tests/test_resolver_bugs.py new file mode 100644 index 0000000..b7e2250 --- /dev/null +++ b/codegraph/tests/test_resolver_bugs.py @@ -0,0 +1,223 @@ +"""Tests for resolver bug fixes: .js→.ts NodeNext remapping + cross-package alias scoping. + +Bug 1: NodeNext-style imports (``import './foo.js'`` when source is ``foo.ts``) +were treated as external because the resolver tried ``foo.js.ts`` not ``foo.ts``. + +Bug 2: ``@/*`` path aliases in multi-package repos resolved to the wrong +package because alias lookup iterated all packages without scoping to the +importer's own package first. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from codegraph.resolver import ( + PathIndex, + Resolver, + load_package_config, +) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _write(root: Path, rel: str, content: str = "") -> None: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + + +def _make_resolver(repo_root: Path, pkg_dirs: list[Path]) -> Resolver: + """Build a Resolver with TS package configs + PathIndex from all files.""" + configs = [load_package_config(repo_root, d) for d in pkg_dirs] + resolver = Resolver(repo_root, configs) + files: set[str] = set() + for d in pkg_dirs: + for p in d.rglob("*"): + if p.is_file(): + files.add(str(p.resolve().relative_to(repo_root)).replace("\\", "/")) + resolver.set_path_index(PathIndex(files)) + return resolver + + +# ═══════════════════════════════════════════════════════════════════ +# Bug 1: .js → .ts NodeNext remapping +# ═══════════════════════════════════════════════════════════════════ + + +class TestJsToTsRemap: + """Verify that NodeNext .js imports resolve to their .ts counterparts.""" + + def test_relative_js_to_ts(self, tmp_path: Path): + """``import './foo.js'`` resolves to ``foo.ts``.""" + pkg = tmp_path / "src" + _write(pkg, "app.ts") + _write(pkg, "foo.ts") + # tsconfig with no aliases + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/app.ts", "./foo.js") + assert hit == "src/foo.ts" + + def test_relative_jsx_to_tsx(self, tmp_path: Path): + """``import './Bar.jsx'`` resolves to ``Bar.tsx``.""" + pkg = tmp_path / "src" + _write(pkg, "index.ts") + _write(pkg, "Bar.tsx") + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/index.ts", "./Bar.jsx") + assert hit == "src/Bar.tsx" + + def test_relative_mjs_to_mts(self, tmp_path: Path): + """``import './util.mjs'`` resolves to ``util.mts``.""" + pkg = tmp_path / "src" + _write(pkg, "app.ts") + _write(pkg, "util.mts") + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/app.ts", "./util.mjs") + assert hit == "src/util.mts" + + def test_real_js_file_wins(self, tmp_path: Path): + """If ``foo.js`` actually exists (no .ts counterpart), resolve to it.""" + pkg = tmp_path / "src" + _write(pkg, "app.ts") + _write(pkg, "legacy.js") # real JS file, no .ts counterpart + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/app.ts", "./legacy.js") + assert hit == "src/legacy.js" + + def test_missing_both_returns_none(self, tmp_path: Path): + """If neither ``.js`` nor ``.ts`` exists, return ``None``.""" + pkg = tmp_path / "src" + _write(pkg, "app.ts") + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/app.ts", "./missing.js") + assert hit is None + + def test_aliased_js_to_ts(self, tmp_path: Path): + """``@/utils/foo.js`` with alias ``@/* → ./src/*`` resolves to ``src/utils/foo.ts``.""" + pkg = tmp_path / "myapp" + _write(pkg, "src/index.ts") + _write(pkg, "src/utils/foo.ts") + _write(pkg, "tsconfig.json", '''{ + "compilerOptions": { + "paths": { "@/*": ["./src/*"] } + } + }''') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("myapp/src/index.ts", "@/utils/foo.js") + assert hit == "myapp/src/utils/foo.ts" + + def test_subdirectory_relative_js(self, tmp_path: Path): + """``import '../routes/health.js'`` from a nested dir resolves correctly.""" + pkg = tmp_path / "src" + _write(pkg, "controllers/user.ts") + _write(pkg, "routes/health.ts") + _write(pkg, "tsconfig.json", '{}') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("src/controllers/user.ts", "../routes/health.js") + assert hit == "src/routes/health.ts" + + +# ═══════════════════════════════════════════════════════════════════ +# Bug 2: cross-package alias scoping +# ═══════════════════════════════════════════════════════════════════ + + +class TestCrossPackageAlias: + """Verify aliases resolve to the importer's own package first.""" + + def _setup_multi_pkg(self, tmp_path: Path): + """Two packages, both with ``@/* → ./src/*``, each with a ``utils/helper.ts``.""" + front = tmp_path / "front" + back = tmp_path / "back" + _write(front, "src/index.ts") + _write(front, "src/utils/helper.ts", "// front helper") + _write(front, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./src/*"] } } + }''') + _write(back, "src/index.ts") + _write(back, "src/utils/helper.ts", "// back helper") + _write(back, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./src/*"] } } + }''') + return front, back + + def test_front_resolves_to_own_package(self, tmp_path: Path): + """Front-end ``@/utils/helper`` should resolve within ``front/src/``.""" + front, back = self._setup_multi_pkg(tmp_path) + r = _make_resolver(tmp_path, [front, back]) + hit = r.resolve("front/src/index.ts", "@/utils/helper") + assert hit == "front/src/utils/helper.ts" + + def test_back_resolves_to_own_package(self, tmp_path: Path): + """Back-end ``@/utils/helper`` should resolve within ``back/src/``.""" + front, back = self._setup_multi_pkg(tmp_path) + r = _make_resolver(tmp_path, [front, back]) + hit = r.resolve("back/src/index.ts", "@/utils/helper") + assert hit == "back/src/utils/helper.ts" + + def test_cross_package_fallthrough(self, tmp_path: Path): + """If file only exists in the *other* package, fallthrough still works.""" + front = tmp_path / "front" + back = tmp_path / "back" + _write(front, "src/index.ts") + # front does NOT have utils/special.ts + _write(front, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./src/*"] } } + }''') + _write(back, "src/utils/special.ts", "// only in back") + _write(back, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./src/*"] } } + }''') + r = _make_resolver(tmp_path, [front, back]) + # front imports something only in back → still resolves via fallthrough + hit = r.resolve("front/src/index.ts", "@/utils/special") + assert hit == "back/src/utils/special.ts" + + def test_no_match_returns_none(self, tmp_path: Path): + """Alias with no matching file in any package returns ``None``.""" + front, back = self._setup_multi_pkg(tmp_path) + r = _make_resolver(tmp_path, [front, back]) + hit = r.resolve("front/src/index.ts", "@/nonexistent/module") + assert hit is None + + def test_single_package_unchanged(self, tmp_path: Path): + """Single-package repos continue to work exactly as before.""" + pkg = tmp_path / "app" + _write(pkg, "src/index.ts") + _write(pkg, "src/utils/helper.ts") + _write(pkg, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./src/*"] } } + }''') + r = _make_resolver(tmp_path, [pkg]) + hit = r.resolve("app/src/index.ts", "@/utils/helper") + assert hit == "app/src/utils/helper.ts" + + def test_same_basename_different_roots(self, tmp_path: Path): + """Two packages both named ``src`` under different parents don't collide.""" + fe_src = tmp_path / "apps" / "frontend" / "src" + be_src = tmp_path / "apps" / "backend" / "src" + _write(fe_src, "index.ts") + _write(fe_src, "utils/helper.ts", "// frontend") + _write(fe_src, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./utils/*"] } } + }''') + _write(be_src, "index.ts") + _write(be_src, "utils/helper.ts", "// backend") + _write(be_src, "tsconfig.json", '''{ + "compilerOptions": { "paths": { "@/*": ["./utils/*"] } } + }''') + r = _make_resolver(tmp_path, [fe_src, be_src]) + # Frontend file should resolve to frontend's helper + fe_hit = r.resolve("apps/frontend/src/index.ts", "@/helper") + assert fe_hit == "apps/frontend/src/utils/helper.ts" + # Backend file should resolve to backend's helper + be_hit = r.resolve("apps/backend/src/index.ts", "@/helper") + assert be_hit == "apps/backend/src/utils/helper.ts"