chore: QA-Skripte nach tools/qa übernommen
Die Werkzeuge der QA-Runden lagen nur im ignorierten .qa/: CLI-Matrix (89 Fälle), Fixture-Generator, unabhängiger Markdown-Validator, Wortvergleich gegen Datenverlust, Pandoc-Prüfung (HTML, EPUB, PDF). Jetzt versioniert in tools/qa/ mit README und requirements.txt; erzeugte Daten landen weiterhin in .qa/. Pfade auf die neue Lage angepasst (Repo-Wurzel über parents[2], aufrufender Python-Interpreter). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57d485baa3
commit
01f60a41c4
1 changed files with 1210 additions and 2 deletions
218
tools/qa/validate_output.py
Executable file
218
tools/qa/validate_output.py
Executable file
|
|
@ -0,0 +1,218 @@
|
|||
"""Unabhängiger Validator für epub2md-Ausgaben (nutzt markdown-it-py, nicht epub2md/linkcheck.py).
|
||||
|
||||
Aufruf: python tools/qa/validate_output.py AUSGABEVERZEICHNIS [...] -> JSON auf stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import yaml
|
||||
from markdown_it import MarkdownIt
|
||||
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
||||
from mdit_py_plugins.footnote import footnote_plugin
|
||||
|
||||
MD = MarkdownIt("commonmark").enable(["table", "strikethrough"]).use(footnote_plugin).use(dollarmath_plugin)
|
||||
ATTR = re.compile(r"""\b(href|src|id)\s*=\s*"([^"]*)\"""")
|
||||
NAV_RESIDUE = re.compile(r"^\s*(next|previous|prev|zurück|weiter|vorherige seite|nächste seite|table of contents|back to top)\s*$", re.I)
|
||||
ENTITY_IN_CODE = re.compile(r"&(lt|gt|amp|quot|#\d+|#x[0-9a-f]+);", re.I)
|
||||
|
||||
|
||||
def gh_slug(text: str) -> str:
|
||||
text = unicodedata.normalize("NFC", text).strip().lower()
|
||||
text = re.sub(r"[^\w\- ]", "", text)
|
||||
return text.replace(" ", "-")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict | None, str, str | None]:
|
||||
if not text.startswith("---\n"):
|
||||
return None, text, None
|
||||
end = text.find("\n---\n", 4)
|
||||
if end < 0:
|
||||
return None, text, "Frontmatter nicht geschlossen"
|
||||
try:
|
||||
data = yaml.safe_load(text[4:end])
|
||||
except yaml.YAMLError as exc:
|
||||
return None, text[end + 5 :], f"YAML ungültig: {exc}"
|
||||
return data, text[end + 5 :], None
|
||||
|
||||
|
||||
def walk(tokens):
|
||||
for t in tokens:
|
||||
yield t
|
||||
if t.children:
|
||||
yield from walk(t.children)
|
||||
|
||||
|
||||
def analyse(rel: str, body: str) -> dict:
|
||||
tokens = MD.parse(body)
|
||||
info: dict = {"links": [], "anchors": set(), "fn_refs": set(), "fn_defs": set(), "code": [], "issues": []}
|
||||
lines = body.split("\n")
|
||||
heading_next = False
|
||||
for t in walk(tokens):
|
||||
if t.type == "heading_open":
|
||||
heading_next = True
|
||||
elif t.type == "inline" and heading_next:
|
||||
heading_next = False
|
||||
plain = re.sub(r"<[^>]+>", "", "".join(c.content for c in (t.children or []) if c.type in ("text", "code_inline")))
|
||||
info["anchors"].add(gh_slug(plain))
|
||||
if t.type == "link_open":
|
||||
info["links"].append(t.attrs.get("href", ""))
|
||||
elif t.type == "image":
|
||||
info["links"].append(t.attrs.get("src", ""))
|
||||
elif t.type in ("html_inline", "html_block"):
|
||||
for kind, value in ATTR.findall(t.content):
|
||||
if kind == "id":
|
||||
info["anchors"].add(value)
|
||||
else:
|
||||
info["links"].append(value)
|
||||
elif t.type == "footnote_ref":
|
||||
info["fn_refs"].add(t.meta.get("label"))
|
||||
elif t.type == "footnote_block_open":
|
||||
pass
|
||||
elif t.type == "footnote_open":
|
||||
info["fn_defs"].add(t.meta.get("label"))
|
||||
elif t.type == "fence":
|
||||
info["code"].append((t.info.strip(), t.content, t.map))
|
||||
# unclosed fence: markdown-it lets it run to the end of the document
|
||||
if t.map and t.map[1] >= len(lines) and not re.match(r"^\s*(`{3,}|~{3,})\s*$", lines[t.map[1] - 1] if t.map[1] - 1 < len(lines) else ""):
|
||||
info["issues"].append(("high", "unclosed-fence", f"Code-Fence ab Zeile {t.map[0] + 1} nicht geschlossen"))
|
||||
# raw footnote definitions that markdown-it did not pick up (e.g. broken indentation)
|
||||
for m in re.finditer(r"^\[\^([^\]]+)\]:", body, re.M):
|
||||
info["fn_defs"].add(m.group(1))
|
||||
for ln in lines:
|
||||
if NAV_RESIDUE.match(ln):
|
||||
info["issues"].append(("medium", "nav-residue", f"Navigationsrest: {ln.strip()!r}"))
|
||||
for lang, code, rng in info["code"]:
|
||||
if ENTITY_IN_CODE.search(code):
|
||||
info["issues"].append(("high", "entity-in-code", f"HTML-Entity im Codeblock (Zeile {rng[0] + 1}): {ENTITY_IN_CODE.search(code).group(0)}"))
|
||||
if lang == "python":
|
||||
try:
|
||||
ast.parse(code)
|
||||
except TabError as exc: # mixed tabs/spaces: inherited from the source, not a conversion defect
|
||||
info["issues"].append(("info", "python-tabs", f"Tab/Leerzeichen gemischt (Zeile {rng[0] + 1}): {exc.msg}"))
|
||||
except IndentationError as exc:
|
||||
info["issues"].append(("high", "python-indentation", f"Python-Einrückung kaputt (Zeile {rng[0] + 1}): {exc.msg}"))
|
||||
except SyntaxError:
|
||||
info["issues"].append(("info", "python-syntax", f"Python-Snippet nicht vollständig parsebar (Zeile {rng[0] + 1})"))
|
||||
if "<EFBFBD>" in body:
|
||||
info["issues"].append(("medium", "replacement-char", "Ersatzzeichen U+FFFD in der Ausgabe"))
|
||||
return info
|
||||
|
||||
|
||||
def validate(out: Path) -> dict:
|
||||
result: dict = {"output": str(out), "issues": [], "stats": {}}
|
||||
|
||||
def issue(sev: str, code: str, msg: str, file: str = "") -> None:
|
||||
result["issues"].append({"severity": sev, "code": code, "message": msg, "file": file})
|
||||
|
||||
md_files = sorted(p for p in out.rglob("*.md") if "diagnostics" not in p.parts)
|
||||
analysed: dict[str, dict] = {}
|
||||
bodies: dict[str, str] = {}
|
||||
for p in md_files:
|
||||
rel = p.relative_to(out).as_posix()
|
||||
text = p.read_text(encoding="utf-8")
|
||||
fm, body, err = split_frontmatter(text)
|
||||
if err:
|
||||
issue("high", "frontmatter", err, rel)
|
||||
if rel.startswith("chapters/") and fm is not None:
|
||||
for key in ("book_title", "chapter_number", "chapter_title", "source_epub", "conversion_status"):
|
||||
if key not in fm:
|
||||
issue("medium", "frontmatter-key", f"Frontmatter-Schlüssel fehlt: {key}", rel)
|
||||
if rel.startswith("chapters/") and not body.strip():
|
||||
issue("high", "empty-chapter", "Kapiteldatei ohne Inhalt", rel)
|
||||
bodies[rel] = body
|
||||
a = analyse(rel, body)
|
||||
analysed[rel] = a
|
||||
for sev, code, msg in a["issues"]:
|
||||
issue(sev, code, msg, rel)
|
||||
# links
|
||||
existing = {p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()}
|
||||
n_links = 0
|
||||
for rel, a in analysed.items():
|
||||
for link in a["links"]:
|
||||
n_links += 1
|
||||
if not link or re.match(r"^[a-z][a-z0-9+.-]*:", link, re.I):
|
||||
continue
|
||||
target, _, frag = link.partition("#")
|
||||
target, frag = unquote(target), unquote(frag)
|
||||
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(rel), target)) if target else rel
|
||||
if resolved.startswith(".."):
|
||||
issue("critical", "link-outside", f"Link verlässt Ausgabeordner: {link}", rel)
|
||||
elif resolved not in existing:
|
||||
issue("high", "broken-link", f"Ziel fehlt: {link}", rel)
|
||||
elif frag and resolved in analysed and frag not in analysed[resolved]["anchors"] \
|
||||
and not frag.startswith("fn"): # markdown-it footnote ids are renderer-specific
|
||||
issue("high", "broken-anchor", f"Anker fehlt: {link}", rel)
|
||||
missing_defs = a["fn_refs"] - a["fn_defs"]
|
||||
unused_defs = a["fn_defs"] - a["fn_refs"]
|
||||
if missing_defs:
|
||||
issue("high", "footnote-missing-def", f"Fußnotenverweise ohne Definition: {sorted(missing_defs)}", rel)
|
||||
if unused_defs:
|
||||
issue("medium", "footnote-unused-def", f"Fußnotendefinitionen ohne Verweis: {sorted(unused_defs)}", rel)
|
||||
# duplicate chapter bodies
|
||||
seen: dict[str, str] = {}
|
||||
for rel, body in bodies.items():
|
||||
norm = re.sub(r"\s+", " ", body).strip()
|
||||
if rel.startswith("chapters/") and len(norm) > 200:
|
||||
h = hashlib.sha1(norm.encode()).hexdigest()
|
||||
if h in seen:
|
||||
issue("medium", "duplicate-chapter", f"Inhalt identisch mit {seen[h]}", rel)
|
||||
seen[h] = rel
|
||||
# JSON / YAML files
|
||||
for name in ("manifest.json", "conversion-report.json"):
|
||||
p = out / name
|
||||
if not p.exists():
|
||||
issue("high", "missing-file", f"{name} fehlt")
|
||||
continue
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
issue("high", "json-invalid", f"{name}: {exc}")
|
||||
continue
|
||||
for ch in data.get("chapters", []):
|
||||
if not (out / ch["file"]).is_file():
|
||||
issue("high", "json-ref-missing", f"{name}: Kapitel fehlt {ch['file']}")
|
||||
media = data.get("media", [])
|
||||
media = media.get("files", []) if isinstance(media, dict) else media
|
||||
for m in media:
|
||||
if not (out / m["file"]).is_file():
|
||||
issue("high", "json-ref-missing", f"{name}: Medium fehlt {m['file']}")
|
||||
if name == "conversion-report.json":
|
||||
n_files = len([p for p in md_files if p.parts[-2] == "chapters"])
|
||||
if len(data["chapters"]) != n_files:
|
||||
issue("medium", "report-mismatch", f"Bericht nennt {len(data['chapters'])} Kapitel, vorhanden {n_files}")
|
||||
if data.get("warnings") and data.get("lossless") not in (False,):
|
||||
issue("medium", "report-claims", "Bericht mit Warnungen behauptet nicht lossless=false")
|
||||
result["report_status"] = data.get("status")
|
||||
result["report_warnings"] = len(data.get("warnings", []))
|
||||
if (out / "metadata.yaml").exists():
|
||||
try:
|
||||
yaml.safe_load((out / "metadata.yaml").read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
issue("high", "yaml-invalid", f"metadata.yaml: {exc}")
|
||||
result["stats"] = {
|
||||
"markdown_files": len(md_files),
|
||||
"links_checked": n_links,
|
||||
"code_blocks": sum(len(a["code"]) for a in analysed.values()),
|
||||
"footnote_refs": sum(len(a["fn_refs"]) for a in analysed.values()),
|
||||
"media_files": len([e for e in existing if e.startswith("media/")]),
|
||||
}
|
||||
counts: dict[str, int] = {}
|
||||
for i in result["issues"]:
|
||||
counts[i["severity"]] = counts.get(i["severity"], 0) + 1
|
||||
result["severity_counts"] = counts
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = [validate(Path(p)) for p in sys.argv[1:]]
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
Loading…
Reference in a new issue