Skip to content
Open
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
9 changes: 7 additions & 2 deletions desloppify/app/commands/helpers/lang.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def resolve_detection_root(
markers = marker_provider()
project_root_path = (
project_root if project_root is not None else get_project_root()
)
).resolve()

raw_path = getattr(args, "path", None)
if not raw_path:
Expand All @@ -106,7 +106,12 @@ def resolve_detection_root(
candidate = candidate.resolve()
candidate_root = candidate if candidate.is_dir() else candidate.parent

for probe_root in (candidate_root, *candidate_root.parents):
probe_roots = (candidate_root, *candidate_root.parents)
if candidate_root.is_relative_to(project_root_path):
project_root_index = probe_roots.index(project_root_path)
probe_roots = probe_roots[: project_root_index + 1]

for probe_root in probe_roots:
if any((probe_root / marker).exists() for marker in markers):
return probe_root
return candidate_root
Expand Down
59 changes: 45 additions & 14 deletions desloppify/languages/_framework/treesitter/imports/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,33 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .cache import get_or_parse_tree
from desloppify.base.discovery.file_paths import resolve_scan_file

from ..analysis.extractors import _get_parser, _make_query, _run_query, _unwrap_node
from .cache import get_or_parse_tree

if TYPE_CHECKING:
from desloppify.languages._framework.treesitter import TreeSitterLangSpec


def _source_path(filepath: str) -> Path:
"""Resolve discovery keys against the project root without changing case."""
return resolve_scan_file(filepath)


def _import_path(filepath: str, scan_path: Path) -> Path:
"""Resolve a resolver result using its scan-root-relative contract."""
path = Path(filepath)
if path.is_absolute():
return path.resolve()
return (scan_path / path).resolve()


def _path_identity(path: Path) -> str:
"""Return a comparison identity without changing the path used for I/O."""
return os.path.normcase(str(path))


def ts_build_dep_graph(
path: Path,
spec: TreeSitterLangSpec,
Expand All @@ -30,16 +50,27 @@ def ts_build_dep_graph(
parser, language = _get_parser(spec.grammar)
query = _make_query(language, spec.import_query)

scan_path = str(path.resolve())
file_set = set(file_list)
scan_path = path.resolve()
file_paths_by_key = {filepath: _source_path(filepath) for filepath in file_list}
file_keys_by_path: dict[str, str] = {}
for filepath, resolved_path in file_paths_by_key.items():
identity = _path_identity(resolved_path)
previous_key = file_keys_by_path.get(identity)
if previous_key is not None and previous_key != filepath:
raise ValueError(
"Tree-sitter dependency graph received duplicate paths "
f"{previous_key!r} and {filepath!r} for {resolved_path}"
)
file_keys_by_path[identity] = filepath
graph: dict[str, dict[str, Any]] = {}

# Initialize all files in the graph.
for f in file_list:
graph[f] = {"imports": set(), "importers": set()}

for filepath in file_list:
cached = get_or_parse_tree(filepath, parser, spec.grammar)
source_path = file_paths_by_key[filepath]
cached = get_or_parse_tree(str(source_path), parser, spec.grammar)
if cached is None:
continue
_source, tree = cached
Expand Down Expand Up @@ -71,21 +102,21 @@ def ts_build_dep_graph(
).strip("\"'`")
import_text = f"{prefix_text}\\{import_text}"

resolved = spec.resolve_import(import_text, filepath, scan_path)
resolved = spec.resolve_import(
import_text, str(source_path), str(scan_path)
)
if resolved is None:
continue

# Normalize to absolute path.
if not os.path.isabs(resolved):
resolved = os.path.normpath(os.path.join(scan_path, resolved))

# Only track edges within the scanned file set.
if resolved not in file_set:
# Match by filesystem identity, then store the caller's original key.
resolved_key = file_keys_by_path.get(
_path_identity(_import_path(resolved, scan_path))
)
if resolved_key is None:
continue

graph[filepath]["imports"].add(resolved)
if resolved in graph:
graph[resolved]["importers"].add(filepath)
graph[filepath]["imports"].add(resolved_key)
graph[resolved_key]["importers"].add(filepath)

# Finalize: add counts.
for data in graph.values():
Expand Down
50 changes: 50 additions & 0 deletions desloppify/tests/commands/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,39 @@ def test_auto_detect_falls_back_to_project_root_for_subdir_path(
assert lang is not None
assert lang.name == "python"

def test_auto_detect_ignores_markers_above_active_project_root(
self, tmp_path, monkeypatch
):
(tmp_path / "package.json").write_text('{"name":"outside"}\n')

project_root = tmp_path / "project"
project_root.mkdir()
scripts = project_root / "scripts"
scripts.mkdir()
(scripts / "job.py").write_text("print('x')\n")

monkeypatch.setattr(lang_helpers_mod, "get_project_root", lambda: project_root)
args = SimpleNamespace(lang=None, path=str(scripts))

assert lang_helpers_mod.resolve_detection_root(args) == scripts
lang = resolve_lang(args)
assert lang is not None
assert lang.name == "python"

def test_detection_root_includes_active_project_root_boundary(
self, tmp_path, monkeypatch
):
project_root = tmp_path / "project"
project_root.mkdir()
(project_root / "package.json").write_text('{"name":"project"}\n')
source = project_root / "src"
source.mkdir()

monkeypatch.setattr(lang_helpers_mod, "get_project_root", lambda: project_root)
args = SimpleNamespace(path=str(source))

assert lang_helpers_mod.resolve_detection_root(args) == project_root

def test_auto_detect_walks_up_from_external_subdir_path(
self, tmp_path, monkeypatch
):
Expand All @@ -846,6 +879,23 @@ def test_auto_detect_walks_up_from_external_subdir_path(
assert lang is not None
assert lang.name == "typescript"

def test_detection_root_still_walks_to_external_target_marker(
self, tmp_path, monkeypatch
):
project_root = tmp_path / "active_project"
project_root.mkdir()

external_root = tmp_path / "external_project"
external_root.mkdir()
(external_root / "package.json").write_text('{"name":"target"}\n')
external_src = external_root / "src"
external_src.mkdir()

monkeypatch.setattr(lang_helpers_mod, "get_project_root", lambda: project_root)
args = SimpleNamespace(path=str(external_src))

assert lang_helpers_mod.resolve_detection_root(args) == external_root

def test_auto_detect_prefers_path_subtree_when_no_markers(
self, tmp_path, monkeypatch
):
Expand Down
Loading