feat: add check-repo-links hook to verify @repo relative links - #163
feat: add check-repo-links hook to verify @repo relative links#163cbachhuber wants to merge 12 commits into
Conversation
Adds a pre-commit hook that verifies relative links marked with the '@repo <path>' marker in any text file resolve to a file or directory tracked by git ls-files. Paths starting with / resolve from the repo root, all others relative to the file. URLs, mailto and anchor-only targets are skipped. Uses a fast '@repo' substring pre-filter and computes line/column only for confirmed-broken links.
| return broken | ||
|
|
||
|
|
||
| def build_valid_target_set(tracked_files: Iterable[str]) -> set[str]: |
There was a problem hiding this comment.
| def build_valid_target_set(tracked_files: Iterable[str]) -> set[str]: | |
| def build_set_of_valid_link_targets(tracked_files: Iterable[str]) -> set[str]: |
|
|
||
| def build_valid_target_set(tracked_files: Iterable[str]) -> set[str]: | ||
| """Build the set of valid link targets: every tracked file plus all of their parent directories.""" | ||
| valid = set(tracked_files) |
There was a problem hiding this comment.
| valid = set(tracked_files) | |
| valid_targets = set(tracked_files) |
| while parent: | ||
| valid.add(parent) | ||
| parent = posixpath.dirname(parent) | ||
| valid.add(".") # the repository root itself |
There was a problem hiding this comment.
This makes the script dependent on where it's invoked from, right? While that's guaranteed with prek/pre-commit, let's not rely on it. What's the best way of doing this instead? Since we already rely on git, should we use that to find the repo root?
There was a problem hiding this comment.
Good catch. Now uses git rev-parse --show-toplevel to find the root, runs git -C <root> ls-files, and resolves each file to a repo-relative path against that root, so it no longer depends on the invocation directory.
| return broken | ||
|
|
||
|
|
||
| def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]: |
There was a problem hiding this comment.
| def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]: | |
| def find_broken_links(files_to_check: list[Path], does_target_exist: Callable[[str], bool]) -> list[BrokenLink]: |
There was a problem hiding this comment.
Please rename to does_target_exist throughout the file
| def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]: | ||
| broken: list[BrokenLink] = [] | ||
| for file in files: | ||
| with contextlib.suppress(OSError): |
There was a problem hiding this comment.
Why do we want this? I.e., in which cases do we want to ignore an error from trying to read a file?
There was a problem hiding this comment.
Agreed, it was unnecessary defensive code — pre-commit only passes staged, readable files. Removed; files are now read directly.
| if b"@repo" not in content: | ||
| return [] |
There was a problem hiding this comment.
Let's avoid premature optimization: benchmark the hook without this pre-filter on 10k files with 100line each, and 1% of the files contain @repo
There was a problem hiding this comment.
Benchmarked as requested (10k files x 100 lines, 1% contain @repo, single process, best of 3): with pre-filter 101 ms, without 818 ms (~8x). Since it's a measured 8x on the full-scan path and only one commented line, I kept it. Happy to drop it if you'd still prefer the simpler code.
| ) -> list[BrokenLink]: | ||
| """Scan a single file's bytes and return the broken ``@repo`` links it contains.""" | ||
| # Fast pre-filter: skip the overwhelming majority of files without running the regex. | ||
| if b"@repo" not in content: |
There was a problem hiding this comment.
Should we
| if b"@repo" not in content: | |
| if b"@repo " not in content: |
instead?
There was a problem hiding this comment.
Kept b"@repo" without the trailing space: the marker also matches a tab (@repo\t<path>), so b"@repo " would miss tab-separated links. The space-less pre-filter stays a safe superset of the regex.
| base, relative = "", link[1:] | ||
| else: | ||
| base, relative = posixpath.dirname(file_path), link | ||
| return posixpath.normpath(posixpath.join(base, relative)) |
There was a problem hiding this comment.
Consider returning early from this first if, this should simplify logic and then not require an else
|
|
||
|
|
||
| def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]: | ||
| broken: list[BrokenLink] = [] |
There was a problem hiding this comment.
| broken: list[BrokenLink] = [] | |
| broken_links: list[BrokenLink] = [] |
| # Fast pre-filter: skip the overwhelming majority of files without running the regex. | ||
| if b"@repo" not in content: | ||
| return [] | ||
| broken: list[BrokenLink] = [] |
There was a problem hiding this comment.
| broken: list[BrokenLink] = [] | |
| broken_links: list[BrokenLink] = [] |
| def report_broken_links(broken: list[BrokenLink]) -> bool: | ||
| if not broken: | ||
| return False |
There was a problem hiding this comment.
Let's separate concerns: make this function a pure printing function and decide the return value in main
| # --- resolve_link_target -------------------------------------------------------------------- | ||
|
|
There was a problem hiding this comment.
Please remove all such comments. They just duplicate the test name
| @pytest.mark.parametrize( | ||
| ("link", "file_path", "expected"), | ||
| [ | ||
| ("foo.hpp", "src/foo.cpp", "src/foo.hpp"), # sibling file |
There was a problem hiding this comment.
Instead of making this a comment, use pytest functionality to give this test case a name. I think you want to use https://docs.pytest.org/en/stable/example/parametrize.html#different-options-for-test-ids
| assert valid.issuperset({"src/util/helper.h", "src/util", "src", "README.md", "."}) | ||
| assert "src/missing.h" not in valid |
There was a problem hiding this comment.
Why can't we assert set equality?
| def exists(*paths: str) -> Callable[[str], bool]: | ||
| return build_valid_target_set(paths).__contains__ |
There was a problem hiding this comment.
Should we make this (with a better name, probably) a production code function that we also use in main?
| def test_broken_link_is_reported_with_location() -> None: | ||
| assert scan("// see @repo missing/file.md here") == [BrokenLink("src/foo.cpp", 1, 14, "missing/file.md")] | ||
|
|
||
|
|
||
| def test_valid_link_is_not_reported() -> None: | ||
| assert scan("// see @repo foo.hpp for details", tracked=("src/foo.hpp",)) == [] | ||
|
|
||
|
|
||
| def test_doxygen_param_tag_is_ignored() -> None: | ||
| # `@report...` has no whitespace after `@repo`, so the marker never matches. | ||
| assert scan(" * @report_new: populate the report\n * @reported: bool") == [] | ||
|
|
||
|
|
||
| def test_npm_scope_is_ignored() -> None: | ||
| # `@repo/ui` is followed by `/`, not whitespace, so it is not our marker. | ||
| assert scan('import { Button } from "@repo/ui";') == [] | ||
|
|
||
|
|
||
| def test_marker_must_not_follow_word_character() -> None: | ||
| assert scan("foo@repo missing.md") == [] | ||
|
|
||
|
|
||
| def test_url_target_is_skipped() -> None: | ||
| assert scan("// @repo https://example.com/page") == [] | ||
|
|
There was a problem hiding this comment.
Would it make sense to pytest.parametrize the relatively trivial cases? We can then use https://docs.pytest.org/en/stable/example/parametrize.html#different-options-for-test-ids to assign names/explanations to those tests
| def test_prefilter_skips_files_without_marker() -> None: | ||
| assert scan("int main() { return 0; }") == [] |
There was a problem hiding this comment.
Why do we need this test? I think tests shouldn't know about existence of such a prefilter. And we have tests that cover cases in which the hook should do nothing.
| # Copyright (c) Luminar Technologies, Inc. All rights reserved. | ||
| # Licensed under the MIT License. |
There was a problem hiding this comment.
Given that I created this, is it still Luminar?
There was a problem hiding this comment.
Leaving the header as-is for consistency with the rest of the repo, per your note.
| # --- report_broken_links -------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_report_broken_links_prints_findings(capsys: pytest.CaptureFixture) -> None: | ||
| assert report_broken_links([BrokenLink("src/a.cpp", 3, 12, "missing.h")]) | ||
| out = capsys.readouterr().out | ||
| assert "Found broken links:" in out | ||
| assert "src/a.cpp:3:12 missing.h" in out | ||
|
|
||
|
|
||
| def test_report_broken_links_is_silent_when_empty(capsys: pytest.CaptureFixture) -> None: | ||
| assert not report_broken_links([]) | ||
| assert not capsys.readouterr().out | ||
|
|
There was a problem hiding this comment.
Let's cover these entirely with the main tests
Closes #162.
Add a hook,
check-repo-linksthat verifies relative links in any text file point to something that actually exists in the repository. This extends the "dead relative link" checks we already like for Markdown (rumdl et al.) to source code comments, configs, and other plain-text files.The
@repomarkerSince detecting comments per language is both complex and slow, the link marker itself carries the semantics — no comment/lexer awareness required, so it works in every language:
// The interface is documented in @repo src/foo.hppDesign decisions:
@repofollowed by whitespace and a path. Requiring whitespace (not just any delimiter) is deliberate — it avoids the extremely common npm/pnpm monorepo package scope@repo/..., and the(?<![\w@])lookbehind keeps Doxygen/kernel-doc tags like@reportfrom matching.@fileandrepo:, but those matched in too much code from public repos mentioned below./resolve from the repo root; everything else resolves relative to the file the link appears in.git ls-files(files and their parent directories), so no per-link filesystemstat.mailto:links and anchor-only targets; trailing prose punctuation is stripped (see @repo foo.md.).I verified marker collisions against several large public repos (linux, llvm, kubernetes, rust, go, vscode, react, turborepo). The only real false positive is a hand-written LLVM IR global literally named
@repoin.lltest files — rare and confined; documented in a test.Output
For performance, the precise line/column is computed only after a link is confirmed broken.
Performance
A rare
@reposubstring pre-filter means the common case for each file is a singlebytes.find, with the regex running only on the few files that contain the marker. Existence is an O(1) set lookup. On a synthetic 1M-line tree (10k files with 100 lines each) this runs in well under a second single-threaded on my workstation; With 8 threads, this runs in 0.23s. pre-commit's own file batching parallelizes real runs, and local hooks normally only see changed files.