Skip to content
Merged
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
30 changes: 1 addition & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

> **IMPORTANT**: When you modify any code in this project, also update this file if the change affects agent behavior, project structure, dependencies, git workflow, or any guidance documented here.
> **IMPORTANT**: When you modify any code in this project, also update this file if the change affects agent behavior, git workflow, or any guidance documented here.

## Git Rules (MUST FOLLOW)

Expand All @@ -19,19 +19,6 @@ A pre-commit hook repo that auto-installs and runs `clang-format` and `clang-tid

**Hook definitions**: `.pre-commit-hooks.yaml`

## Key Files

| File | Purpose |
|------|---------|
| `pyproject.toml` | Build config, deps (`pip>=26.1`, `filelock>=3.16`), dev deps, entry points |
| `cpp_linter_hooks/util.py` | Installed-wheel check, PyPI version resolution + pip install (prefix matching, offline fallback), all under a `filelock` lock on the hook environment so parallel hook processes install once |
| `cpp_linter_hooks/clang_format.py` | clang-format wrapper (`--verbose`, `--dry-run`) |
| `cpp_linter_hooks/clang_tidy.py` | clang-tidy wrapper (`--compile-commands`, missing-database policy, `--jobs`, `--fix`, `--all-lines`, `-quiet` + status-line filter, `--checks` repair, error hints) |
| `cpp_linter_hooks/changed_lines.py` | Added line ranges per file from `git diff` (staged changes, or `PRE_COMMIT_FROM_REF...PRE_COMMIT_TO_REF`) |
| `.pre-commit-hooks.yaml` | Hook metadata for pre-commit framework |
| `testing/` | Test fixtures (`main.c`, `good.c`, `.clang-format`, `.clang-tidy`, CMakeLists) |
| `examples/` | Example configs (CMake, Meson, large-project scoping) |

## Development

```bash
Expand All @@ -40,18 +27,3 @@ uv run pytest -vv # Run tests
uv run coverage run --source=tests,cpp_linter_hooks -m pytest -vv # With coverage
bash testing/run.sh # Integration tests (full pre-commit pipeline)
```

## Core Patterns

- **Version resolution**: A wheel already installed in the hook environment (found by its `<name>-<version>.dist-info` directory in site-packages, e.g. from `additional_dependencies`, with its executable in the environment's scripts directory) is used without network access when it matches `--version` (exact or prefix) or when no `--version` is given. Otherwise the version is resolved dynamically from PyPI at runtime. No hardcoded version lists. Prefix match supported (`--version=20` → `20.1.8`).
- **Return values**: All hooks return `tuple[int, str]` — `(retval, output)`, where `retval` is the tool's exit code (`0` on success, non-zero on failure; `--dry-run` auto-adds `--Werror` so formatting issues fail).
- **clang-tidy compile DB**: Auto-detected from `build/`, `out/`, `cmake-build-debug/`, `_build/`, `builddir/`, then `cmake-build-*/`, `build-*/`, `build/*/`, `out/build/*/` (newest wins). Override with `--compile-commands=<dir>`, disable with `--no-compile-commands`. If none is found, no `-p`/`--extra-arg*` is given and clang-tidy finds no `compile_commands.json`/`compile_flags.txt` next to the files or in their parents, the hook skips clang-tidy (retval 0, message on stderr), or fails when `CI` is set.
- **clang-tidy changed lines**: By default only diagnostics on added lines are reported, via clang-tidy's `--line-filter`, so `-fix` applies only the fixes of those diagnostics (a fix may still edit an unchanged line nearby). Files without added lines are not checked. If any passed file has no changes (`--all-files`), differs on disk from the diffed version (index or `PRE_COMMIT_TO_REF`), or git fails, every line is checked. `--all-lines` or a user `--line-filter` turns this off. Tests: `tests/conftest.py` disables this and the missing-database check by default; use the `real_changed_lines` / `real_compile_db_check` fixtures to test them.
- **clang-tidy parallel**: `--jobs=N` runs per-file in parallel. Forced serial when `--export-fixes`, `-fix`, or `-fix-errors` is used.
- **clang-tidy output**: Without `--verbose`, the hook adds `-quiet` (unless the user set it) and strips status lines (`N warnings generated.`, `Suppressed N warnings ...`, `Use -header-filter=...`, `[1/N] Processing file ...`). With `--verbose`, output is unchanged.
- **clang-tidy check lists**: `--checks=` / `--warnings-as-errors=` lists that YAML split at commas are joined again, quotes inside the value are removed, and `--checks=<file>` (e.g. `.clang-tidy`) gets a note. Notes are appended to the output, which pre-commit shows when the hook fails.

## CI

- `test.yml` — matrix (3 OS × 6 Python versions), pytest + coverage + integration tests
- `codspeed.yml` — benchmarks via CodSpeed on path-filtered changes
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ picks and installs the wheel the first time it runs:
give clang-tidy the major version.
- clang-format wheels cover LLVM 6 to 23 and clang-tidy wheels LLVM 13 to 23. For a version without
a wheel, the hook fails and lists some of the versions that exist.
- When the hook environment already has a wheel that matches `--version` (or any wheel, without
`--version`), the hook uses it and does not contact pypi.org. Otherwise it looks the version up
on pypi.org and installs it, so that run needs network access. pre-commit runs the hook on
groups of files in parallel; one of them installs the wheel and the others wait for it.
- Each `--version` is installed once, into its own directory in the hook environment, and later
runs use it without contacting pypi.org. pre-commit shares the hook environment between all
your projects that use the same `rev`, so projects that ask for different versions keep their
own copy instead of reinstalling. A wheel from `additional_dependencies` that matches
`--version` is used as well.
- Only a run that finds no matching wheel installed looks the version up on pypi.org and
installs it, so that run needs network access. pre-commit runs the hook on groups of files in
parallel; one of them installs the wheel and the others wait for it.

### Install the tool with the hook

Expand Down
117 changes: 102 additions & 15 deletions cpp_linter_hooks/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@
return sysconfig.get_paths()


def _dist_name(tool: str) -> str:
"""Return the normalized distribution name, as in ``clang_format``."""
return re.sub(r"[-_.]+", "_", tool).lower()


def _installed_wheel_version(tool: str) -> str | None:
"""Return the version of the *tool* wheel installed in this Python environment.

Expand All @@ -167,7 +172,7 @@
only once its ``RECORD`` file exists: pip writes it last, so a wheel another
hook process is still installing is not used half-written.
"""
prefix = f"{re.sub(r'[-_.]+', '_', tool).lower()}-"
prefix = f"{_dist_name(tool)}-"
suffix = ".dist-info"
paths = _env_paths()
site_dirs = dict.fromkeys((paths["purelib"], paths["platlib"]))
Expand Down Expand Up @@ -214,6 +219,77 @@
return Path(path), installed


def _versions_dir() -> str:
"""Return the directory that holds one install per requested ``--version``.

pre-commit shares one hook environment between all projects on a machine
that use the same ``rev``. Each version gets its own directory there, so
projects that ask for different versions do not replace each other's wheel.
"""
return os.path.join(sys.prefix, "cpp-linter-hooks")


def _versioned_executable(tool: str, target: str) -> str | None:
"""Return the executable inside a wheel installed with ``pip --target``.

The wheel's own console script cannot run from there, because its package
is not on ``sys.path``, so this returns the binary the wheel ships: in
``<package>/data/bin`` (or ``<package>/bin`` in old clang-format wheels).
"""
package = os.path.join(target, _dist_name(tool))
bin_dirs = (os.path.join(package, "data", "bin"), os.path.join(package, "bin"))
return shutil.which(tool, path=os.pathsep.join(bin_dirs))


def _version_key(version: str) -> tuple[int, ...]:
"""Return a sortable key for a release version such as ``21.1.8``."""
return tuple(int(part) for part in version.split(".") if part.isdigit())


def _find_versioned_install(tool: str, version: str) -> tuple[Path, str] | None:
"""Return (path, version) of the newest finished install that matches *version*.

An install counts only once its ``RECORD`` file exists, so a directory left
behind by an interrupted ``pip install`` is never used.
"""
base = _versions_dir()
prefix = f"{tool}-"
try:
names = os.listdir(base)
except OSError:
return None
found = []
for name in names:
installed = name[len(prefix) :]
if not name.startswith(prefix) or not _version_satisfies(installed, version):
continue
target = os.path.join(base, name)
record = os.path.join(
target, f"{_dist_name(tool)}-{installed}.dist-info", "RECORD"
)
executable = _versioned_executable(tool, target)
if os.path.isfile(record) and executable:
found.append((_version_key(installed), Path(executable), installed))
if not found:
return None
_, path, installed = max(found, key=lambda item: item[0])
return path, installed


def _find_installed(tool: str, version: str | None) -> tuple[Path, str] | None:
"""Return (path, version) of an installed tool that satisfies *version*.

The wheel in the hook environment (from ``additional_dependencies``, or from
a run without ``--version``) comes first; then the per-version installs.
A run without ``--version`` never uses a per-version install, which another
project may have asked for.
"""
installed = _find_installed_wheel(tool, version)
if installed is None and version is not None:
installed = _find_versioned_install(tool, version)
return installed


# Seconds to wait for another hook process to finish installing.
_INSTALL_LOCK_TIMEOUT = 300

Expand Down Expand Up @@ -246,15 +322,23 @@
lock.release()


def _install_tool(tool: str, version: str) -> Path | None:
"""Install a tool using pip, logging output on failure."""
result = subprocess.run(
[sys.executable, "-m", "pip", "install", f"{tool}=={version}"],
capture_output=True,
text=True,
check=False,
)
def _install_tool(tool: str, version: str, versioned: bool = False) -> Path | None:
"""Install a tool using pip, logging output on failure.

With *versioned*, the wheel goes to its own directory under
:func:`_versions_dir` instead of the hook environment.
"""
command = [sys.executable, "-m", "pip", "install", f"{tool}=={version}"]
if versioned:
target = os.path.join(_versions_dir(), f"{tool}-{version}")
# Remove what an interrupted install left behind; pip --target does
# not replace an existing directory.
shutil.rmtree(target, ignore_errors=True)
command += ["--target", target]
result = subprocess.run(command, capture_output=True, text=True, check=False)

Check failure on line 338 in cpp_linter_hooks/util.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Command Argument Injection via faulty LLM-supplied CLI arguments in util._install_tool()

See more on https://sonarcloud.io/project/issues?id=cpp-linter_cpp-linter-hooks&issues=AaFqPy_8jdls-0Cr8pRX&open=AaFqPy_8jdls-0Cr8pRX&pullRequest=310

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same S8705 finding as on #303, which was triaged as a false positive. The command is a list run without a shell, and the only user input in it is --version from the hook's .pre-commit-config.yaml. That input reaches pip only as the single argument clang-format==<version>, where <version> is the matching version from PyPI's release list. The new --target path is built from the same version under the hook environment. No input from an LLM is involved, and the input can't add a pip option. The finding needs a maintainer to mark it as a false positive in SonarCloud.


Generated by Claude Code

if result.returncode == 0:
if versioned:
return _versioned_executable(tool, target)
return _env_executable(tool) or shutil.which(tool)
LOG.error("pip failed to install %s %s", tool, version)
LOG.error(result.stdout)
Expand All @@ -269,18 +353,20 @@

A wheel already installed in the hook's environment is used as is when it
matches ``--version`` (or when no version was given), so pinned
``additional_dependencies`` are kept and the hook works offline.
Otherwise the version is resolved dynamically from PyPI — no hardcoded
list is maintained in-tree.
``additional_dependencies`` are kept and the hook works offline. So is an
earlier per-version install that matches ``--version``. Otherwise the
version is resolved dynamically from PyPI — no hardcoded list is
maintained in-tree — and a ``--version`` is installed into its own
directory.

Installing runs under :func:`_install_lock`, so when several hook processes
start at once, one installs and the others wait and then use its wheel.
"""
installed_wheel = _find_installed_wheel(tool, version)
installed_wheel = _find_installed(tool, version)
if installed_wheel is None:
with _install_lock():
# Another hook process may have installed it while this one waited.
installed_wheel = _find_installed_wheel(tool, version)
installed_wheel = _find_installed(tool, version)
if installed_wheel is None:
return _resolve_and_install(tool, version, verbose)
path, installed = installed_wheel
Expand Down Expand Up @@ -316,7 +402,8 @@
)

return (
_is_version_installed(tool, user_version) or _install_tool(tool, user_version),
_is_version_installed(tool, user_version)
or _install_tool(tool, user_version, versioned=version is not None),
None,
)

Expand Down
Loading
Loading