mirror of
https://github.com/wassname/ml-debug.git
synced 2026-07-14 11:16:55 +08:00
287 lines
11 KiB
Python
287 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from urllib.parse import unquote
|
|
|
|
LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
|
FOOTNOTE_RE = re.compile(r"\[\^([^\]]+)\]")
|
|
FOOTNOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:", re.MULTILINE)
|
|
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})(.*)$")
|
|
HEADING_RE = re.compile(r"^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$")
|
|
YAML_KEY_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):", re.MULTILINE)
|
|
LINE_ANCHOR_RE = re.compile(r"\bL(\d+)(?:-(\d+))?")
|
|
|
|
|
|
def is_frozen_evidence(path: Path, root: Path) -> bool:
|
|
relative = "/" + path.relative_to(root).as_posix() + "/"
|
|
return "/docs/evidence/" in relative
|
|
|
|
|
|
def authored_markdown(root: Path) -> list[Path]:
|
|
return [
|
|
path
|
|
for path in sorted(root.rglob("*.md"))
|
|
if ".git" not in path.parts and not is_frozen_evidence(path, root)
|
|
]
|
|
|
|
|
|
def prose_lines(path: Path, errors: list[str]) -> list[tuple[int, str]]:
|
|
lines = path.read_text(errors="replace").splitlines()
|
|
prose: list[tuple[int, str]] = []
|
|
opened: tuple[str, int, int] | None = None
|
|
for line_number, line in enumerate(lines, 1):
|
|
match = FENCE_RE.match(line)
|
|
if match:
|
|
marker = match.group(2)
|
|
remainder = match.group(3)
|
|
if opened is None:
|
|
if marker[0] == "`" and "`" in remainder:
|
|
errors.append(f"{path}:{line_number}: backtick fence info contains a backtick")
|
|
opened = (marker[0], len(marker), line_number)
|
|
elif marker[0] == opened[0] and len(marker) >= opened[1]:
|
|
if remainder.strip():
|
|
errors.append(f"{path}:{line_number}: closing fence has trailing text")
|
|
opened = None
|
|
continue
|
|
if opened is None:
|
|
prose.append((line_number, line))
|
|
if opened is not None:
|
|
errors.append(f"{path}: unclosed {opened[0] * opened[1]} fence from line {opened[2]}")
|
|
return prose
|
|
|
|
|
|
def local_target(raw_target: str, source: Path) -> tuple[Path, str] | None:
|
|
target = raw_target.strip()
|
|
if target.startswith("<") and target.endswith(">"):
|
|
target = target[1:-1]
|
|
path_text, _, fragment = target.partition("#")
|
|
path_text = unquote(path_text)
|
|
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", path_text):
|
|
return None
|
|
path = source if not path_text else (source.parent / path_text).resolve()
|
|
return path, unquote(fragment)
|
|
|
|
|
|
def markdown_anchors(path: Path) -> set[str]:
|
|
anchors: set[str] = set()
|
|
counts: dict[str, int] = {}
|
|
for line in path.read_text(errors="replace").splitlines():
|
|
match = HEADING_RE.match(line)
|
|
if not match:
|
|
continue
|
|
heading = re.sub(r"<[^>]+>", "", match.group(1))
|
|
explicit = re.search(r"\{#([A-Za-z0-9_.:-]+)\}\s*$", heading)
|
|
if explicit:
|
|
anchors.add(explicit.group(1))
|
|
heading = heading[: explicit.start()].rstrip()
|
|
slug = re.sub(r"[^\w\s-]", "", heading.lower())
|
|
slug = re.sub(r"[\s-]+", "-", slug).strip("-")
|
|
duplicate = counts.get(slug, 0)
|
|
counts[slug] = duplicate + 1
|
|
anchors.add(slug if duplicate == 0 else f"{slug}-{duplicate}")
|
|
return anchors
|
|
|
|
|
|
def check_frontmatter(root: Path, errors: list[str]) -> None:
|
|
for path in sorted(root.rglob("SKILL.md")):
|
|
if ".git" in path.parts:
|
|
continue
|
|
lines = path.read_text(errors="replace").splitlines()
|
|
if not lines or lines[0] != "---":
|
|
errors.append(f"{path}: missing YAML frontmatter")
|
|
continue
|
|
try:
|
|
closing = lines.index("---", 1)
|
|
except ValueError:
|
|
errors.append(f"{path}: unclosed YAML frontmatter")
|
|
continue
|
|
keys = YAML_KEY_RE.findall("\n".join(lines[1:closing]))
|
|
if sorted(keys) != ["description", "name"]:
|
|
errors.append(f"{path}: frontmatter keys must be name and description, got {keys}")
|
|
values = {}
|
|
for line in lines[1:closing]:
|
|
if ":" not in line:
|
|
errors.append(f"{path}: invalid frontmatter line {line!r}")
|
|
continue
|
|
key, raw_value = line.split(":", 1)
|
|
value = raw_value.strip()
|
|
if value[:1] in {"\"", "'"} and (len(value) < 2 or value[-1] != value[0]):
|
|
errors.append(f"{path}: unterminated quoted frontmatter value for {key}")
|
|
values[key] = value.strip("'\"")
|
|
if not values.get("name") or not values.get("description"):
|
|
errors.append(f"{path}: name and description must be nonempty")
|
|
elif not re.fullmatch(r"[a-z0-9-]+", values["name"]):
|
|
errors.append(f"{path}: invalid skill name {values['name']!r}")
|
|
|
|
|
|
def audit(root: Path) -> list[str]:
|
|
root = root.resolve()
|
|
errors: list[str] = []
|
|
check_frontmatter(root, errors)
|
|
for path in authored_markdown(root):
|
|
prose = prose_lines(path, errors)
|
|
prose_text = "\n".join(line for _, line in prose)
|
|
definitions = set(FOOTNOTE_DEF_RE.findall(prose_text))
|
|
uses = set(FOOTNOTE_RE.findall(prose_text))
|
|
for missing in sorted(uses - definitions):
|
|
errors.append(f"{path}: missing footnote definition [^{missing}]")
|
|
for line_number, line in prose:
|
|
if line.count("[") != line.count("]"):
|
|
errors.append(f"{path}:{line_number}: unbalanced square brackets")
|
|
for match in LINK_RE.finditer(line):
|
|
resolved = local_target(match.group(1), path)
|
|
if resolved is None:
|
|
continue
|
|
target, fragment = resolved
|
|
if not target.exists():
|
|
errors.append(f"{path}:{line_number}: missing local link {match.group(1)!r}")
|
|
continue
|
|
if fragment and target.is_file() and target.suffix.lower() == ".md":
|
|
if fragment not in markdown_anchors(target):
|
|
errors.append(f"{path}:{line_number}: missing Markdown fragment #{fragment}")
|
|
relative = "/" + target.as_posix() + "/"
|
|
if "/docs/evidence/" not in relative or not target.is_file():
|
|
continue
|
|
line_count = len(target.read_text(errors="replace").splitlines())
|
|
for anchor in LINE_ANCHOR_RE.finditer(line[match.end():]):
|
|
start = int(anchor.group(1))
|
|
end = int(anchor.group(2) or anchor.group(1))
|
|
if start < 1 or end < start or end > line_count:
|
|
errors.append(
|
|
f"{path}:{line_number}: invalid evidence anchor L{start}-{end} for "
|
|
f"{target} ({line_count} lines)"
|
|
)
|
|
return errors
|
|
|
|
|
|
def write_fixture(root: Path) -> None:
|
|
(root / "docs/evidence").mkdir(parents=True)
|
|
(root / "docs/evidence/source.md").write_text("one\ntwo\nthree\n")
|
|
(root / "SKILL.md").write_text(
|
|
"---\nname: fixture\ndescription: fixture skill\n---\n\n# Fixture\n"
|
|
)
|
|
(root / "guide.md").write_text(
|
|
"# Fixture section\n"
|
|
"[skill](SKILL.md)\n"
|
|
"[section](guide.md#fixture-section)\n"
|
|
"\n"
|
|
"[cache](docs/evidence/source.md): L2-3\n"
|
|
"Claim.[^source]\n\n"
|
|
"[^source]: Evidence.\n"
|
|
)
|
|
|
|
|
|
def self_test() -> None:
|
|
mutations = [
|
|
(
|
|
"missing local link",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text() + "\n[broken](missing.md)\n"
|
|
),
|
|
),
|
|
(
|
|
"missing local link",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text().replace(
|
|
"docs/evidence/source.md)", "missing-image.png)", 1
|
|
)
|
|
),
|
|
),
|
|
(
|
|
"unclosed",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text() + "\n```python\n"
|
|
),
|
|
),
|
|
(
|
|
"backtick fence info",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text() + "\n```py`thon\n```\n"
|
|
),
|
|
),
|
|
(
|
|
"missing footnote",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text() + "\nMissing.[^absent]\n"
|
|
),
|
|
),
|
|
(
|
|
"frontmatter keys",
|
|
lambda root: (root / "SKILL.md").write_text(
|
|
(root / "SKILL.md").read_text().replace("name:", "title:")
|
|
),
|
|
),
|
|
(
|
|
"unterminated quoted frontmatter",
|
|
lambda root: (root / "SKILL.md").write_text(
|
|
(root / "SKILL.md").read_text().replace(
|
|
"description: fixture skill", 'description: "fixture skill'
|
|
)
|
|
),
|
|
),
|
|
(
|
|
"invalid evidence anchor",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text().replace("L2-3", "L0")
|
|
),
|
|
),
|
|
(
|
|
"invalid evidence anchor",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text().replace("L2-3", "L3-2")
|
|
),
|
|
),
|
|
(
|
|
"missing Markdown fragment",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text().replace(
|
|
"#fixture-section", "#missing-section"
|
|
)
|
|
),
|
|
),
|
|
(
|
|
"unbalanced square brackets",
|
|
lambda root: (root / "guide.md").write_text(
|
|
(root / "guide.md").read_text() + "\n**Broken** [label\n"
|
|
),
|
|
),
|
|
]
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
clean = Path(directory) / "clean"
|
|
write_fixture(clean)
|
|
assert not audit(clean), audit(clean)
|
|
for expected, mutate in mutations:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
write_fixture(root)
|
|
mutate(root)
|
|
errors = audit(root)
|
|
assert any(expected in error for error in errors), (expected, errors)
|
|
print(f"audit self-test: PASS ({len(mutations)} injected defects rejected)")
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("root", nargs="?", type=Path, default=Path.cwd())
|
|
parser.add_argument("--self-test", action="store_true")
|
|
args = parser.parse_args()
|
|
errors = audit(args.root)
|
|
if errors:
|
|
print("\n".join(errors))
|
|
return 1
|
|
if args.self_test:
|
|
self_test()
|
|
print(
|
|
f"audit: PASS ({len(authored_markdown(args.root.resolve()))} authored Markdown files, "
|
|
f"{len(list(args.root.resolve().rglob('SKILL.md')))} skills)"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|