diff --git a/desloppify/languages/_framework/treesitter/phases.py b/desloppify/languages/_framework/treesitter/phases.py index e05273a59..357ac7a3e 100644 --- a/desloppify/languages/_framework/treesitter/phases.py +++ b/desloppify/languages/_framework/treesitter/phases.py @@ -10,6 +10,7 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING @@ -26,7 +27,27 @@ # ── Phase factories ──────────────────────────────────────── -def make_ast_smells_phase(spec: TreeSitterLangSpec) -> DetectorPhase: +def _group_files_by_spec( + file_list: list[str], + spec: TreeSitterLangSpec, + spec_for_file: Callable[[str], TreeSitterLangSpec] | None, +) -> list[tuple[TreeSitterLangSpec, list[str]]]: + """Assign each discovered file to exactly one grammar/query specification.""" + # Resolver callables can be unhashable, so group by spec identity rather + # than imposing a new hashability requirement on existing spec callers. + groups: dict[int, tuple[TreeSitterLangSpec, list[str]]] = {} + for filepath in file_list: + file_spec = spec_for_file(filepath) if spec_for_file else spec + group = groups.setdefault(id(file_spec), (file_spec, [])) + group[1].append(filepath) + return list(groups.values()) + + +def make_ast_smells_phase( + spec: TreeSitterLangSpec, + *, + spec_for_file: Callable[[str], TreeSitterLangSpec] | None = None, +) -> DetectorPhase: """Create an AST smells phase: empty catches + unreachable code.""" def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: @@ -35,12 +56,18 @@ def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, i detect_unreachable_code, ) - file_list = lang.file_finder(path) + groups = _group_files_by_spec(lang.file_finder(path), spec, spec_for_file) issues: list[Issue] = [] potentials: dict[str, int] = {} + catch_entries: list[dict] = [] + unreachable_entries: list[dict] = [] + for file_spec, files in groups: + catch_entries.extend(detect_empty_catches(files, file_spec)) + unreachable_entries.extend(detect_unreachable_code(files, file_spec)) + catches = normalize_smell_matches( - detect_empty_catches(file_list, spec), + catch_entries, content_key="type", default_content="catch", ) @@ -55,7 +82,7 @@ def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, i log(f" empty catch blocks: {len(catches)}") unreachable = normalize_smell_matches( - detect_unreachable_code(file_list, spec), + unreachable_entries, content_key="after", default_content="return", ) @@ -74,7 +101,11 @@ def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, i return DetectorPhase("AST smells", run) -def make_cohesion_phase(spec: TreeSitterLangSpec) -> DetectorPhase: +def make_cohesion_phase( + spec: TreeSitterLangSpec, + *, + spec_for_file: Callable[[str], TreeSitterLangSpec] | None = None, +) -> DetectorPhase: """Create a responsibility cohesion phase.""" def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: @@ -82,11 +113,14 @@ def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, i detect_responsibility_cohesion, ) - file_list = lang.file_finder(path) + groups = _group_files_by_spec(lang.file_finder(path), spec, spec_for_file) issues: list[Issue] = [] potentials: dict[str, int] = {} - entries, checked = detect_responsibility_cohesion(file_list, spec) + entries: list[dict] = [] + for file_spec, files in groups: + group_entries, _checked = detect_responsibility_cohesion(files, file_spec) + entries.extend(group_entries) for e in entries: families = ", ".join(e["families"][:4]) issues.append(make_issue( diff --git a/desloppify/languages/cxx/README.md b/desloppify/languages/cxx/README.md index 31a25f044..5fdb5cb72 100644 --- a/desloppify/languages/cxx/README.md +++ b/desloppify/languages/cxx/README.md @@ -2,6 +2,43 @@ This document explains what the C/C++ plugin needs for a full tool-backed scan. +## Native C and C++ AST analysis + +Without build metadata, the `cxx` plugin uses the C Tree-sitter grammar for +`.c` files and the C++ grammar for C++ sources and headers. This matters even +when both grammars accept a file: for example, a C callback named `throw` is a function call, +whereas C++ interprets `throw(value)` as an exception statement. + +When `compile_commands.json` is present at the scan root, AST smells and +cohesion use its source-file compilation language. Both `arguments` and `command` +entries are supported. Relative `directory` paths are resolved from the database +directory, and source paths from the command's `directory`. Missing or malformed +metadata falls back to file extensions. + +Supported overrides include GCC/Clang `-x c`, `-x c++`, their joined forms, +header/preprocessed equivalents and `-x none`; the language in force at the +source argument is used. MSVC `/TC`, `/TP`, `/TcFILE` and `/TpFILE` (including +separate filenames and clang-cl's dash forms) are supported. Recognized C++ +drivers (`c++`, `g++`, `clang++`, versioned/cross-prefixed variants, optionally +behind `ccache`, `sccache` or `distcc`) and Clang `--driver-mode` inform defaults. +`-std=` alone does not determine the input language. + +Headers, including `.h`, retain C++ parsing because they may be shared with C++ +translation units; compilation entries do not change that policy. Conflicting C +and C++ configurations for a source retain the plugin's previous C++ behavior, +with a warning. Findings for that file have low confidence and list both +`compilation_languages` in their details. This represents only the C++ context, +not both configurations; C-specific results may be incomplete. + +This is not a compiler frontend: response files, forwarded frontend flags, +arbitrary compiler wrappers, shell expansion and cross-host command syntax are +not interpreted. Use +`arguments` to avoid platform-specific shell quoting limits. Metadata is read +once per AST phase, without a global cache. Dependency resolution and external +tools continue to use build metadata separately. Function extraction for +duplicate/signature analysis and structural complexity signals are separate +from these AST phases. + ## What "full" means for C/C++ The C/C++ plugin can still scan a repository when external tooling is missing, but the deepest and most accurate analysis depends on build metadata and external analyzers. diff --git a/desloppify/languages/cxx/__init__.py b/desloppify/languages/cxx/__init__.py index 0698eeb8a..b7302e619 100644 --- a/desloppify/languages/cxx/__init__.py +++ b/desloppify/languages/cxx/__init__.py @@ -17,7 +17,6 @@ from desloppify.languages._framework.generic_parts.tool_factories import make_tool_phase from desloppify.languages._framework.registry.registration import register_full_plugin from desloppify.languages._framework.registry.state import register_lang_hooks -from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.cxx import test_coverage as cxx_test_coverage_hooks from desloppify.languages.cxx._helpers import build_cxx_dep_graph from desloppify.languages.cxx._zones import CXX_ENTRY_PATTERNS, CXX_ZONE_RULES @@ -40,6 +39,7 @@ api_surface, module_patterns, ) +from desloppify.languages.cxx.treesitter import make_cxx_treesitter_phases class CxxConfig(LangConfig): @@ -49,11 +49,6 @@ 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, @@ -66,7 +61,7 @@ def __init__(self): DetectorPhase("Structural analysis", phase_structural), DetectorPhase("Coupling + cycles + orphaned", phase_coupling), DetectorPhase("cppcheck", phase_cppcheck_issue), - *tree_sitter_phases, + *make_cxx_treesitter_phases(), detector_phase_signature(), detector_phase_test_coverage(), detector_phase_security(), diff --git a/desloppify/languages/cxx/compile_commands.py b/desloppify/languages/cxx/compile_commands.py new file mode 100644 index 000000000..a8096ad65 --- /dev/null +++ b/desloppify/languages/cxx/compile_commands.py @@ -0,0 +1,70 @@ +"""Compilation-database loading shared by C/C++ analysis.""" + +from __future__ import annotations + +import json +import os +import shlex +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class CompileCommand: + """One command with paths resolved relative to its compilation database.""" + + source: str + directory: Path + arguments: list[str] + + +def _parse_command_tokens(entry: dict[str, Any]) -> list[str]: + arguments = entry.get("arguments") + if not isinstance(arguments, list): + command = entry.get("command") + arguments = ( + shlex.split(command, posix=os.name != "nt") + if isinstance(command, str) and command.strip() + else [] + ) + if not all(isinstance(token, str) and "\0" not in token for token in arguments): + raise ValueError("Compilation arguments must be strings without NUL bytes") + return arguments + + +def load_compile_commands(path: Path) -> list[CompileCommand]: + """Read root metadata once; ignore malformed entries without losing peers.""" + root = path.resolve() + try: + payload = json.loads((root / "compile_commands.json").read_text()) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return [] + if not isinstance(payload, list): + return [] + + commands: list[CompileCommand] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + file_value = entry.get("file") + if not isinstance(file_value, str) or not file_value.strip(): + continue + try: + directory_value = entry.get("directory") + directory = ( + Path(directory_value) + if isinstance(directory_value, str) and directory_value.strip() + else root + ) + if not directory.is_absolute(): + directory = root / directory + directory = directory.resolve() + source = Path(file_value) + if not source.is_absolute(): + source = directory / source + arguments = _parse_command_tokens(entry) + commands.append(CompileCommand(str(source.resolve()), directory, arguments)) + except (ValueError, OSError): + continue + return commands diff --git a/desloppify/languages/cxx/detectors/deps.py b/desloppify/languages/cxx/detectors/deps.py index e39d29559..33de4ad1d 100644 --- a/desloppify/languages/cxx/detectors/deps.py +++ b/desloppify/languages/cxx/detectors/deps.py @@ -2,10 +2,8 @@ from __future__ import annotations -import json import os import re -import shlex from pathlib import Path from typing import Any @@ -14,6 +12,7 @@ from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_cxx_include, ) +from desloppify.languages.cxx.compile_commands import load_compile_commands from desloppify.languages.cxx.extractors import find_cxx_files _INCLUDE_RE = re.compile(r'(?m)^\s*#include\s*([<"])([^>"]+)[>"]') @@ -54,17 +53,6 @@ def _read_include_specs(filepath: str) -> list[tuple[str, bool]]: ] -def _parse_command_tokens(entry: dict[str, Any]) -> list[str]: - arguments = entry.get("arguments") - if isinstance(arguments, list): - return [str(token) for token in arguments] - - command = entry.get("command") - if isinstance(command, str) and command.strip(): - return shlex.split(command, posix=os.name != "nt") - return [] - - def _extract_include_dirs(tokens: list[str], directory: Path) -> list[Path]: include_dirs: list[Path] = [] idx = 0 @@ -116,50 +104,17 @@ def _resolve_with_compile_commands( return _match_production_file(fallback, file_index) -def _resolve_compile_command_source(entry: dict[str, Any], scan_root: Path) -> str | None: - file_value = entry.get("file") - if not isinstance(file_value, str) or not file_value.strip(): - return None - - directory_value = entry.get("directory") - directory = ( - Path(directory_value).resolve() - if isinstance(directory_value, str) and directory_value.strip() - else scan_root - ) - source_file = Path(file_value) - if not source_file.is_absolute(): - source_file = directory / source_file - return _normalize_graph_path(source_file) - - def _build_from_compile_commands( path: Path, files: list[str], ) -> dict[str, dict[str, Any]]: graph = _init_graph(files) file_index = _production_file_index(files) - compile_commands_path = path / "compile_commands.json" - try: - payload = json.loads(compile_commands_path.read_text()) - except (json.JSONDecodeError, OSError, UnicodeDecodeError): - return finalize_graph(graph) - if not isinstance(payload, list): - return finalize_graph(graph) - - for entry in payload: - if not isinstance(entry, dict): - continue - source = _resolve_compile_command_source(entry, path) - if source is None or source not in graph: + for command in load_compile_commands(path): + source = command.source + if source not in graph: continue - directory_value = entry.get("directory") - directory = ( - Path(directory_value).resolve() - if isinstance(directory_value, str) and directory_value.strip() - else path - ) - include_dirs = _extract_include_dirs(_parse_command_tokens(entry), directory) + include_dirs = _extract_include_dirs(command.arguments, command.directory) source_file = Path(source) for include_spec, is_quoted in _read_include_specs(source): resolved = _resolve_with_compile_commands( diff --git a/desloppify/languages/cxx/tests/fixtures/native_c/callback.c b/desloppify/languages/cxx/tests/fixtures/native_c/callback.c new file mode 100644 index 000000000..7e48f12a7 --- /dev/null +++ b/desloppify/languages/cxx/tests/fixtures/native_c/callback.c @@ -0,0 +1,10 @@ +/* Valid C17: throw is an ordinary identifier, and the callback may return. */ +void dispatch(void (*throw)(int), void (*cleanup)(void)) { + throw(1); + cleanup(); +} + +int unreachable(void) { + return 1; + return 2; +} diff --git a/desloppify/languages/cxx/tests/fixtures/native_c/constructs.c b/desloppify/languages/cxx/tests/fixtures/native_c/constructs.c new file mode 100644 index 000000000..ff6427774 --- /dev/null +++ b/desloppify/languages/cxx/tests/fixtures/native_c/constructs.c @@ -0,0 +1,43 @@ +/* C17 syntax probes; these constructs do not imply unreachable code. */ +#include + +struct item { int value; }; +union number { int integer; double real; }; +enum kind { INTEGER, REAL }; +typedef struct item item; +typedef int (*callback)(int); + +_Static_assert(sizeof(int) >= 2, "int width"); + +int identity(int value) { + return value; +} + +int invoke(int (*function)(int), int value) { + return function(value); +} + +int (*select_callback(callback function))(int) { + return function; +} + +int process(int values[static 2], size_t count) { + _Atomic int offset = 1; + item current = (item){.value = values[0]}; + union number number = {.integer = values[1]}; + for (size_t i = 0; i < count; ++i) { + current.value += values[i]; + } + return _Generic(current.value, int: current.value + number.integer + offset, + default: 0); +} + +#if defined(ENABLE_NATIVE) +int conditional(int value) { + return value; +} +#else +int conditional(int value) { + return -value; +} +#endif diff --git a/desloppify/languages/cxx/tests/fixtures/native_c/cpp_override.c b/desloppify/languages/cxx/tests/fixtures/native_c/cpp_override.c new file mode 100644 index 000000000..9589c2c62 --- /dev/null +++ b/desloppify/languages/cxx/tests/fixtures/native_c/cpp_override.c @@ -0,0 +1,5 @@ +/* This file is compiled as C++17 using an explicit compiler language. */ +void f() { + throw(1); + f(); +} diff --git a/desloppify/languages/cxx/tests/test_compilation_language.py b/desloppify/languages/cxx/tests/test_compilation_language.py new file mode 100644 index 000000000..49c21383e --- /dev/null +++ b/desloppify/languages/cxx/tests/test_compilation_language.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from desloppify.languages._framework.treesitter import C_SPEC, CPP_SPEC +from desloppify.languages.cxx.compile_commands import ( + CompileCommand, + load_compile_commands, +) +from desloppify.languages.cxx.treesitter import _command_language, _compilation_specs + + +@pytest.mark.parametrize( + "arguments, expected", + [ + (["cc", "native.c"], "c"), + (["gcc", "native.c"], "c"), + (["c++", "native.c"], "cpp"), + (["g++", "native.c"], "cpp"), + (["clang++-18", "native.c"], "cpp"), + (["x86_64-linux-gnu-g++-14", "native.c"], "cpp"), + (["ccache", "/usr/bin/clang++", "native.c"], "cpp"), + (["clang", "-std=c++17", "native.c"], "c"), + (["clang", "--driver-mode=g++", "native.c"], "cpp"), + (["clang++", "--driver-mode=gcc", "native.c"], "c"), + (["g++", "-std=c17", "native.c"], "cpp"), + (["g++", "-x", "c", "native.c"], "c"), + (["gcc", "-xc++", "native.c"], "cpp"), + (["g++", "-xc", "native.c"], "c"), + (["gcc", "-x", "c++-cpp-output", "native.c"], "cpp"), + (["g++", "-x", "cpp-output", "native.c"], "c"), + (["gcc", "-x", "c++", "-x", "none", "native.c"], "c"), + (["g++", "-xc", "-xnone", "native.c"], "cpp"), + (["gcc", "-xc++", "native.c", "-xc"], "cpp"), + (["gcc", "-xc", "native.c", "-xc++"], "c"), + (["gcc", "-include", "native.c", "-xc++", "native.c"], "cpp"), + (["gcc", "-xc++", "--", "native.c"], "cpp"), + (["clang-cl", "/TP", "native.c"], "cpp"), + (["cl.exe", "/TC", "native.c"], "c"), + (["cl", "native.c", "/TP"], "cpp"), + (["cl", "/TP", "/TC", "native.c"], "c"), + (["cl", "/TC", "/TP", "native.c"], "cpp"), + (["cl", "/TP", "/Tcnative.c"], "c"), + (["cl", "/TC", "/Tpnative.c"], "cpp"), + (["cl", "/Tc", "native.c", "/TP"], "c"), + (["cl", "/TC", "-Tp", "native.c"], "cpp"), + (["cl", "/Tpother.c", "native.c"], "c"), + ], +) +def test_compiler_language_options(tmp_path, arguments, expected): + command = CompileCommand(str(tmp_path / "native.c"), tmp_path, arguments) + assert _command_language(command) == expected + + +def test_cpp_extension_and_explicit_c_override(tmp_path): + source = str(tmp_path / "native.cpp") + assert ( + _command_language(CompileCommand(source, tmp_path, ["gcc", "native.cpp"])) + == "cpp" + ) + assert ( + _command_language( + CompileCommand(source, tmp_path, ["gcc", "-xc", "native.cpp"]) + ) + == "c" + ) + + +@pytest.mark.parametrize("representation", ["arguments", "command"]) +def test_relative_compilation_paths_use_database_root( + tmp_path, monkeypatch, representation +): + root = tmp_path / "project" + root.mkdir() + (root / "build").mkdir() + other = tmp_path / "unrelated" + other.mkdir() + monkeypatch.chdir(other) + invocation = ( + ["cc", "-xc++", "-c", "../src/file space.c"] + if representation == "arguments" + else "cc -xc++ -c '../src/file space.c'" + ) + (root / "compile_commands.json").write_text( + json.dumps( + [ + { + "directory": "build", + "file": "../src/file space.c", + representation: invocation, + } + ] + ) + ) + + commands = load_compile_commands(root) + specs, conflicts = _compilation_specs(root) + + source = str(root / "src" / "file space.c") + assert len(commands) == 1 + assert commands[0].directory == root / "build" + assert commands[0].source == source + assert commands[0].arguments[-1] == "../src/file space.c" + assert specs == {os.path.normcase(source): CPP_SPEC} + assert conflicts == set() + + +@pytest.mark.parametrize("payload", [None, b"not json", b"{}", b"null", b"[", b"\xff"]) +def test_missing_or_malformed_database(tmp_path, payload): + if payload is not None: + (tmp_path / "compile_commands.json").write_bytes(payload) + assert load_compile_commands(tmp_path) == [] + assert _compilation_specs(tmp_path) == ({}, set()) + + +def test_bad_entries_do_not_hide_valid_commands(tmp_path): + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + None, + 42, + {}, + {"file": 10}, + {"file": ""}, + {"file": "bad.c", "command": "cc 'unterminated"}, + {"file": "bad.c", "arguments": ["cc", None]}, + {"file": "bad.c", "arguments": ["cc", "bad\u0000.c"]}, + {"file": "bad.c", "command": "cc bad\u0000.c"}, + {"file": "bad\u0000.c", "arguments": ["cc", "bad.c"]}, + { + "file": "bad.c", + "directory": "bad\u0000", + "arguments": ["cc", "bad.c"], + }, + {"file": "good.c", "arguments": ["cc", "-xc++", "good.c"]}, + ] + ) + ) + commands = load_compile_commands(tmp_path) + assert [Path(command.source).name for command in commands] == ["good.c"] + assert _compilation_specs(tmp_path) == ( + {os.path.normcase(str(tmp_path / "good.c")): CPP_SPEC}, + set(), + ) + + +def test_headers_keep_cpp_even_with_explicit_c_metadata(tmp_path): + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + {"file": "shared.h", "arguments": ["cc", "-xc", "shared.h"]}, + {"file": "native.c", "arguments": ["cc", "-xc", "native.c"]}, + ] + ) + ) + specs, conflicts = _compilation_specs(tmp_path) + assert specs == {os.path.normcase(str(tmp_path / "native.c")): C_SPEC} + assert conflicts == set() diff --git a/desloppify/languages/cxx/tests/test_compile_commands.py b/desloppify/languages/cxx/tests/test_compile_commands.py index 335fb8a1d..921e0a54d 100644 --- a/desloppify/languages/cxx/tests/test_compile_commands.py +++ b/desloppify/languages/cxx/tests/test_compile_commands.py @@ -37,6 +37,7 @@ def test_build_dep_graph_from_compile_commands(tmp_path): assert str(source.resolve()) in graph assert str(header.resolve()) in graph[str(source.resolve())]["imports"] + def test_build_dep_graph_distinguishes_angle_vs_quoted_include_resolution(tmp_path): source = _write( tmp_path, @@ -76,3 +77,27 @@ def test_build_dep_graph_distinguishes_angle_vs_quoted_include_resolution(tmp_pa assert str(include_header.resolve()) in imports assert str(local_header.resolve()) in imports assert str((tmp_path / "src" / "widget.hpp").resolve()) not in imports + + +def test_relative_compile_directory_is_relative_to_database(tmp_path, monkeypatch): + source = _write(tmp_path, "src/main.c", '#include "generated.h"\n') + header = _write(tmp_path, "generated/generated.h", "#pragma once\n") + (tmp_path / "build").mkdir() + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + { + "directory": "build", + "file": "../src/main.c", + "arguments": ["cc", "-I../generated", "-c", "../src/main.c"], + } + ] + ) + ) + unrelated = tmp_path / "other" + unrelated.mkdir() + monkeypatch.chdir(unrelated) + + graph = build_cxx_dep_graph(tmp_path) + + assert str(header) in graph[str(source)]["imports"] diff --git a/desloppify/languages/cxx/tests/test_init.py b/desloppify/languages/cxx/tests/test_init.py index ee2422907..696d6cfff 100644 --- a/desloppify/languages/cxx/tests/test_init.py +++ b/desloppify/languages/cxx/tests/test_init.py @@ -1,7 +1,9 @@ from __future__ import annotations import desloppify.languages.cxx as cxx_mod +import desloppify.languages.cxx.treesitter as cxx_treesitter from desloppify.languages._framework.base.types import DetectorPhase +from desloppify.languages._framework.treesitter import C_SPEC, CPP_SPEC def test_cxx_uses_full_plugin_config(): @@ -18,19 +20,22 @@ def test_cxx_keeps_cppcheck_phase(): assert "cppcheck" in labels -def test_cxx_includes_tree_sitter_phases(monkeypatch): - captured: dict[str, str] = {} +def test_cxx_includes_tree_sitter_phases(tmp_path, monkeypatch): + captured: dict[str, object] = {} sentinel = DetectorPhase("Tree-sitter sentinel", lambda *_args: ([], {})) - def fake_all_treesitter_phases(spec_name: str): - captured["spec_name"] = spec_name - return [sentinel] + def fake_phase(spec, *, spec_for_file=None): + captured["spec_name"] = spec.grammar + captured["spec_for_file"] = spec_for_file + return sentinel monkeypatch.setattr( - cxx_mod, - "all_treesitter_phases", - fake_all_treesitter_phases, - raising=False, + cxx_treesitter, + "make_ast_smells_phase", + fake_phase, + ) + monkeypatch.setattr( + "desloppify.languages._framework.treesitter.is_available", lambda: True ) cfg = cxx_mod.CxxConfig() @@ -38,9 +43,35 @@ def fake_all_treesitter_phases(spec_name: str): assert captured["spec_name"] == "cpp" assert "Tree-sitter sentinel" in labels + next(phase for phase in cfg.phases if phase.label == sentinel.label).run( + tmp_path, cfg + ) + select = captured["spec_for_file"] + assert callable(select) + assert select("native.c") is C_SPEC + assert select("widget.cpp") is CPP_SPEC + assert select("shared.h") is CPP_SPEC def test_cxx_excludes_unused_imports_phase(): cfg = cxx_mod.CxxConfig() labels = {phase.label for phase in cfg.phases} assert "Unused imports" not in labels + + +def test_cxx_keeps_non_ast_phases_without_optional_treesitter(monkeypatch): + monkeypatch.setattr( + "desloppify.languages._framework.treesitter.is_available", lambda: False + ) + + cfg = cxx_mod.CxxConfig() + labels = {phase.label for phase in cfg.phases} + + assert "AST smells" not in labels + assert "Responsibility cohesion" not in labels + assert { + "Structural analysis", + "cppcheck", + "Signature analysis", + "Duplicates", + } <= labels diff --git a/desloppify/languages/cxx/tests/test_treesitter.py b/desloppify/languages/cxx/tests/test_treesitter.py new file mode 100644 index 000000000..b8e7bf112 --- /dev/null +++ b/desloppify/languages/cxx/tests/test_treesitter.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import desloppify.languages.cxx.treesitter as cxx_treesitter +from desloppify.languages._framework.runtime_support.runtime import make_lang_run +from desloppify.languages._framework.treesitter import ( + C_SPEC, + CPP_SPEC, + disable_parse_cache, + enable_parse_cache, + is_available, +) +from desloppify.languages._framework.treesitter.analysis import cohesion, smells +from desloppify.languages._framework.treesitter.analysis.extractors import _get_parser +from desloppify.languages._framework.treesitter.analysis.smells import ( + detect_unreachable_code, +) +from desloppify.languages._framework.treesitter.phases import make_ast_smells_phase +from desloppify.languages.cxx import CxxConfig + +pytestmark = pytest.mark.skipif( + not is_available(), reason="tree-sitter-language-pack not installed" +) + +_FIXTURES = Path(__file__).parent / "fixtures" + + +def _phase(label): + return next(phase for phase in CxxConfig().phases if phase.label == label) + + +@pytest.mark.parametrize("representation", ["arguments", "command"]) +def test_c_file_explicitly_compiled_as_cpp_keeps_unreachable_throw( + tmp_path, representation +): + source = tmp_path / "native.c" + source.write_bytes((_FIXTURES / "native_c" / "cpp_override.c").read_bytes()) + invocation = ( + ["c++", "-x", "c++", "-std=c++17", "-c", "native.c"] + if representation == "arguments" + else "c++ -x c++ -std=c++17 -c native.c" + ) + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + { + "directory": str(tmp_path), + "file": "native.c", + representation: invocation, + } + ] + ) + ) + lang = SimpleNamespace(file_finder=lambda _: [str(source)]) + + issues, potentials = _phase("AST smells").run(tmp_path, lang) + + assert len(issues) == 1 + assert issues[0]["summary"] == "Unreachable code after throw_statement" + assert issues[0]["id"].endswith("unreachable_code::4") + assert potentials == {"unreachable_code": 1} + + +def test_c_callback_named_throw_is_not_an_exception(tmp_path): + source = tmp_path / "callback.c" + source.write_bytes((_FIXTURES / "native_c" / "callback.c").read_bytes()) + + # Reuse the scan cache to verify that C and C++ trees stay separate. + enable_parse_cache() + try: + for spec in (C_SPEC, CPP_SPEC): + parser, _ = _get_parser(spec.grammar) + assert not parser.parse(source.read_bytes()).root_node.has_error + assert len(detect_unreachable_code([str(source)], CPP_SPEC)) == 2 + assert detect_unreachable_code([str(source)], C_SPEC) == [ + {"file": str(source), "line": 9, "after": "return_statement"} + ] + + issues, potentials = _phase("AST smells").run( + tmp_path, make_lang_run(CxxConfig()) + ) + finally: + disable_parse_cache() + + assert len(issues) == 1 + assert issues[0]["summary"] == "Unreachable code after return_statement" + assert issues[0]["id"].endswith("unreachable_code::9") + assert potentials == {"unreachable_code": 1} + + +@pytest.mark.parametrize( + "suffix", + [ + ".cpp", + ".cc", + ".cxx", + ".h", + ".hh", + ".hpp", + ".hxx", + ".ipp", + ".inl", + ".tpp", + ".txx", + ".tcc", + ], +) +def test_mixed_project_preserves_cpp_and_header_findings(tmp_path, suffix): + native = tmp_path / "native.c" + native.write_bytes((_FIXTURES / "native_c" / "callback.c").read_bytes()) + cpp = tmp_path / f"widget{suffix}" + cpp.write_text( + "void work();\n" + "template void sample() {\n" + " try { work(); } catch (...) {}\n" + " throw 1;\n" + " work();\n" + "}\n" + ) + lang = make_lang_run(CxxConfig()) + # The established C++ path must still report exactly the same findings. + cpp_lang = SimpleNamespace(file_finder=lambda _path: [str(cpp)]) + expected_cpp, _ = make_ast_smells_phase(CPP_SPEC).run(tmp_path, cpp_lang) + + issues, potentials = _phase("AST smells").run(tmp_path, lang) + + assert len(issues) == len({issue["id"] for issue in issues}) == 3 + assert potentials == {"empty_catch": 1, "unreachable_code": 2} + assert [ + issue for issue in issues if issue["file"].endswith(cpp.name) + ] == expected_cpp + + +def test_native_c17_constructs_do_not_produce_smells(tmp_path): + source = tmp_path / "native.c" + source.write_bytes((_FIXTURES / "native_c" / "constructs.c").read_bytes()) + + issues, potentials = _phase("AST smells").run(tmp_path, make_lang_run(CxxConfig())) + + assert issues == [] + assert potentials == {} + + +@pytest.mark.parametrize("suffixes", [(".c",), (".c", ".cpp")]) +@pytest.mark.parametrize( + "compile_command", [None, "cc -std=c17 clusters.c", "g++ -xc -std=c17 clusters.c"] +) +def test_cohesion_combines_c_and_cpp_findings(tmp_path, suffixes, compile_command): + # Three pairs and two standalone functions: five disconnected clusters. + functions = [ + f"int leaf{i}(void) {{ return {i}; }}\n" + f"int root{i}(void) {{ return leaf{i}(); }}\n" + for i in range(3) + ] + functions.extend( + ["int solo1(void) { return 1; }\n", "int solo2(void) { return 2; }\n"] + ) + content = "".join(functions) + "\n" * 200 + for suffix in suffixes: + # mutable is an ordinary C identifier. The C++ query silently misses + # its definition, dropping this file below the eight-function minimum. + source = content.replace("solo1", "mutable") if suffix == ".c" else content + (tmp_path / f"clusters{suffix}").write_text(source) + if compile_command is not None: + (tmp_path / "compile_commands.json").write_text( + json.dumps([{"file": "clusters.c", "command": compile_command}]) + ) + + assert cohesion.detect_responsibility_cohesion( + [str(tmp_path / "clusters.c")], CPP_SPEC + ) == ([], 1) + + issues, potentials = _phase("Responsibility cohesion").run( + tmp_path, make_lang_run(CxxConfig()) + ) + + assert len(issues) == len({issue["id"] for issue in issues}) == len(suffixes) + assert {Path(issue["file"]).suffix for issue in issues} == set(suffixes) + assert all(issue["detail"]["cluster_count"] == 5 for issue in issues) + assert potentials == {"responsibility_cohesion": len(suffixes)} + + +def test_phases_use_runtime_file_finder_once_per_grammar(tmp_path, monkeypatch): + files = [ + "src/./native.c", + str(tmp_path / "dir.with.c" / "other.c"), + "src/widget.cpp", + "include/shared.h", + ] + calls = [] + + def record(detector): + def detect(file_list, spec): + calls.append((detector, spec.grammar, file_list)) + return ([], len(file_list)) if detector == "cohesion" else [] + + return detect + + monkeypatch.setattr(smells, "detect_empty_catches", record("catches")) + monkeypatch.setattr(smells, "detect_unreachable_code", record("unreachable")) + monkeypatch.setattr(cohesion, "detect_responsibility_cohesion", record("cohesion")) + lang = SimpleNamespace(file_finder=lambda _path: files) + for label in ("AST smells", "Responsibility cohesion"): + assert _phase(label).run(tmp_path, lang) == ([], {}) + + for detector in ("catches", "unreachable", "cohesion"): + assert [call[1:] for call in calls if call[0] == detector] == [ + ("c", files[:2]), + ("cpp", files[2:]), + ] + + +def test_c_parser_initialization_failure_keeps_cpp_findings(tmp_path, monkeypatch): + (tmp_path / "native.c").write_text("int f(void) { return 1; }\n") + (tmp_path / "widget.cpp").write_text("void f() { throw 1; f(); }\n") + + def get_parser(grammar): + if grammar == "c": + raise ImportError("C parser unavailable") + return _get_parser(grammar) + + monkeypatch.setattr(smells, "_get_parser", get_parser) + monkeypatch.setattr(cohesion, "_get_parser", get_parser) + lang = make_lang_run(CxxConfig()) + + issues, potentials = _phase("AST smells").run(tmp_path, lang) + + assert len(issues) == 1 + assert issues[0]["file"].endswith("widget.cpp") + assert potentials == {"unreachable_code": 1} + assert _phase("Responsibility cohesion").run(tmp_path, lang) == ([], {}) + + +def test_native_c_findings_reach_scan_pipeline(tmp_path): + from desloppify.engine.planning.scan import generate_issues + + source = tmp_path / "main.c" + source.write_bytes((_FIXTURES / "native_c" / "callback.c").read_bytes()) + cfg = CxxConfig() + cfg.phases = [ + phase + for phase in cfg.phases + if phase.label in {"AST smells", "Responsibility cohesion"} + ] + + issues, potentials = generate_issues(tmp_path, cfg) + + assert len(issues) == 1 + assert issues[0]["lang"] == "cxx" + assert issues[0]["zone"] == "production" + assert issues[0]["confidence"] == "high" + assert potentials == {"unreachable_code": 1} + + +def test_compilation_aware_mixed_project_preserves_each_context(tmp_path): + (tmp_path / "native.c").write_bytes( + (_FIXTURES / "native_c" / "callback.c").read_bytes() + ) + for name in ("override.c", "ordinary.cpp", "shared.h"): + (tmp_path / name).write_bytes( + (_FIXTURES / "native_c" / "cpp_override.c").read_bytes() + ) + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + {"file": "native.c", "arguments": ["g++", "-xc", "native.c"]}, + {"file": "override.c", "command": "gcc -xc++ override.c"}, + {"file": "ordinary.cpp", "command": "gcc ordinary.cpp"}, + {"file": "shared.h", "command": "gcc -xc shared.h"}, + ] + ) + ) + + issues, potentials = _phase("AST smells").run(tmp_path, make_lang_run(CxxConfig())) + + assert len(issues) == len({issue["id"] for issue in issues}) == 4 + assert potentials == {"unreachable_code": 4} + native = next(issue for issue in issues if issue["file"].endswith("native.c")) + assert native["id"].endswith("unreachable_code::9") + assert native["summary"] == "Unreachable code after return_statement" + assert all( + issue["summary"] == "Unreachable code after throw_statement" + for issue in issues + if issue is not native + ) + assert all(issue["confidence"] == "high" for issue in issues) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_conflicting_compile_contexts_keep_cpp_and_disclose_ambiguity( + tmp_path, caplog, reverse +): + (tmp_path / "native.c").write_bytes( + (_FIXTURES / "native_c" / "cpp_override.c").read_bytes() + ) + entries = [ + {"file": "native.c", "arguments": ["gcc", "-xc", "native.c"]}, + {"file": "./native.c", "arguments": ["gcc", "-xc++", "native.c"]}, + ] + (tmp_path / "compile_commands.json").write_text( + json.dumps(entries[::-1] if reverse else entries) + ) + + issues, potentials = _phase("AST smells").run(tmp_path, make_lang_run(CxxConfig())) + + assert len(issues) == 1 + assert potentials == {"unreachable_code": 1} + assert issues[0]["summary"] == "Unreachable code after throw_statement" + assert issues[0]["confidence"] == "low" + assert issues[0]["detail"]["compilation_languages"] == ["c", "cpp"] + assert "Conflicting C/C++ compile languages" in caplog.text + assert "represent only that context" in caplog.text + + +def test_equivalent_commands_do_not_duplicate_or_reduce_findings(tmp_path, caplog): + (tmp_path / "native.c").write_bytes( + (_FIXTURES / "native_c" / "cpp_override.c").read_bytes() + ) + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + {"file": "native.c", "command": "clang++ native.c"}, + {"file": "./native.c", "arguments": ["cc", "-xc++", "./native.c"]}, + ] + ) + ) + + issues, potentials = _phase("AST smells").run(tmp_path, make_lang_run(CxxConfig())) + + assert len(issues) == 1 + assert potentials == {"unreachable_code": 1} + assert issues[0]["confidence"] == "high" + assert "compilation_languages" not in issues[0]["detail"] + assert not caplog.records + + +def test_scan_roots_and_metadata_changes_do_not_share_selection(tmp_path): + cpp_root = tmp_path / "cpp" + c_root = tmp_path / "c" + cpp_root.mkdir() + c_root.mkdir() + (cpp_root / "native.c").write_bytes( + (_FIXTURES / "native_c" / "cpp_override.c").read_bytes() + ) + (c_root / "native.c").write_bytes( + (_FIXTURES / "native_c" / "callback.c").read_bytes() + ) + database = cpp_root / "compile_commands.json" + database.write_text( + json.dumps([{"file": "native.c", "command": "cc -xc++ native.c"}]) + ) + lang = make_lang_run(CxxConfig()) + phase = next(phase for phase in lang.phases if phase.label == "AST smells") + + for root, after in ( + (cpp_root, "throw_statement"), + (c_root, "return_statement"), + (cpp_root, "throw_statement"), + ): + issues, potentials = phase.run(root, lang) + assert len(issues) == 1 + assert issues[0]["summary"] == f"Unreachable code after {after}" + assert potentials == {"unreachable_code": 1} + + database.write_text("[]") + (cpp_root / "native.c").write_bytes( + (_FIXTURES / "native_c" / "callback.c").read_bytes() + ) + issues, _ = phase.run(cpp_root, lang) + assert len(issues) == 1 + assert issues[0]["summary"] == "Unreachable code after return_statement" + + +def test_compilation_database_is_loaded_once_per_phase(tmp_path, monkeypatch): + for index in range(10): + (tmp_path / f"native{index}.c").write_text("int f(void) { return 1; }\n") + original = cxx_treesitter.load_compile_commands + reads = [] + + def load(path): + reads.append(path) + return original(path) + + monkeypatch.setattr(cxx_treesitter, "load_compile_commands", load) + lang = make_lang_run(CxxConfig()) + for label in ("AST smells", "Responsibility cohesion"): + assert _phase(label).run(tmp_path, lang) == ([], {}) + assert reads == [tmp_path, tmp_path] + + +def test_unmatched_or_excluded_metadata_does_not_change_native_c(tmp_path, caplog): + source = tmp_path / "native.c" + source.write_bytes((_FIXTURES / "native_c" / "callback.c").read_bytes()) + (tmp_path / "compile_commands.json").write_text( + json.dumps( + [ + {"file": "other/native.c", "command": "cc -xc++ other/native.c"}, + {"file": "excluded.c", "command": "cc -xc excluded.c"}, + {"file": "excluded.c", "command": "cc -xc++ excluded.c"}, + ] + ) + ) + lang = SimpleNamespace(file_finder=lambda _: [str(source)]) + + issues, _ = _phase("AST smells").run(tmp_path, lang) + + assert len(issues) == 1 + assert issues[0]["summary"] == "Unreachable code after return_statement" + assert not caplog.records diff --git a/desloppify/languages/cxx/treesitter.py b/desloppify/languages/cxx/treesitter.py new file mode 100644 index 000000000..bf10e6b27 --- /dev/null +++ b/desloppify/languages/cxx/treesitter.py @@ -0,0 +1,180 @@ +"""Compilation-aware Tree-sitter phases for the existing C/C++ plugin.""" + +from __future__ import annotations + +import logging +import os +import re +from collections.abc import Callable +from pathlib import Path + +from desloppify.base.discovery.file_paths import resolve_path +from desloppify.languages._framework import treesitter +from desloppify.languages._framework.base.types import ( + DetectorPhase, + LangRuntimeContract, +) +from desloppify.languages._framework.treesitter import ( + C_SPEC, + CPP_SPEC, + TreeSitterLangSpec, +) +from desloppify.languages._framework.treesitter.phases import ( + make_ast_smells_phase, + make_cohesion_phase, +) +from desloppify.languages.cxx.compile_commands import ( + CompileCommand, + load_compile_commands, +) +from desloppify.languages.cxx.extractors import CXX_SOURCE_EXTENSIONS +from desloppify.state_io import Issue + +logger = logging.getLogger(__name__) + +_CPP_DRIVER = re.compile(r"(?:^|-)(?:g\+\+|c\+\+|clang\+\+)(?:-\d+(?:\.\d+)*)?$") +_WRAPPERS = {"ccache", "sccache", "distcc"} +_LANGUAGES = { + "c": "c", + "c-header": "c", + "cpp-output": "c", + "c++": "cpp", + "c++-header": "cpp", + "c++-cpp-output": "cpp", +} +_OPTION_VALUES = { + "-o", + "-I", + "-isystem", + "-iquote", + "-include", + "-imacros", + "-isysroot", + "--sysroot", + "-MF", + "-MT", + "-MQ", + "-Xclang", + "-target", + "--target", + "-arch", + "-D", + "-U", +} + + +def _command_path(value: str, directory: Path) -> str: + path = Path(value.strip('"')) + return os.path.normcase(str((directory / path).resolve())) + + +def _command_language(command: CompileCommand) -> str: + """Interpret input language, not the language-standard warning option.""" + tokens = command.arguments + drivers = iter(tokens) + driver = Path(next(drivers, "").strip('"')).name.lower().removesuffix(".exe") + while driver in _WRAPPERS: + driver = Path(next(drivers, "").strip('"')).name.lower().removesuffix(".exe") + default = ( + "cpp" + if Path(command.source).suffix != ".c" or _CPP_DRIVER.search(driver) + else "c" + ) + language = "none" + input_language: str | None = None + msvc_global: str | None = None + msvc_input: str | None = None + source = os.path.normcase(command.source) + args = iter(tokens[1:]) + for token in args: + if token == "--": + if any(_command_path(arg, command.directory) == source for arg in args): + input_language = language + break + if token == "-x": + language = next(args, "none") + elif token.startswith("-x"): + language = token[2:] + elif token in {"/TC", "-TC", "/TP", "-TP"}: + msvc_global = "c" if token.endswith("C") else "cpp" + elif token.startswith(("/Tc", "-Tc", "/Tp", "-Tp")): + target = token[3:] or next(args, "") + if target and _command_path(target, command.directory) == source: + msvc_input = "c" if token[2] == "c" else "cpp" + elif token in _OPTION_VALUES: + next(args, None) + elif token.startswith("--driver-mode="): + mode = token.partition("=")[2] + if mode == "g++": + default = "cpp" + elif mode in {"gcc", "cl", "cpp"}: + default = "c" if Path(command.source).suffix == ".c" else "cpp" + elif not token.startswith(("-", "/")) or Path(token).is_absolute(): + if _command_path(token, command.directory) == source: + input_language = language + return ( + msvc_input + or msvc_global + or _LANGUAGES.get( + input_language if input_language is not None else language, default + ) + ) + + +def _compilation_specs(path: Path) -> tuple[dict[str, TreeSitterLangSpec], set[str]]: + languages: dict[str, set[str]] = {} + for command in load_compile_commands(path): + # Header language is intentionally unchanged, even for explicit entries. + if Path(command.source).suffix not in CXX_SOURCE_EXTENSIONS: + continue + languages.setdefault(os.path.normcase(command.source), set()).add( + _command_language(command) + ) + conflicts = {file for file, modes in languages.items() if len(modes) > 1} + specs = { + file: CPP_SPEC if "cpp" in modes else C_SPEC + for file, modes in languages.items() + } + return specs, conflicts + + +def _compilation_aware_phase(factory: Callable[..., DetectorPhase]) -> DetectorPhase: + base = factory(CPP_SPEC) + + def run( + path: Path, lang: LangRuntimeContract + ) -> tuple[list[Issue], dict[str, int]]: + # Prepare per invocation: no per-file DB reads or configuration shared + # between scans, even when one CxxConfig is used for different roots. + specs, conflicts = _compilation_specs(path) + + def select(filepath: str) -> TreeSitterLangSpec: + file = os.path.normcase(resolve_path(filepath)) + if file in conflicts: + logger.warning( + "Conflicting C/C++ compile languages for %s; retaining C++ parsing. " + "%s findings represent only that context and have low confidence.", + file, + base.label, + ) + fallback = C_SPEC if Path(filepath).suffix == ".c" else CPP_SPEC + return specs.get(file, fallback) + + issues, potentials = factory(CPP_SPEC, spec_for_file=select).run(path, lang) + for issue in issues: + if os.path.normcase(resolve_path(issue["file"])) in conflicts: + issue["confidence"] = "low" + issue["detail"]["compilation_languages"] = ["c", "cpp"] + return issues, potentials + + return DetectorPhase(base.label, run, slow=base.slow) + + +def make_cxx_treesitter_phases() -> list[DetectorPhase]: + """Reuse shared AST phases with scan-local compilation context.""" + if not treesitter.is_available(): + return [] + return [ + _compilation_aware_phase(factory) + for factory in (make_ast_smells_phase, make_cohesion_phase) + ] diff --git a/desloppify/tests/lang/common/test_treesitter_phases.py b/desloppify/tests/lang/common/test_treesitter_phases.py new file mode 100644 index 000000000..54658b99a --- /dev/null +++ b/desloppify/tests/lang/common/test_treesitter_phases.py @@ -0,0 +1,64 @@ +"""Contracts for file-specific Tree-sitter specifications in shared phases.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from types import SimpleNamespace + +import pytest + +from desloppify.languages._framework.treesitter import CPP_SPEC +from desloppify.languages._framework.treesitter.analysis import cohesion, smells +from desloppify.languages._framework.treesitter.phases import ( + make_ast_smells_phase, + make_cohesion_phase, +) + + +@dataclass +class _Resolver: + """A valid callable resolver need not be hashable.""" + + def __call__(self, spec: str, source: str, root: str) -> str | None: + return None + + +@pytest.mark.parametrize("factory", [make_ast_smells_phase, make_cohesion_phase]) +@pytest.mark.parametrize("dispatch", [False, True]) +def test_specs_do_not_require_hashable_resolvers( + tmp_path, monkeypatch, factory, dispatch +): + files = ["native.c", "widget.cpp", "shared.h"] + spec = replace(CPP_SPEC, resolve_import=_Resolver()) + calls = [] + + def detect(file_list, file_spec): + calls.append((file_list, file_spec)) + return [] + + def detect_cohesion(file_list, file_spec): + return detect(file_list, file_spec), len(file_list) + + monkeypatch.setattr(smells, "detect_empty_catches", detect) + monkeypatch.setattr(smells, "detect_unreachable_code", detect) + monkeypatch.setattr(cohesion, "detect_responsibility_cohesion", detect_cohesion) + phase = factory(spec, spec_for_file=(lambda _: spec) if dispatch else None) + + assert phase.run(tmp_path, SimpleNamespace(file_finder=lambda _: files)) == ([], {}) + assert calls == [(files, spec)] * (2 if factory is make_ast_smells_phase else 1) + + +@pytest.mark.parametrize("factory", [make_ast_smells_phase, make_cohesion_phase]) +def test_empty_file_list_does_not_initialize_parsers(tmp_path, monkeypatch, factory): + def unavailable(_grammar): + raise AssertionError("Empty scans must not initialize a parser") + + for module in ("smells", "cohesion"): + monkeypatch.setattr( + f"desloppify.languages._framework.treesitter.analysis.{module}._get_parser", + unavailable, + ) + + assert factory(CPP_SPEC).run( + tmp_path, SimpleNamespace(file_finder=lambda _: []) + ) == ([], {})