dynamo/.github/workflows/detect_broken_links.py

1005 lines
36 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Script to detect broken links in markdown files and problematic symbolic links within a git repository.
This script:
1. Finds all .md files in the specified directory (recursively)
2. Parses each file to extract links to other .md files
3. Validates if the linked files exist
4. Detects problematic symbolic links (broken, circular, outside repo)
5. Generates a JSON or HTML report of broken links and problematic symlinks with line numbers
"""
import argparse
import json
import logging
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
def setup_logging(
verbose: bool = False, log_file: Optional[str] = None
) -> logging.Logger:
"""
Set up logging configuration.
Args:
verbose: If True, set log level to DEBUG, otherwise INFO
log_file: Optional file path to write logs to
Returns:
Configured logger instance
"""
# Create logger
logger = logging.getLogger("detect_broken_links")
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
# Clear any existing handlers
logger.handlers.clear()
# Create formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Console handler
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# File handler (if specified)
if log_file:
try:
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.info(f"Logging to file: {log_file}")
except Exception as e:
logger.error(f"Failed to set up file logging to {log_file}: {e}")
return logger
def path_relative_to_git_root(
file_path: Path, git_root_dir: str, logger: logging.Logger
) -> str:
"""
Convert a file path to be relative to the git repository root.
Args:
file_path: The file path to convert
git_root_dir: The git repository root directory
logger: Logger instance for logging
Returns:
Path relative to git repository root as string
"""
try:
git_root_abs = Path(git_root_dir).resolve()
file_abs = Path(file_path).resolve()
# Calculate relative path from git repo root to file
relative_path = file_abs.relative_to(git_root_abs)
result = str(relative_path)
logger.debug(f"Converted {file_path} to git-relative path: {result}")
return result
except ValueError as e:
# If relative_to fails, it means the file is not under the git repo
logger.warning(
f"File {file_path} is not under Git repository {git_root_dir}: {e}"
)
return str(file_path)
except Exception as e:
logger.warning(f"Error converting path {file_path} to git-relative: {e}")
return str(file_path)
def get_git_info(
logger: logging.Logger, start_path: str = "."
) -> Optional[Dict[str, str]]:
"""
Get Git repository information including remote URL and current branch.
Args:
logger: Logger instance for logging
start_path: Starting path to search for git repository
Returns:
Dictionary with 'remote_url', 'branch', and 'git_root_dir' keys, or None if not in a Git repo
"""
try:
logger.debug(f"Attempting to get Git repository information from: {start_path}")
# Find the git repository root
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
cwd=start_path,
)
if result.returncode != 0:
logger.warning("Not in a Git repository or Git not available")
return None
git_root_dir = result.stdout.strip()
logger.debug(f"Found Git repository root: {git_root_dir}")
# Check if this repository has a remote origin URL
remote_result = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
cwd=git_root_dir,
)
if remote_result.returncode != 0 or not remote_result.stdout.strip():
logger.warning("Git repository has no remote origin URL")
return None
remote_url = remote_result.stdout.strip()
logger.debug(f"Remote URL: {remote_url}")
# Get current branch
branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=git_root_dir,
)
if branch_result.returncode != 0:
logger.warning("Could not get current branch")
return None
branch = branch_result.stdout.strip()
logger.debug(f"Current branch: {branch}")
# Get current commit hash
commit_result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=git_root_dir,
)
if commit_result.returncode != 0:
logger.warning("Could not get current commit hash")
commit_hash = "unknown"
else:
commit_hash = commit_result.stdout.strip()
logger.debug(f"Current commit: {commit_hash}")
# Get short commit hash (first 7 characters)
short_commit = commit_hash[:7] if commit_hash != "unknown" else "unknown"
return {
"remote_url": remote_url,
"branch": branch,
"commit_hash": commit_hash,
"short_commit": short_commit,
"git_root_dir": git_root_dir,
}
except Exception as e:
logger.warning(f"Error getting Git information: {e}")
return None
def construct_github_url(
file_path: str,
git_info: Dict[str, str],
logger: logging.Logger,
line_number: Optional[int] = None,
) -> str:
"""
Construct a GitHub URL for a file, optionally linking to a specific line.
Args:
file_path: Path to the file relative to the repository root
git_info: Git repository information
logger: Logger instance for logging
line_number: Optional line number to link to (adds #L{line_number} anchor)
Returns:
GitHub URL for the file, optionally with line anchor
"""
try:
remote_url = git_info["remote_url"]
branch = git_info["branch"]
# Handle SSH and HTTPS URLs
if remote_url.startswith("git@github.com:"):
# Convert SSH to HTTPS
repo_path = remote_url.replace("git@github.com:", "")
if repo_path.endswith(".git"):
repo_path = repo_path[:-4]
github_base = f"https://github.com/{repo_path}"
elif remote_url.startswith("https://github.com/"):
# Already HTTPS
github_base = remote_url
if github_base.endswith(".git"):
github_base = github_base[:-4]
else:
logger.warning(f"Unsupported remote URL format: {remote_url}")
return ""
# Handle file path - if it's already relative to git root, use as-is
# If it's absolute, convert it to be relative to the repository root
if os.path.isabs(file_path):
git_root_dir = git_info.get("git_root_dir", ".")
try:
# Get absolute paths
git_repo_abs_path = Path(git_root_dir).resolve()
file_abs_path = Path(file_path).resolve()
# Calculate relative path from git repo root to file
relative_path = file_abs_path.relative_to(git_repo_abs_path)
file_path = str(relative_path)
logger.debug(
f"Calculated relative path from Git repo root: {file_path}"
)
except ValueError as e:
# If relative_to fails, it means the file is not under the git repo
logger.warning(
f"File {file_path} is not under Git repository {git_root_dir}: {e}"
)
return ""
else:
# Path is already relative to git root, use as-is
logger.debug(f"Using git-relative path as-is: {file_path}")
# Construct the GitHub URL
github_url = f"{github_base}/blob/{branch}/{file_path}"
# Add line anchor if specified
if line_number is not None:
# For markdown files, we need to add ?plain=1 to make line anchors work
if file_path.lower().endswith(".md"):
github_url += f"?plain=1#L{line_number}"
else:
github_url += f"#L{line_number}"
logger.debug(f"Constructed GitHub URL with line anchor: {github_url}")
else:
logger.debug(f"Constructed GitHub URL: {github_url}")
return github_url
except Exception as e:
logger.warning(f"Error constructing GitHub URL: {e}")
return ""
def find_markdown_files(root_dir: str, logger: logging.Logger) -> List[Path]:
"""Find all .md files recursively in the given directory or specific file."""
root_path = Path(root_dir)
logger.debug(f"Searching for markdown files in: {root_dir}")
if not root_path.exists():
error_msg = f"Path does not exist: {root_dir}"
logger.error(error_msg)
raise ValueError(error_msg)
# Patterns for files to skip (attribution files, etc.)
skip_patterns = [
"ATTRIBUTION",
"ATTRIBUTIONS",
"THIRD_PARTY",
"THIRD-PARTY",
"LICENSES",
]
md_files = []
if root_path.is_file():
# If it's a specific file, check if it's a markdown file
if root_path.suffix.lower() == ".md":
md_files.append(root_path)
logger.debug(f"Added single markdown file: {root_path}")
else:
logger.warning(f"Specified file {root_path} is not a markdown file")
else:
# If it's a directory, find all .md files recursively
logger.debug(f"Scanning directory recursively: {root_path}")
for file_path in root_path.rglob("*.md"):
# Skip attribution files
file_name_upper = file_path.name.upper()
if any(pattern in file_name_upper for pattern in skip_patterns):
logger.debug(f"Skipping attribution file: {file_path}")
continue
md_files.append(file_path)
logger.info(f"Found {len(md_files)} markdown files in {root_dir}")
logger.debug(f"Markdown files: {[str(f) for f in md_files]}")
return sorted(md_files)
def extract_markdown_links(
content: str, file_path: Path, logger: logging.Logger
) -> List[Tuple[str, int, str]]:
"""
Extract markdown links from file content.
Args:
content: File content as string
file_path: Path to the file being processed
logger: Logger instance for logging
Returns:
List of tuples: (link_text, line_number, link_url)
"""
links = []
logger.debug(f"Extracting markdown links from: {file_path}")
# Regex to match markdown links: [text](url)
# This captures the link text and URL, excluding image links (![text](url))
link_pattern = r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)"
for line_num, line in enumerate(content.split("\n"), 1):
matches = re.finditer(link_pattern, line)
for match in matches:
link_text = match.group(1)
link_url = match.group(2)
# Only process links that could be to .md files
# Skip external URLs, anchors, and non-markdown files
if (
not link_url.startswith("http")
and not link_url.startswith("mailto:")
and not link_url.startswith("#")
and not link_url.startswith("ftp://")
and not link_url.startswith("//")
):
links.append((link_text, line_num, link_url))
logger.debug(
f"Found link at line {line_num}: [{link_text}]({link_url})"
)
logger.debug(f"Extracted {len(links)} potential markdown links from {file_path}")
return links
def resolve_link_path(
link_url: str,
source_file: Path,
logger: logging.Logger,
git_root_dir: Optional[str] = None,
) -> Path:
"""
Resolve a relative or absolute link to an absolute file path.
Args:
link_url: The link URL from the markdown file
source_file: The source markdown file path
logger: Logger instance for logging
git_root_dir: Git repository root directory for resolving GitHub-style absolute paths
Returns:
Resolved absolute path to the target file
"""
logger.debug(f"Resolving link: {link_url} from source: {source_file}")
# Remove any anchor/fragment from the URL
original_link_url = link_url
link_url = link_url.split("#")[0]
if not link_url:
logger.debug(f"Empty link URL, returning source file: {source_file}")
return source_file
# Handle GitHub-style absolute paths (starting with /) that should be relative to git repo root
if link_url.startswith("/") and git_root_dir:
# Strip the leading '/' and resolve relative to git repository root
repo_relative_path = link_url[1:] # Remove leading '/'
git_root_path = Path(git_root_dir)
resolved_path = git_root_path / repo_relative_path
final_path = resolved_path.resolve()
logger.debug(
f"GitHub-style absolute path resolved: {original_link_url} -> {final_path}"
)
return final_path
# If it's already an absolute path (and not a GitHub-style path), return as is
if os.path.isabs(link_url):
logger.debug(f"Absolute filesystem path detected: {link_url}")
return Path(link_url)
# For symlinks, resolve relative paths from the symlink's location, not the
# target's location. This matches GitHub's behavior where links in symlinked
# files are resolved relative to the symlink
source_dir = source_file.parent
if source_file.is_symlink():
logger.debug(
f"Source file is a symlink, resolving links from symlink location: "
f"{source_dir}"
)
resolved_path = source_dir / link_url
# Normalize the path (resolve any .. or . components)
final_path = resolved_path.resolve()
logger.debug(f"Resolved {original_link_url} -> {final_path}")
return final_path
def is_markdown_file(file_path: Path) -> bool:
"""Check if a file is a markdown file."""
return file_path.suffix.lower() == ".md"
def validate_links(
md_files: List[Path],
logger: logging.Logger,
git_root_dir: Optional[str] = None,
git_info: Optional[Dict[str, str]] = None,
) -> Dict[str, List[Dict]]:
"""
Validate all links in markdown files.
Args:
md_files: List of markdown files to validate
logger: Logger instance for logging
git_root_dir: Git repository root directory for path normalization
git_info: Git repository information for generating GitHub URLs
Returns:
Dictionary with file paths as keys and lists of broken links as values
"""
broken_links_report = {}
total_links_checked = 0
total_broken_links = 0
logger.info(f"Starting validation of {len(md_files)} markdown files")
for md_file in md_files:
logger.debug(f"Processing file: {md_file}")
try:
with open(md_file, "r", encoding="utf-8") as f:
content = f.read()
logger.debug(f"Successfully read {len(content)} characters from {md_file}")
except Exception as e:
logger.error(f"Could not read {md_file}: {e}")
continue
links = extract_markdown_links(content, md_file, logger)
broken_links = []
file_links_checked = 0
for link_text, line_num, link_url in links:
file_links_checked += 1
total_links_checked += 1
try:
resolved_path = resolve_link_path(
link_url, md_file, logger, git_root_dir
)
# Determine if this is a markdown file, directory, or should be skipped
is_markdown = is_markdown_file(resolved_path)
is_directory_link = link_url.endswith("/") or (
resolved_path.exists() and resolved_path.is_dir()
)
# Check if link has a non-markdown file extension (image, code file, etc.)
has_other_extension = (
resolved_path.suffix
and resolved_path.suffix.lower() not in [".md", ""]
)
# Skip non-markdown files (images, videos, code files, etc.)
# but validate markdown files and directory links (with or without trailing slash)
if not is_markdown and not is_directory_link and has_other_extension:
logger.debug(
f"Skipping non-markdown file link in {md_file}:{line_num} - {link_url}"
)
continue
# Validate the link
is_broken = False
error_reason = None
if is_markdown:
# Check markdown file exists
if not resolved_path.exists():
is_broken = True
error_reason = f"Markdown file does not exist: {resolved_path}"
else:
logger.debug(
f"Valid markdown link in {md_file}:{line_num} - {link_url} -> {resolved_path}"
)
elif is_directory_link:
# It's a directory link (ends with / or resolves to existing directory)
# Check if directory exists
if not resolved_path.exists():
is_broken = True
error_reason = f"Directory does not exist: {resolved_path}"
elif not resolved_path.is_dir():
is_broken = True
error_reason = (
f"Path exists but is not a directory: {resolved_path}"
)
else:
logger.debug(
f"Valid directory link in {md_file}:{line_num} - {link_url} -> {resolved_path}"
)
else:
# Link without extension that's not markdown or directory
# Could be LICENSE, Makefile, etc. - check if it exists
if not resolved_path.exists():
is_broken = True
error_reason = (
f"File does not exist: {resolved_path}. "
f"If this is a directory link, add a trailing slash (/)"
)
else:
logger.debug(
f"Valid file link in {md_file}:{line_num} - {link_url} -> {resolved_path}"
)
# Report broken link if found
if is_broken:
# Generate GitHub URL for the broken link line
file_for_github = (
path_relative_to_git_root(md_file, git_root_dir, logger)
if git_root_dir
else str(md_file)
)
github_url = (
construct_github_url(
file_for_github, git_info, logger, line_num
)
if git_info
else ""
)
broken_link_info = {
"line": line_num,
"link_text": link_text,
"link_url": link_url,
"resolved_path": str(resolved_path),
"error_reason": error_reason,
"github_url": github_url,
}
broken_links.append(broken_link_info)
total_broken_links += 1
logger.warning(
f"Broken link found in {md_file}:{line_num} - {link_url} -> {error_reason}"
)
except Exception as e:
# If we can't resolve the path, consider it broken
# Generate GitHub URL for the broken link line
file_for_github = (
path_relative_to_git_root(md_file, git_root_dir, logger)
if git_root_dir
else str(md_file)
)
github_url = (
construct_github_url(file_for_github, git_info, logger, line_num)
if git_info
else ""
)
broken_link_info = {
"line": line_num,
"link_text": link_text,
"link_url": link_url,
"resolved_path": "ERROR",
"error_reason": f"Error resolving link: {e}",
"github_url": github_url,
}
broken_links.append(broken_link_info)
total_broken_links += 1
logger.error(
f"Error resolving link in {md_file}:{line_num} - {link_url}: {e}"
)
if broken_links:
# Use git-relative path for the report key if git_root_dir is available
report_key = (
path_relative_to_git_root(md_file, git_root_dir, logger)
if git_root_dir
else str(md_file)
)
broken_links_report[report_key] = broken_links
logger.info(
f"File {md_file}: {len(broken_links)} broken links out of {file_links_checked} total links"
)
else:
logger.debug(f"File {md_file}: All {file_links_checked} links are valid")
logger.info(
f"Validation complete: {total_broken_links} broken links found out of {total_links_checked} total links checked"
)
return broken_links_report
def find_symbolic_links(root_dir: str, logger: logging.Logger) -> List[Path]:
"""
Find all symbolic links in the given directory recursively.
Args:
root_dir: Root directory to search for symbolic links
logger: Logger instance for logging
Returns:
List of Path objects representing symbolic links
"""
symlinks = []
root_path = Path(root_dir).resolve()
logger.debug(f"Searching for symbolic links in: {root_path}")
try:
for item in root_path.rglob("*"):
if item.is_symlink():
symlinks.append(item)
logger.debug(f"Found symbolic link: {item}")
except (OSError, PermissionError) as e:
logger.warning(f"Error accessing path during symlink search: {e}")
logger.info(f"Found {len(symlinks)} symbolic links in {root_dir}")
return symlinks
def detect_problematic_symlinks(
symlinks: List[Path], git_root_dir: Optional[str], logger: logging.Logger
) -> Dict[str, List[Dict[str, str]]]:
"""
Detect problematic symbolic links including broken, circular, and external links.
Args:
symlinks: List of symbolic link paths to check
git_root_dir: Git repository root directory for relative path calculation
logger: Logger instance for logging
Returns:
Dictionary with categories of problematic symlinks
"""
problematic_symlinks = {
"broken": [],
"circular": [],
"external": [],
"suspicious": [],
}
git_root_path = Path(git_root_dir).resolve() if git_root_dir else None
for symlink in symlinks:
try:
symlink_path = symlink.resolve()
target_path = symlink.readlink()
# Get relative path from git root for reporting
if git_root_path:
try:
relative_symlink = symlink.relative_to(git_root_path)
except ValueError:
relative_symlink = symlink
else:
relative_symlink = symlink
symlink_info = {
"symlink_path": str(relative_symlink),
"target_path": str(target_path),
"absolute_symlink_path": str(symlink),
"issue": "",
}
# Check if symlink is broken (target doesn't exist)
if not symlink_path.exists():
symlink_info[
"issue"
] = f"Broken symlink: target '{target_path}' does not exist"
problematic_symlinks["broken"].append(symlink_info)
logger.warning(f"Broken symlink found: {symlink} -> {target_path}")
continue
# Check for circular symlinks
try:
# Try to resolve the symlink completely
resolved_path = symlink.resolve(strict=True)
if resolved_path == symlink:
symlink_info["issue"] = "Circular symlink: points to itself"
problematic_symlinks["circular"].append(symlink_info)
logger.warning(f"Circular symlink found: {symlink}")
continue
except (OSError, RuntimeError) as e:
if "Too many levels of symbolic links" in str(e):
symlink_info[
"issue"
] = "Circular symlink: too many levels of symbolic links"
problematic_symlinks["circular"].append(symlink_info)
logger.warning(f"Circular symlink found: {symlink}")
continue
# Check if symlink points outside the repository
if git_root_path:
try:
symlink_path.relative_to(git_root_path)
except ValueError:
symlink_info[
"issue"
] = f"External symlink: points outside repository to '{symlink_path}'"
problematic_symlinks["external"].append(symlink_info)
logger.warning(
f"External symlink found: {symlink} -> {symlink_path}"
)
continue
# Check for suspicious patterns (e.g., very long paths, unusual targets)
if len(str(target_path)) > 200:
symlink_info[
"issue"
] = f"Suspicious symlink: unusually long target path ({len(str(target_path))} characters)"
problematic_symlinks["suspicious"].append(symlink_info)
logger.info(f"Suspicious symlink found: {symlink} (long path)")
# Check if target is in a different directory tree (potential maintenance issue)
if "../" in str(target_path) and str(target_path).count("../") > 3:
symlink_info[
"issue"
] = f"Suspicious symlink: target requires many directory traversals ('{target_path}')"
problematic_symlinks["suspicious"].append(symlink_info)
logger.info(f"Suspicious symlink found: {symlink} (many traversals)")
except (OSError, PermissionError) as e:
symlink_info = {
"symlink_path": str(symlink),
"target_path": "unknown",
"absolute_symlink_path": str(symlink),
"issue": f"Error accessing symlink: {e}",
}
problematic_symlinks["broken"].append(symlink_info)
logger.error(f"Error processing symlink {symlink}: {e}")
# Log summary
total_issues = sum(len(issues) for issues in problematic_symlinks.values())
if total_issues > 0:
logger.warning(f"Found {total_issues} problematic symbolic links:")
for category, issues in problematic_symlinks.items():
if issues:
logger.warning(f" {category}: {len(issues)}")
else:
logger.info("No problematic symbolic links found")
return problematic_symlinks
def main():
parser = argparse.ArgumentParser(
description="Detect broken links in markdown files and problematic symbolic links",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Check all markdown files in the current directory
python detect_broken_links.py
# Check markdown files in a specific directory
python detect_broken_links.py /path/to/docs
# Check markdown files in multiple directories
python detect_broken_links.py /path/to/docs1 /path/to/docs2
# Output to JSON file
python detect_broken_links.py --format json --output report.json
# Verbose logging
python detect_broken_links.py --verbose
# Log to file
python detect_broken_links.py --log-file debug.log
""",
)
parser.add_argument(
"directories",
nargs="*",
default=["."],
help="Directories to scan for markdown files (default: current directory)",
)
parser.add_argument(
"--output",
"-o",
type=str,
help="Output file for report (default: print to stdout)",
)
parser.add_argument(
"--format",
"-f",
choices=["json"],
default="json",
help="Output format (default: json)",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print verbose output including file processing information",
)
parser.add_argument(
"--log-file", type=str, help="Log file path for detailed logging"
)
parser.add_argument(
"--check-symlinks",
action="store_true",
help="Also check for problematic symbolic links (broken, circular, external)",
)
args = parser.parse_args()
# Set up logging
logger = setup_logging(verbose=args.verbose, log_file=args.log_file)
logger.info("Starting broken links detection")
logger.info(
f"Arguments: directories={args.directories}, format={args.format}, output={args.output}, verbose={args.verbose}"
)
# Get git repository information for path normalization
git_info = get_git_info(logger, args.directories[0] if args.directories else ".")
git_root_dir = git_info.get("git_root_dir") if git_info else None
all_broken_links = {}
total_files_processed = 0
all_processed_files = []
for directory in args.directories:
logger.info(f"Processing directory: {directory}")
try:
md_files = find_markdown_files(directory, logger)
total_files_processed += len(md_files)
# Convert file paths to git-relative paths for the report
if git_root_dir:
git_relative_files = [
path_relative_to_git_root(f, git_root_dir, logger) for f in md_files
]
all_processed_files.extend(git_relative_files)
else:
all_processed_files.extend([str(f) for f in md_files])
logger.info(f"Found {len(md_files)} markdown files in {directory}")
broken_links = validate_links(md_files, logger, git_root_dir, git_info)
all_broken_links.update(broken_links)
except Exception as e:
logger.error(f"Error processing directory {directory}: {e}")
continue
# Check for problematic symbolic links if requested
all_problematic_symlinks = {}
if args.check_symlinks:
logger.info("Checking for problematic symbolic links...")
for directory in args.directories:
try:
symlinks = find_symbolic_links(directory, logger)
if symlinks:
problematic_symlinks = detect_problematic_symlinks(
symlinks, git_root_dir, logger
)
# Only include categories that have issues
for category, issues in problematic_symlinks.items():
if issues:
if category not in all_problematic_symlinks:
all_problematic_symlinks[category] = []
all_problematic_symlinks[category].extend(issues)
except Exception as e:
logger.error(f"Error checking symlinks in directory {directory}: {e}")
continue
# Prepare the final report
total_problematic_symlinks = sum(
len(issues) for issues in all_problematic_symlinks.values()
)
report = {
"summary": {
"total_files_processed": total_files_processed,
"files_with_broken_links": len(all_broken_links),
"total_broken_links": sum(
len(links) for links in all_broken_links.values()
),
"total_problematic_symlinks": total_problematic_symlinks,
"symlink_check_enabled": args.check_symlinks,
},
"broken_links": all_broken_links,
"problematic_symlinks": all_problematic_symlinks,
"all_processed_files": sorted(all_processed_files),
}
logger.info(f"Report summary: {report['summary']}")
# Generate JSON output
logger.info("Generating JSON report")
# Create a cleaned version of the report without resolved_path for JSON output
cleaned_broken_links = {}
for file_path, links in report["broken_links"].items():
cleaned_links = []
for link in links:
# Create a copy of the link without resolved_path
cleaned_link = {k: v for k, v in link.items() if k != "resolved_path"}
cleaned_links.append(cleaned_link)
cleaned_broken_links[file_path] = cleaned_links
cleaned_report = {
"summary": report["summary"],
"broken_links": cleaned_broken_links,
"problematic_symlinks": report["problematic_symlinks"],
"all_processed_files": report["all_processed_files"],
}
output_content = json.dumps(cleaned_report, indent=2, ensure_ascii=False)
# Output the report
if args.output:
try:
logger.info(f"Writing report to: {args.output}")
with open(args.output, "w", encoding="utf-8") as f:
f.write(output_content)
logger.info(f"Report successfully written to: {args.output}")
print(f"Report written to: {args.output}", file=sys.stderr)
except Exception as e:
logger.error(f"Error writing to {args.output}: {e}")
print(f"Error writing to {args.output}: {e}", file=sys.stderr)
print(output_content)
else:
logger.info("Writing report to stdout")
print(output_content)
# Exit with error code if broken links or problematic symlinks were found
# Note: "suspicious" symlinks are warnings only and don't cause failure
has_broken_links = bool(all_broken_links)
# Only count critical symlink issues (broken, circular, external) as errors
critical_symlink_categories = ["broken", "circular", "external"]
critical_symlinks = {
category: issues
for category, issues in all_problematic_symlinks.items()
if category in critical_symlink_categories
}
has_critical_symlinks = bool(critical_symlinks)
total_critical_symlinks = sum(len(issues) for issues in critical_symlinks.values())
# Log suspicious symlinks separately as warnings
suspicious_symlinks = all_problematic_symlinks.get("suspicious", [])
if suspicious_symlinks:
logger.warning(
f"Found {len(suspicious_symlinks)} suspicious symlinks (warnings only, not causing failure)"
)
if has_broken_links or has_critical_symlinks:
error_msg = []
if has_broken_links:
error_msg.append(f"{len(all_broken_links)} files with broken links")
if has_critical_symlinks:
error_msg.append(f"{total_critical_symlinks} critical problematic symlinks")
logger.warning(f"Exiting with error code 1 due to: {', '.join(error_msg)}")
sys.exit(1)
else:
success_msg = "No broken links found"
if args.check_symlinks:
success_msg += " and no critical problematic symlinks found"
logger.info(f"{success_msg} - exiting successfully")
if __name__ == "__main__":
main()