212 lines
7.6 KiB
Python
Executable File
212 lines
7.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# file: scripts/audit_markdown_tables.py
|
|
# version: 2
|
|
|
|
"""Validate KSP Markdown tables and vertical spacing for explicitly supplied files or directories."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
_SEPARATOR_CELL = re.compile(r"^:?-{3,}:?$")
|
|
_IGNORED_DIRECTORY_NAMES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"})
|
|
|
|
|
|
def _markdown_files(paths: list[str]) -> list[pathlib.Path]:
|
|
files: list[pathlib.Path] = []
|
|
for raw_path in paths:
|
|
path = pathlib.Path(raw_path)
|
|
if path.is_dir():
|
|
files.extend(
|
|
sorted(
|
|
candidate
|
|
for candidate in path.rglob("*.md")
|
|
if candidate.is_file() and not any(part in _IGNORED_DIRECTORY_NAMES for part in candidate.parts)
|
|
)
|
|
)
|
|
elif path.is_file() and path.suffix.lower() == ".md":
|
|
files.append(path)
|
|
else:
|
|
print(f"Markdown table audit: unsupported or missing path: {path}", file=sys.stderr)
|
|
return sorted(set(files))
|
|
|
|
|
|
def _is_table_row(line: str) -> bool:
|
|
return line.startswith("|") and line.endswith("|")
|
|
|
|
|
|
def _cells(line: str) -> list[str]:
|
|
return line.split("|")[1:-1]
|
|
|
|
|
|
def _is_separator_row(line: str) -> bool:
|
|
if not _is_table_row(line):
|
|
return False
|
|
cells = _cells(line)
|
|
return bool(cells) and all(_SEPARATOR_CELL.fullmatch(cell) is not None for cell in cells)
|
|
|
|
|
|
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 Markdown table cells")
|
|
|
|
header_cells = _cells(rows[0])
|
|
column_count = len(header_cells)
|
|
parsed_rows = [_cells(row) for row in rows]
|
|
for offset, row_cells in enumerate(parsed_rows):
|
|
if len(row_cells) != column_count:
|
|
errors.append(
|
|
f"{path}:{start_line + offset}: table row has {len(row_cells)} columns; expected {column_count}; "
|
|
"a literal pipe inside a cell is forbidden"
|
|
)
|
|
return errors
|
|
|
|
for column_index in range(column_count):
|
|
raw_cells = [row[column_index] for row in parsed_rows]
|
|
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_index + 1} is not vertically aligned; raw widths are {widths}"
|
|
)
|
|
continue
|
|
|
|
content_cells = [cell for cell in raw_cells if _SEPARATOR_CELL.fullmatch(cell) is None]
|
|
if not content_cells:
|
|
errors.append(f"{path}:{start_line}: column {column_index + 1} has no header/data content")
|
|
continue
|
|
max_content_width = max(len(cell.strip()) for cell in content_cells)
|
|
required_width = max_content_width + 2
|
|
if expected_width != required_width:
|
|
errors.append(
|
|
f"{path}:{start_line}: column {column_index + 1} width is {expected_width}; expected {required_width} "
|
|
"(longest content plus exactly one space on each side)"
|
|
)
|
|
|
|
for cell in content_cells:
|
|
if len(cell) < 2 or not cell.startswith(" ") or cell.startswith(" ") or not cell.endswith(" "):
|
|
errors.append(
|
|
f"{path}:{start_line}: column {column_index + 1} content cells must start with exactly one space and use right padding only"
|
|
)
|
|
break
|
|
if len(cell.strip()) == max_content_width and cell.endswith(" "):
|
|
errors.append(
|
|
f"{path}:{start_line}: column {column_index + 1} widest content must have exactly one space before the closing pipe"
|
|
)
|
|
break
|
|
|
|
separator = raw_cells[1]
|
|
if len(separator) != expected_width or _SEPARATOR_CELL.fullmatch(separator) is None:
|
|
errors.append(
|
|
f"{path}:{start_line + 1}: separator for column {column_index + 1} must fill the exact column width with hyphens and optional alignment colons"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def _validate_blank_lines(path: pathlib.Path, lines: list[str]) -> list[str]:
|
|
errors: list[str] = []
|
|
fence_marker: str | None = None
|
|
blank_run_start: int | None = None
|
|
blank_run_length = 0
|
|
|
|
def flush_blank_run() -> None:
|
|
nonlocal blank_run_start, blank_run_length
|
|
if blank_run_start is not None and blank_run_length >= 2:
|
|
errors.append(
|
|
f"{path}:{blank_run_start}: {blank_run_length} consecutive blank lines are forbidden outside fenced code blocks; keep at most one"
|
|
)
|
|
blank_run_start = None
|
|
blank_run_length = 0
|
|
|
|
for line_number, line in enumerate(lines, start=1):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
flush_blank_run()
|
|
marker = stripped[:3]
|
|
if fence_marker is None:
|
|
fence_marker = marker
|
|
elif marker == fence_marker:
|
|
fence_marker = None
|
|
continue
|
|
if fence_marker is not None:
|
|
continue
|
|
if line.strip() == "":
|
|
if blank_run_start is None:
|
|
blank_run_start = line_number
|
|
blank_run_length += 1
|
|
continue
|
|
flush_blank_run()
|
|
|
|
flush_blank_run()
|
|
return errors
|
|
|
|
|
|
def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
errors = _validate_blank_lines(path, lines)
|
|
table_count = 0
|
|
fence_marker: str | None = None
|
|
index = 0
|
|
while index < len(lines):
|
|
stripped = lines[index].lstrip()
|
|
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
marker = stripped[:3]
|
|
if fence_marker is None:
|
|
fence_marker = marker
|
|
elif marker == fence_marker:
|
|
fence_marker = None
|
|
index += 1
|
|
continue
|
|
if fence_marker is not None:
|
|
index += 1
|
|
continue
|
|
if index + 1 < len(lines) and _is_table_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_table_row(lines[index]):
|
|
rows.append(lines[index])
|
|
index += 1
|
|
table_count += 1
|
|
errors.extend(_validate_table(path, start + 1, rows))
|
|
continue
|
|
index += 1
|
|
return table_count, errors
|
|
|
|
|
|
def main() -> int:
|
|
"""Audit KSP Markdown formatting in the explicitly selected scope."""
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit")
|
|
arguments = parser.parse_args()
|
|
files = _markdown_files(arguments.paths)
|
|
if not files:
|
|
print("Markdown table audit: no Markdown files selected", file=sys.stderr)
|
|
return 2
|
|
|
|
total_tables = 0
|
|
all_errors: list[str] = []
|
|
for path in files:
|
|
table_count, errors = _audit_file(path)
|
|
total_tables += table_count
|
|
all_errors.extend(errors)
|
|
|
|
if all_errors:
|
|
for error in all_errors:
|
|
print(error, file=sys.stderr)
|
|
print(f"Markdown table audit: {len(all_errors)} error(s) across {len(files)} file(s)", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Markdown table audit: clean ({total_tables} table(s), {len(files)} file(s))")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|