Files
docs/scripts/check_cross_refs.py
Mason Daugherty e0d7bb27e9 feat: add CI check for unresolved @[ref] cross-references (#3051)
## Description
Adds a check that validates all `@[ref]` cross-references in source
MDX/MD files resolve against entries in `link_map.py`. Previously,
unresolved references silently passed through the build pipeline and
appeared as raw `@[ClassName]` text in the published docs. Available via
`make check-cross-refs`.

Note: I couldn't modify `.github/workflows/_check-links.yml` due to
workflow permissions. To integrate into CI, add this step to
`_check-links.yml` after "Install Python dependencies":
```yaml
- name: Check for unresolved cross-references
  run: make check-cross-refs
```

## Test Plan
- [ ] `make check-cross-refs` reports unresolved references with file,
line number, and scope
- [ ] 12 unit tests covering: valid refs, unresolved refs, scope fences,
code block skipping, escaped refs, titled refs, backtick refs, and
code-samples exclusion

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Naomi Pentrel <5212232+npentrel@users.noreply.github.com>
2026-03-13 17:35:55 -04:00

134 lines
4.2 KiB
Python

"""Check for unresolved cross-references in documentation source files.
Scans all .mdx and .md files under src/ for @[ref] patterns and validates
that each reference has a corresponding entry in link_map.py. Respects
:::python/:::js scope fences so references are checked against the correct
scope's link map.
Exit code 0 if all references resolve, 1 if any are unresolved.
"""
import sys
from pathlib import Path
from pipeline.preprocessors.handle_auto_links import (
CODE_FENCE_PATTERN,
CONDITIONAL_FENCE_PATTERN,
CROSS_REFERENCE_PATTERN,
)
from pipeline.preprocessors.link_map import SCOPE_LINK_MAPS
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
SCOPES_FOR_PATH: dict[str, list[str]] = {
"oss/python/": ["python"],
"oss/javascript/": ["js"],
}
def _default_scopes_for_file(rel_path: str) -> list[str]:
"""Return the default scope(s) a file is built under."""
for prefix, scopes in SCOPES_FOR_PATH.items():
if rel_path.startswith(prefix):
return scopes
if rel_path.startswith("oss/"):
return ["python", "js"]
# Non-OSS content (e.g., langsmith/) only generates Python-scope pages
return ["python"]
def _extract_refs(
content: str,
default_scopes: list[str],
) -> list[tuple[int, str, list[str]]]:
"""Extract (line_number, ref_name, scopes) from file content."""
refs: list[tuple[int, str, list[str]]] = []
current_scopes = default_scopes
in_code_block = False
for line_number, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if CODE_FENCE_PATTERN.match(stripped):
in_code_block = not in_code_block
continue
if in_code_block:
continue
fence_match = CONDITIONAL_FENCE_PATTERN.match(stripped)
if fence_match:
language = fence_match.group("language")
if language and language.lower() in ("python", "js"):
current_scopes = [language.lower()]
else:
current_scopes = default_scopes
continue
for match in CROSS_REFERENCE_PATTERN.finditer(line):
# CROSS_REFERENCE_PATTERN uses (?<!\\) lookbehind to skip escaped refs
ref_name = match.group("link_name_with_title") or match.group("link_name")
if ref_name:
refs.append((line_number, ref_name, list(current_scopes)))
return refs
def check_cross_refs(src_dir: Path) -> list[tuple[str, int, str, list[str]]]:
"""Return list of (file, line, ref_name, scopes) for unresolved refs."""
errors: list[tuple[str, int, str, list[str]]] = []
md_files = sorted(
[*src_dir.rglob("*.mdx"), *src_dir.rglob("*.md")],
)
for file_path in md_files:
rel_path = str(file_path.relative_to(src_dir))
if rel_path.startswith("snippets/code-samples/"):
continue
if "node_modules" in rel_path:
continue
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
print(f" WARNING: skipping {rel_path} (not valid UTF-8: {exc})")
continue
default_scopes = _default_scopes_for_file(rel_path)
refs = _extract_refs(content, default_scopes)
for line_number, ref_name, scopes in refs:
resolved = any(
ref_name in SCOPE_LINK_MAPS.get(scope, {}) for scope in scopes
)
if not resolved:
errors.append((rel_path, line_number, ref_name, scopes))
return errors
def main() -> None:
"""CLI entrypoint for cross-reference validation."""
errors = check_cross_refs(SRC_DIR)
if not errors:
print("✅ All cross-references resolved")
sys.exit(0)
print(f"found {len(errors)} unresolved cross-reference(s):\n")
for file_path, line_number, ref_name, scopes in errors:
scope_str = ", ".join(scopes)
print(
f" {file_path}:{line_number}: @[{ref_name}] not in scope(s): {scope_str}"
)
print(
f"\n{len(errors)} unresolved cross-reference(s). "
"Add entries to pipeline/preprocessors/link_map.py or fix the reference."
)
sys.exit(1)
if __name__ == "__main__":
main()