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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## Next Release

### Features

#### CPP Rules

- Add `noDeleteInGtestTeardown` AST check flagging `delete`/`delete[]` expressions anywhere inside a member function named `TearDown`, `TearDownTestSuite`, or `TearDownTestCase` — the fixture-teardown hooks GTest calls by name. Manually deleting a raw pointer there is fragile (a `SetUp()` that throws or returns early skips the matching `delete`, and a test body that already freed the pointer causes a double-free); prefer an RAII/smart-pointer owner instead. Only a literal `delete`/`delete[]` lexically inside the teardown function's own body is flagged, not calls into other functions. Takes no configuration

<!-- insertion marker -->
## [0.8.0](https://github.com/repo/owner/releases/tag/0.8.0) - 2026-09-30

Expand Down
5 changes: 5 additions & 0 deletions docs/source/sections/ast_checks/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ Available checks
* - :doc:`noThrowParen <no_throw_paren>`
- ``throw(...)`` wrapping the whole thrown expression.
- None
* - :doc:`noDeleteInGtestTeardown <no_delete_in_gtest_teardown>`
- ``delete``/``delete[]`` inside a GTest ``TearDown``/``TearDownTestSuite``/
``TearDownTestCase`` function.
- None

.. toctree::
:maxdepth: 1
Expand All @@ -107,3 +111,4 @@ Available checks
no_global_using
no_global_using_enum
no_throw_paren
no_delete_in_gtest_teardown
49 changes: 49 additions & 0 deletions docs/source/sections/ast_checks/no_delete_in_gtest_teardown.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
noDeleteInGtestTeardown
=========================

Flags any ``delete``/``delete[]`` expression appearing anywhere inside a
member function named ``TearDown``, ``TearDownTestSuite``, or
``TearDownTestCase`` — the fixture-teardown hooks GTest calls by name.
Manually deleting a raw pointer there is fragile: if ``SetUp()`` throws or
returns early, the matching ``delete`` in ``TearDown()`` never runs, and if
a test body already frees the pointer, the ``TearDown()`` delete
double-frees it.

.. code-block:: cpp

class FooTest : public ::testing::Test {
protected:
void TearDown() override {
delete ptr_; // Bad — flagged.
}

Foo* ptr_;
};

class BarTest : public ::testing::Test {
protected:
void TearDown() override {
ptr_.reset(); // Good — RAII, not flagged.
}

std::unique_ptr<Bar> ptr_;
};

Only a literal ``delete``/``delete[]`` lexically inside the teardown
function's own body is flagged — a call to some other function that itself
deletes something is not traced into. A declaration with no body (e.g. a
pure-virtual ``TearDown() = 0``) is never flagged, and neither is a free
function of the same name that isn't a member function.

Configuration
--------------

This check takes no configuration.

Disabling
----------

.. code-block:: toml

[cpp]
ast_check_disabled_ids = ["noDeleteInGtestTeardown"]
113 changes: 113 additions & 0 deletions src/devops/cpp/ast/checks/no_delete_in_gtest_teardown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Flag `delete`/`delete[]` expressions inside a GTest teardown function.

A member function named ``TearDown``, ``TearDownTestSuite``, or
``TearDownTestCase`` — the fixture-teardown hooks GTest calls by name
(``TearDown()`` after every test, the other two once per test suite) — is
reported for any ``delete``/``delete[]`` expression appearing anywhere in
its body. Manually deleting a raw pointer there is exactly the kind of
lifetime bookkeeping RAII/smart pointers exist to make unnecessary, and it's
especially fragile in a teardown hook: if ``SetUp()`` throws or returns
early, the matching ``delete`` in ``TearDown()`` never runs, and if a test
body already frees the pointer, the ``TearDown()`` delete double-frees it.
Prefer ``std::unique_ptr``/``std::shared_ptr`` (or another RAII owner) for
anything the fixture needs to release, so its lifetime doesn't depend on a
teardown hook running at all.

Only a literal ``delete``/``delete[]`` lexically inside the teardown
function's own body is flagged — a call to some other function that itself
deletes something is not traced into.

To disable this check for a project set::

[cpp]
ast_check_disabled_ids = ["noDeleteInGtestTeardown"]
"""

from __future__ import annotations

import clang.cindex as clang

from devops.cpp.ast.base import Check, Diagnostic

# GTest calls these fixture methods by name; no base-class relationship is
# required by the framework itself, so none is checked here either.
_TEARDOWN_NAMES = frozenset(
(
"TearDown",
"TearDownTestSuite",
"TearDownTestCase",
)
)


class NoDeleteInGtestTeardown(Check):
"""Flag `delete`/`delete[]` expressions inside a GTest teardown function."""

id = "noDeleteInGtestTeardown"

def visit(self, cursor: clang.Cursor, filename: str) -> list[Diagnostic]:
"""Flag any delete-expression inside a GTest teardown function body.

Parameters
----------
cursor: clang.Cursor
The AST node currently being visited.
filename: str
Path of the file being checked.

Returns
-------
list[Diagnostic]
One diagnostic per `delete`/`delete[]` expression found inside
`cursor`'s body, if `cursor` is a GTest teardown function;
otherwise an empty list.

"""
if cursor.kind != clang.CursorKind.CXX_METHOD:
return []
if cursor.spelling not in _TEARDOWN_NAMES:
return []
if not cursor.is_definition():
return []

return [
self._make_diagnostic(delete_expr, cursor.spelling, filename)
for delete_expr in cursor.walk_preorder()
if delete_expr.kind == clang.CursorKind.CXX_DELETE_EXPR
]

def _make_diagnostic(
self, delete_expr: clang.Cursor, function_name: str, filename: str
) -> Diagnostic:
"""Build the diagnostic for one flagged delete-expression.

Parameters
----------
delete_expr: clang.Cursor
The `CXX_DELETE_EXPR` cursor to report.
function_name: str
Name of the enclosing teardown function, for the message.
filename: str
Path of the file being checked.

Returns
-------
Diagnostic
The diagnostic for `delete_expr`.

"""
tokens = [t.spelling for t in delete_expr.get_tokens()]
spelling = "delete[]" if tokens[1:2] == ["["] else "delete"
loc = delete_expr.location
return Diagnostic(
file=filename,
line=loc.line,
column=loc.column,
message=(
f"do not use '{spelling}' inside GTest teardown function "
f"'{function_name}()' — prefer an RAII/smart-pointer owner "
"whose lifetime doesn't depend on teardown running"
),
check_id=self.id,
severity="style",
)
2 changes: 2 additions & 0 deletions src/devops/cpp/ast/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from devops.cpp.ast.checks.enforce_param_name_for_type import EnforceParamNameForType
from devops.cpp.ast.checks.macro_replacement import MacroReplacement
from devops.cpp.ast.checks.no_delete_in_gtest_teardown import NoDeleteInGtestTeardown
from devops.cpp.ast.checks.no_global_using import NoGlobalUsing
from devops.cpp.ast.checks.no_global_using_enum import NoGlobalUsingEnum
from devops.cpp.ast.checks.no_throw_paren import NoThrowParen
Expand All @@ -40,6 +41,7 @@
EnforceNoPublicLeadingUnderscore(),
EnforceParamNameForType(),
MacroReplacement(),
NoDeleteInGtestTeardown(),
NoGlobalUsing(),
NoGlobalUsingEnum(),
NoThrowParen(),
Expand Down
150 changes: 150 additions & 0 deletions tests/cpp/ast/test_no_delete_in_gtest_teardown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Tests for the NoDeleteInGtestTeardown AST check."""

from __future__ import annotations

import typing

import pytest

from devops.cpp.ast.checks.no_delete_in_gtest_teardown import NoDeleteInGtestTeardown
from devops.cpp.ast.engine import run_ast_checks

if typing.TYPE_CHECKING:
from pathlib import Path

pytest.importorskip("clang.cindex")

_CHECK = [NoDeleteInGtestTeardown()]
_ARGS = ["-std=c++17"]


def _diags(code: str, tmp_path: Path) -> list[str]:
p = tmp_path / "test.cpp"
p.write_text(code)
return [d.message for d in run_ast_checks(p, code, _ARGS, checks=_CHECK)]


class TestDeleteInTeardownFlagged:
"""`delete`/`delete[]` in a recognised teardown hook is flagged."""

def test_delete_in_teardown_flagged(self, tmp_path: Path) -> None:
"""Test delete in TearDown flagged."""
code = (
"struct FooTest {\n"
" void TearDown() {\n"
" int* p = new int(1);\n"
" delete p;\n"
" }\n"
"};\n"
)
diags = _diags(code, tmp_path)
assert len(diags) == 1
assert "'delete'" in diags[0]
assert "TearDown()" in diags[0]

def test_delete_array_in_teardown_flagged(self, tmp_path: Path) -> None:
"""Test delete[] in TearDown flagged."""
code = (
"struct FooTest {\n"
" void TearDown() {\n"
" int* p = new int[3];\n"
" delete[] p;\n"
" }\n"
"};\n"
)
diags = _diags(code, tmp_path)
assert len(diags) == 1
assert "'delete[]'" in diags[0]

def test_delete_in_teardown_test_suite_flagged(self, tmp_path: Path) -> None:
"""Test delete in TearDownTestSuite flagged."""
code = (
"struct FooTest {\n"
" static void TearDownTestSuite() {\n"
" delete shared_;\n"
" }\n"
" static int* shared_;\n"
"};\n"
)
diags = _diags(code, tmp_path)
assert len(diags) == 1
assert "TearDownTestSuite()" in diags[0]

def test_delete_in_teardown_test_case_flagged(self, tmp_path: Path) -> None:
"""Test delete in TearDownTestCase flagged."""
code = (
"struct FooTest {\n"
" static void TearDownTestCase() {\n"
" delete shared_;\n"
" }\n"
" static int* shared_;\n"
"};\n"
)
diags = _diags(code, tmp_path)
assert len(diags) == 1
assert "TearDownTestCase()" in diags[0]

def test_multiple_deletes_all_flagged(self, tmp_path: Path) -> None:
"""Test multiple deletes all flagged."""
code = (
"struct FooTest {\n"
" void TearDown() {\n"
" int* a = nullptr;\n"
" int* b = nullptr;\n"
" delete a;\n"
" delete b;\n"
" }\n"
"};\n"
)
assert len(_diags(code, tmp_path)) == 2

def test_delete_in_nested_lambda_flagged(self, tmp_path: Path) -> None:
"""Test delete inside a lambda defined in TearDown is still flagged."""
code = (
"struct FooTest {\n"
" void TearDown() {\n"
" auto f = [](int* p) { delete p; };\n"
" f(nullptr);\n"
" }\n"
"};\n"
)
assert len(_diags(code, tmp_path)) == 1


class TestDeleteElsewhereAllowed:
"""`delete` outside a recognised teardown hook is not flagged."""

def test_delete_in_set_up_allowed(self, tmp_path: Path) -> None:
"""Test delete in SetUp allowed."""
code = "struct FooTest {\n void SetUp() {\n delete p;\n }\n};\n"
assert _diags(code, tmp_path) == []

def test_delete_in_unrelated_method_allowed(self, tmp_path: Path) -> None:
"""Test delete in an unrelated method allowed."""
code = "struct S {\n void Cleanup() {\n delete p;\n }\n};\n"
assert _diags(code, tmp_path) == []

def test_delete_in_free_function_named_teardown_allowed(
self, tmp_path: Path
) -> None:
"""Test delete in a free function (not a method) named TearDown allowed."""
code = "void TearDown() {\n int* p = nullptr;\n delete p;\n}\n"
assert _diags(code, tmp_path) == []

def test_teardown_declaration_without_body_allowed(self, tmp_path: Path) -> None:
"""Test a TearDown declaration with no body is not flagged."""
code = "struct Base {\n virtual void TearDown() = 0;\n};\n"
assert _diags(code, tmp_path) == []

def test_clean_teardown_produces_no_diagnostics(self, tmp_path: Path) -> None:
"""Test clean file produces no diagnostics."""
code = "struct FooTest {\n void TearDown() {\n ptr_.reset();\n }\n};\n"
assert _diags(code, tmp_path) == []


class TestNoDeleteInGtestTeardownCheckId:
"""The check id is correct and the check is selectable."""

def test_check_id(self) -> None:
"""Test check id."""
assert NoDeleteInGtestTeardown().id == "noDeleteInGtestTeardown"
Loading