Skip to content

Commit 4b22922

Browse files
committed
[green] Enforce portable documentation link casing
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 3103e8b commit 4b22922

2 files changed

Lines changed: 41 additions & 6 deletions

File tree

‎docs/decisions/0018-standard-micropython-neopixel.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ recorded by the existing footprint/build gates, and no new Flutter dependency.
103103

104104
## Related
105105

106-
- [Firmware requirements](../specifications/Firmware/specs.md)
106+
- [Firmware requirements](../specifications/firmware/specs.md)
107107
- [App requirements §4.10](../specifications/App/specs.md)
108108
- [App TDD §4.6](../specifications/App/TDD.md)
109109
- [Hardware support](../specifications/hardware.md)

‎tools/ci/docs_links.py‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import argparse
9+
import os
910
import re
1011
import subprocess
1112
from 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+
6489
def 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

Comments
 (0)