Skip to content
Closed
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
32 changes: 32 additions & 0 deletions desloppify/engine/detectors/coverage/mapping_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
from desloppify.engine.hook_registry import get_lang_hook



def _load_lang_test_coverage_module(lang_name: str | None):
"""Load language-specific test coverage helpers from ``lang/<name>/test_coverage.py``."""
return get_lang_hook(lang_name, "test_coverage") or object()



def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str | None:
"""Infer language from known file extensions when explicit lang is unavailable."""
paths = list(test_files) + list(production_files)
Expand Down Expand Up @@ -40,6 +42,33 @@ def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str |
return None



def _discover_additional_test_mapping_files(
test_files: set[str],
production_files: set[str],
lang_name: str | None = None,
) -> set[str]:
"""Allow language hooks to contribute mapping-only files for coverage discovery."""
if lang_name is None:
lang_name = _infer_lang_name(test_files, production_files)
mod = _load_lang_test_coverage_module(lang_name)
discover = getattr(mod, "discover_test_mapping_files", None)
if not callable(discover):
return set()

discovered = discover(test_files, production_files)
if not discovered:
return set()

result: set[str] = set()
for path in discovered:
if not path:
continue
result.add(str(Path(path).resolve()))
return result



def _resolve_import(
spec: str,
test_path: str,
Expand All @@ -53,6 +82,7 @@ def _resolve_import(
return None



def _resolve_barrel_reexports(
filepath: str,
production_files: set[str],
Expand All @@ -68,6 +98,7 @@ def _resolve_barrel_reexports(
return set()



def _parse_test_imports(
test_path: str,
production_files: set[str],
Expand Down Expand Up @@ -111,6 +142,7 @@ def _parse_test_imports(


__all__ = [
"_discover_additional_test_mapping_files",
"_infer_lang_name",
"_load_lang_test_coverage_module",
"_parse_test_imports",
Expand Down
17 changes: 15 additions & 2 deletions desloppify/engine/detectors/test_coverage/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
naming_based_mapping,
transitive_coverage,
)
from desloppify.engine.detectors.coverage.mapping_imports import (
_discover_additional_test_mapping_files,
)
from desloppify.engine.policy.zones import FileZoneMap

from .discovery import (
Expand All @@ -21,6 +24,7 @@
)



def detect_test_coverage(
graph: dict,
zone_map: FileZoneMap,
Expand Down Expand Up @@ -49,14 +53,23 @@ def detect_test_coverage(
entries = _no_tests_issues(scorable, graph, lang_name, complexity_map)
return entries, potential

directly_tested = set(inline_tested)
mapping_test_files = set(test_files)
if test_files:
mapping_test_files |= _discover_additional_test_mapping_files(
test_files,
production_files,
lang_name,
)

directly_tested = set(inline_tested)
if mapping_test_files:
directly_tested |= import_based_mapping(
graph,
test_files,
mapping_test_files,
production_files,
lang_name,
)
if test_files:
directly_tested |= naming_based_mapping(test_files, production_files, lang_name)

transitively_tested = transitive_coverage(directly_tested, graph, production_files)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,27 +147,28 @@ def _extract_import_name(import_path: str) -> str:
"MyApp::Model::User" -> "User"
"Data.List" -> "List"
"""
# Strip common path separators and take the last segment.
for sep in ("::", ".", "/", "\\"):
if sep in import_path:
parts = import_path.split(sep)
# Filter out empty segments and take the last.
parts = [p for p in parts if p]
candidate = import_path.strip()
for sep in ("/", "\\"):
if sep in candidate:
parts = [p for p in candidate.split(sep) if p]
if parts:
name = parts[-1]
# Strip file extensions.
for ext in (".go", ".rs", ".rb", ".py", ".js", ".jsx", ".ts",
".tsx", ".java", ".kt", ".cs", ".fs", ".ml",
".ex", ".erl", ".hs", ".lua", ".zig", ".pm",
".sh", ".pl", ".scala", ".swift", ".php",
".dart", ".mjs", ".cjs"):
if name.endswith(ext):
name = name[:-len(ext)]
break
return name

# No separator — the path itself is the name.
return import_path
candidate = parts[-1]

for ext in (".go", ".rs", ".rb", ".py", ".js", ".jsx", ".ts",
".tsx", ".java", ".kt", ".cs", ".fs", ".ml",
".ex", ".erl", ".hs", ".lua", ".zig", ".pm",
".sh", ".pl", ".scala", ".swift", ".php",
".dart", ".mjs", ".cjs", ".h", ".hh", ".hpp"):
if candidate.endswith(ext):
return candidate[:-len(ext)]

for sep in ("::", "."):
if sep in candidate:
parts = [p for p in candidate.split(sep) if p]
if parts:
return parts[-1]

return candidate


__all__ = ["detect_unused_imports"]
7 changes: 6 additions & 1 deletion desloppify/languages/cxx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ def detect_lang_security_detailed(self, files, zone_map) -> LangSecurityResult:
return detect_cxx_security(files, zone_map)

def __init__(self):
tree_sitter_phases = [
phase for phase in all_treesitter_phases("cpp")
if phase.label != "Unused imports"
]

super().__init__(
name="cxx",
extensions=CXX_EXTENSIONS,
Expand All @@ -61,7 +66,7 @@ def __init__(self):
DetectorPhase("Structural analysis", phase_structural),
DetectorPhase("Coupling + cycles + orphaned", phase_coupling),
DetectorPhase("cppcheck", phase_cppcheck_issue),
*all_treesitter_phases("cpp"),
*tree_sitter_phases,
detector_phase_signature(),
detector_phase_test_coverage(),
detector_phase_security(),
Expand Down
8 changes: 7 additions & 1 deletion desloppify/languages/cxx/detectors/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,13 @@ def detect_cxx_security(
tool_results.append(_run_clang_tidy(scan_root, scoped_files))
tool_results.append(_run_cppcheck(scan_root, scoped_files))

tool_entries = [entry for result in tool_results for entry in result.entries]
scoped_file_set = {str(Path(filepath).resolve()) for filepath in scoped_files}
tool_entries = [
entry
for result in tool_results
for entry in result.entries
if str(Path(str(entry.get("file", ""))).resolve()) in scoped_file_set
]
covered_files = {
filepath
for result in tool_results
Expand Down
76 changes: 73 additions & 3 deletions desloppify/languages/cxx/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@
BARREL_BASENAMES: set[str] = set()
_INCLUDE_RE = re.compile(r'(?m)^\s*#include\s*[<"]([^>"]+)[>"]')
_SOURCE_EXTENSIONS = (".c", ".cc", ".cpp", ".cxx")
_HEADER_EXTENSIONS = (".h", ".hh", ".hpp")
_CMAKE_COMMENT_RE = re.compile(r"(?m)#.*$")
_CMAKE_COMMAND_RE = re.compile(r"\b(?:add_executable|add_library|target_sources)\s*\(", re.IGNORECASE)
_CMAKE_SOURCE_SPEC_RE = re.compile(
r'"([^"\n]+\.(?:cpp|cxx|cc|c|hpp|hh|h))"|([^\s()"]+\.(?:cpp|cxx|cc|c|hpp|hh|h))',
re.IGNORECASE,
)
_TESTABLE_LOGIC_RE = re.compile(
r"\b(?:class|struct|enum|namespace)\b|^\s*(?:inline\s+|static\s+)?[A-Za-z_]\w*(?:[\s*&:<>]+[A-Za-z_]\w*)*\s+\w+\s*\(",
re.MULTILINE,
)
)


def has_testable_logic(filepath: str, content: str) -> bool:
Expand All @@ -31,6 +38,7 @@ def has_testable_logic(filepath: str, content: str) -> bool:
return bool(_TESTABLE_LOGIC_RE.search(content))



def _match_candidate(candidate: Path, production_files: set[str]) -> str | None:
resolved = str(candidate.resolve())
normalized = {str(Path(path).resolve()): path for path in production_files}
Expand All @@ -39,6 +47,7 @@ def _match_candidate(candidate: Path, production_files: set[str]) -> str | None:
return None



def resolve_import_spec(
spec: str,
test_path: str,
Expand Down Expand Up @@ -67,15 +76,74 @@ def resolve_import_spec(
return None



def resolve_barrel_reexports(filepath: str, production_files: set[str]) -> set[str]:
"""C/C++ has no barrel-file re-export expansion."""
del filepath, production_files
return set()



def _unique_preserving_order(specs: list[str]) -> list[str]:
seen: set[str] = set()
ordered: list[str] = []
for spec in specs:
cleaned = (spec or "").strip()
if not cleaned or cleaned in seen:
continue
seen.add(cleaned)
ordered.append(cleaned)
return ordered



def _parse_cmake_source_specs(content: str) -> list[str]:
if not _CMAKE_COMMAND_RE.search(content):
return []
stripped = _CMAKE_COMMENT_RE.sub("", content)
specs: list[str] = []
for quoted, bare in _CMAKE_SOURCE_SPEC_RE.findall(stripped):
spec = quoted or bare
if spec:
specs.append(spec)
return _unique_preserving_order(specs)



def parse_test_import_specs(content: str) -> list[str]:
"""Return include-like specs from test content."""
return [match.group(1).strip() for match in _INCLUDE_RE.finditer(content)]
"""Return include-like specs from test content and test build files."""
include_specs = [match.group(1).strip() for match in _INCLUDE_RE.finditer(content)]
cmake_specs = _parse_cmake_source_specs(content)
return _unique_preserving_order(include_specs + cmake_specs)



def _iter_test_tree_ancestors(test_file: Path) -> list[Path]:
ancestors = [test_file.parent, *test_file.parents]
stop_at: int | None = None
for index, ancestor in enumerate(ancestors):
if ancestor.name.lower() in {"tests", "test"}:
stop_at = index
break
if stop_at is None:
return []
return ancestors[: stop_at + 1]



def discover_test_mapping_files(test_files: set[str], production_files: set[str]) -> set[str]:
"""Find CMake/Make build files that define test target sources within test trees."""
del production_files
discovered: set[str] = set()
for test_path in sorted(test_files):
test_file = Path(test_path).resolve()
for ancestor in _iter_test_tree_ancestors(test_file):
for build_file in ("CMakeLists.txt", "Makefile"):
candidate = ancestor / build_file
if candidate.is_file():
discovered.add(str(candidate.resolve()))
return discovered



def map_test_to_source(test_path: str, production_set: set[str]) -> str | None:
Expand Down Expand Up @@ -104,6 +172,7 @@ def map_test_to_source(test_path: str, production_set: set[str]) -> str | None:
return None



def strip_test_markers(basename: str) -> str | None:
"""Strip common C/C++ test-name markers to derive source basename."""
stem, ext = os.path.splitext(basename)
Expand All @@ -120,6 +189,7 @@ def strip_test_markers(basename: str) -> str | None:
return None



def strip_comments(content: str) -> str:
"""Strip C-style comments while preserving string literals."""
return strip_c_style_comments(content)
Loading
Loading