param( [string]$DocumentPath = (Join-Path $PSScriptRoot "..\http-client-app-plan.md"), [string[]]$DisabledKeywords = @( "<<<<<<<", ">>>>>>>", "TODO_BLOCKER", "FIXME_BLOCKER" ) ) $ErrorActionPreference = "Stop" $resolvedDocument = Resolve-Path -LiteralPath $DocumentPath $python = Get-Command python -ErrorAction SilentlyContinue $usePyLauncher = $false if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue $usePyLauncher = $true } if (-not $python) { Write-Error "Python was not found. Install Python 3 or make the 'python'/'py' command available on PATH." } $checker = @' import argparse import json import re import sys from pathlib import Path def line_col(text, offset): line = text.count("\n", 0, offset) + 1 last_newline = text.rfind("\n", 0, offset) column = offset + 1 if last_newline == -1 else offset - last_newline return line, column def check_code_fences(text): errors = [] fence_pattern = re.compile(r"^[ \t]*```", re.MULTILINE) matches = list(fence_pattern.finditer(text)) if len(matches) % 2 != 0: line, _ = line_col(text, matches[-1].start()) errors.append(f"Unbalanced fenced code block near line {line}.") return errors def check_json_blocks(text): errors = [] block_pattern = re.compile(r"^[ \t]*```json[^\n]*\n(.*?)(?:\n^[ \t]*```[ \t]*$)", re.MULTILINE | re.DOTALL) for index, match in enumerate(block_pattern.finditer(text), start=1): raw_json = match.group(1) try: json.loads(raw_json) except json.JSONDecodeError as exc: start_line, _ = line_col(text, match.start(1)) errors.append( f"Invalid JSON code block #{index} at line {start_line + exc.lineno - 1}, " f"column {exc.colno}: {exc.msg}." ) return errors def is_table_separator(line): stripped = line.strip() if "|" not in stripped: return False cells = [cell.strip() for cell in stripped.strip("|").split("|")] return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells) def pipe_count(line): stripped = line.strip() return stripped.count("|") def check_tables(text): errors = [] lines = text.splitlines() in_fence = False for index, line in enumerate(lines): if re.match(r"^[ \t]*```", line): in_fence = not in_fence continue if in_fence or not is_table_separator(line): continue table_lines = [] cursor = index - 1 while cursor >= 0 and "|" in lines[cursor]: table_lines.insert(0, (cursor + 1, lines[cursor])) cursor -= 1 table_lines.append((index + 1, line)) cursor = index + 1 while cursor < len(lines) and "|" in lines[cursor]: table_lines.append((cursor + 1, lines[cursor])) cursor += 1 counts = {pipe_count(table_line) for _, table_line in table_lines} if len(counts) > 1: first_line = table_lines[0][0] errors.append(f"Markdown table pipe count mismatch near line {first_line}.") return errors def check_disabled_keywords(text, keywords): errors = [] for keyword in keywords: if not keyword: continue offset = text.find(keyword) if offset != -1: line, column = line_col(text, offset) errors.append(f"Disabled keyword '{keyword}' found at line {line}, column {column}.") return errors def main(): parser = argparse.ArgumentParser(description="Verify Markdown planning document structure.") parser.add_argument("document") parser.add_argument("--keyword", action="append", default=[]) args = parser.parse_args() document = Path(args.document) text = document.read_text(encoding="utf-8-sig") errors = [] errors.extend(check_code_fences(text)) errors.extend(check_json_blocks(text)) errors.extend(check_tables(text)) errors.extend(check_disabled_keywords(text, args.keyword)) if errors: print(f"Documentation verification failed for {document}:") for error in errors: print(f" - {error}") return 1 print(f"Documentation verification passed for {document}.") return 0 if __name__ == "__main__": sys.exit(main()) '@ $tempChecker = Join-Path ([System.IO.Path]::GetTempPath()) ("verify-docs-{0}.py" -f ([System.Guid]::NewGuid().ToString("N"))) try { Set-Content -LiteralPath $tempChecker -Value $checker -Encoding UTF8 $arguments = @() if ($usePyLauncher) { $arguments += "-3" } $arguments += @($tempChecker, $resolvedDocument.Path) foreach ($keyword in $DisabledKeywords) { $arguments += @("--keyword", $keyword) } & $python.Source @arguments exit $LASTEXITCODE } finally { if (Test-Path -LiteralPath $tempChecker) { Remove-Item -LiteralPath $tempChecker -Force } }