0.0.2-alpha.4

This commit is contained in:
2026-09-28 22:42:48 +02:00
parent b9508b0228
commit 3f8c795cc0
34 changed files with 2108 additions and 249 deletions

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# file: scripts/audit_markdown_tables.py
# version: 2
"""Validate source formatting of Markdown tables in explicitly selected paths."""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
_SEPARATOR = re.compile(r"^:?-{3,}:?$")
_IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target", "builds"})
_MARKDOWN_FENCE_LANGUAGES = frozenset({"markdown", "md"})
def _selected_files(raw_paths: list[str]) -> tuple[list[pathlib.Path], list[str]]:
files: list[pathlib.Path] = []
errors: list[str] = []
for raw_path in raw_paths:
path = pathlib.Path(raw_path)
if path.is_dir():
files.extend(
candidate
for candidate in path.rglob("*.md")
if candidate.is_file() and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts)
)
elif path.is_file() and path.suffix.lower() == ".md":
files.append(path)
else:
errors.append(f"Markdown table audit: unsupported or missing path: {path}")
return sorted(set(files)), errors
def _is_row(line: str) -> bool:
return line.startswith("|") and line.endswith("|")
def _cells(line: str) -> list[str]:
return line.split("|")[1:-1]
def _separator_marker(cell: str) -> str | None:
marker = cell.strip()
return marker if _SEPARATOR.fullmatch(marker) is not None else None
def _is_separator_row(line: str) -> bool:
if not _is_row(line):
return False
cells = _cells(line)
return bool(cells) and all(_separator_marker(cell) is not None for cell in cells)
def _alignment(marker: str) -> str:
if marker.startswith(":") and marker.endswith(":"):
return "center"
if marker.endswith(":"):
return "right"
return "left"
def _padding(cell: str) -> tuple[int, int]:
return len(cell) - len(cell.lstrip(" ")), len(cell) - len(cell.rstrip(" "))
def _validate_table(path: pathlib.Path, start_line: int, rows: list[str]) -> list[str]:
errors: list[str] = []
if any("\\|" in row for row in rows):
errors.append(f"{path}:{start_line}: escaped pipe is forbidden inside a table cell")
parsed = [_cells(row) for row in rows]
column_count = len(parsed[0])
for offset, row in enumerate(parsed):
if len(row) != column_count:
errors.append(f"{path}:{start_line + offset}: table row has {len(row)} columns; expected {column_count}")
return errors
for column in range(column_count):
raw_cells = [row[column] for row in parsed]
widths = [len(cell) for cell in raw_cells]
expected_width = widths[0]
if any(width != expected_width for width in widths):
errors.append(f"{path}:{start_line}: column {column + 1} is not vertically aligned; raw widths are {widths}")
continue
marker = _separator_marker(raw_cells[1])
if marker is None:
errors.append(f"{path}:{start_line + 1}: separator for column {column + 1} is malformed")
continue
content_cells = [cell for index, cell in enumerate(raw_cells) if index != 1]
longest_content = max(len(cell.strip()) for cell in content_cells)
required_width = max(longest_content + 2, 3 + marker.count(":"))
if expected_width != required_width:
errors.append(
f"{path}:{start_line}: column {column + 1} width is {expected_width}; expected {required_width}"
)
continue
alignment = _alignment(marker)
for cell in content_cells:
content_width = len(cell.strip())
left, right = _padding(cell)
expected_padding = expected_width - content_width
if content_width + left + right != len(cell) or left < 1 or right < 1:
errors.append(f"{path}:{start_line}: column {column + 1} must use spaces only for outer padding")
break
if alignment == "left" and (left != 1 or right != expected_padding - 1):
errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the left")
break
if alignment == "right" and (right != 1 or left != expected_padding - 1):
errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the right")
break
if alignment == "center" and abs(left - right) > 1:
errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the center")
break
if len(raw_cells[1]) != expected_width or _SEPARATOR.fullmatch(raw_cells[1]) is None:
errors.append(f"{path}:{start_line + 1}: separator for column {column + 1} must fill the column width")
return errors
def _fence_language(stripped: str) -> str | None:
info = stripped[3:].strip()
if not info:
return None
return info.split(None, 1)[0].lower()
def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]:
lines = path.read_text(encoding="utf-8").splitlines()
errors: list[str] = []
tables = 0
fence: str | None = None
audit_fenced_markdown = False
index = 0
while index < len(lines):
stripped = lines[index].lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
marker = stripped[:3]
if fence is None:
fence = marker
audit_fenced_markdown = _fence_language(stripped) in _MARKDOWN_FENCE_LANGUAGES
elif fence == marker:
fence = None
audit_fenced_markdown = False
index += 1
continue
if fence is not None and not audit_fenced_markdown:
index += 1
continue
if index + 1 < len(lines) and _is_row(lines[index]) and _is_separator_row(lines[index + 1]):
start = index
rows = [lines[index], lines[index + 1]]
index += 2
while index < len(lines) and _is_row(lines[index]):
rows.append(lines[index])
index += 1
tables += 1
errors.extend(_validate_table(path, start + 1, rows))
continue
index += 1
return tables, errors
def main() -> int:
"""Run the read-only Markdown table audit."""
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit")
arguments = parser.parse_args()
files, selection_errors = _selected_files(arguments.paths)
if selection_errors:
for error in selection_errors:
print(error, file=sys.stderr)
return 2
if not files:
print("Markdown table audit: no Markdown files selected", file=sys.stderr)
return 2
table_count = 0
errors: list[str] = []
for path in files:
current_count, current_errors = _audit_file(path)
table_count += current_count
errors.extend(current_errors)
if errors:
for error in errors:
print(error, file=sys.stderr)
print(f"Markdown table audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr)
return 1
print(f"Markdown table audit: clean ({table_count} table(s), {len(files)} file(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())