Skip to content
Draft
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
7 changes: 6 additions & 1 deletion desloppify/engine/detectors/test_coverage/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
_no_tests_issues,
_normalize_graph_paths,
)
from .heuristics import _has_inline_tests
from .heuristics import _expand_direct_test_targets, _has_inline_tests
from .issues import (
_generate_issues,
)
Expand Down Expand Up @@ -71,6 +71,11 @@ def detect_test_coverage(
)
if test_files:
directly_tested |= naming_based_mapping(test_files, production_files, lang_name)
directly_tested |= _expand_direct_test_targets(
directly_tested,
production_files,
lang_name,
)

transitively_tested = transitive_coverage(directly_tested, graph, production_files)
test_quality = analyze_test_quality(test_files, lang_name)
Expand Down
17 changes: 17 additions & 0 deletions desloppify/engine/detectors/test_coverage/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ def _has_inline_tests(filepath: str, lang_name: str) -> bool:
return False


def _expand_direct_test_targets(
directly_tested: set[str],
production_files: set[str],
lang_name: str,
) -> set[str]:
"""Apply a language's semantic direct-test ownership expansion."""
mod = _load_lang_test_coverage_module(lang_name)
expand = getattr(mod, "expand_direct_test_targets", None)
if not callable(expand):
return set()
try:
return set(expand(directly_tested, production_files))
except (OSError, TypeError, ValueError):
logger.debug("direct test target expansion failed", exc_info=True)
return set()


def _is_runtime_entrypoint(filepath: str, lang_name: str) -> bool:
"""Best-effort runtime entrypoint detection for no-tests classification."""
read_result = read_coverage_file(filepath, context="runtime_entrypoint")
Expand Down
13 changes: 13 additions & 0 deletions desloppify/languages/rust/detectors/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
build_production_file_index,
build_workspace_package_index,
find_rust_files,
iter_include_files,
iter_mod_targets,
iter_use_specs,
read_text_or_none,
resolve_mod_declaration,
resolve_include_file,
resolve_use_spec,
)

Expand Down Expand Up @@ -52,6 +54,17 @@ def build_dep_graph(
graph[filepath]["imports"].add(resolved)
graph[resolved]["importers"].add(filepath)

for include_path in iter_include_files(content):
resolved = resolve_include_file(
include_path,
filepath,
file_set,
production_index=production_index,
)
if resolved and resolved != filepath:
graph[filepath]["imports"].add(resolved)
graph[resolved]["importers"].add(filepath)

for spec in iter_use_specs(content):
resolved = resolve_use_spec(
spec,
Expand Down
47 changes: 46 additions & 1 deletion desloppify/languages/rust/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
_MOD_LINE_RE = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;")
_ATTR_RE = re.compile(r"#\[[^\]]+\]")
_PATH_ATTR_RE = re.compile(r'#\s*\[\s*path\s*=\s*"([^"\n]+)"\s*\]')
_INCLUDE_FILE_RE = re.compile(
r'(?m)^\s*include!\s*\(\s*"([^"\n]+\.rs)"\s*\)\s*;'
)
_PUBLIC_ITEM_RE = re.compile(r"(?m)^\s*pub\s+(?:struct|enum|trait|type|fn|mod)\s+")
_RUST_LOG_RE = re.compile(r"^\s*(?:println!|eprintln!|dbg!|tracing::)", re.MULTILINE)

Expand Down Expand Up @@ -68,8 +71,27 @@ def build_production_file_index(
*,
project_root: Path | None = None,
) -> RustProductionFileIndex:
"""Build O(1) absolute/relative lookup maps for production files."""
"""Return cached O(1) absolute/relative production-file lookups."""
root = (project_root or get_project_root()).resolve()
return _build_production_file_index_cached(
str(root),
tuple(sorted(production_files)),
)


@functools.lru_cache(maxsize=16)
def _build_production_file_index_cached(
project_root: str,
production_files: tuple[str, ...],
) -> RustProductionFileIndex:
"""Resolve one stable production scope once per project root.

Rust import mapping asks for the same index once per ``use`` specification.
Resolving every production path on each call turns coverage analysis into a
filesystem-stat storm on large workspaces, despite the index being
immutable for the duration of a scan.
"""
root = Path(project_root)
by_absolute: dict[str, str] = {}
by_relative: dict[str, str] = {}
for production_file in production_files:
Expand Down Expand Up @@ -223,6 +245,11 @@ def iter_use_specs(content: str) -> list[str]:
return _iter_use_specs_with_pattern(content, USE_STATEMENT_RE)


def iter_include_files(content: str) -> list[str]:
"""Return literal Rust source files textually owned through ``include!``."""
return _INCLUDE_FILE_RE.findall(strip_rust_comments(content))


def iter_pub_use_specs(content: str) -> list[str]:
"""Return normalized `pub use` specs from a file."""
return _iter_use_specs_with_pattern(content, PUB_USE_STATEMENT_RE)
Expand Down Expand Up @@ -663,6 +690,22 @@ def resolve_mod_declaration(
return None


def resolve_include_file(
include_path: str,
source_file: str | Path,
production_files: set[str],
*,
production_index: RustProductionFileIndex | None = None,
) -> str | None:
"""Resolve a literal ``include!("path.rs")`` relative to its owner."""
source = Path(resolve_path(str(source_file))).resolve()
return _candidate_matches(
source.parent / include_path,
production_files,
production_index=production_index,
)


def resolve_use_spec(
spec: str,
source_file: str | Path,
Expand Down Expand Up @@ -1021,6 +1064,7 @@ def _load_toml_dict(path: Path) -> dict[str, Any] | None:
"has_public_api_markers",
"iter_mod_declarations",
"iter_mod_targets",
"iter_include_files",
"iter_pub_use_specs",
"iter_use_specs",
"match_production_candidate",
Expand All @@ -1031,6 +1075,7 @@ def _load_toml_dict(path: Path) -> dict[str, Any] | None:
"read_package_name",
"resolve_barrel_targets",
"resolve_mod_declaration",
"resolve_include_file",
"resolve_use_spec",
"strip_rust_comments",
]
38 changes: 38 additions & 0 deletions desloppify/languages/rust/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@

from desloppify.languages.rust.support import (
build_workspace_package_index,
build_production_file_index,
describe_rust_file,
find_workspace_root,
iter_use_specs,
iter_include_files,
match_production_candidate,
normalize_rust_body,
resolve_barrel_targets,
resolve_use_spec,
resolve_include_file,
read_text_or_none,
strip_rust_comments,
)

Expand Down Expand Up @@ -93,6 +97,39 @@ def parse_test_import_specs(content: str) -> list[str]:
return iter_use_specs(content)


def expand_direct_test_targets(
directly_tested: set[str],
production_files: set[str],
) -> set[str]:
"""Treat textually included Rust source as part of its tested owner.

``include!`` does not create a Rust module boundary: the included tokens
are compiled in the including module. A direct test of that owner is
therefore also a direct test of every recursively included source file.
"""
production_index = build_production_file_index(production_files)
expanded: set[str] = set()
queue = list(directly_tested)
visited = set(queue)
while queue:
owner = queue.pop()
content = read_text_or_none(owner)
if content is None:
continue
for include_path in iter_include_files(content):
target = resolve_include_file(
include_path,
owner,
production_files,
production_index=production_index,
)
if target is not None and target not in visited:
visited.add(target)
expanded.add(target)
queue.append(target)
return expanded


def map_test_to_source(test_path: str, production_set: set[str]) -> str | None:
"""Map `tests/foo.rs` to `src/foo.rs` or `src/foo/mod.rs` when present."""
test_file = Path(test_path)
Expand Down Expand Up @@ -150,6 +187,7 @@ def _candidate_matches(candidate: Path, production_files: set[str]) -> str | Non
"TEST_FUNCTION_RE",
"has_inline_tests",
"has_testable_logic",
"expand_direct_test_targets",
"is_runtime_entrypoint",
"map_test_to_source",
"parse_test_import_specs",
Expand Down
12 changes: 12 additions & 0 deletions desloppify/languages/rust/tests/test_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,15 @@ def test_build_dep_graph_can_exclude_mod_edges_for_cycle_analysis(tmp_path):
assert graph["src/lib.rs"]["imports"] == set()
assert graph["src/foo.rs"]["imports"] == {"src/bar.rs"}
assert graph["src/bar.rs"]["imports"] == {"src/foo.rs"}


def test_build_dep_graph_resolves_literal_include_files(tmp_path):
_write(tmp_path, "Cargo.toml", "[package]\nname = 'demo-app'\nversion = '0.1.0'\n")
_write(tmp_path, "src/lib.rs", 'include!("internal/codec.rs");\n')
_write(tmp_path, "src/internal/codec.rs", "pub fn decode() {}\n")

with runtime_scope(RuntimeContext(project_root=tmp_path)):
graph = build_dep_graph(tmp_path)

assert graph["src/lib.rs"]["imports"] == {"src/internal/codec.rs"}
assert graph["src/internal/codec.rs"]["importers"] == {"src/lib.rs"}
13 changes: 13 additions & 0 deletions desloppify/languages/rust/tests/test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,16 @@ def test_match_production_candidate_uses_relative_index_key(tmp_path):
index = build_production_file_index(production_files)
assert match_production_candidate(prod, production_files) == "src/lib.rs"
assert index.by_relative["src/lib.rs"] == "src/lib.rs"


def test_production_file_index_is_reused_for_stable_scan_scope(tmp_path):
_write(tmp_path, "src/lib.rs", "pub fn run() {}\n")
production_files = {"src/lib.rs"}

from desloppify.base.runtime_state import RuntimeContext, runtime_scope

with runtime_scope(RuntimeContext(project_root=tmp_path)):
first = build_production_file_index(production_files)
second = build_production_file_index(set(production_files))

assert second is first
11 changes: 11 additions & 0 deletions desloppify/languages/rust/tests/test_test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,14 @@ def test_resolve_import_spec_uses_workspace_dependency_alias(tmp_path):
{str(source.resolve())},
)
assert resolved == str(source.resolve())


def test_direct_test_targets_expand_through_recursive_literal_includes(tmp_path):
owner = _write(tmp_path, "src/lib.rs", 'include!("internal.rs");\n')
internal = _write(tmp_path, "src/internal.rs", 'include!("nested/codec.rs");\n')
codec = _write(tmp_path, "src/nested/codec.rs", "pub fn decode() {}\n")
production = {str(owner.resolve()), str(internal.resolve()), str(codec.resolve())}

expanded = rust_cov.expand_direct_test_targets({str(owner.resolve())}, production)

assert expanded == {str(internal.resolve()), str(codec.resolve())}