Skip to content

feat: add check-repo-links hook to verify @repo relative links - #163

Draft
cbachhuber wants to merge 12 commits into
masterfrom
feat/check-repo-links
Draft

feat: add check-repo-links hook to verify @repo relative links#163
cbachhuber wants to merge 12 commits into
masterfrom
feat/check-repo-links

Conversation

@cbachhuber

@cbachhuber cbachhuber commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #162.

Add a hook, check-repo-links that 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 @repo marker

Since 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.hpp

Design decisions:

  • Marker: the literal @repo followed 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 @report from matching.
    • Also considered @file and repo: , but those matched in too much code from public repos mentioned below.
  • Resolution: paths starting with / resolve from the repo root; everything else resolves relative to the file the link appears in.
  • Existence: checked against git ls-files (files and their parent directories), so no per-link filesystem stat.
  • Skipped: URLs, 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 @repo in .ll test files — rare and confined; documented in a test.

Output

Found broken links:
<file>:<line>:<column> <link>

For performance, the precise line/column is computed only after a link is confirmed broken.

Performance

A rare @repo substring pre-filter means the common case for each file is a single bytes.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.

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.
Comment thread dev_tools/check_repo_links.py Outdated
return broken


def build_valid_target_set(tracked_files: Iterable[str]) -> set[str]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Suggested change
def build_valid_target_set(tracked_files: Iterable[str]) -> set[str]:
def build_set_of_valid_link_targets(tracked_files: Iterable[str]) -> set[str]:

Comment thread dev_tools/check_repo_links.py Outdated

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Suggested change
valid = set(tracked_files)
valid_targets = set(tracked_files)

Comment thread dev_tools/check_repo_links.py Outdated
while parent:
valid.add(parent)
parent = posixpath.dirname(parent)
valid.add(".") # the repository root itself

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread dev_tools/check_repo_links.py Outdated
return broken


def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Suggested change
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]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Please rename to does_target_exist throughout the file

Comment thread dev_tools/check_repo_links.py Outdated
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):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Why do we want this? I.e., in which cases do we want to ignore an error from trying to read a file?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, it was unnecessary defensive code — pre-commit only passes staged, readable files. Removed; files are now read directly.

Comment on lines +70 to +71
if b"@repo" not in content:
return []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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:

@cbachhuber cbachhuber Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Should we

Suggested change
if b"@repo" not in content:
if b"@repo " not in content:

instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread dev_tools/check_repo_links.py Outdated
Comment on lines +50 to +53
base, relative = "", link[1:]
else:
base, relative = posixpath.dirname(file_path), link
return posixpath.normpath(posixpath.join(base, relative))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Consider returning early from this first if, this should simplify logic and then not require an else

Comment thread dev_tools/check_repo_links.py Outdated


def find_broken_links(files: list[Path], target_exists: Callable[[str], bool]) -> list[BrokenLink]:
broken: list[BrokenLink] = []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Suggested change
broken: list[BrokenLink] = []
broken_links: list[BrokenLink] = []

Comment thread dev_tools/check_repo_links.py Outdated
# Fast pre-filter: skip the overwhelming majority of files without running the regex.
if b"@repo" not in content:
return []
broken: list[BrokenLink] = []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Suggested change
broken: list[BrokenLink] = []
broken_links: list[BrokenLink] = []

Comment thread dev_tools/check_repo_links.py Outdated
Comment on lines +109 to +111
def report_broken_links(broken: list[BrokenLink]) -> bool:
if not broken:
return False

@cbachhuber cbachhuber Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Let's separate concerns: make this function a pure printing function and decide the return value in main

Comment on lines +38 to +39
# --- resolve_link_target --------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Please remove all such comments. They just duplicate the test name

Comment thread tests/test_check_repo_links.py Outdated
@pytest.mark.parametrize(
("link", "file_path", "expected"),
[
("foo.hpp", "src/foo.cpp", "src/foo.hpp"), # sibling file

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Comment thread tests/test_check_repo_links.py Outdated
Comment on lines +76 to +77
assert valid.issuperset({"src/util/helper.h", "src/util", "src", "README.md", "."})
assert "src/missing.h" not in valid

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Why can't we assert set equality?

Comment thread tests/test_check_repo_links.py Outdated
Comment on lines +30 to +31
def exists(*paths: str) -> Callable[[str], bool]:
return build_valid_target_set(paths).__contains__

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Should we make this (with a better name, probably) a production code function that we also use in main?

Comment on lines +83 to +107
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") == []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Comment thread tests/test_check_repo_links.py Outdated
Comment on lines +121 to +122
def test_prefilter_skips_files_without_marker() -> None:
assert scan("int main() { return 0; }") == []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1 to +2
# Copyright (c) Luminar Technologies, Inc. All rights reserved.
# Licensed under the MIT License.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Given that I created this, is it still Luminar?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving the header as-is for consistency with the rest of the repo, per your note.

Comment thread tests/test_check_repo_links.py Outdated
Comment on lines +139 to +152
# --- 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Let's cover these entirely with the main tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Local link verification in source code

1 participant