From cdd909dabf35918b2073e06f388ab7cfd58fcb81 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 11:50:04 +0100 Subject: [PATCH 1/9] feat: Add pre-commit script for detecting non-conforming test doubles --- .pre-commit-config.yaml | 6 + scripts/fix_test_doubles_naming_precommit.py | 315 +++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 scripts/fix_test_doubles_naming_precommit.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bf7f6222..0705d83d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,12 @@ repos: language: system files: (BUILD|BUILD\.bazel|WORKSPACE|\.bazelrc|[^/]+\.(py|bzl|bazel|yaml|yml|rs|c|cpp|h|hpp))$ pass_filenames: false + - id: test-doubles-naming-fix + name: test-doubles-naming-fix + entry: python3 ./scripts/fix_test_doubles_naming_precommit.py --update-usage + language: system + files: '\.(cc|cpp|h|hpp)$' + pass_filenames: true - repo: https://github.com/pocc/pre-commit-hooks rev: v1.3.5 hooks: diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py new file mode 100644 index 000000000..e32193a67 --- /dev/null +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Check that test doubles (mocks, stubs. etc.) follow our agreed naming convention. + +This script searches for test doubles and optionally fixes any doubles with non-conforming filenames. +The script is designed to be compatible with precommit hooks, where the files to check are passed as +parameters. The tool can be configured with a number of command line options, as detailed in help. + +Usage: + python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [--update-usage] + [--naming-format {suffix,prefix}] [filenames ...] + +Example: + python3 ./fix_test_doubles_naming_precommit.py --dry-run --naming-format suffix component_mock.cpp +""" + +import logging +import sys +import argparse +import re +from pathlib import Path +from enum import Enum +from dataclasses import dataclass + + +class TestDoubleName(Enum): + MOCK = "mock" + STUB = "stub" + FAKE = "fake" + + @staticmethod + def from_path(path: Path): + for double in TestDoubleName: + if double.value in str(path): + return double + raise NotImplementedError + + @staticmethod + def to_regex_alternation() -> str: + return "(" + ("|".join([name.value for name in TestDoubleName]) + ")") + + +class NamingConvention(Enum): + SUFFIX = "suffix" + PREFIX = "prefix" + + +SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName] +SUPPORTED_NAMING_CONVENTIONS = [convention.value for convention in NamingConvention] + +TEST_DOUBLE_SEARCH_PATTERNS = [ + re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)") + for name in SUPPORTED_TEST_DOUBLE_NAMES +] + +ENDING_MOCK_PATTERN = re.compile(r"(?i)mock$") + + +@dataclass(frozen=True) +class RenameOperation: + """ + Contains the necessary information to conduct a file rename operation + """ + + source: Path + target: Path + + +def split_name_and_extension(path: Path) -> tuple[str, str]: + """ + Split a path into a file name and file extension. + """ + suffix = "".join(path.suffixes) + if suffix: + return path.name[: -len(suffix)], suffix + return path.name, "" + + +def normalize_base_name(stem: str) -> str: + """ + Remove any occurances of the test double name from a filename + """ + base = stem + double_name = TestDoubleName.to_regex_alternation() + base = re.sub(rf"(?i)(^|[_-]){double_name}(?=($|[_-]))", r"\1", base) + base = re.sub(rf"(?i){double_name}$", "", base) + base = re.sub(r"[_-]{2,}", "_", base) + return base.strip("_-") + + +def build_target_name(path: Path, naming_format: str) -> Path | None: + """ + Create a path containing the corrected name of the test double + """ + filename, extension = split_name_and_extension(path) + test_double_name = TestDoubleName.from_path(path).value + + base = normalize_base_name(filename) + + if naming_format == NamingConvention.SUFFIX.value: + target = f"{test_double_name}_{base}{extension}" + else: + target = f"{base}_{test_double_name}{extension}" + + return path.with_name(target) + + +def is_double(path: Path) -> bool: + return any([r.search(str(path)) is not None for r in TEST_DOUBLE_SEARCH_PATTERNS]) + + +def define_operations( + filenames: list[str], naming_format: str +) -> list[RenameOperation]: + operations: list[RenameOperation] = [] + for path_str in filenames: + path = Path(path_str) + + # Skip paths that don't exist as files + if not path.exists() or not path.is_file(): + logging.warning(f"Skipping {path}, it does not exist as a file") + continue + + # Skip files that are not test doubles + if not is_double(path): + logging.info(f"Ignoring {path}, it is not a recognised test double") + continue + + target_name = build_target_name(path, naming_format) + + # Skip files that are already named correctly + if path == target_name: + continue + + operations.append(RenameOperation(source=path, target=target_name)) + return operations + + +def apply_operations(operations: list[RenameOperation]) -> None: + for operation in operations: + operation.source.rename(operation.target) + + +def replace_usage_in_files(files: list[Path], old_name: str, new_name: str) -> int: + """Replace old filename with new filename in the given files.""" + replacements_made = 0 + + # Create pattern that matches the old filename in various contexts + pattern = re.compile(r"\b" + re.escape(old_name) + r"\b", re.IGNORECASE) + + for file_path in files: + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + original_content = f.read() + + # Replace all occurrences + new_content = pattern.sub(new_name, original_content) + + if new_content != original_content: + with open(file_path, "w", encoding="utf-8") as f: + f.write(new_content) + replacements_made += 1 + print(f" Updated usages in: {file_path}") + except (OSError, IOError) as e: + print(f" Warning: Could not update {file_path}: {e}") + + return replacements_made + + +def file_contains_pattern(file_path: Path, pattern: str) -> bool: + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + except (OSError, IOError): + return False + return pattern.lower() in content.lower() + + +def has_conflicts(operations: list[RenameOperation]): + seen = {} + conflicts = 0 + + for operation in operations: + if operation.target.exists(): + logging.error( + f"Conflict detected: Renaming {operation.source} to {operation.target} will overwrite an exiting file" + ) + conflicts += 1 + if operation.target in seen: + conflicting_source = seen[operation.target] + logging.error( + f"Conflict detected: Renaming {operation.source} to {operation.target} will conflict with the rename from {conflicting_source} to {operation.target}" + ) + conflicts += 1 + else: + seen[operation.target] = operation.source + + return conflicts > 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Rename files containing 'mock' using suffix '_mock.' or prefix 'mock_.' format." + ) + parser.add_argument( + "filenames", + nargs="*", + help="Files to check.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Do not make any changes to any files.", + ) + parser.add_argument( + "--update-usage", + action="store_true", + help="Replace usage of old filenames with the correct filename (does not replace in --dry-run mode).", + ) + parser.add_argument( + "--naming-format", + choices=SUPPORTED_NAMING_CONVENTIONS, + default=NamingConvention.SUFFIX, + help="Desired naming convention for the test double.", + ) + args = parser.parse_args() + + operations = define_operations(args.filenames, args.naming_format) + + if has_conflicts(operations): + logging.error("Conflicts detected, aborting.") + return 1 + + if not operations: + logging.info("No files need renaming.") + return 0 + + action = "DRY-RUN" if args.dry_run else "RENAME" + for operation in operations: + print(f"{action}: {operation.source} -> {operation.target}") + + print(f"\nTotal planned renames: {len(operations)}") + + if not args.dry_run: + apply_operations(operations) + logging.info("Renaming completed.") + + # If --update-usage is set, search for and replace usage of the old filename + if args.update_usage: + logging.info("\nSearching for usages of old filenames...") + total_updated = 0 + + usage_files = [ + Path(raw) + for raw in args.filenames + if Path(raw).exists() and Path(raw).is_file() + ] + + for operation in operations: + old_name = operation.source.name + new_name = operation.target.name + + logging.info(f"\nProcessing: {old_name} -> {new_name}") + files_with_usages = [ + path + for path in usage_files + if file_contains_pattern(path, old_name) + ] + + # Exclude the renamed file itself from the search results + files_with_usages = [ + f + for f in files_with_usages + if f.resolve() != operation.target.resolve() + ] + + if files_with_usages: + logging.info( + f" Found {len(files_with_usages)} file(s) with usages of '{old_name}':" + ) + if not args.dry_run: + updated = replace_usage_in_files( + files_with_usages, old_name, new_name + ) + total_updated += updated + else: + logging.info(f" No usages found for '{old_name}'") + + if total_updated > 0: + logging.info(f"\n✓ Updated usages in {total_updated} file(s)") + else: + logging.info("\nNo usages found or updated") + else: + logging.info("Dry run only. Re-run without --dry-run to perform changes.") + + return 0 + + +if __name__ == "__main__": + logging.basicConfig( + format="%(levelname)s: %(message)s", + ) + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + raise SystemExit(main()) From 3035c5e0c503cb873bd688c78737c49c7d535624 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 12:51:41 +0100 Subject: [PATCH 2/9] fix: Handle multiple double names in file --- scripts/fix_test_doubles_naming_precommit.py | 70 ++++++++++++-------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index e32193a67..d584d7334 100644 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -44,7 +44,7 @@ def from_path(path: Path): for double in TestDoubleName: if double.value in str(path): return double - raise NotImplementedError + raise NotImplementedError @staticmethod def to_regex_alternation() -> str: @@ -56,17 +56,6 @@ class NamingConvention(Enum): PREFIX = "prefix" -SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName] -SUPPORTED_NAMING_CONVENTIONS = [convention.value for convention in NamingConvention] - -TEST_DOUBLE_SEARCH_PATTERNS = [ - re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)") - for name in SUPPORTED_TEST_DOUBLE_NAMES -] - -ENDING_MOCK_PATTERN = re.compile(r"(?i)mock$") - - @dataclass(frozen=True) class RenameOperation: """ @@ -87,14 +76,19 @@ def split_name_and_extension(path: Path) -> tuple[str, str]: return path.name, "" -def normalize_base_name(stem: str) -> str: +def normalize_base_name(old_name: str) -> str: """ - Remove any occurances of the test double name from a filename + Remove the first occurrence of a test double name from a filename """ - base = stem double_name = TestDoubleName.to_regex_alternation() - base = re.sub(rf"(?i)(^|[_-]){double_name}(?=($|[_-]))", r"\1", base) - base = re.sub(rf"(?i){double_name}$", "", base) + # Match the double name only when it sits between separators (`_`/`-`) or + # the start/end of the string, so e.g. "mockable" is left alone. The two + # boundary groups are captured so a shared separator (e.g. the "_" in + # "foo_mock_bar") is preserved rather than consumed twice; the double + # name itself (group 2) is dropped by omitting it from the replacement. + base = re.sub(rf"(?i)(^|[_-]){double_name}($|[_-])", r"\1\3", old_name, count=1) + # Collapse any doubled-up separator left behind (e.g. "foo__bar") and + # trim a leading/trailing one (e.g. from "mock_foo" or "foo_mock"). base = re.sub(r"[_-]{2,}", "_", base) return base.strip("_-") @@ -117,9 +111,20 @@ def build_target_name(path: Path, naming_format: str) -> Path | None: def is_double(path: Path) -> bool: + SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName] + TEST_DOUBLE_SEARCH_PATTERNS = [ + re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)") + for name in SUPPORTED_TEST_DOUBLE_NAMES + ] return any([r.search(str(path)) is not None for r in TEST_DOUBLE_SEARCH_PATTERNS]) +def has_multiple_double_names(path: Path) -> bool: + pattern = TestDoubleName.to_regex_alternation() + matches = re.findall(pattern, str(path), flags=re.IGNORECASE) + return len(matches) > 1 + + def define_operations( filenames: list[str], naming_format: str ) -> list[RenameOperation]: @@ -129,14 +134,20 @@ def define_operations( # Skip paths that don't exist as files if not path.exists() or not path.is_file(): - logging.warning(f"Skipping {path}, it does not exist as a file") + logging.warning(f"Skipping {path}, it does not exist as a file.") continue # Skip files that are not test doubles if not is_double(path): - logging.info(f"Ignoring {path}, it is not a recognised test double") + logging.info(f"Ignoring {path}, it is not a recognised test double.") continue + # Error if we find more than one test double name in a file name + if has_multiple_double_names(path): + raise ValueError( + f"Invalid file name {path} contains multiple double names." + ) + target_name = build_target_name(path, naming_format) # Skip files that are already named correctly @@ -171,9 +182,9 @@ def replace_usage_in_files(files: list[Path], old_name: str, new_name: str) -> i with open(file_path, "w", encoding="utf-8") as f: f.write(new_content) replacements_made += 1 - print(f" Updated usages in: {file_path}") + print(f" Updated usages in: {file_path}.") except (OSError, IOError) as e: - print(f" Warning: Could not update {file_path}: {e}") + print(f" Warning: Could not update {file_path}: {e}.") return replacements_made @@ -227,6 +238,7 @@ def main() -> int: action="store_true", help="Replace usage of old filenames with the correct filename (does not replace in --dry-run mode).", ) + SUPPORTED_NAMING_CONVENTIONS = [convention.value for convention in NamingConvention] parser.add_argument( "--naming-format", choices=SUPPORTED_NAMING_CONVENTIONS, @@ -247,9 +259,9 @@ def main() -> int: action = "DRY-RUN" if args.dry_run else "RENAME" for operation in operations: - print(f"{action}: {operation.source} -> {operation.target}") + print(f"{action}: {operation.source} -> {operation.target}.") - print(f"\nTotal planned renames: {len(operations)}") + print(f"\nTotal planned renames: {len(operations)}.") if not args.dry_run: apply_operations(operations) @@ -270,7 +282,7 @@ def main() -> int: old_name = operation.source.name new_name = operation.target.name - logging.info(f"\nProcessing: {old_name} -> {new_name}") + logging.info(f"\nProcessing: {old_name} -> {new_name}.") files_with_usages = [ path for path in usage_files @@ -294,15 +306,19 @@ def main() -> int: ) total_updated += updated else: - logging.info(f" No usages found for '{old_name}'") + logging.info(f" No usages found for '{old_name}'.") if total_updated > 0: - logging.info(f"\n✓ Updated usages in {total_updated} file(s)") + logging.info(f"\n✓ Updated usages in {total_updated} file(s).") else: - logging.info("\nNo usages found or updated") + logging.info("\nNo usages found or updated.") else: logging.info("Dry run only. Re-run without --dry-run to perform changes.") + # We return success only if no rename operations were required + if operations: + return 1 + return 0 From 7a36ac7139aad2e86d166764ebad2002f5b52162 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 13:59:37 +0100 Subject: [PATCH 3/9] refactor: Simplify script by removing update-usage --- .pre-commit-config.yaml | 4 +- README.md | 2 +- scripts/fix_test_doubles_naming_precommit.py | 118 ++----------------- 3 files changed, 15 insertions(+), 109 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0705d83d7..6350b5245 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,8 +21,8 @@ repos: files: (BUILD|BUILD\.bazel|WORKSPACE|\.bazelrc|[^/]+\.(py|bzl|bazel|yaml|yml|rs|c|cpp|h|hpp))$ pass_filenames: false - id: test-doubles-naming-fix - name: test-doubles-naming-fix - entry: python3 ./scripts/fix_test_doubles_naming_precommit.py --update-usage + name: Check and fix test double file names + entry: python3 ./scripts/fix_test_doubles_naming_precommit.py language: system files: '\.(cc|cpp|h|hpp)$' pass_filenames: true diff --git a/README.md b/README.md index df8e8c7d3..da8688114 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 ----------------------------------------------------------------------------- --> - +# bad_mock.hpp # Lifecycle & Health ## Overview diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index d584d7334..2a3d813ce 100644 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -18,11 +18,10 @@ parameters. The tool can be configured with a number of command line options, as detailed in help. Usage: - python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [--update-usage] - [--naming-format {suffix,prefix}] [filenames ...] + python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [filenames ...] Example: - python3 ./fix_test_doubles_naming_precommit.py --dry-run --naming-format suffix component_mock.cpp + python3 ./fix_test_doubles_naming_precommit.py --dry-run component_mock.cpp """ import logging @@ -51,11 +50,6 @@ def to_regex_alternation() -> str: return "(" + ("|".join([name.value for name in TestDoubleName]) + ")") -class NamingConvention(Enum): - SUFFIX = "suffix" - PREFIX = "prefix" - - @dataclass(frozen=True) class RenameOperation: """ @@ -93,7 +87,7 @@ def normalize_base_name(old_name: str) -> str: return base.strip("_-") -def build_target_name(path: Path, naming_format: str) -> Path | None: +def build_target_name(path: Path) -> Path | None: """ Create a path containing the corrected name of the test double """ @@ -101,11 +95,8 @@ def build_target_name(path: Path, naming_format: str) -> Path | None: test_double_name = TestDoubleName.from_path(path).value base = normalize_base_name(filename) - - if naming_format == NamingConvention.SUFFIX.value: - target = f"{test_double_name}_{base}{extension}" - else: - target = f"{base}_{test_double_name}{extension}" + # Build a new name using the test double as a prefix + target = f"{test_double_name}_{base}{extension}" return path.with_name(target) @@ -125,9 +116,7 @@ def has_multiple_double_names(path: Path) -> bool: return len(matches) > 1 -def define_operations( - filenames: list[str], naming_format: str -) -> list[RenameOperation]: +def define_operations(filenames: list[str]) -> list[RenameOperation]: operations: list[RenameOperation] = [] for path_str in filenames: path = Path(path_str) @@ -148,7 +137,7 @@ def define_operations( f"Invalid file name {path} contains multiple double names." ) - target_name = build_target_name(path, naming_format) + target_name = build_target_name(path) # Skip files that are already named correctly if path == target_name: @@ -163,32 +152,6 @@ def apply_operations(operations: list[RenameOperation]) -> None: operation.source.rename(operation.target) -def replace_usage_in_files(files: list[Path], old_name: str, new_name: str) -> int: - """Replace old filename with new filename in the given files.""" - replacements_made = 0 - - # Create pattern that matches the old filename in various contexts - pattern = re.compile(r"\b" + re.escape(old_name) + r"\b", re.IGNORECASE) - - for file_path in files: - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - original_content = f.read() - - # Replace all occurrences - new_content = pattern.sub(new_name, original_content) - - if new_content != original_content: - with open(file_path, "w", encoding="utf-8") as f: - f.write(new_content) - replacements_made += 1 - print(f" Updated usages in: {file_path}.") - except (OSError, IOError) as e: - print(f" Warning: Could not update {file_path}: {e}.") - - return replacements_made - - def file_contains_pattern(file_path: Path, pattern: str) -> bool: try: content = file_path.read_text(encoding="utf-8", errors="ignore") @@ -204,7 +167,7 @@ def has_conflicts(operations: list[RenameOperation]): for operation in operations: if operation.target.exists(): logging.error( - f"Conflict detected: Renaming {operation.source} to {operation.target} will overwrite an exiting file" + f"Conflict detected: Renaming {operation.source} to {operation.target} will overwrite an existing file" ) conflicts += 1 if operation.target in seen: @@ -233,21 +196,9 @@ def main() -> int: action="store_true", help="Do not make any changes to any files.", ) - parser.add_argument( - "--update-usage", - action="store_true", - help="Replace usage of old filenames with the correct filename (does not replace in --dry-run mode).", - ) - SUPPORTED_NAMING_CONVENTIONS = [convention.value for convention in NamingConvention] - parser.add_argument( - "--naming-format", - choices=SUPPORTED_NAMING_CONVENTIONS, - default=NamingConvention.SUFFIX, - help="Desired naming convention for the test double.", - ) args = parser.parse_args() - operations = define_operations(args.filenames, args.naming_format) + operations = define_operations(args.filenames) if has_conflicts(operations): logging.error("Conflicts detected, aborting.") @@ -257,61 +208,16 @@ def main() -> int: logging.info("No files need renaming.") return 0 + logging.info("Planned rename operations:") action = "DRY-RUN" if args.dry_run else "RENAME" for operation in operations: - print(f"{action}: {operation.source} -> {operation.target}.") + logging.info(f"{action}: {operation.source} -> {operation.target}.") - print(f"\nTotal planned renames: {len(operations)}.") + logging.info(f"Total planned renames: {len(operations)}.") if not args.dry_run: apply_operations(operations) logging.info("Renaming completed.") - - # If --update-usage is set, search for and replace usage of the old filename - if args.update_usage: - logging.info("\nSearching for usages of old filenames...") - total_updated = 0 - - usage_files = [ - Path(raw) - for raw in args.filenames - if Path(raw).exists() and Path(raw).is_file() - ] - - for operation in operations: - old_name = operation.source.name - new_name = operation.target.name - - logging.info(f"\nProcessing: {old_name} -> {new_name}.") - files_with_usages = [ - path - for path in usage_files - if file_contains_pattern(path, old_name) - ] - - # Exclude the renamed file itself from the search results - files_with_usages = [ - f - for f in files_with_usages - if f.resolve() != operation.target.resolve() - ] - - if files_with_usages: - logging.info( - f" Found {len(files_with_usages)} file(s) with usages of '{old_name}':" - ) - if not args.dry_run: - updated = replace_usage_in_files( - files_with_usages, old_name, new_name - ) - total_updated += updated - else: - logging.info(f" No usages found for '{old_name}'.") - - if total_updated > 0: - logging.info(f"\n✓ Updated usages in {total_updated} file(s).") - else: - logging.info("\nNo usages found or updated.") else: logging.info("Dry run only. Re-run without --dry-run to perform changes.") From 210bd685872a8828666dbbb5106dc87de1abc136 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 14:50:38 +0100 Subject: [PATCH 4/9] chore: Tidy up some logs --- scripts/fix_test_doubles_naming_precommit.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index 2a3d813ce..efc79962b 100644 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -208,16 +208,15 @@ def main() -> int: logging.info("No files need renaming.") return 0 - logging.info("Planned rename operations:") - action = "DRY-RUN" if args.dry_run else "RENAME" + logging.warning("Non-conformant files have been found, fixing now.") + action = "Renaming" + if args.dry_run: + action += " (dry-run)" for operation in operations: - logging.info(f"{action}: {operation.source} -> {operation.target}.") - - logging.info(f"Total planned renames: {len(operations)}.") + logging.warning(f"{action}: {operation.source} -> {operation.target}.") if not args.dry_run: apply_operations(operations) - logging.info("Renaming completed.") else: logging.info("Dry run only. Re-run without --dry-run to perform changes.") From 8a53aca72302ea4cead7c1c497e27999dcebc5b9 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 15:02:31 +0100 Subject: [PATCH 5/9] chore: More tidying --- scripts/fix_test_doubles_naming_precommit.py | 25 +++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index efc79962b..4a1c9feef 100644 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -102,6 +102,9 @@ def build_target_name(path: Path) -> Path | None: def is_double(path: Path) -> bool: + """ + Return true if the given path is a test double + """ SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName] TEST_DOUBLE_SEARCH_PATTERNS = [ re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)") @@ -111,12 +114,18 @@ def is_double(path: Path) -> bool: def has_multiple_double_names(path: Path) -> bool: + """ + Return true if more than one test double names are present in a file name + """ pattern = TestDoubleName.to_regex_alternation() matches = re.findall(pattern, str(path), flags=re.IGNORECASE) return len(matches) > 1 def define_operations(filenames: list[str]) -> list[RenameOperation]: + """ + Define all the rename operations required to make the source files conformant + """ operations: list[RenameOperation] = [] for path_str in filenames: path = Path(path_str) @@ -148,19 +157,17 @@ def define_operations(filenames: list[str]) -> list[RenameOperation]: def apply_operations(operations: list[RenameOperation]) -> None: + """ + Do the renaming of the files + """ for operation in operations: operation.source.rename(operation.target) -def file_contains_pattern(file_path: Path, pattern: str) -> bool: - try: - content = file_path.read_text(encoding="utf-8", errors="ignore") - except (OSError, IOError): - return False - return pattern.lower() in content.lower() - - def has_conflicts(operations: list[RenameOperation]): + """ + Check if any of the operations will conflict with a previous operation, or an existing file + """ seen = {} conflicts = 0 @@ -184,7 +191,7 @@ def has_conflicts(operations: list[RenameOperation]): def main() -> int: parser = argparse.ArgumentParser( - description="Rename files containing 'mock' using suffix '_mock.' or prefix 'mock_.' format." + description="Rename test double files to conform with a prefix format." ) parser.add_argument( "filenames", From 66fc09256700acc74565265c6eb2c338ba3f2638 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Thu, 13 Aug 2026 15:08:28 +0100 Subject: [PATCH 6/9] docs: Removing superfluous text --- scripts/fix_test_doubles_naming_precommit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index 4a1c9feef..c824d8add 100644 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -14,8 +14,8 @@ """Check that test doubles (mocks, stubs. etc.) follow our agreed naming convention. This script searches for test doubles and optionally fixes any doubles with non-conforming filenames. -The script is designed to be compatible with precommit hooks, where the files to check are passed as -parameters. The tool can be configured with a number of command line options, as detailed in help. +This is designed to be compatible with precommit hooks, where the files to check are passed as +parameters. Usage: python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [filenames ...] From dd5e7b36310c60847613e0561c1b6d9421265636 Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Fri, 14 Aug 2026 09:24:44 +0100 Subject: [PATCH 7/9] chore: Run as a python executable & simplify split --- .pre-commit-config.yaml | 4 ++-- README.md | 2 +- scripts/fix_test_doubles_naming_precommit.py | 20 ++++++++------------ 3 files changed, 11 insertions(+), 15 deletions(-) mode change 100644 => 100755 scripts/fix_test_doubles_naming_precommit.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6350b5245..aac7ebd82 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,8 @@ repos: pass_filenames: false - id: test-doubles-naming-fix name: Check and fix test double file names - entry: python3 ./scripts/fix_test_doubles_naming_precommit.py - language: system + entry: ./scripts/fix_test_doubles_naming_precommit.py + language: python files: '\.(cc|cpp|h|hpp)$' pass_filenames: true - repo: https://github.com/pocc/pre-commit-hooks diff --git a/README.md b/README.md index da8688114..df8e8c7d3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 ----------------------------------------------------------------------------- --> -# bad_mock.hpp + # Lifecycle & Health ## Overview diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py old mode 100644 new mode 100755 index c824d8add..a5a044130 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -39,7 +39,7 @@ class TestDoubleName(Enum): FAKE = "fake" @staticmethod - def from_path(path: Path): + def from_path(path: Path) -> "TestDoubleName": for double in TestDoubleName: if double.value in str(path): return double @@ -60,16 +60,6 @@ class RenameOperation: target: Path -def split_name_and_extension(path: Path) -> tuple[str, str]: - """ - Split a path into a file name and file extension. - """ - suffix = "".join(path.suffixes) - if suffix: - return path.name[: -len(suffix)], suffix - return path.name, "" - - def normalize_base_name(old_name: str) -> str: """ Remove the first occurrence of a test double name from a filename @@ -91,7 +81,7 @@ def build_target_name(path: Path) -> Path | None: """ Create a path containing the corrected name of the test double """ - filename, extension = split_name_and_extension(path) + filename, extension = path.stem, path.suffix test_double_name = TestDoubleName.from_path(path).value base = normalize_base_name(filename) @@ -146,6 +136,12 @@ def define_operations(filenames: list[str]) -> list[RenameOperation]: f"Invalid file name {path} contains multiple double names." ) + # Error if there is more than one file extension + if len(path.suffixes) > 1: + raise ValueError( + f"More than one file extension found for test double {path}. Fix this!" + ) + target_name = build_target_name(path) # Skip files that are already named correctly From 593e76e1fc0ddfea0f4f3d19abbf11cf20fb5f6a Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Fri, 14 Aug 2026 09:49:27 +0100 Subject: [PATCH 8/9] chore: from __future__ import annotations --- scripts/fix_test_doubles_naming_precommit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index a5a044130..75d6d4f98 100755 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -24,6 +24,7 @@ python3 ./fix_test_doubles_naming_precommit.py --dry-run component_mock.cpp """ +from __future__ import annotations import logging import sys import argparse @@ -39,7 +40,7 @@ class TestDoubleName(Enum): FAKE = "fake" @staticmethod - def from_path(path: Path) -> "TestDoubleName": + def from_path(path: Path) -> TestDoubleName: for double in TestDoubleName: if double.value in str(path): return double From c057fbf5a0cfcc8f85484b58f77b0083c65cdeaf Mon Sep 17 00:00:00 2001 From: Cameron Craig Date: Mon, 17 Aug 2026 08:29:28 +0100 Subject: [PATCH 9/9] fix: Prevent renaming dashes to underscores --- scripts/fix_test_doubles_naming_precommit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/fix_test_doubles_naming_precommit.py b/scripts/fix_test_doubles_naming_precommit.py index 75d6d4f98..114616e12 100755 --- a/scripts/fix_test_doubles_naming_precommit.py +++ b/scripts/fix_test_doubles_naming_precommit.py @@ -66,16 +66,16 @@ def normalize_base_name(old_name: str) -> str: Remove the first occurrence of a test double name from a filename """ double_name = TestDoubleName.to_regex_alternation() - # Match the double name only when it sits between separators (`_`/`-`) or + # Match the double name only when it sits between separators (`_`) or # the start/end of the string, so e.g. "mockable" is left alone. The two # boundary groups are captured so a shared separator (e.g. the "_" in # "foo_mock_bar") is preserved rather than consumed twice; the double # name itself (group 2) is dropped by omitting it from the replacement. - base = re.sub(rf"(?i)(^|[_-]){double_name}($|[_-])", r"\1\3", old_name, count=1) + base = re.sub(rf"(?i)(^|[_]){double_name}($|[_])", r"\1\3", old_name, count=1) # Collapse any doubled-up separator left behind (e.g. "foo__bar") and # trim a leading/trailing one (e.g. from "mock_foo" or "foo_mock"). - base = re.sub(r"[_-]{2,}", "_", base) - return base.strip("_-") + base = re.sub(r"[_]{2,}", "_", base) + return base.strip("_") def build_target_name(path: Path) -> Path | None: