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
48 changes: 41 additions & 7 deletions desloppify/languages/_framework/treesitter/phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

Expand All @@ -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]]:
Expand All @@ -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",
)
Expand All @@ -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",
)
Expand All @@ -74,19 +101,26 @@ 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]]:
from desloppify.languages._framework.treesitter.analysis.cohesion import (
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(
Expand Down
37 changes: 37 additions & 0 deletions desloppify/languages/cxx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 2 additions & 7 deletions desloppify/languages/cxx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +39,7 @@
api_surface,
module_patterns,
)
from desloppify.languages.cxx.treesitter import make_cxx_treesitter_phases


class CxxConfig(LangConfig):
Expand All @@ -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,
Expand All @@ -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(),
Expand Down
70 changes: 70 additions & 0 deletions desloppify/languages/cxx/compile_commands.py
Original file line number Diff line number Diff line change
@@ -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
55 changes: 5 additions & 50 deletions desloppify/languages/cxx/detectors/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

from __future__ import annotations

import json
import os
import re
import shlex
from pathlib import Path
from typing import Any

Expand All @@ -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*([<"])([^>"]+)[>"]')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions desloppify/languages/cxx/tests/fixtures/native_c/callback.c
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions desloppify/languages/cxx/tests/fixtures/native_c/constructs.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* C17 syntax probes; these constructs do not imply unreachable code. */
#include <stddef.h>

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
Loading