66from __future__ import annotations
77
88import argparse
9+ import os
910import re
1011import subprocess
1112from pathlib import Path
@@ -61,8 +62,33 @@ def _markdown_anchors(markdown: Path) -> set[str]:
6162 return anchors
6263
6364
65+ def _path_case_status (root : Path , candidate : Path ) -> str :
66+ """Return whether every requested path component has exact disk casing."""
67+ try :
68+ relative = candidate .relative_to (root )
69+ except ValueError :
70+ return "outside"
71+
72+ current = root
73+ for component in relative .parts :
74+ try :
75+ entries = {entry .name : entry for entry in current .iterdir ()}
76+ except OSError :
77+ return "missing"
78+
79+ exact = entries .get (component )
80+ if exact is not None :
81+ current = exact
82+ continue
83+ if any (name .casefold () == component .casefold () for name in entries ):
84+ return "mismatch"
85+ return "missing"
86+ return "exact"
87+
88+
6489def find_broken_links (root : Path ) -> list [str ]:
6590 """Return human-readable errors for missing repository-local targets."""
91+ root = root .resolve ()
6692 errors : list [str ] = []
6793 for markdown in markdown_files (root ):
6894 text = markdown .read_text (encoding = "utf-8" )
@@ -87,22 +113,31 @@ def find_broken_links(root: Path) -> list[str]:
87113
88114 relative = unquote (parsed .path )
89115 if not relative :
90- target = markdown . resolve ()
116+ candidate = markdown
91117 else :
92- target = (
118+ candidate = (
93119 root / relative .lstrip ("/" )
94120 if relative .startswith ("/" )
95121 else markdown .parent / relative
96- ).resolve ()
122+ )
123+ lexical_target = Path (os .path .normpath (str (candidate )))
124+ target = lexical_target .resolve ()
97125 try :
98- target .relative_to (root . resolve () )
126+ target .relative_to (root )
99127 except ValueError :
100128 errors .append (
101129 f"{ markdown .relative_to (root )} : link escapes repository: "
102130 f"{ destination } "
103131 )
104132 continue
105- if not target .exists ():
133+ case_status = _path_case_status (root , lexical_target )
134+ if case_status == "mismatch" :
135+ errors .append (
136+ f"{ markdown .relative_to (root )} : target case mismatch: "
137+ f"{ destination } "
138+ )
139+ continue
140+ if case_status == "missing" or not target .exists ():
106141 errors .append (
107142 f"{ markdown .relative_to (root )} : missing target: { destination } "
108143 )
0 commit comments