Files
ECC/skills/docs-governance/scripts/markdown_links.py
T
haelyra aac9b3d34b fix: preserve activity errors, asset bytes and runner summaries
Treat non-2xx activity responses as feed errors, preserve non-Markdown Copilot agent assets, and apply the reviewed reference escape parity correction with regenerated bindings. Allow the test runner to drain piped output on its existing success or failure exit status. Validate installed workflows and bounded real Node20/24 pipe regressions.
2026-10-09 19:33:37 -04:00

203 lines
6.4 KiB
Python

"""Small Markdown link parser used by the documentation-governance audit."""
from __future__ import annotations
import re
from urllib.parse import unquote
INLINE_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(")
REFERENCE_DEFINITION_RE = re.compile(
r"""(?mx)
^\ {0,3}\[(?P<label>[^\]\n]+)\]:[\t\ ]*
(?:\r?\n[\t\ ]*)?
(?P<target><[^>\n]+>|[^\t\ \n]+)
(?:[\t\ ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\([^()]*\)))?
[\t\ ]*$
"""
)
REFERENCE_USAGE_RE = re.compile(
r"(?<![\\])(?:\\\\)*\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
)
EXTERNAL_URI_RE = re.compile(
r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)",
re.IGNORECASE,
)
def without_fenced_code(text: str) -> str:
output: list[str] = []
fence_character: str | None = None
fence_length = 0
for line in text.splitlines(keepends=True):
marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
if fence_character is None and marker:
fence_character = marker.group(1)[0]
fence_length = len(marker.group(1))
output.append("\n" if line.endswith("\n") else "")
continue
if fence_character is not None:
closing = re.match(
rf"^ {{0,3}}{re.escape(fence_character)}{{{fence_length},}}[ \t]*(?:\r?\n)?$",
line,
)
if closing:
fence_character = None
fence_length = 0
output.append("\n" if line.endswith("\n") else "")
continue
output.append(line)
return "".join(output)
def without_inline_code(text: str) -> str:
output: list[str] = []
cursor = 0
while cursor < len(text):
if text[cursor] != "`":
output.append(text[cursor])
cursor += 1
continue
end_of_marker = cursor
while end_of_marker < len(text) and text[end_of_marker] == "`":
end_of_marker += 1
marker = text[cursor:end_of_marker]
closing = text.find(marker, end_of_marker)
if closing == -1:
output.append(marker)
cursor = end_of_marker
continue
output.extend(
"\n" if character == "\n" else " "
for character in text[cursor : closing + len(marker)]
)
cursor = closing + len(marker)
return "".join(output)
def inline_link_targets(text: str) -> list[str]:
targets: list[str] = []
matches = list(INLINE_LINK_START_RE.finditer(text))
index = 0
cursor = 0
while index < len(matches):
match = matches[index]
if match.start() < cursor:
index += 1
continue
start = match.end()
if start < len(text) and text[start] == "<":
end = text.find(">", start + 1)
if end != -1:
targets.append(text[start : end + 1])
cursor = end + 1
index += 1
continue
index += 1
if index < len(matches):
continue
break
depth = 0
quote: str | None = None
escaped = False
closed = False
saw_closing = False
for end in range(start, len(text)):
character = text[end]
if quote is not None:
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == quote:
quote = None
continue
if escaped:
escaped = False
continue
if character == "\\":
escaped = True
continue
if (
character in {"'", '"'}
and depth == 0
and end > start
and text[end - 1].isspace()
):
quote = character
continue
if character == "(":
depth += 1
elif character == ")":
saw_closing = True
if depth == 0:
targets.append(text[start:end])
cursor = end + 1
closed = True
break
depth -= 1
if closed:
index += 1
continue
index += 1
if not saw_closing or index >= len(matches):
break
return targets
def normalize_reference_label(label: str) -> str:
unescaped = re.sub(r"\\([\\\[\]])", r"\1", label)
return re.sub(r"\s+", " ", unescaped.strip()).casefold()
def reference_link_targets(text: str) -> list[str]:
definition_matches = list(REFERENCE_DEFINITION_RE.finditer(text))
definitions = {
normalize_reference_label(match.group("label")): match.group("target")
for match in definition_matches
}
targets: list[str] = []
for match in REFERENCE_USAGE_RE.finditer(text):
if any(
definition.start() <= match.start() < definition.end()
for definition in definition_matches
):
continue
following = text[match.end() : match.end() + 1]
if following == "(":
continue
label = match.group("label")
key = normalize_reference_label(label or match.group("text"))
if key in definitions:
targets.append(definitions[key])
return targets
def markdown_link_targets(text: str) -> list[str]:
text = without_inline_code(without_fenced_code(text))
return inline_link_targets(text) + reference_link_targets(text)
def normalize_link_target(raw: str) -> str | None:
target = raw.strip()
is_unc_path = target.startswith("\\\\")
if target.startswith("<"):
closing = target.find(">")
if closing == -1:
return None
target = target[1:closing]
else:
title = re.search(
r'\s+(?:"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|\([^()]*\))\s*$',
target,
)
if title:
target = target[: title.start()]
# Unescape supported Markdown punctuation before Windows path separators.
target = re.sub(r"\\([()\[\]'])", r"\1", target)
target = unquote(target.split("#", 1)[0].split("?", 1)[0]).replace("\\", "/")
if not target or (target.startswith("//") and not is_unc_path):
return None
if EXTERNAL_URI_RE.match(target):
return None
return target