From e46b63dc519de05d135579f372c1096a9e43e1d5 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Mon, 10 Aug 2026 04:10:21 -0400 Subject: [PATCH 01/18] 162: check stale references from any plain text file --- .pre-commit-hooks.yaml | 12 +++ README.md | 9 +- dev_tools/check_stale_references.py | 129 +++++++++++++++++++++++ pyproject.toml | 2 + tests/test_check_stale_references.py | 152 +++++++++++++++++++++++++++ 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 dev_tools/check_stale_references.py create mode 100644 tests/test_check_stale_references.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index b299a3a..69b7a66 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -281,6 +281,18 @@ always_run: true additional_dependencies: - pre-commit==4.6.0 +- id: check-stale-references + name: Check for stale references to deleted/renamed files + description: |- + When files are deleted or renamed, check that no remaining tracked file still references the old path. + Searches for both the full repo-relative path and the basename of deleted files. + This catches broken cross-references in comments, documentation, configs, and build files. + entry: check-stale-references + language: python + pass_filenames: false + always_run: true + stages: + - pre-commit - id: check-max-one-sentence-per-line name: Check max one sentence per line description: |- diff --git a/README.md b/README.md index 0328328..c97a486 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ These tools are used to help developers in their day-to-day tasks. > I try to contribute back if possible but since I am no longer working at Luminar, it is not guaranteed that my contributions are accepted. - - [Tools](#tools) - [Configure VS Code for Bazel](#configure-vs-code-for-bazel) - [Whoowns](#whoowns) @@ -39,10 +38,10 @@ These tools are used to help developers in their day-to-day tasks. - [`print-pre-commit-metrics`](#print-pre-commit-metrics) - [`sync-vscode-config`](#sync-vscode-config) - [`sync-tool-versions`](#sync-tool-versions) + - [`check-stale-references`](#check-stale-references) - [`check-max-one-sentence-per-line`](#check-max-one-sentence-per-line) - [`check-ownership`](#check-ownership) - [Contributing](#contributing) - ## Tools @@ -225,6 +224,12 @@ sync_versions: version_override: '314' ``` +### `check-stale-references` + +When files are deleted or renamed, check that no remaining tracked file still references the old path. +Searches for both the full repo-relative path and the basename of deleted files. +This catches broken cross-references in comments, documentation, configs, and build files. + ### `check-max-one-sentence-per-line` This hook is a simplified version of [rumdl MD013 sentence-per-line-mode](https://github.com/rvben/rumdl/blob/main/docs/md013.md#sentence-per-line-mode). diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py new file mode 100644 index 0000000..874a087 --- /dev/null +++ b/dev_tools/check_stale_references.py @@ -0,0 +1,129 @@ +# Copyright (c) Luminar Technologies, Inc. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + +@dataclass(frozen=True) +class StaleReference: + """A reference in a tracked file that points to a deleted/renamed path.""" + + file: str + line: int + deleted_path: str + + +def get_repo_root() -> str: + return subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() # noqa: S607 + + +def get_deleted_paths() -> list[str]: + """Return repo-relative paths of files being deleted or renamed away in the staged commit.""" + output = subprocess.check_output( + ["git", "diff", "--cached", "--diff-filter=DR", "--name-status"], # noqa: S607 + text=True, + ) + deleted: list[str] = [] + for line in output.splitlines(): + parts = line.split("\t") + if parts[0].startswith("D"): + deleted.append(parts[1]) + elif parts[0].startswith("R"): + # For renames, the old (now-gone) path is the second column + deleted.append(parts[1]) + return deleted + + +def get_tracked_files() -> list[str]: + """Return all files that will exist after the staged commit.""" + return subprocess.check_output(["git", "ls-files"], text=True).splitlines() # noqa: S607 + + +def build_path_patterns(deleted_paths: list[str]) -> dict[str, re.Pattern[str]]: + """Build a regex per deleted path that matches references to it. + + For path "a/b/c.txt", matches (with non-word boundaries): + - /?a/b/c.txt (full path, optional leading /) + - (../)*b/c.txt (intermediate suffix with optional ../ prefix) + - (../)*c.txt (basename with optional ../ prefix) + """ + patterns: dict[str, re.Pattern[str]] = {} + for path in deleted_paths: + segments = path.split("/") + alternatives: list[str] = [] + for i in range(len(segments)): + suffix = "/".join(segments[i:]) + escaped_suffix = re.escape(suffix) + if i == 0: + alternatives.append(rf"/?{escaped_suffix}") + else: + alternatives.append(rf"(?:\.\./)*{escaped_suffix}") + patterns[path] = re.compile(rf"(? list[StaleReference]: + """Search tracked files for references to deleted paths.""" + if not deleted_paths: + return [] + + path_patterns = build_path_patterns(deleted_paths) + deleted_set = set(deleted_paths) + stale: list[StaleReference] = [] + + for filepath in tracked_files: + if filepath in deleted_set: + continue + try: + if read_file is not None: + content = read_file(filepath) + else: + with Path(filepath).open(errors="replace") as f: + content = f.read() + except OSError: + continue + + for line_number, line in enumerate(content.splitlines(), start=1): + for deleted_path, pattern in path_patterns.items(): + if pattern.search(line): + stale.append(StaleReference(filepath, line_number, deleted_path)) + + return stale + + +def print_stale_references(stale_refs: list[StaleReference]) -> None: + print("Stale references to deleted/renamed files:") + for ref in stale_refs: + print(f" {ref.file}:{ref.line} references {ref.deleted_path}") + + +def main(argv: Sequence[str] | None = None) -> int: + del argv + deleted_paths = get_deleted_paths() + if not deleted_paths: + return 0 + + tracked_files = get_tracked_files() + + if stale_refs := find_stale_references(tracked_files, deleted_paths): + print_stale_references(stale_refs) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 0ff56d6..4a84c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ generate-hook-docs = "dev_tools.generate_hook_docs:main" print-pre-commit-metrics = "dev_tools.print_pre_commit_metrics:main" sync-vscode-config = "dev_tools.sync_vscode_config:main" sync-tool-versions = "dev_tools.sync_tool_versions:main" +check-stale-references = "dev_tools.check_stale_references:main" [tool.pytest.ini_options] addopts = "--cov-report term-missing --cov=dev_tools --cov=packages/whoowns/whoowns --cov=packages/pre_commit_excludes/pre_commit_excludes --cov=packages/configure_vscode_for_bazel/configure_vscode_for_bazel -vv" @@ -82,6 +83,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = [ "ANN201", # don't require -> None for every test. Accept non-enforcement for test helper functions + "D101", # test classes don't need docstrings "PLR2004", # Magic values in tests are ok since they are often the expected values "S101" # allow assertions in tests ] diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py new file mode 100644 index 0000000..7bef6ad --- /dev/null +++ b/tests/test_check_stale_references.py @@ -0,0 +1,152 @@ +# Copyright (c) Luminar Technologies, Inc. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dev_tools.check_stale_references import ( + StaleReference, + build_path_patterns, + find_stale_references, + print_stale_references, +) + +if TYPE_CHECKING: + import pytest + + +def make_reader(files: dict[str, str]): + def read_file(path: str) -> str: + return files[path] + + return read_file + + +class TestBuildPathPatterns: + def test_matches_full_path(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see src/lib/foo.hpp for details") + + def test_matches_full_path_with_leading_slash(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see /src/lib/foo.hpp for details") + + def test_matches_intermediate_suffix(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see lib/foo.hpp here") + + def test_matches_intermediate_suffix_with_dotdot(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see ../lib/foo.hpp here") + + def test_matches_basename(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see foo.hpp for details") + + def test_matches_basename_with_dotdot(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert patterns["src/lib/foo.hpp"].search("see ../../foo.hpp for details") + + def test_no_substring_match(self) -> None: + patterns = build_path_patterns(["src/lib/foo.hpp"]) + assert not patterns["src/lib/foo.hpp"].search("notfoo.hpp") + + +class TestFindStaleReferences: + def test_finds_reference_to_deleted_file(self) -> None: + files = { + "src/main.cpp": "// see src/utils/helper.hpp for details", + } + result = find_stale_references( + list(files.keys()), + ["src/utils/helper.hpp"], + read_file=make_reader(files), + ) + assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] + + def test_no_reference_no_finding(self) -> None: + files = { + "src/main.cpp": "// nothing relevant here", + } + result = find_stale_references( + list(files.keys()), + ["src/utils/helper.hpp"], + read_file=make_reader(files), + ) + assert result == [] + + def test_skips_deleted_file_itself(self) -> None: + files = { + "src/utils/helper.hpp": "#include helper.hpp", + "src/main.cpp": "no reference", + } + result = find_stale_references( + list(files.keys()), + ["src/utils/helper.hpp"], + read_file=make_reader(files), + ) + assert result == [] + + def test_empty_deleted_paths_returns_empty(self) -> None: + assert find_stale_references(["a.cpp"], [], read_file=make_reader({"a.cpp": "x"})) == [] + + def test_finds_basename_reference(self) -> None: + files = { + "docs/guide.md": "Refer to helper.hpp for the API", + } + result = find_stale_references( + list(files.keys()), + ["src/utils/helper.hpp"], + read_file=make_reader(files), + ) + assert result == [StaleReference("docs/guide.md", 1, "src/utils/helper.hpp")] + + def test_correct_line_number(self) -> None: + files = { + "src/main.cpp": "line1\nline2\n// see helper.hpp\nline4", + } + result = find_stale_references( + list(files.keys()), + ["src/utils/helper.hpp"], + read_file=make_reader(files), + ) + assert result == [StaleReference("src/main.cpp", 3, "src/utils/helper.hpp")] + + def test_multiple_deleted_paths(self) -> None: + files = { + "src/main.cpp": "// uses foo.hpp and bar.py", + } + result = find_stale_references( + list(files.keys()), + ["lib/foo.hpp", "scripts/bar.py"], + read_file=make_reader(files), + ) + assert len(result) == 2 + + def test_multiple_files_with_references(self) -> None: + files = { + "a.cpp": "see helper.hpp", + "b.py": "no ref here", + "c.md": "also helper.hpp", + } + result = find_stale_references( + list(files.keys()), + ["src/helper.hpp"], + read_file=make_reader(files), + ) + assert len(result) == 2 + assert {r.file for r in result} == {"a.cpp", "c.md"} + + +class TestPrintStaleReferences: + def test_prints_all_references(self, capsys: pytest.CaptureFixture) -> None: + refs = [ + StaleReference("src/main.cpp", 5, "src/utils/helper.hpp"), + StaleReference("docs/guide.md", 12, "src/utils/helper.hpp"), + ] + print_stale_references(refs) + output = capsys.readouterr().out + assert "src/main.cpp:5" in output + assert "src/utils/helper.hpp" in output + assert "docs/guide.md:12" in output From 0d78ed164ebf318e8de69342153141ba049141fd Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Tue, 11 Aug 2026 01:47:09 -0400 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20use=20git=20grep,=20subprocess.run,=20tighten=20bou?= =?UTF-8?q?ndaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Python file I/O with git grep for performance - Extract _run_git() helper using subprocess.run(check=True) - Simplify D/R branch in get_deleted_paths (both use parts[1]) - Add __str__ to StaleReference, remove print_stale_references - Tighten regex boundaries to [\w.-] to reject foo.hpp.bak etc. - Remove unused get_repo_root and get_tracked_files --- dev_tools/check_stale_references.py | 134 ++++++++++----------- tests/test_check_stale_references.py | 171 +++++++++------------------ 2 files changed, 122 insertions(+), 183 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 874a087..b7f7044 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -7,11 +7,10 @@ import subprocess import sys from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Sequence @dataclass(frozen=True) @@ -22,93 +21,89 @@ class StaleReference: line: int deleted_path: str + def __str__(self) -> str: + """Format as file:line references deleted_path.""" + return f"{self.file}:{self.line} references {self.deleted_path}" -def get_repo_root() -> str: - return subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() # noqa: S607 + +def _run_git(*args: str) -> str: + return subprocess.run( + ["git", *args], # noqa: S607 + capture_output=True, + text=True, + check=True, + ).stdout def get_deleted_paths() -> list[str]: """Return repo-relative paths of files being deleted or renamed away in the staged commit.""" - output = subprocess.check_output( - ["git", "diff", "--cached", "--diff-filter=DR", "--name-status"], # noqa: S607 - text=True, - ) + output = _run_git("diff", "--cached", "--diff-filter=DR", "--name-status") deleted: list[str] = [] for line in output.splitlines(): parts = line.split("\t") - if parts[0].startswith("D"): - deleted.append(parts[1]) - elif parts[0].startswith("R"): - # For renames, the old (now-gone) path is the second column - deleted.append(parts[1]) + # Both D(eleted) and R(enamed) have the vanishing path in column 1 + deleted.append(parts[1]) return deleted -def get_tracked_files() -> list[str]: - """Return all files that will exist after the staged commit.""" - return subprocess.check_output(["git", "ls-files"], text=True).splitlines() # noqa: S607 +def build_path_pattern(deleted_path: str) -> re.Pattern[str]: + """Build a regex for a deleted path that matches references to it. - -def build_path_patterns(deleted_paths: list[str]) -> dict[str, re.Pattern[str]]: - """Build a regex per deleted path that matches references to it. - - For path "a/b/c.txt", matches (with non-word boundaries): + For path "a/b/c.txt", matches (with non-word/dot/dash boundaries): - /?a/b/c.txt (full path, optional leading /) - (../)*b/c.txt (intermediate suffix with optional ../ prefix) - (../)*c.txt (basename with optional ../ prefix) """ - patterns: dict[str, re.Pattern[str]] = {} - for path in deleted_paths: - segments = path.split("/") - alternatives: list[str] = [] - for i in range(len(segments)): - suffix = "/".join(segments[i:]) - escaped_suffix = re.escape(suffix) - if i == 0: - alternatives.append(rf"/?{escaped_suffix}") - else: - alternatives.append(rf"(?:\.\./)*{escaped_suffix}") - patterns[path] = re.compile(rf"(? list[StaleReference]: - """Search tracked files for references to deleted paths.""" + segments = deleted_path.split("/") + alternatives: list[str] = [] + for i in range(len(segments)): + suffix = "/".join(segments[i:]) + escaped_suffix = re.escape(suffix) + if i == 0: + alternatives.append(rf"/?{escaped_suffix}") + else: + alternatives.append(rf"(?:\.\./)*{escaped_suffix}") + return re.compile(rf"(? list[tuple[str, int, str]]: + """Run git grep and return (file, line_number, line_text) tuples.""" + result = subprocess.run( + ["git", "grep", "-nE", pattern], # noqa: S607 + capture_output=True, + text=True, + check=False, + ) + matches: list[tuple[str, int, str]] = [] + for line in result.stdout.splitlines(): + # git grep output: file:line_number:matched_line + file, line_no, text = line.split(":", 2) + matches.append((file, int(line_no), text)) + return matches + + +def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: + """Search tracked files for references to deleted paths using git grep.""" if not deleted_paths: return [] - path_patterns = build_path_patterns(deleted_paths) deleted_set = set(deleted_paths) stale: list[StaleReference] = [] - for filepath in tracked_files: - if filepath in deleted_set: - continue - try: - if read_file is not None: - content = read_file(filepath) - else: - with Path(filepath).open(errors="replace") as f: - content = f.read() - except OSError: - continue - - for line_number, line in enumerate(content.splitlines(), start=1): - for deleted_path, pattern in path_patterns.items(): - if pattern.search(line): - stale.append(StaleReference(filepath, line_number, deleted_path)) + for deleted_path in deleted_paths: + pattern = build_path_pattern(deleted_path) + # Build a git grep ERE from the path suffixes (unanchored, case-sensitive) + segments = deleted_path.split("/") + suffixes = ["/".join(segments[i:]) for i in range(len(segments))] + grep_pattern = "|".join(re.escape(s) for s in suffixes) - return stale + for file, line_no, text in git_grep(grep_pattern): + if file in deleted_set: + continue + if pattern.search(text): + stale.append(StaleReference(file, line_no, deleted_path)) - -def print_stale_references(stale_refs: list[StaleReference]) -> None: - print("Stale references to deleted/renamed files:") - for ref in stale_refs: - print(f" {ref.file}:{ref.line} references {ref.deleted_path}") + return stale def main(argv: Sequence[str] | None = None) -> int: @@ -117,10 +112,11 @@ def main(argv: Sequence[str] | None = None) -> int: if not deleted_paths: return 0 - tracked_files = get_tracked_files() - - if stale_refs := find_stale_references(tracked_files, deleted_paths): - print_stale_references(stale_refs) + stale_refs = find_stale_references(deleted_paths) + if stale_refs: + print("Stale references to deleted/renamed files:") + for ref in stale_refs: + print(f" {ref}") return 1 return 0 diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 7bef6ad..30237c8 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -3,150 +3,93 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from unittest.mock import patch from dev_tools.check_stale_references import ( StaleReference, - build_path_patterns, + build_path_pattern, find_stale_references, - print_stale_references, ) -if TYPE_CHECKING: - import pytest - -def make_reader(files: dict[str, str]): - def read_file(path: str) -> str: - return files[path] - - return read_file - - -class TestBuildPathPatterns: +class TestBuildPathPattern: def test_matches_full_path(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see src/lib/foo.hpp for details") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see src/lib/foo.hpp for details") def test_matches_full_path_with_leading_slash(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see /src/lib/foo.hpp for details") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see /src/lib/foo.hpp for details") def test_matches_intermediate_suffix(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see lib/foo.hpp here") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see lib/foo.hpp here") def test_matches_intermediate_suffix_with_dotdot(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see ../lib/foo.hpp here") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see ../lib/foo.hpp here") def test_matches_basename(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see foo.hpp for details") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see foo.hpp for details") def test_matches_basename_with_dotdot(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert patterns["src/lib/foo.hpp"].search("see ../../foo.hpp for details") + pattern = build_path_pattern("src/lib/foo.hpp") + assert pattern.search("see ../../foo.hpp for details") def test_no_substring_match(self) -> None: - patterns = build_path_patterns(["src/lib/foo.hpp"]) - assert not patterns["src/lib/foo.hpp"].search("notfoo.hpp") + pattern = build_path_pattern("src/lib/foo.hpp") + assert not pattern.search("notfoo.hpp") + + def test_no_match_with_dot_suffix(self) -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not pattern.search("foo.hpp.bak") + + def test_no_match_with_dash_suffix(self) -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not pattern.search("foo.hpp-old") + + +class TestStaleReferenceStr: + def test_str(self) -> None: + ref = StaleReference("src/main.cpp", 5, "src/utils/helper.hpp") + assert str(ref) == "src/main.cpp:5 references src/utils/helper.hpp" class TestFindStaleReferences: - def test_finds_reference_to_deleted_file(self) -> None: - files = { - "src/main.cpp": "// see src/utils/helper.hpp for details", - } - result = find_stale_references( - list(files.keys()), - ["src/utils/helper.hpp"], - read_file=make_reader(files), - ) + @patch("dev_tools.check_stale_references.git_grep") + def test_finds_reference_to_deleted_file(self, mock_grep) -> None: + mock_grep.return_value = [("src/main.cpp", 1, "// see src/utils/helper.hpp for details")] + result = find_stale_references(["src/utils/helper.hpp"]) assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] - def test_no_reference_no_finding(self) -> None: - files = { - "src/main.cpp": "// nothing relevant here", - } - result = find_stale_references( - list(files.keys()), - ["src/utils/helper.hpp"], - read_file=make_reader(files), - ) + @patch("dev_tools.check_stale_references.git_grep") + def test_no_reference_no_finding(self, mock_grep) -> None: + mock_grep.return_value = [] + result = find_stale_references(["src/utils/helper.hpp"]) assert result == [] - def test_skips_deleted_file_itself(self) -> None: - files = { - "src/utils/helper.hpp": "#include helper.hpp", - "src/main.cpp": "no reference", - } - result = find_stale_references( - list(files.keys()), - ["src/utils/helper.hpp"], - read_file=make_reader(files), - ) + @patch("dev_tools.check_stale_references.git_grep") + def test_skips_deleted_file_itself(self, mock_grep) -> None: + mock_grep.return_value = [("src/utils/helper.hpp", 1, "#include helper.hpp")] + result = find_stale_references(["src/utils/helper.hpp"]) assert result == [] def test_empty_deleted_paths_returns_empty(self) -> None: - assert find_stale_references(["a.cpp"], [], read_file=make_reader({"a.cpp": "x"})) == [] - - def test_finds_basename_reference(self) -> None: - files = { - "docs/guide.md": "Refer to helper.hpp for the API", - } - result = find_stale_references( - list(files.keys()), - ["src/utils/helper.hpp"], - read_file=make_reader(files), - ) - assert result == [StaleReference("docs/guide.md", 1, "src/utils/helper.hpp")] - - def test_correct_line_number(self) -> None: - files = { - "src/main.cpp": "line1\nline2\n// see helper.hpp\nline4", - } - result = find_stale_references( - list(files.keys()), - ["src/utils/helper.hpp"], - read_file=make_reader(files), - ) - assert result == [StaleReference("src/main.cpp", 3, "src/utils/helper.hpp")] - - def test_multiple_deleted_paths(self) -> None: - files = { - "src/main.cpp": "// uses foo.hpp and bar.py", - } - result = find_stale_references( - list(files.keys()), - ["lib/foo.hpp", "scripts/bar.py"], - read_file=make_reader(files), - ) - assert len(result) == 2 - - def test_multiple_files_with_references(self) -> None: - files = { - "a.cpp": "see helper.hpp", - "b.py": "no ref here", - "c.md": "also helper.hpp", - } - result = find_stale_references( - list(files.keys()), - ["src/helper.hpp"], - read_file=make_reader(files), - ) - assert len(result) == 2 - assert {r.file for r in result} == {"a.cpp", "c.md"} + assert find_stale_references([]) == [] + @patch("dev_tools.check_stale_references.git_grep") + def test_filters_false_positive_from_git_grep(self, mock_grep) -> None: + mock_grep.return_value = [("a.cpp", 1, "notfoo.hpp")] + result = find_stale_references(["src/lib/foo.hpp"]) + assert result == [] -class TestPrintStaleReferences: - def test_prints_all_references(self, capsys: pytest.CaptureFixture) -> None: - refs = [ - StaleReference("src/main.cpp", 5, "src/utils/helper.hpp"), - StaleReference("docs/guide.md", 12, "src/utils/helper.hpp"), + @patch("dev_tools.check_stale_references.git_grep") + def test_multiple_files_with_references(self, mock_grep) -> None: + mock_grep.return_value = [ + ("a.cpp", 1, "see helper.hpp"), + ("c.md", 3, "also helper.hpp"), ] - print_stale_references(refs) - output = capsys.readouterr().out - assert "src/main.cpp:5" in output - assert "src/utils/helper.hpp" in output - assert "docs/guide.md:12" in output + result = find_stale_references(["src/helper.hpp"]) + assert len(result) == 2 + assert {r.file for r in result} == {"a.cpp", "c.md"} From 7ede57018d9a5961a8ee6ea1b035053926f60523 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:48:44 +0000 Subject: [PATCH 03/18] style: pre-commit.ci fixes --- dev_tools/check_stale_references.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index b7f7044..8071e87 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -112,8 +112,7 @@ def main(argv: Sequence[str] | None = None) -> int: if not deleted_paths: return 0 - stale_refs = find_stale_references(deleted_paths) - if stale_refs: + if (stale_refs := find_stale_references(deleted_paths)): print("Stale references to deleted/renamed files:") for ref in stale_refs: print(f" {ref}") From e6058eed81c33164571eafbf79e55a5c79ba7845 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Tue, 11 Aug 2026 01:53:01 -0400 Subject: [PATCH 04/18] Add test for re.escape necessity in build_path_pattern --- tests/test_check_stale_references.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 30237c8..58e5f38 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -49,6 +49,10 @@ def test_no_match_with_dash_suffix(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") assert not pattern.search("foo.hpp-old") + def test_dot_in_extension_is_literal(self) -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not pattern.search("fooXhpp") + class TestStaleReferenceStr: def test_str(self) -> None: From 6b072518b18c8f2e0dcd69ca6c5635151279e886 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:54:03 +0000 Subject: [PATCH 05/18] style: pre-commit.ci fixes --- dev_tools/check_stale_references.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 8071e87..ffac1bc 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -112,7 +112,7 @@ def main(argv: Sequence[str] | None = None) -> int: if not deleted_paths: return 0 - if (stale_refs := find_stale_references(deleted_paths)): + if stale_refs := find_stale_references(deleted_paths): print("Stale references to deleted/renamed files:") for ref in stale_refs: print(f" {ref}") From ce177b7710adc7658f45638dc6aa24677434a57d Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Tue, 11 Aug 2026 23:47:26 -0400 Subject: [PATCH 06/18] Use list comprehension --- dev_tools/check_stale_references.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index ffac1bc..03526e0 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -38,12 +38,8 @@ def _run_git(*args: str) -> str: def get_deleted_paths() -> list[str]: """Return repo-relative paths of files being deleted or renamed away in the staged commit.""" output = _run_git("diff", "--cached", "--diff-filter=DR", "--name-status") - deleted: list[str] = [] - for line in output.splitlines(): - parts = line.split("\t") - # Both D(eleted) and R(enamed) have the vanishing path in column 1 - deleted.append(parts[1]) - return deleted + # Both D(eleted) and R(enamed) have the vanishing path in column 1 + return [line.split("\t")[1] for line in output.splitlines()] def build_path_pattern(deleted_path: str) -> re.Pattern[str]: From 87dc29f736da1e2fe7583f17cec15dbc439e2618 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 00:00:55 -0400 Subject: [PATCH 07/18] reuse method --- dev_tools/check_stale_references.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 03526e0..21a8450 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -26,12 +26,12 @@ def __str__(self) -> str: return f"{self.file}:{self.line} references {self.deleted_path}" -def _run_git(*args: str) -> str: +def _run_git(*args: str, check: bool = True) -> str: return subprocess.run( ["git", *args], # noqa: S607 capture_output=True, text=True, - check=True, + check=check, ).stdout @@ -64,25 +64,16 @@ def build_path_pattern(deleted_path: str) -> re.Pattern[str]: def git_grep(pattern: str) -> list[tuple[str, int, str]]: """Run git grep and return (file, line_number, line_text) tuples.""" - result = subprocess.run( - ["git", "grep", "-nE", pattern], # noqa: S607 - capture_output=True, - text=True, - check=False, - ) + output = _run_git("grep", "-nE", pattern, check=False) matches: list[tuple[str, int, str]] = [] - for line in result.stdout.splitlines(): - # git grep output: file:line_number:matched_line + for line in output.splitlines(): file, line_no, text = line.split(":", 2) matches.append((file, int(line_no), text)) return matches def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: - """Search tracked files for references to deleted paths using git grep.""" - if not deleted_paths: - return [] - + """Search tracked files for references to deleted paths.""" deleted_set = set(deleted_paths) stale: list[StaleReference] = [] From 1067af86718195201a88d13427effa08ed12a305 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 00:12:16 -0400 Subject: [PATCH 08/18] Use git's PRCE to simplify code --- dev_tools/check_stale_references.py | 21 +++++++-------------- tests/test_check_stale_references.py | 27 +++++++++++---------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 21a8450..f516fd3 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -42,8 +42,8 @@ def get_deleted_paths() -> list[str]: return [line.split("\t")[1] for line in output.splitlines()] -def build_path_pattern(deleted_path: str) -> re.Pattern[str]: - """Build a regex for a deleted path that matches references to it. +def build_path_pattern(deleted_path: str) -> str: + """Build a PCRE pattern for a deleted path that matches references to it. For path "a/b/c.txt", matches (with non-word/dot/dash boundaries): - /?a/b/c.txt (full path, optional leading /) @@ -59,12 +59,12 @@ def build_path_pattern(deleted_path: str) -> re.Pattern[str]: alternatives.append(rf"/?{escaped_suffix}") else: alternatives.append(rf"(?:\.\./)*{escaped_suffix}") - return re.compile(rf"(? list[tuple[str, int, str]]: - """Run git grep and return (file, line_number, line_text) tuples.""" - output = _run_git("grep", "-nE", pattern, check=False) + """Run git grep with PCRE and return (file, line_number, line_text) tuples.""" + output = _run_git("grep", "-nP", pattern, check=False) matches: list[tuple[str, int, str]] = [] for line in output.splitlines(): file, line_no, text = line.split(":", 2) @@ -79,16 +79,11 @@ def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: for deleted_path in deleted_paths: pattern = build_path_pattern(deleted_path) - # Build a git grep ERE from the path suffixes (unanchored, case-sensitive) - segments = deleted_path.split("/") - suffixes = ["/".join(segments[i:]) for i in range(len(segments))] - grep_pattern = "|".join(re.escape(s) for s in suffixes) - for file, line_no, text in git_grep(grep_pattern): + for file, line_no, _text in git_grep(pattern): if file in deleted_set: continue - if pattern.search(text): - stale.append(StaleReference(file, line_no, deleted_path)) + stale.append(StaleReference(file, line_no, deleted_path)) return stale @@ -96,8 +91,6 @@ def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: def main(argv: Sequence[str] | None = None) -> int: del argv deleted_paths = get_deleted_paths() - if not deleted_paths: - return 0 if stale_refs := find_stale_references(deleted_paths): print("Stale references to deleted/renamed files:") diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 58e5f38..2f48ce9 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re from unittest.mock import patch from dev_tools.check_stale_references import ( @@ -15,43 +16,43 @@ class TestBuildPathPattern: def test_matches_full_path(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see src/lib/foo.hpp for details") + assert re.search(pattern, "see src/lib/foo.hpp for details") def test_matches_full_path_with_leading_slash(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see /src/lib/foo.hpp for details") + assert re.search(pattern, "see /src/lib/foo.hpp for details") def test_matches_intermediate_suffix(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see lib/foo.hpp here") + assert re.search(pattern, "see lib/foo.hpp here") def test_matches_intermediate_suffix_with_dotdot(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see ../lib/foo.hpp here") + assert re.search(pattern, "see ../lib/foo.hpp here") def test_matches_basename(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see foo.hpp for details") + assert re.search(pattern, "see foo.hpp for details") def test_matches_basename_with_dotdot(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert pattern.search("see ../../foo.hpp for details") + assert re.search(pattern, "see ../../foo.hpp for details") def test_no_substring_match(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert not pattern.search("notfoo.hpp") + assert not re.search(pattern, "notfoo.hpp") def test_no_match_with_dot_suffix(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert not pattern.search("foo.hpp.bak") + assert not re.search(pattern, "foo.hpp.bak") def test_no_match_with_dash_suffix(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert not pattern.search("foo.hpp-old") + assert not re.search(pattern, "foo.hpp-old") def test_dot_in_extension_is_literal(self) -> None: pattern = build_path_pattern("src/lib/foo.hpp") - assert not pattern.search("fooXhpp") + assert not re.search(pattern, "fooXhpp") class TestStaleReferenceStr: @@ -82,12 +83,6 @@ def test_skips_deleted_file_itself(self, mock_grep) -> None: def test_empty_deleted_paths_returns_empty(self) -> None: assert find_stale_references([]) == [] - @patch("dev_tools.check_stale_references.git_grep") - def test_filters_false_positive_from_git_grep(self, mock_grep) -> None: - mock_grep.return_value = [("a.cpp", 1, "notfoo.hpp")] - result = find_stale_references(["src/lib/foo.hpp"]) - assert result == [] - @patch("dev_tools.check_stale_references.git_grep") def test_multiple_files_with_references(self, mock_grep) -> None: mock_grep.return_value = [ From 5c73c0b1d9070f8f34f6bb8e441006931b5c8a08 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 00:21:21 -0400 Subject: [PATCH 09/18] Remove unused output --- dev_tools/check_stale_references.py | 12 ++++++------ tests/test_check_stale_references.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index f516fd3..cd1082c 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -62,13 +62,13 @@ def build_path_pattern(deleted_path: str) -> str: return rf"(? list[tuple[str, int, str]]: - """Run git grep with PCRE and return (file, line_number, line_text) tuples.""" +def git_grep(pattern: str) -> list[tuple[str, int]]: + """Run git grep with PCRE and return (file, line_number) tuples.""" output = _run_git("grep", "-nP", pattern, check=False) - matches: list[tuple[str, int, str]] = [] + matches: list[tuple[str, int]] = [] for line in output.splitlines(): - file, line_no, text = line.split(":", 2) - matches.append((file, int(line_no), text)) + file, line_no, _text = line.split(":", 2) + matches.append((file, int(line_no))) return matches @@ -80,7 +80,7 @@ def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: for deleted_path in deleted_paths: pattern = build_path_pattern(deleted_path) - for file, line_no, _text in git_grep(pattern): + for file, line_no in git_grep(pattern): if file in deleted_set: continue stale.append(StaleReference(file, line_no, deleted_path)) diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 2f48ce9..27cc5e2 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -64,7 +64,7 @@ def test_str(self) -> None: class TestFindStaleReferences: @patch("dev_tools.check_stale_references.git_grep") def test_finds_reference_to_deleted_file(self, mock_grep) -> None: - mock_grep.return_value = [("src/main.cpp", 1, "// see src/utils/helper.hpp for details")] + mock_grep.return_value = [("src/main.cpp", 1)] result = find_stale_references(["src/utils/helper.hpp"]) assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] @@ -76,7 +76,7 @@ def test_no_reference_no_finding(self, mock_grep) -> None: @patch("dev_tools.check_stale_references.git_grep") def test_skips_deleted_file_itself(self, mock_grep) -> None: - mock_grep.return_value = [("src/utils/helper.hpp", 1, "#include helper.hpp")] + mock_grep.return_value = [("src/utils/helper.hpp", 1)] result = find_stale_references(["src/utils/helper.hpp"]) assert result == [] @@ -86,8 +86,8 @@ def test_empty_deleted_paths_returns_empty(self) -> None: @patch("dev_tools.check_stale_references.git_grep") def test_multiple_files_with_references(self, mock_grep) -> None: mock_grep.return_value = [ - ("a.cpp", 1, "see helper.hpp"), - ("c.md", 3, "also helper.hpp"), + ("a.cpp", 1), + ("c.md", 3), ] result = find_stale_references(["src/helper.hpp"]) assert len(result) == 2 From 78caaa04e28e28d2bacfd906fe0f74bab876c321 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 00:51:20 -0400 Subject: [PATCH 10/18] Simplify --- dev_tools/check_stale_references.py | 29 ++++++++++----------------- tests/test_check_stale_references.py | 30 ++++++++++++++++++---------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index cd1082c..584d7a8 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -35,11 +35,11 @@ def _run_git(*args: str, check: bool = True) -> str: ).stdout -def get_deleted_paths() -> list[str]: +def get_deleted_paths() -> set[str]: """Return repo-relative paths of files being deleted or renamed away in the staged commit.""" output = _run_git("diff", "--cached", "--diff-filter=DR", "--name-status") # Both D(eleted) and R(enamed) have the vanishing path in column 1 - return [line.split("\t")[1] for line in output.splitlines()] + return {line.split("\t")[1] for line in output.splitlines()} def build_path_pattern(deleted_path: str) -> str: @@ -72,27 +72,20 @@ def git_grep(pattern: str) -> list[tuple[str, int]]: return matches -def find_stale_references(deleted_paths: list[str]) -> list[StaleReference]: +def find_stale_references() -> list[StaleReference]: """Search tracked files for references to deleted paths.""" - deleted_set = set(deleted_paths) - stale: list[StaleReference] = [] - - for deleted_path in deleted_paths: - pattern = build_path_pattern(deleted_path) - - for file, line_no in git_grep(pattern): - if file in deleted_set: - continue - stale.append(StaleReference(file, line_no, deleted_path)) - - return stale + deleted_paths = get_deleted_paths() + return [ + StaleReference(file, line_no, deleted_path) + for deleted_path in deleted_paths + for file, line_no in git_grep(build_path_pattern(deleted_path)) + if file not in deleted_paths + ] def main(argv: Sequence[str] | None = None) -> int: del argv - deleted_paths = get_deleted_paths() - - if stale_refs := find_stale_references(deleted_paths): + if stale_refs := find_stale_references(): print("Stale references to deleted/renamed files:") for ref in stale_refs: print(f" {ref}") diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 27cc5e2..dce3365 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -63,32 +63,42 @@ def test_str(self) -> None: class TestFindStaleReferences: @patch("dev_tools.check_stale_references.git_grep") - def test_finds_reference_to_deleted_file(self, mock_grep) -> None: + @patch("dev_tools.check_stale_references.get_deleted_paths") + def test_finds_reference_to_deleted_file(self, mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} mock_grep.return_value = [("src/main.cpp", 1)] - result = find_stale_references(["src/utils/helper.hpp"]) + result = find_stale_references() assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] @patch("dev_tools.check_stale_references.git_grep") - def test_no_reference_no_finding(self, mock_grep) -> None: + @patch("dev_tools.check_stale_references.get_deleted_paths") + def test_no_reference_no_finding(self, mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} mock_grep.return_value = [] - result = find_stale_references(["src/utils/helper.hpp"]) + result = find_stale_references() assert result == [] @patch("dev_tools.check_stale_references.git_grep") - def test_skips_deleted_file_itself(self, mock_grep) -> None: + @patch("dev_tools.check_stale_references.get_deleted_paths") + def test_skips_deleted_file_itself(self, mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} mock_grep.return_value = [("src/utils/helper.hpp", 1)] - result = find_stale_references(["src/utils/helper.hpp"]) + result = find_stale_references() assert result == [] - def test_empty_deleted_paths_returns_empty(self) -> None: - assert find_stale_references([]) == [] + @patch("dev_tools.check_stale_references.get_deleted_paths") + def test_empty_deleted_paths_returns_empty(self, mock_deleted) -> None: + mock_deleted.return_value = set() + assert find_stale_references() == [] @patch("dev_tools.check_stale_references.git_grep") - def test_multiple_files_with_references(self, mock_grep) -> None: + @patch("dev_tools.check_stale_references.get_deleted_paths") + def test_multiple_files_with_references(self, mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/helper.hpp"} mock_grep.return_value = [ ("a.cpp", 1), ("c.md", 3), ] - result = find_stale_references(["src/helper.hpp"]) + result = find_stale_references() assert len(result) == 2 assert {r.file for r in result} == {"a.cpp", "c.md"} From 5418f4d4e3d9a5825d9f0693b35390105e9d3f2e Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:14:15 -0400 Subject: [PATCH 11/18] Fix name --- dev_tools/check_stale_references.py | 10 +++++----- tests/test_check_stale_references.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 584d7a8..12500ef 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -17,13 +17,13 @@ class StaleReference: """A reference in a tracked file that points to a deleted/renamed path.""" - file: str - line: int - deleted_path: str + from_file: str + from_line: int + to_path: str def __str__(self) -> str: """Format as file:line references deleted_path.""" - return f"{self.file}:{self.line} references {self.deleted_path}" + return f"{self.from_file}:{self.from_line} references {self.to_path}" def _run_git(*args: str, check: bool = True) -> str: @@ -76,7 +76,7 @@ def find_stale_references() -> list[StaleReference]: """Search tracked files for references to deleted paths.""" deleted_paths = get_deleted_paths() return [ - StaleReference(file, line_no, deleted_path) + StaleReference(from_file=file, from_line=line_no, to_path=deleted_path) for deleted_path in deleted_paths for file, line_no in git_grep(build_path_pattern(deleted_path)) if file not in deleted_paths diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index dce3365..de13f99 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -101,4 +101,4 @@ def test_multiple_files_with_references(self, mock_deleted, mock_grep) -> None: ] result = find_stale_references() assert len(result) == 2 - assert {r.file for r in result} == {"a.cpp", "c.md"} + assert {r.from_file for r in result} == {"a.cpp", "c.md"} From 0b29f9efd8e7785f7676566fba4d5ca180a05942 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:16:06 -0400 Subject: [PATCH 12/18] Remove license note --- dev_tools/check_stale_references.py | 3 --- tests/test_check_stale_references.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 12500ef..5df4c7d 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -1,6 +1,3 @@ -# Copyright (c) Luminar Technologies, Inc. All rights reserved. -# Licensed under the MIT License. - from __future__ import annotations import re diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index de13f99..c00ce08 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -1,6 +1,3 @@ -# Copyright (c) Luminar Technologies, Inc. All rights reserved. -# Licensed under the MIT License. - from __future__ import annotations import re From f07eec296f443ef36bdc037ee67c524c2c26e477 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:36:01 -0400 Subject: [PATCH 13/18] Remove unused variable --- dev_tools/check_stale_references.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 5df4c7d..77a667a 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -64,7 +64,7 @@ def git_grep(pattern: str) -> list[tuple[str, int]]: output = _run_git("grep", "-nP", pattern, check=False) matches: list[tuple[str, int]] = [] for line in output.splitlines(): - file, line_no, _text = line.split(":", 2) + file, line_no = line.split(":", 1) matches.append((file, int(line_no))) return matches From 3c6b84bfe7e075fe94832744a0c0c6505b55ba49 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:40:23 -0400 Subject: [PATCH 14/18] use pytest --- tests/test_check_stale_references.py | 188 ++++++++++++++------------- 1 file changed, 99 insertions(+), 89 deletions(-) diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index c00ce08..656cfd1 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -10,92 +10,102 @@ ) -class TestBuildPathPattern: - def test_matches_full_path(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see src/lib/foo.hpp for details") - - def test_matches_full_path_with_leading_slash(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see /src/lib/foo.hpp for details") - - def test_matches_intermediate_suffix(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see lib/foo.hpp here") - - def test_matches_intermediate_suffix_with_dotdot(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see ../lib/foo.hpp here") - - def test_matches_basename(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see foo.hpp for details") - - def test_matches_basename_with_dotdot(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert re.search(pattern, "see ../../foo.hpp for details") - - def test_no_substring_match(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert not re.search(pattern, "notfoo.hpp") - - def test_no_match_with_dot_suffix(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert not re.search(pattern, "foo.hpp.bak") - - def test_no_match_with_dash_suffix(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert not re.search(pattern, "foo.hpp-old") - - def test_dot_in_extension_is_literal(self) -> None: - pattern = build_path_pattern("src/lib/foo.hpp") - assert not re.search(pattern, "fooXhpp") - - -class TestStaleReferenceStr: - def test_str(self) -> None: - ref = StaleReference("src/main.cpp", 5, "src/utils/helper.hpp") - assert str(ref) == "src/main.cpp:5 references src/utils/helper.hpp" - - -class TestFindStaleReferences: - @patch("dev_tools.check_stale_references.git_grep") - @patch("dev_tools.check_stale_references.get_deleted_paths") - def test_finds_reference_to_deleted_file(self, mock_deleted, mock_grep) -> None: - mock_deleted.return_value = {"src/utils/helper.hpp"} - mock_grep.return_value = [("src/main.cpp", 1)] - result = find_stale_references() - assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] - - @patch("dev_tools.check_stale_references.git_grep") - @patch("dev_tools.check_stale_references.get_deleted_paths") - def test_no_reference_no_finding(self, mock_deleted, mock_grep) -> None: - mock_deleted.return_value = {"src/utils/helper.hpp"} - mock_grep.return_value = [] - result = find_stale_references() - assert result == [] - - @patch("dev_tools.check_stale_references.git_grep") - @patch("dev_tools.check_stale_references.get_deleted_paths") - def test_skips_deleted_file_itself(self, mock_deleted, mock_grep) -> None: - mock_deleted.return_value = {"src/utils/helper.hpp"} - mock_grep.return_value = [("src/utils/helper.hpp", 1)] - result = find_stale_references() - assert result == [] - - @patch("dev_tools.check_stale_references.get_deleted_paths") - def test_empty_deleted_paths_returns_empty(self, mock_deleted) -> None: - mock_deleted.return_value = set() - assert find_stale_references() == [] - - @patch("dev_tools.check_stale_references.git_grep") - @patch("dev_tools.check_stale_references.get_deleted_paths") - def test_multiple_files_with_references(self, mock_deleted, mock_grep) -> None: - mock_deleted.return_value = {"src/helper.hpp"} - mock_grep.return_value = [ - ("a.cpp", 1), - ("c.md", 3), - ] - result = find_stale_references() - assert len(result) == 2 - assert {r.from_file for r in result} == {"a.cpp", "c.md"} +def test_matches_full_path() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see src/lib/foo.hpp for details") + + +def test_matches_full_path_with_leading_slash() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see /src/lib/foo.hpp for details") + + +def test_matches_intermediate_suffix() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see lib/foo.hpp here") + + +def test_matches_intermediate_suffix_with_dotdot() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see ../lib/foo.hpp here") + + +def test_matches_basename() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see foo.hpp for details") + + +def test_matches_basename_with_dotdot() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert re.search(pattern, "see ../../foo.hpp for details") + + +def test_no_substring_match() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not re.search(pattern, "notfoo.hpp") + + +def test_no_match_with_dot_suffix() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not re.search(pattern, "foo.hpp.bak") + + +def test_no_match_with_dash_suffix() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not re.search(pattern, "foo.hpp-old") + + +def test_dot_in_extension_is_literal() -> None: + pattern = build_path_pattern("src/lib/foo.hpp") + assert not re.search(pattern, "fooXhpp") + + +def test_stale_reference_str() -> None: + ref = StaleReference("src/main.cpp", 5, "src/utils/helper.hpp") + assert str(ref) == "src/main.cpp:5 references src/utils/helper.hpp" + + +@patch("dev_tools.check_stale_references.git_grep") +@patch("dev_tools.check_stale_references.get_deleted_paths") +def test_finds_reference_to_deleted_file(mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} + mock_grep.return_value = [("src/main.cpp", 1)] + result = find_stale_references() + assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] + + +@patch("dev_tools.check_stale_references.git_grep") +@patch("dev_tools.check_stale_references.get_deleted_paths") +def test_no_reference_no_finding(mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} + mock_grep.return_value = [] + result = find_stale_references() + assert result == [] + + +@patch("dev_tools.check_stale_references.git_grep") +@patch("dev_tools.check_stale_references.get_deleted_paths") +def test_skips_deleted_file_itself(mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/utils/helper.hpp"} + mock_grep.return_value = [("src/utils/helper.hpp", 1)] + result = find_stale_references() + assert result == [] + + +@patch("dev_tools.check_stale_references.get_deleted_paths") +def test_empty_deleted_paths_returns_empty(mock_deleted) -> None: + mock_deleted.return_value = set() + assert find_stale_references() == [] + + +@patch("dev_tools.check_stale_references.git_grep") +@patch("dev_tools.check_stale_references.get_deleted_paths") +def test_multiple_files_with_references(mock_deleted, mock_grep) -> None: + mock_deleted.return_value = {"src/helper.hpp"} + mock_grep.return_value = [ + ("a.cpp", 1), + ("c.md", 3), + ] + result = find_stale_references() + assert len(result) == 2 + assert {r.from_file for r in result} == {"a.cpp", "c.md"} From 4a98124477c3657b81931947dd3e1447c091a8e4 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:49:16 -0400 Subject: [PATCH 15/18] Reviewing unit tests --- tests/test_check_stale_references.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/test_check_stale_references.py b/tests/test_check_stale_references.py index 656cfd1..441122a 100644 --- a/tests/test_check_stale_references.py +++ b/tests/test_check_stale_references.py @@ -20,12 +20,12 @@ def test_matches_full_path_with_leading_slash() -> None: assert re.search(pattern, "see /src/lib/foo.hpp for details") -def test_matches_intermediate_suffix() -> None: +def test_matches_partial_path() -> None: pattern = build_path_pattern("src/lib/foo.hpp") assert re.search(pattern, "see lib/foo.hpp here") -def test_matches_intermediate_suffix_with_dotdot() -> None: +def test_matches_partial_path_with_dotdot() -> None: pattern = build_path_pattern("src/lib/foo.hpp") assert re.search(pattern, "see ../lib/foo.hpp here") @@ -60,11 +60,6 @@ def test_dot_in_extension_is_literal() -> None: assert not re.search(pattern, "fooXhpp") -def test_stale_reference_str() -> None: - ref = StaleReference("src/main.cpp", 5, "src/utils/helper.hpp") - assert str(ref) == "src/main.cpp:5 references src/utils/helper.hpp" - - @patch("dev_tools.check_stale_references.git_grep") @patch("dev_tools.check_stale_references.get_deleted_paths") def test_finds_reference_to_deleted_file(mock_deleted, mock_grep) -> None: @@ -106,6 +101,4 @@ def test_multiple_files_with_references(mock_deleted, mock_grep) -> None: ("a.cpp", 1), ("c.md", 3), ] - result = find_stale_references() - assert len(result) == 2 - assert {r.from_file for r in result} == {"a.cpp", "c.md"} + assert {reference.from_file for reference in find_stale_references()} == {"a.cpp", "c.md"} From feac3406b5035e79b48d9b49dd44324af5a3ea5d Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:50:44 -0400 Subject: [PATCH 16/18] fix doc --- .pre-commit-hooks.yaml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 69b7a66..8bf9656 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -286,7 +286,7 @@ description: |- When files are deleted or renamed, check that no remaining tracked file still references the old path. Searches for both the full repo-relative path and the basename of deleted files. - This catches broken cross-references in comments, documentation, configs, and build files. + This catches broken cross-references in dead code such as comments, documentation, and configs. entry: check-stale-references language: python pass_filenames: false diff --git a/README.md b/README.md index c97a486..b8a77bb 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ sync_versions: When files are deleted or renamed, check that no remaining tracked file still references the old path. Searches for both the full repo-relative path and the basename of deleted files. -This catches broken cross-references in comments, documentation, configs, and build files. +This catches broken cross-references in dead code such as comments, documentation, and configs. ### `check-max-one-sentence-per-line` From feba935aa4edd20d07a1f7f7c572b40e763d8c68 Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:53:09 -0400 Subject: [PATCH 17/18] Remove ai slop --- .pre-commit-hooks.yaml | 2 -- pyproject.toml | 1 - 2 files changed, 3 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 8bf9656..1f6ab43 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -291,8 +291,6 @@ language: python pass_filenames: false always_run: true - stages: - - pre-commit - id: check-max-one-sentence-per-line name: Check max one sentence per line description: |- diff --git a/pyproject.toml b/pyproject.toml index 4a84c91..b4d8c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = [ "ANN201", # don't require -> None for every test. Accept non-enforcement for test helper functions - "D101", # test classes don't need docstrings "PLR2004", # Magic values in tests are ok since they are often the expected values "S101" # allow assertions in tests ] From 684564b99f0940f6fb08c74a70c8f4ea92d1e32d Mon Sep 17 00:00:00 2001 From: Chris Bachhuber Date: Wed, 12 Aug 2026 23:55:05 -0400 Subject: [PATCH 18/18] Remove unused variable --- dev_tools/check_stale_references.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dev_tools/check_stale_references.py b/dev_tools/check_stale_references.py index 77a667a..8908f54 100644 --- a/dev_tools/check_stale_references.py +++ b/dev_tools/check_stale_references.py @@ -4,10 +4,6 @@ import subprocess import sys from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Sequence @dataclass(frozen=True) @@ -80,8 +76,7 @@ def find_stale_references() -> list[StaleReference]: ] -def main(argv: Sequence[str] | None = None) -> int: - del argv +def main() -> int: if stale_refs := find_stale_references(): print("Stale references to deleted/renamed files:") for ref in stale_refs: