#!/usr/bin/env python3 # file: scripts/audit_markdown_tables.py # version: 1 """Validate games.sasedev 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_marker(cell) is not None for cell in cells) def _separator_marker(cell: str) -> str | None: marker = cell.strip() if _SEPARATOR_CELL.fullmatch(marker) is None: return None return marker def _separator_alignment(marker: str) -> str: if marker.startswith(":") and marker.endswith(":"): return "center" if marker.endswith(":"): return "right" return "left" def _space_padding(cell: str) -> tuple[int, int]: left = len(cell) - len(cell.lstrip(" ")) right = len(cell) - len(cell.rstrip(" ")) return left, right 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 row_index, cell in enumerate(raw_cells) if row_index != 1] 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) marker = _separator_marker(raw_cells[1]) if marker is None: errors.append(f"{path}:{start_line + 1}: separator for column {column_index + 1} is malformed") continue minimum_separator_width = 3 + marker.count(":") required_width = max(max_content_width + 2, minimum_separator_width) 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 outer padding, or the minimum Markdown separator width)" ) alignment = _separator_alignment(marker) for cell in content_cells: content_width = len(cell.strip()) left_padding, right_padding = _space_padding(cell) if content_width + left_padding + right_padding != len(cell) or left_padding < 1 or right_padding < 1: errors.append( f"{path}:{start_line}: column {column_index + 1} content cells must use spaces only for outer alignment padding" ) break expected_padding = expected_width - content_width if alignment == "left" and (left_padding != 1 or right_padding != expected_padding - 1): errors.append( f"{path}:{start_line}: column {column_index + 1} is left-aligned; content must use one leading space and right padding only" ) break if alignment == "right" and (right_padding != 1 or left_padding != expected_padding - 1): errors.append( f"{path}:{start_line}: column {column_index + 1} is right-aligned; content must use left padding and one trailing space" ) break if alignment == "center" and abs(left_padding - right_padding) > 1: errors.append( f"{path}:{start_line}: column {column_index + 1} is centered; left and right padding may differ by at most one space" ) 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 games.sasedev 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())