3"""Validate first-party Markdown links, anchors, and repository paths.
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
15from __future__
import annotations
24from collections
import Counter
25from collections.abc
import Iterable
26from dataclasses
import dataclass
27from pathlib
import Path
28from typing
import NoReturn
30sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
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 (
37 AUTHORED_VENDOR_INDEXES,
39 BARE_MARKDOWN_PATTERN,
40 COMPONENT_RELATIVE_PREFIXES,
41 DECLARED_BARE_CODE_FILES,
42 DECLARED_BARE_CONTEXT_SHA256,
45 HTML_PATH_SEPARATOR_RE,
47 LIBWEBP_ABSENCE_CLAUSE,
49 LOCAL_LINE_FRAGMENT_RE,
50 MIN_FIRST_PARTY_MARKDOWN,
56 QUALIFICATION_RELEASE_SOURCES,
62 SHORTCUT_PATH_REFERENCE_RE,
63 SOUP_DECLARED_ABSENCES,
66 SYSTEM_HEADER_BASENAMES,
67 TOOL_PRIVATE_CLAUSE_PATTERNS,
68 TOOL_PRIVATE_OWNERSHIP_INDEXES,
69 TOOL_PRIVATE_VENDOR_SOURCES,
75REPO_ROOT = Path(__file__).resolve().parents[2]
78@dataclass(frozen=True)
80 """One structurally parsed Markdown or HTML target."""
86@dataclass(frozen=True)
88 """One repository path extracted from a code span or fenced example."""
96@dataclass(frozen=True)
98 """One generated heading id or explicit HTML anchor."""
104@dataclass(frozen=True)
106 """One stale Markdown reference."""
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}"
119@dataclass(frozen=True)
121 """Parsed Markdown structures needed by the checker."""
123 anchors: frozenset[str]
124 anchor_collisions: tuple[AnchorRef, ...]
125 missing_references: tuple[LinkRef, ...]
126 links: tuple[LinkRef, ...]
127 paths: tuple[PathRef, ...]
132 """Mutable collections populated while one Markdown document is parsed."""
135 explicit_anchors: list[AnchorRef]
136 heading_bases: list[AnchorRef]
137 reference_definitions: dict[str, LinkRef]
138 reference_uses: list[LinkRef]
141class CheckError(RuntimeError):
142 """The checker could not establish a trustworthy result."""
145def _fail(message: str) -> NoReturn:
146 raise CheckError(message)
149def _git(root: Path, *args: str, input_text: str |
None =
None) -> subprocess.CompletedProcess[str]:
150 git_bin = trusted_git_executable()
151 return subprocess.run(
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")
168 for path
in tracked_proc.stdout.split(
"\0")
169 if path
and Path(path).suffix.lower() ==
".md" and (root / path).is_file()
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")
178 for path
in all_proc.stdout.split(
"\0")
179 if path
and Path(path).suffix.lower() ==
".md" and (root / path).is_file()
181 return paths, tracked
184def _is_vendor(path: str) -> bool:
185 return path
not in AUTHORED_VENDOR_INDEXES
and path.startswith(VENDOR_PREFIXES)
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]] = []
193 while cursor < len(line):
194 if line[cursor] !=
"`":
198 while end_run < len(line)
and line[end_run] ==
"`":
200 marker = line[cursor:end_run]
201 close = line.find(marker, end_run)
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)
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)
223def _reference_label(raw: str) -> str:
224 """Normalize a reference-link label using Markdown's case/space rules."""
225 return " ".join(raw.split()).casefold()
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)
240 column_offset: int = 0,
242 include_bare_code_files: bool =
False,
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)
254 PathRef(line, column_offset + start + match.start(1), token, source_line)
256 if include_bare_code_files:
260 column_offset + start + match.start(1),
264 for match
in BARE_CODE_FILE_RE.finditer(span)
266 return sorted(refs, key=
lambda ref: ref.column)
269def _visible_structures(
272 previous_visible: str,
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))
281 sink.links.append(LinkRef(line_no, target))
282 sink.reference_definitions[_reference_label(definition.group(1))] = LinkRef(
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)
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
295 LinkRef(line_no, match.group(2))
for match
in HTML_TARGET_RE.finditer(visible)
297 sink.explicit_anchors.extend(
298 AnchorRef(line_no, match.group(2))
for match
in EXPLICIT_ANCHOR_RE.finditer(visible)
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())))
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] = []
315 occurrence = seen[ref.value]
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)
328def parse_document(text: str) -> Document:
329 """Parse links, headings, code spans, and fenced examples from Markdown."""
330 paths: list[PathRef] = []
331 sink = StructureSink([], [], [], {}, [])
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)
343 fence_char = marker[0]
344 fence_len = len(marker)
345 elif marker[0] == fence_char
and len(marker) >= fence_len:
347 previous_visible =
""
350 paths.extend(_path_refs(line, line_no, line, include_bare_code_files=
True))
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:
364 include_bare_code_files=
True,
367 paths.extend(_path_refs(_prose_without_link_targets(visible), line_no, source_context))
369 _visible_structures(visible, line_no, previous_visible, sink)
370 previous_visible = visible
372 anchors, collisions = _deduplicated_anchors(sink.heading_bases, sink.explicit_anchors)
374 use
for use
in sink.reference_uses
if use.target
not in sink.reference_definitions
376 return Document(anchors, collisions, missing, tuple(sink.links), tuple(paths))
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()
390 target = source_path
if not path_text
else (source_path.parent / path_text).resolve()
391 return target, fragment,
"local"
394PLACEHOLDER_RE = re.compile(
395 r"(?:<[a-z][a-z0-9_-]*>|\$\{[A-Z][A-Z0-9_]*}|\{[a-z][a-z0-9_]*}|\.\.\.)"
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 "*?{}$<>")
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 "{}$<>")
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)
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])
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(
"/")
431 (index
for index, segment
in enumerate(segments)
if segment.startswith(
"build")),
434 if build_index
is None:
436 owner_token =
"/".join(segments[:build_index])
438 return (base /
".git").exists()
439 owner = base / owner_token
442 if not _has_only_supported_dynamic_syntax(owner_token):
444 return _glob_matches(base, _dynamic_glob(owner_token))
447def _brace_expansions(pattern: str) -> tuple[str, ...]:
448 """Expand comma braces without invoking a shell."""
449 match = re.search(
r"\{([^{}]*,[^{}]*)}", 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)
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:
464 for pattern
in _brace_expansions(token.rstrip(
"/")):
466 if next(base.glob(pattern),
None)
is not None:
468 except (OSError, ValueError):
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:
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(
"/"):
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
493 owner = (base / pattern_path).parent
494 return owner.is_dir()
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":
501 policy_placeholder = re.fullmatch(
r"docs/SOMETHING_(?:TODO|ROADMAP|TICKET)\.md", ref.token)
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
509def _declared_work_fixture(source: str, ref: PathRef) -> bool:
510 """Recognize the exact intentionally path-shaped workflow-key fixture."""
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
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)
522 source
in QUALIFICATION_RELEASE_SOURCES
523 and release
is not None
524 and (root /
"docs/qualification/release/README.md").is_file()
527 tool_private = re.fullmatch(
r"tools/<tool>/third_party/<(?:component|dep)>", ref.token)
528 clause_template = TOOL_PRIVATE_CLAUSE_PATTERNS.get(source)
531 if clause_template
is None
532 else re.search(clause_template.format(token=re.escape(ref.token)), ref.source_line)
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
538 current_exclusion_holds = (
539 source
not in TOOL_PRIVATE_OWNERSHIP_INDEXES
or excludes_current
is not None
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
549def _declared_soup_absence(source: str, ref: PathRef) -> bool:
550 """Recognize only exact reviewed SOUP paths in a bounded negative claim."""
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
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):
562 if _glob_matches(base, token):
564 return _has_only_supported_dynamic_syntax(token)
and _glob_matches(base, _dynamic_glob(token))
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():
575 current = current.parent
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"):
589 index.setdefault(Path(path).name, []).append(path)
590 return {name: tuple(paths)
for name, paths
in index.items()}
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):
597 patterns = _brace_expansions(_dynamic_glob(token))
598 index = _tracked_basename_index(str(root.resolve()))
600 name
for name
in index
if any(fnmatch.fnmatchcase(name, pattern)
for pattern
in patterns)
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, ())):
609 return any(name
in index
for name
in names)
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:
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()
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/"):
634 match = SOUP_LOCAL_PATH_RE.search((root / source).read_text(encoding=
"utf-8", errors=
"replace"))
637 rel = match.group(1).rstrip(
"/")
638 if not rel.startswith(VENDOR_PREFIXES):
640 candidate = (root / rel).resolve()
641 return candidate
if candidate.is_dir()
else None
644def _normalized_path_token(ref: PathRef) -> tuple[str, bool]:
645 """Strip citation/symbol syntax and report whether a line citation existed."""
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(
"./"):
653 return token, had_line_citation
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."""
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)
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()
671 target.relative_to(base.resolve())
674 return target.exists()
or _generated_owner_exists(base, token)
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."""
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)
687 if ".." in Path(token).parts:
688 error =
"component-relative path contains traversal"
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}"
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
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:
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())
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):
720 if any(char
in token
for char
in "*?{}$<>")
and not _has_only_supported_dynamic_syntax(token):
721 reason =
"contains unsupported dynamic syntax"
723 base, reason = _base_for_path(root, source, token, soup_root)
725 target = (base / token.rstrip(
"/")).resolve()
727 rel = target.relative_to(root.resolve()).as_posix()
728 if token.startswith(COMPONENT_RELATIVE_PREFIXES):
729 target.relative_to(base.resolve())
731 reason =
"resolves outside its path authority"
735 or _generated_owner_exists(base, token)
736 or _declared_soup_absence(source, ref)
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"
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):
749 if had_line_citation:
750 return "uses a rot-prone line-number citation; cite a symbol instead"
752 if BARE_CODE_FILE_RE.fullmatch(token):
753 if _bare_code_file_exists(root, source, token)
or _declared_bare_code_file(source, ref):
755 return f
"no tracked file has a basename matching {token}"
756 return _ordinary_path_reason(root, source, ref, token)
759def _bare_declaration_findings(
761 parsed: dict[str, Document],
762 declarations: dict[tuple[str, str], str] |
None =
None,
763 contexts: dict[tuple[str, str], tuple[str, ...]] |
None =
None,
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)
774 ()
if document
is None else tuple(ref
for ref
in document.paths
if ref.token == token)
776 observed_contexts = tuple(sorted(_context_sha256(ref.source_line)
for ref
in extracted))
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"
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"
791 findings.append(Finding(
"stale-bare-declaration", source, 1, token, detail))
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]] = []
799 data = (root / path).read_bytes()
803 "classification":
"vendored" if _is_vendor(path)
else "first_party",
804 "sha256": hashlib.sha256(data).hexdigest(),
806 "lines": len(data.splitlines()),
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)]
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),
821 for label, actual, minimum
in populations:
823 _fail(f
"{label} census collapsed: {actual} < {minimum}")
824 return first_party, vendored
828 root: Path, source: str, link: LinkRef, parsed: dict[str, Document]
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:
835 rel_target = target.relative_to(root.resolve()).as_posix()
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)
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)
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] = []
859 for source, document
in parsed.items():
866 "document id is already defined",
868 for collision
in document.anchor_collisions
872 "missing-reference-definition",
876 "reference-style link has no definition",
878 for reference
in document.missing_references
880 for link
in document.links:
882 if finding := _link_finding(root, source, link, parsed):
883 findings.append(finding)
884 for path_ref
in document.paths:
886 if reason := _path_reason(root, source, path_ref):
888 Finding(
"missing-code-path", source, path_ref.line, path_ref.token, reason)
890 return findings, links, paths
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)]
901 tracked_first, tracked_vendor = _enforce_population(tracked)
902 authored = [path
for path
in scope
if not _is_vendor(path)]
904 path: parse_document((root / path).read_text(encoding=
"utf-8", errors=
"replace"))
907 findings, link_count, path_count = _check_documents(root, parsed)
909 findings.extend(_bare_declaration_findings(root, parsed))
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}")
916 "tracked_markdown": len(tracked),
917 "tracked_first_party": len(tracked_first),
918 "tracked_vendored": len(tracked_vendor),
919 "scanned_first_party": len(authored),
921 "code_paths": path_count,
923 return findings, counts, _inventory(root, tracked)
928bare_declaration_findings = _bare_declaration_findings
929context_sha256 = _context_sha256
930declared_bare_code_file = _declared_bare_code_file
931declared_work_fixture = _declared_work_fixture
934is_vendor = _is_vendor
935path_reason = _path_reason
936tracked_basename_index = _tracked_basename_index