ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
markdown_references.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Validate first-party Markdown links, anchors, and repository paths.
4
5Every tracked Markdown file is inventoried. Local links and fragments must
6resolve, as must repository paths in prose, code spans, and fenced examples.
7Only proven-balanced inline-link destinations are masked, and HTML wrapper
8separators cannot merge adjacent path claims. Parsed link destinations have one
9structural owner. Generated, ignored, and placeholder paths require a current
10owner. Historical changelog prose and vendored Markdown are not interpreted,
11though links in the changelog and repository-authored vendor indexes remain
12checked.
13"""
14
15from __future__ import annotations
16
17import fnmatch
18import functools
19import hashlib
20import re
21import subprocess
22import sys
23import urllib.parse
24from collections import Counter
25from collections.abc import Iterable
26from dataclasses import dataclass
27from pathlib import Path
28from typing import NoReturn
29
30sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
31
32from git_environment import trusted_git_executable
33from line_citation_lex import CITES_OK_RE
34from markdown_link_lex import inline_link_targets, mask_inline_link_targets, split_link_destination
35from markdown_reference_policy import (
36 ATX_HEADING_RE,
37 AUTHORED_VENDOR_INDEXES,
38 BARE_CODE_FILE_RE,
39 BARE_MARKDOWN_PATTERN,
40 COMPONENT_RELATIVE_PREFIXES,
41 DECLARED_BARE_CODE_FILES,
42 DECLARED_BARE_CONTEXT_SHA256,
43 EXPLICIT_ANCHOR_RE,
44 FENCE_RE,
45 HTML_PATH_SEPARATOR_RE,
46 HTML_TARGET_RE,
47 LIBWEBP_ABSENCE_CLAUSE,
48 LINE_CITATION_RE,
49 LOCAL_LINE_FRAGMENT_RE,
50 MIN_FIRST_PARTY_MARKDOWN,
51 MIN_LINK_REFERENCES,
52 MIN_PATH_REFERENCES,
53 MIN_TRACKED_MARKDOWN,
54 MIN_VENDOR_MARKDOWN,
55 PATH_RE,
56 QUALIFICATION_RELEASE_SOURCES,
57 REFERENCE_DEF_RE,
58 REFERENCE_USE_RE,
59 REMOTE_SCHEMES,
60 ROOT_FILE_PATTERN,
61 SETEXT_HEADING_RE,
62 SHORTCUT_PATH_REFERENCE_RE,
63 SOUP_DECLARED_ABSENCES,
64 SOUP_LOCAL_PATH_RE,
65 SYMBOL_SUFFIX_RE,
66 SYSTEM_HEADER_BASENAMES,
67 TOOL_PRIVATE_CLAUSE_PATTERNS,
68 TOOL_PRIVATE_OWNERSHIP_INDEXES,
69 TOOL_PRIVATE_VENDOR_SOURCES,
70 TRAILING_PATH_JUNK,
71 VENDOR_PREFIXES,
72 WORK_FIXTURE_PATH,
73)
74
75REPO_ROOT = Path(__file__).resolve().parents[2]
76
77
78@dataclass(frozen=True)
79class LinkRef:
80 """One structurally parsed Markdown or HTML target."""
81
82 line: int
83 target: str
84
85
86@dataclass(frozen=True)
87class PathRef:
88 """One repository path extracted from a code span or fenced example."""
89
90 line: int
91 column: int
92 token: str
93 source_line: str
94
95
96@dataclass(frozen=True)
97class AnchorRef:
98 """One generated heading id or explicit HTML anchor."""
99
100 line: int
101 value: str
102
103
104@dataclass(frozen=True)
105class Finding:
106 """One stale Markdown reference."""
107
108 kind: str
109 path: str
110 line: int
111 value: str
112 detail: str
113
114 def render(self) -> str:
115 """Render an editor-jumpable diagnostic."""
116 return f"{self.path}:{self.line}: {self.kind}: {self.value!r} -- {self.detail}"
117
118
119@dataclass(frozen=True)
120class Document:
121 """Parsed Markdown structures needed by the checker."""
122
123 anchors: frozenset[str]
124 anchor_collisions: tuple[AnchorRef, ...]
125 missing_references: tuple[LinkRef, ...]
126 links: tuple[LinkRef, ...]
127 paths: tuple[PathRef, ...]
128
129
130@dataclass
131class StructureSink:
132 """Mutable collections populated while one Markdown document is parsed."""
133
134 links: list[LinkRef]
135 explicit_anchors: list[AnchorRef]
136 heading_bases: list[AnchorRef]
137 reference_definitions: dict[str, LinkRef]
138 reference_uses: list[LinkRef]
139
140
141class CheckError(RuntimeError):
142 """The checker could not establish a trustworthy result."""
143
144
145def _fail(message: str) -> NoReturn:
146 raise CheckError(message)
147
148
149def _git(root: Path, *args: str, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
150 git_bin = trusted_git_executable()
151 return subprocess.run( # noqa: S603 -- fixed executable and caller-owned argv
152 [git_bin, *args],
153 cwd=root,
154 input=input_text,
155 capture_output=True,
156 text=True,
157 check=False,
158 )
159
160
161def _git_files(root: Path, *, include_untracked: bool) -> tuple[list[str], list[str]]:
162 """Return Markdown scope and the tracked-only population."""
163 tracked_proc = _git(root, "ls-files", "-z")
164 if tracked_proc.returncode != 0:
165 _fail(tracked_proc.stderr.strip() or "git ls-files failed")
166 tracked = sorted(
167 path
168 for path in tracked_proc.stdout.split("\0")
169 if path and Path(path).suffix.lower() == ".md" and (root / path).is_file()
170 )
171 if not include_untracked:
172 return tracked, tracked
173 all_proc = _git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
174 if all_proc.returncode != 0:
175 _fail(all_proc.stderr.strip() or "git ls-files including untracked failed")
176 paths = sorted(
177 path
178 for path in all_proc.stdout.split("\0")
179 if path and Path(path).suffix.lower() == ".md" and (root / path).is_file()
180 )
181 return paths, tracked
182
183
184def _is_vendor(path: str) -> bool:
185 return path not in AUTHORED_VENDOR_INDEXES and path.startswith(VENDOR_PREFIXES)
186
187
188def _code_spans(line: str) -> tuple[list[tuple[int, str]], str]:
189 """Extract same-run backtick spans and blank them in the visible text."""
190 spans: list[tuple[int, str]] = []
191 visible = list(line)
192 cursor = 0
193 while cursor < len(line):
194 if line[cursor] != "`":
195 cursor += 1
196 continue
197 end_run = cursor
198 while end_run < len(line) and line[end_run] == "`":
199 end_run += 1
200 marker = line[cursor:end_run]
201 close = line.find(marker, end_run)
202 if close < 0:
203 cursor = end_run
204 continue
205 content = line[end_run:close]
206 spans.append((end_run, content))
207 visible[cursor : close + len(marker)] = " " * (close + len(marker) - cursor)
208 cursor = close + len(marker)
209 return spans, "".join(visible)
210
211
212def _prose_without_link_targets(visible: str) -> str:
213 """Remove destinations owned by the link checker from ordinary prose."""
214 masked = list(mask_inline_link_targets(visible))
215 definition = REFERENCE_DEF_RE.match(visible)
216 if definition is not None:
217 masked[definition.start(2) : definition.end(2)] = " " * len(definition.group(2))
218 for match in HTML_TARGET_RE.finditer(visible):
219 masked[match.start(2) : match.end(2)] = " " * len(match.group(2))
220 return "".join(masked)
221
222
223def _reference_label(raw: str) -> str:
224 """Normalize a reference-link label using Markdown's case/space rules."""
225 return " ".join(raw.split()).casefold()
226
227
228def _slug(text: str) -> str:
229 """Return GitHub's practical heading-id form for this ASCII-first tree."""
230 text = re.sub(r"<[^>]*>", "", text)
231 text = re.sub(r"[`*_~]", "", text).strip().lower()
232 text = re.sub(r"[^\w\- ]", "", text)
233 return re.sub(r"[ \t]+", "-", text)
234
235
236def _path_refs(
237 body: str,
238 line: int,
239 source_line: str,
240 column_offset: int = 0,
241 *,
242 include_bare_code_files: bool = False,
243) -> list[PathRef]:
244 """Extract path tokens without letting HTML separators join neighbors."""
245 refs: list[PathRef] = []
246 boundaries = (0, *(match.end() for match in HTML_PATH_SEPARATOR_RE.finditer(body)))
247 endings = (*(match.start() for match in HTML_PATH_SEPARATOR_RE.finditer(body)), len(body))
248 for start, end in zip(boundaries, endings, strict=True):
249 span = body[start:end]
250 for match in PATH_RE.finditer(span):
251 token = match.group(1).rstrip(TRAILING_PATH_JUNK)
252 if token:
253 refs.append(
254 PathRef(line, column_offset + start + match.start(1), token, source_line)
255 )
256 if include_bare_code_files:
257 refs.extend(
258 PathRef(
259 line,
260 column_offset + start + match.start(1),
261 match.group(1),
262 source_line,
263 )
264 for match in BARE_CODE_FILE_RE.finditer(span)
265 )
266 return sorted(refs, key=lambda ref: ref.column)
267
268
269def _visible_structures(
270 visible: str,
271 line_no: int,
272 previous_visible: str,
273 sink: StructureSink,
274) -> None:
275 """Collect structures from one non-code Markdown line."""
276 sink.links.extend(LinkRef(line_no, target) for target in inline_link_targets(visible) if target)
277 definition = REFERENCE_DEF_RE.match(visible)
278 if definition is not None:
279 target = split_link_destination(definition.group(2))
280 if target:
281 sink.links.append(LinkRef(line_no, target))
282 sink.reference_definitions[_reference_label(definition.group(1))] = LinkRef(
283 line_no, target
284 )
285 sink.reference_uses.extend(
286 LinkRef(line_no, _reference_label(match.group(2) or match.group(1)))
287 for match in REFERENCE_USE_RE.finditer(visible)
288 )
289 sink.reference_uses.extend(
290 LinkRef(line_no, _reference_label(match.group(1)))
291 for match in SHORTCUT_PATH_REFERENCE_RE.finditer(visible)
292 if REFERENCE_DEF_RE.match(visible) is None
293 )
294 sink.links.extend(
295 LinkRef(line_no, match.group(2)) for match in HTML_TARGET_RE.finditer(visible)
296 )
297 sink.explicit_anchors.extend(
298 AnchorRef(line_no, match.group(2)) for match in EXPLICIT_ANCHOR_RE.finditer(visible)
299 )
300
301 heading = ATX_HEADING_RE.match(visible)
302 if heading is not None:
303 sink.heading_bases.append(AnchorRef(line_no, _slug(heading.group(2))))
304 elif SETEXT_HEADING_RE.match(visible) and previous_visible.strip():
305 sink.heading_bases.append(AnchorRef(line_no - 1, _slug(previous_visible.strip())))
306
307
308def _deduplicated_anchors(
309 bases: Iterable[AnchorRef], explicit: Iterable[AnchorRef]
310) -> tuple[frozenset[str], tuple[AnchorRef, ...]]:
311 """Apply GitHub heading suffixes and reject duplicate document ids."""
312 seen: Counter[str] = Counter()
313 generated: list[AnchorRef] = []
314 for ref in bases:
315 occurrence = seen[ref.value]
316 seen[ref.value] += 1
317 value = ref.value if occurrence == 0 else f"{ref.value}-{occurrence}"
318 generated.append(AnchorRef(ref.line, value))
319 anchors: set[str] = set()
320 collisions: list[AnchorRef] = []
321 for ref in sorted((*explicit, *generated), key=lambda item: item.line):
322 if ref.value in anchors:
323 collisions.append(ref)
324 anchors.add(ref.value)
325 return frozenset(anchors), tuple(collisions)
326
327
328def parse_document(text: str) -> Document:
329 """Parse links, headings, code spans, and fenced examples from Markdown."""
330 paths: list[PathRef] = []
331 sink = StructureSink([], [], [], {}, [])
332 in_fence = False
333 fence_char = ""
334 fence_len = 0
335 previous_visible = ""
336 lines = text.splitlines()
337 for line_no, line in enumerate(lines, 1):
338 fence_match = FENCE_RE.match(line)
339 if fence_match is not None:
340 marker = fence_match.group(1)
341 if not in_fence:
342 in_fence = True
343 fence_char = marker[0]
344 fence_len = len(marker)
345 elif marker[0] == fence_char and len(marker) >= fence_len:
346 in_fence = False
347 previous_visible = ""
348 continue
349 if in_fence:
350 paths.extend(_path_refs(line, line_no, line, include_bare_code_files=True))
351 continue
352
353 spans, visible = _code_spans(line)
354 source_context = line
355 if line_no < len(lines) and lines[line_no].strip():
356 source_context += " " + lines[line_no].lstrip()
357 for column, content in spans:
358 paths.extend(
359 _path_refs(
360 content,
361 line_no,
362 source_context,
363 column,
364 include_bare_code_files=True,
365 )
366 )
367 paths.extend(_path_refs(_prose_without_link_targets(visible), line_no, source_context))
368
369 _visible_structures(visible, line_no, previous_visible, sink)
370 previous_visible = visible
371
372 anchors, collisions = _deduplicated_anchors(sink.heading_bases, sink.explicit_anchors)
373 missing = tuple(
374 use for use in sink.reference_uses if use.target not in sink.reference_definitions
375 )
376 return Document(anchors, collisions, missing, tuple(sink.links), tuple(paths))
377
378
379def _resolve_link(root: Path, source: str, raw: str) -> tuple[Path | None, str, str]:
380 """Return a local target, fragment, and classification."""
381 parsed = urllib.parse.urlsplit(raw)
382 if parsed.scheme.lower() in REMOTE_SCHEMES or raw.startswith("//"):
383 return None, "", "remote"
384 path_text = urllib.parse.unquote(parsed.path)
385 fragment = urllib.parse.unquote(parsed.fragment)
386 source_path = root / source
387 if raw.startswith("/"):
388 target = (root / path_text.lstrip("/")).resolve()
389 else:
390 target = source_path if not path_text else (source_path.parent / path_text).resolve()
391 return target, fragment, "local"
392
393
394PLACEHOLDER_RE = re.compile(
395 r"(?:<[a-z][a-z0-9_-]*>|\$\{[A-Z][A-Z0-9_]*}|\{[a-z][a-z0-9_]*}|\.\.\.)"
396)
397
398
399def _is_well_formed_dynamic_segment(segment: str) -> bool:
400 """Accept only named, exact placeholder grammars inside a path segment."""
401 replaced, count = PLACEHOLDER_RE.subn("value", segment)
402 return count > 0 and not any(char in replaced for char in "*?{}$<>")
403
404
405def _has_only_supported_dynamic_syntax(token: str) -> bool:
406 """Reject unnamed interpolation while permitting checked glob syntax."""
407 remaining = PLACEHOLDER_RE.sub("", token)
408 remaining = re.sub(r"\{[A-Za-z0-9_./-]*(?:,[A-Za-z0-9_./-]*)+}", "", remaining)
409 remaining = remaining.replace("*", "").replace("?", "")
410 return not any(char in remaining for char in "{}$<>")
411
412
413def _dynamic_glob(token: str) -> str:
414 """Map exact named placeholders to an equivalent filesystem glob."""
415 return PLACEHOLDER_RE.sub(lambda match: "**" if match.group(0) == "..." else "*", token)
416
417
418def _before_build_output(token: str) -> str | None:
419 """Return the owner prefix of a generated build path, if present."""
420 segments = token.split("/")
421 for index, segment in enumerate(segments):
422 if segment == "build" or re.fullmatch(r"build(?:[-*?].*)", segment):
423 return "/".join(segments[:index])
424 return None
425
426
427def _build_owner_exists(base: Path, token: str) -> bool:
428 """Require the nearest static/dynamic owner before a build directory."""
429 segments = token.rstrip("/").split("/")
430 build_index = next(
431 (index for index, segment in enumerate(segments) if segment.startswith("build")),
432 None,
433 )
434 if build_index is None:
435 return False
436 owner_token = "/".join(segments[:build_index])
437 if not owner_token:
438 return (base / ".git").exists()
439 owner = base / owner_token
440 if owner.is_dir():
441 return True
442 if not _has_only_supported_dynamic_syntax(owner_token):
443 return False
444 return _glob_matches(base, _dynamic_glob(owner_token))
445
446
447def _brace_expansions(pattern: str) -> tuple[str, ...]:
448 """Expand comma braces without invoking a shell."""
449 match = re.search(r"\{([^{}]*,[^{}]*)}", pattern)
450 if match is None:
451 return (pattern,)
452 expanded: list[str] = []
453 for choice in match.group(1).split(","):
454 candidate = pattern[: match.start()] + choice + pattern[match.end() :]
455 expanded.extend(_brace_expansions(candidate))
456 return tuple(expanded)
457
458
459def _glob_matches(base: Path, token: str) -> bool:
460 """Require a glob or brace pattern to select at least one current path."""
461 brace_glob = re.search(r"\{[^{}]*,[^{}]*}", token) is not None
462 if not any(char in token for char in "*?") and not brace_glob:
463 return False
464 for pattern in _brace_expansions(token.rstrip("/")):
465 try:
466 if next(base.glob(pattern), None) is not None:
467 return True
468 except (OSError, ValueError):
469 return False
470 return False
471
472
473def _ignore_owner_exists(root: Path, rel: str) -> bool:
474 """Accept ignored local state only while the ignore rule's owner still exists."""
475 proc = _git(root, "check-ignore", "--no-index", "--verbose", "--", rel)
476 if proc.returncode != 0 or "\t" not in proc.stdout:
477 return False
478 rule, _matched = proc.stdout.rstrip("\n").split("\t", 1)
479 source, _line, pattern = rule.rsplit(":", 2)
480 pattern = pattern.lstrip("!")
481 if "/" not in pattern.rstrip("/"):
482 return False
483 if any(segment.startswith("build") for segment in rel.split("/")):
484 return _build_owner_exists(root, rel)
485 base = (root / source).parent
486 pattern_path = pattern.lstrip("/")
487 static = re.split(r"[*?\‍[]", pattern_path, maxsplit=1)[0].rstrip("/")
488 if pattern.endswith("/"):
489 owner = (base / static).parent
490 elif any(char in pattern for char in "*?["):
491 owner = base / static
492 else:
493 owner = (base / pattern_path).parent
494 return owner.is_dir()
495
496
497def _declared_absence(source: str, ref: PathRef) -> bool:
498 """Recognize the two exact policy grammars that name intentionally absent files."""
499 if source != "CLAUDE.md":
500 return False
501 policy_placeholder = re.fullmatch(r"docs/SOMETHING_(?:TODO|ROADMAP|TICKET)\.md", ref.token)
502 return (
503 f"former `{ref.token}`" in ref.source_line
504 or f"`{ref.token}` must remain absent" in ref.source_line
505 or policy_placeholder is not None
506 )
507
508
509def _declared_work_fixture(source: str, ref: PathRef) -> bool:
510 """Recognize the exact intentionally path-shaped workflow-key fixture."""
511 return (
512 source == "scripts/dev/work/tests/fixtures/bad_key.md"
513 and ref.token == WORK_FIXTURE_PATH
514 and "A key that looks like a path" in ref.source_line
515 )
516
517
518def _declared_planned_path(root: Path, source: str, ref: PathRef) -> bool:
519 """Recognize exact future namespaces with a committed policy authority."""
520 release = re.fullmatch(r"docs/qualification/release/<tag>/(?:conformance\.md)?", ref.token)
521 if (
522 source in QUALIFICATION_RELEASE_SOURCES
523 and release is not None
524 and (root / "docs/qualification/release/README.md").is_file()
525 ):
526 return True
527 tool_private = re.fullmatch(r"tools/<tool>/third_party/<(?:component|dep)>", ref.token)
528 clause_template = TOOL_PRIVATE_CLAUSE_PATTERNS.get(source)
529 clause_match = (
530 None
531 if clause_template is None
532 else re.search(clause_template.format(token=re.escape(ref.token)), ref.source_line)
533 )
534 policy_text = (root / source).read_text(encoding="utf-8", errors="replace")
535 excludes_current = re.search(
536 r"No (?:current dependency|dependency currently) qualifies", policy_text
537 )
538 current_exclusion_holds = (
539 source not in TOOL_PRIVATE_OWNERSHIP_INDEXES or excludes_current is not None
540 )
541 return (
542 source in TOOL_PRIVATE_VENDOR_SOURCES
543 and tool_private is not None
544 and clause_match is not None
545 and current_exclusion_holds
546 )
547
548
549def _declared_soup_absence(source: str, ref: PathRef) -> bool:
550 """Recognize only exact reviewed SOUP paths in a bounded negative claim."""
551 return (
552 ref.token in SOUP_DECLARED_ABSENCES.get(source, frozenset())
553 and source == "docs/SOUP/libwebp.md"
554 and ref.source_line == LIBWEBP_ABSENCE_CLAUSE
555 )
556
557
558def _generated_owner_exists(base: Path, token: str) -> bool:
559 """Check the committed authority for a build output or named placeholder."""
560 if _before_build_output(token) is not None and _build_owner_exists(base, token):
561 return True
562 if _glob_matches(base, token):
563 return True
564 return _has_only_supported_dynamic_syntax(token) and _glob_matches(base, _dynamic_glob(token))
565
566
567@functools.lru_cache(maxsize=1024)
568def _component_root(root: Path, source: str) -> Path | None:
569 """Return the closest enclosing CMake component for one document."""
570 current = (root / source).parent
571 resolved_root = root.resolve()
572 while current.resolve() != resolved_root:
573 if (current / "CMakeLists.txt").is_file():
574 return current
575 current = current.parent
576 return None
577
578
579@functools.lru_cache(maxsize=8)
580def _tracked_basename_index(root_text: str) -> dict[str, tuple[str, ...]]:
581 """Index tracked paths by basename for bare code-file references."""
582 root = Path(root_text)
583 proc = _git(root, "ls-files", "-z")
584 if proc.returncode != 0:
585 _fail(proc.stderr.strip() or "git ls-files failed")
586 index: dict[str, list[str]] = {}
587 for path in proc.stdout.split("\0"):
588 if path:
589 index.setdefault(Path(path).name, []).append(path)
590 return {name: tuple(paths) for name, paths in index.items()}
591
592
593def _bare_code_file_exists(root: Path, source: str, token: str) -> bool:
594 """Resolve a bare code filename in its component, then the tracked tree."""
595 if not _has_only_supported_dynamic_syntax(token):
596 return False
597 patterns = _brace_expansions(_dynamic_glob(token))
598 index = _tracked_basename_index(str(root.resolve()))
599 names = tuple(
600 name for name in index if any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns)
601 )
602 if not names:
603 return False
604 component = _component_root(root, source)
605 if component is not None:
606 component_prefix = component.relative_to(root).as_posix().rstrip("/") + "/"
607 if any(path.startswith(component_prefix) for name in names for path in index.get(name, ())):
608 return True
609 return any(name in index for name in names)
610
611
612def _declared_bare_code_file(source: str, ref: PathRef) -> str | None:
613 """Return the reason an exact absent bare filename is intentionally cited."""
614 if ref.token in SYSTEM_HEADER_BASENAMES and f"<{ref.token}>" in ref.source_line:
615 return "toolchain-provided system header"
616 key = (source, ref.token)
617 reason = DECLARED_BARE_CODE_FILES.get(key)
618 contexts = DECLARED_BARE_CONTEXT_SHA256.get(key, ())
619 if reason is None or _context_sha256(ref.source_line) not in contexts:
620 return None
621 return reason
622
623
624def _context_sha256(source_line: str) -> str:
625 """Hash normalized full source context for an exact absence declaration."""
626 normalized = " ".join(source_line.split()).encode("utf-8")
627 return hashlib.sha256(normalized).hexdigest()
628
629
630def _soup_local_root(root: Path, source: str) -> Path | None:
631 """Read a SOUP document's explicit, checked local-vendor authority."""
632 if not source.startswith("docs/SOUP/"):
633 return None
634 match = SOUP_LOCAL_PATH_RE.search((root / source).read_text(encoding="utf-8", errors="replace"))
635 if match is None:
636 return None
637 rel = match.group(1).rstrip("/")
638 if not rel.startswith(VENDOR_PREFIXES):
639 return None
640 candidate = (root / rel).resolve()
641 return candidate if candidate.is_dir() else None
642
643
644def _normalized_path_token(ref: PathRef) -> tuple[str, bool]:
645 """Strip citation/symbol syntax and report whether a line citation existed."""
646 token = ref.token
647 had_line_citation = LINE_CITATION_RE.search(token) is not None
648 token = LINE_CITATION_RE.sub("", token)
649 token = SYMBOL_SUFFIX_RE.sub("", token)
650 token = token.partition("@")[0]
651 while token.startswith("./"):
652 token = token[2:]
653 return token, had_line_citation
654
655
656def _reference_is_declared(root: Path, source: str, ref: PathRef, had_line_citation: bool) -> bool:
657 """Return whether an exact policy declaration owns this absent reference."""
658 return (
659 (had_line_citation and CITES_OK_RE.search(ref.source_line) is not None)
660 or source == "CHANGELOG.md"
661 or _declared_absence(source, ref)
662 or _declared_work_fixture(source, ref)
663 or _declared_planned_path(root, source, ref)
664 )
665
666
667def _path_claimed(base: Path, token: str) -> bool:
668 """Return whether one authority owns an in-bounds exact or generated path."""
669 target = (base / token.rstrip("/")).resolve()
670 try:
671 target.relative_to(base.resolve())
672 except ValueError:
673 return False
674 return target.exists() or _generated_owner_exists(base, token)
675
676
677def _base_for_path(
678 root: Path, source: str, token: str, soup_root: Path | None
679) -> tuple[Path, str | None]:
680 """Select one path authority, rejecting traversal and ambiguous ownership."""
681 error = None
682 if "/" not in token and re.fullmatch(BARE_MARKDOWN_PATTERN, token):
683 base = (root / source).parent
684 elif token.startswith("tests/"):
685 local = soup_root or _component_root(root, source)
686 base = local or root
687 if ".." in Path(token).parts:
688 error = "component-relative path contains traversal"
689 else:
690 bases = tuple(item for item in (root, local) if item is not None)
691 claimed = tuple(item for item in bases if _path_claimed(item, token))
692 if len({item.resolve() for item in claimed}) > 1:
693 owners = ", ".join(item.relative_to(root).as_posix() or "." for item in claimed)
694 error = f"is ambiguous between path authorities: {owners}"
695 elif claimed:
696 base = claimed[0]
697 elif token.startswith(COMPONENT_RELATIVE_PREFIXES):
698 base = soup_root or _component_root(root, source) or (root / source).parent
699 elif token.startswith("../"):
700 base = (root / source).parent
701 else:
702 base = root
703 return base, error
704
705
706def _root_or_soup_file_exists(root: Path, source: str, token: str, soup_root: Path | None) -> bool:
707 """Resolve exact root-authority tokens against sibling/SOUP locations."""
708 if "/" in token or re.fullmatch(ROOT_FILE_PATTERN, token) is None:
709 return False
710 sibling = (root / source).parent / token
711 soup_file = soup_root / token if soup_root is not None else None
712 return sibling.is_file() or (soup_file is not None and soup_file.is_file())
713
714
715def _ordinary_path_reason(root: Path, source: str, ref: PathRef, token: str) -> str | None:
716 """Resolve a non-bare repository path and explain a missing target."""
717 soup_root = _soup_local_root(root, source)
718 if _root_or_soup_file_exists(root, source, token, soup_root):
719 return None
720 if any(char in token for char in "*?{}$<>") and not _has_only_supported_dynamic_syntax(token):
721 reason = "contains unsupported dynamic syntax"
722 else:
723 base, reason = _base_for_path(root, source, token, soup_root)
724 if reason is None:
725 target = (base / token.rstrip("/")).resolve()
726 try:
727 rel = target.relative_to(root.resolve()).as_posix()
728 if token.startswith(COMPONENT_RELATIVE_PREFIXES):
729 target.relative_to(base.resolve())
730 except ValueError:
731 reason = "resolves outside its path authority"
732 else:
733 exists = (
734 target.exists()
735 or _generated_owner_exists(base, token)
736 or _declared_soup_absence(source, ref)
737 )
738 ignore_probe = f"{rel}/.ra8-markdown-reference" if ref.token.endswith("/") else rel
739 if not exists and not _ignore_owner_exists(root, ignore_probe):
740 reason = f"resolves to {rel}, which does not exist"
741 return reason
742
743
744def _path_reason(root: Path, source: str, ref: PathRef) -> str | None:
745 """Return why a literal code path is stale, or ``None`` when it is sound."""
746 token, had_line_citation = _normalized_path_token(ref)
747 if _reference_is_declared(root, source, ref, had_line_citation):
748 return None
749 if had_line_citation:
750 return "uses a rot-prone line-number citation; cite a symbol instead"
751
752 if BARE_CODE_FILE_RE.fullmatch(token):
753 if _bare_code_file_exists(root, source, token) or _declared_bare_code_file(source, ref):
754 return None
755 return f"no tracked file has a basename matching {token}"
756 return _ordinary_path_reason(root, source, ref, token)
757
758
759def _bare_declaration_findings(
760 root: Path,
761 parsed: dict[str, Document],
762 declarations: dict[tuple[str, str], str] | None = None,
763 contexts: dict[tuple[str, str], tuple[str, ...]] | None = None,
764) -> list[Finding]:
765 """Reject declarations whose source/token/reason binding became stale."""
766 declarations = DECLARED_BARE_CODE_FILES if declarations is None else declarations
767 contexts = DECLARED_BARE_CONTEXT_SHA256 if contexts is None else contexts
768 findings: list[Finding] = []
769 for source, token in sorted(declarations.keys() | contexts.keys()):
770 reason = declarations.get((source, token), "")
771 expected_contexts = contexts.get((source, token), ())
772 document = parsed.get(source)
773 extracted = (
774 () if document is None else tuple(ref for ref in document.paths if ref.token == token)
775 )
776 observed_contexts = tuple(sorted(_context_sha256(ref.source_line) for ref in extracted))
777 detail = ""
778 if (source, token) not in declarations:
779 detail = "semantic contexts have no declaration"
780 elif not reason.strip():
781 detail = "declaration reason is empty"
782 elif not extracted:
783 detail = "source no longer extracts this exact bare token"
784 elif not expected_contexts:
785 detail = "declaration has no semantic context binding"
786 elif observed_contexts != expected_contexts:
787 detail = "normalized semantic context binding drifted"
788 elif _bare_code_file_exists(root, source, token):
789 detail = "a tracked basename now exists; remove the absence declaration"
790 if detail:
791 findings.append(Finding("stale-bare-declaration", source, 1, token, detail))
792 return findings
793
794
795def _inventory(root: Path, tracked: Iterable[str]) -> list[dict[str, object]]:
796 """Return a deterministic byte inventory of every tracked Markdown file."""
797 rows: list[dict[str, object]] = []
798 for path in tracked:
799 data = (root / path).read_bytes()
800 rows.append(
801 {
802 "path": path,
803 "classification": "vendored" if _is_vendor(path) else "first_party",
804 "sha256": hashlib.sha256(data).hexdigest(),
805 "bytes": len(data),
806 "lines": len(data.splitlines()),
807 }
808 )
809 return rows
810
811
812def _enforce_population(tracked: list[str]) -> tuple[list[str], list[str]]:
813 """Fail closed if any tracked Markdown population collapses."""
814 first_party = [path for path in tracked if not _is_vendor(path)]
815 vendored = [path for path in tracked if _is_vendor(path)]
816 populations = (
817 ("tracked Markdown", len(tracked), MIN_TRACKED_MARKDOWN),
818 ("first-party Markdown", len(first_party), MIN_FIRST_PARTY_MARKDOWN),
819 ("vendor Markdown", len(vendored), MIN_VENDOR_MARKDOWN),
820 )
821 for label, actual, minimum in populations:
822 if actual < minimum:
823 _fail(f"{label} census collapsed: {actual} < {minimum}")
824 return first_party, vendored
825
826
827def _link_finding(
828 root: Path, source: str, link: LinkRef, parsed: dict[str, Document]
829) -> Finding | None:
830 """Validate one structurally parsed local link."""
831 target, fragment, classification = _resolve_link(root, source, link.target)
832 if classification != "local" or target is None:
833 return None
834 try:
835 rel_target = target.relative_to(root.resolve()).as_posix()
836 except ValueError:
837 return Finding("link-outside-repo", source, link.line, link.target, str(target))
838 if not target.exists():
839 return Finding("missing-link-target", source, link.line, link.target, rel_target)
840 finding = None
841 if LOCAL_LINE_FRAGMENT_RE.fullmatch(fragment):
842 detail = "local line anchors rot when surrounding source moves; cite a symbol instead"
843 finding = Finding("rot-prone-line-link", source, link.line, link.target, detail)
844 elif fragment and target.is_file() and target.suffix.lower() == ".md":
845 target_doc = parsed.get(rel_target)
846 if target_doc is None:
847 target_doc = parse_document(target.read_text(encoding="utf-8", errors="replace"))
848 if fragment not in target_doc.anchors:
849 detail = f"{rel_target} has no anchor {fragment!r}"
850 finding = Finding("missing-link-anchor", source, link.line, link.target, detail)
851 return finding
852
853
854def _check_documents(root: Path, parsed: dict[str, Document]) -> tuple[list[Finding], int, int]:
855 """Validate all parsed references and return findings plus exact censuses."""
856 findings: list[Finding] = []
857 links = 0
858 paths = 0
859 for source, document in parsed.items():
860 findings.extend(
861 Finding(
862 "duplicate-anchor",
863 source,
864 collision.line,
865 collision.value,
866 "document id is already defined",
867 )
868 for collision in document.anchor_collisions
869 )
870 findings.extend(
871 Finding(
872 "missing-reference-definition",
873 source,
874 reference.line,
875 reference.target,
876 "reference-style link has no definition",
877 )
878 for reference in document.missing_references
879 )
880 for link in document.links:
881 links += 1
882 if finding := _link_finding(root, source, link, parsed):
883 findings.append(finding)
884 for path_ref in document.paths:
885 paths += 1
886 if reason := _path_reason(root, source, path_ref):
887 findings.append(
888 Finding("missing-code-path", source, path_ref.line, path_ref.token, reason)
889 )
890 return findings, links, paths
891
892
893def check_tree(
894 root: Path, *, enforce_census: bool = True, include_untracked: bool = True
895) -> tuple[list[Finding], dict[str, int], list[dict[str, object]]]:
896 """Check every first-party Markdown document and return evidence counts."""
897 scope, tracked = _git_files(root, include_untracked=include_untracked)
898 tracked_first = [path for path in tracked if not _is_vendor(path)]
899 tracked_vendor = [path for path in tracked if _is_vendor(path)]
900 if enforce_census:
901 tracked_first, tracked_vendor = _enforce_population(tracked)
902 authored = [path for path in scope if not _is_vendor(path)]
903 parsed = {
904 path: parse_document((root / path).read_text(encoding="utf-8", errors="replace"))
905 for path in authored
906 }
907 findings, link_count, path_count = _check_documents(root, parsed)
908 if enforce_census:
909 findings.extend(_bare_declaration_findings(root, parsed))
910 if enforce_census:
911 if link_count < MIN_LINK_REFERENCES:
912 _fail(f"link-reference census collapsed: {link_count} < {MIN_LINK_REFERENCES}")
913 if path_count < MIN_PATH_REFERENCES:
914 _fail(f"code-path census collapsed: {path_count} < {MIN_PATH_REFERENCES}")
915 counts = {
916 "tracked_markdown": len(tracked),
917 "tracked_first_party": len(tracked_first),
918 "tracked_vendored": len(tracked_vendor),
919 "scanned_first_party": len(authored),
920 "links": link_count,
921 "code_paths": path_count,
922 }
923 return findings, counts, _inventory(root, tracked)
924
925
926# Public selftest seam. Production uses ``check_tree``; the companion
927# both-direction test module uses these aliases without importing private APIs.
928bare_declaration_findings = _bare_declaration_findings
929context_sha256 = _context_sha256
930declared_bare_code_file = _declared_bare_code_file
931declared_work_fixture = _declared_work_fixture
932fail = _fail
933git = _git
934is_vendor = _is_vendor
935path_reason = _path_reason
936tracked_basename_index = _tracked_basename_index