From 101c133ea874c3d8dfa87e4758634e5be468a693 Mon Sep 17 00:00:00 2001 From: Archit Adish Gupta Date: Thu, 23 Jul 2026 01:11:50 +0530 Subject: [PATCH] feat(docs): add failure recovery to build and deploy docs (closes #930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a zero-dependency docs build failure-recovery utility (scripts/recover_docs_build.py) that detects 9 documented failure modes in the build and deploy docs tree and offers automated repairs for the ones that are safe to fix in place: Auto-repairable: - bom_detected (strip + re-encode as UTF-8 LF) - crlf_line_endings (normalise CRLF -> LF) - trailing_whitespace (strip per detected line) - missing_final_newline (append \n) - duplicate_heading_anchor (rename with ( ) suffix) Manual (informational — runbook provided): - empty_file (zero-byte markdown file) - circular_link (A -> B -> A or self-loop) - unicode_replacement_chars (U+FFFD byte corruption) - baseline_sha_mismatch (tree sha256 drift vs --baseline json) Modes: --check CI gate (exit 1 on any failure) --apply execute auto-repairs; idempotent --json machine-readable plan (incl. applied_repairs audit trail) --baseline also detect sha drift against issue #929 snapshot Plugs into the existing docs-regression.yml workflow as two new steps after the markdown-lint + link-check: 1. python scripts/recover_docs_build.py --check 2. python -m pytest tests/test_recover_docs_build.py -q Adds docs/failure-recovery.md as the operator-facing runbook: per-mode table with Auto/Manual tags, recovery CLI snippet (apply / check / json), on-call resolution steps (editor config for prevention), and the baseline-drift triage flow paired with the issue #929 metrics collector. Adds tests/test_recover_docs_build.py with 46 pytest cases covering slugify, every detector (BOM utf-8/16, CRLF, trailing whitespace, final newline, in-file / cross-file duplicate anchors, empty file, U+FFFD, circular self + 2-file + acyclic chains, baseline match + mismatch), every apply pass (BOM strip, CRLF normalise, WS strip, newline append, heading rename, idempotence over multi-failure seed, side-effect isolation across untouched files), and the full CLI surface (default scan, --check pass/fail, --apply --check green path, --json parseable, --baseline match + mismatch + missing sha256 key, missing root, missing baseline). Acceptance criteria from issue #930: [x] Implement the change in the build and deploy docs [x] Add or update tests, docs, or validation for the touched path --- .github/workflows/docs-regression.yml | 24 + docs/failure-recovery.md | 245 ++++++++++ scripts/recover_docs_build.py | 645 ++++++++++++++++++++++++++ tests/test_recover_docs_build.py | 528 +++++++++++++++++++++ 4 files changed, 1442 insertions(+) create mode 100644 docs/failure-recovery.md create mode 100644 scripts/recover_docs_build.py create mode 100644 tests/test_recover_docs_build.py diff --git a/.github/workflows/docs-regression.yml b/.github/workflows/docs-regression.yml index 860170fb..30935046 100644 --- a/.github/workflows/docs-regression.yml +++ b/.github/workflows/docs-regression.yml @@ -6,11 +6,15 @@ on: paths: - "docs/**" - "*.md" + - "scripts/recover_docs_build.py" + - "tests/test_recover_docs_build.py" pull_request: branches: [main] paths: - "docs/**" - "*.md" + - "scripts/recover_docs_build.py" + - "tests/test_recover_docs_build.py" jobs: validate-docs: @@ -28,6 +32,7 @@ jobs: - name: Run Markdown Lint (Syntax Regression) run: | npx markdownlint-cli "docs/**/*.md" "README.md" --config .markdownlint.json --ignore "node_modules" + - name: Check Broken Links (Deploy Readiness Check) uses: gaurav-nelson/github-action-markdown-link-check@v1 with: @@ -35,4 +40,23 @@ jobs: use-verbose-mode: "yes" check-modified-files-only: "yes" base-branch: "main" + + # --- ADDED FOR ISSUE #930: failure recovery for build & deploy docs --- + # Detects documented failure modes (BOM, CRLF, trailing whitespace, + # missing final newline, duplicate headings, empty files, broken + # baselines, circular links, U+FFFD). Auto-repairs what it can; + # fails the build on remaining manual-action failures. + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run docs build failure-recovery check + run: | + python scripts/recover_docs_build.py --check + + - name: Run docs build failure-recovery tests + run: | + python -m pip install --quiet pytest + python -m pytest tests/test_recover_docs_build.py -q \ No newline at end of file diff --git a/docs/failure-recovery.md b/docs/failure-recovery.md new file mode 100644 index 00000000..2a8f09ec --- /dev/null +++ b/docs/failure-recovery.md @@ -0,0 +1,245 @@ +# Docs Build & Deploy Failure Recovery + +> Companion to `.github/workflows/docs-regression.yml`. Runs as a CI gate on +> every push / pull request that touches `docs/**`, `*.md`, the recovery +> util itself, or its tests. Fails the build when manual-action failures +> are detected. + +This document is the failure-mode catalogue + runbook for the +LocalMind build and deploy docs pipeline. The catalogue is enforced by +`scripts/recover_docs_build.py` (issue #930); refer to it for the +machine-readable detector. + +## Why a failure catalogue? + +The build & deploy docs pipeline (`docs-regression.yml`) lints markdown +and checks broken links. Both are silent to **content-level failure +modes** that slip past the linter but break deploys: byte-order marks, +CRLF line endings on Windows-side commits, missing final newlines, +duplicate heading anchors, byte-drift against a previous deploy baseline, +local-link cycles, and corruption markers like U+FFFD. The recovery +utility enumerates those modes, auto-repairs what it can, and on the +rest emits a structured plan with a documented runbook so an on-call +maintainer can resolve the failure in minutes. + +## The script + +`scripts/recover_docs_build.py` (zero dependencies — Python stdlib). + +### Modes + +```bash +# Default: scan + print the recovery plan. +python scripts/recover_docs_build.py + +# CI gate: exit 1 if any failures are detected. +python scripts/recover_docs_build.py --check + +# Apply auto-repairs in-place (idempotent). +python scripts/recover_docs_build.py --apply + +# Repair + verify clean in one command. +python scripts/recover_docs_build.py --apply --check + +# Machine-readable JSON plan (dashboards / chatops integration). +python scripts/recover_docs_build.py --json + +# Also detect tree-level sha256 drift against an issue #929 baseline. +python scripts/recover_docs_build.py \ + --baseline .docs.metrics.baseline.json \ + --check +``` + +## Failure-mode catalogue + +| Tag | Detected by | Auto-repair | On-call runbook | +| -------------------------- | ----------------------------------- | ----------- | --------------- | +| `bom_detected` | File starts with a UTF-8/16 BOM | Yes | [BOM](#bom_detected) | +| `crlf_line_endings` | `\r\n` sequences in content | Yes | [CRLF](#crlf_line_endings) | +| `trailing_whitespace` | Line ends with spaces/tabs | Yes | [Trailing WS](#trailing_whitespace) | +| `missing_final_newline` | File does not end with `\n` | Yes | [Missing newline](#missing_final_newline) | +| `duplicate_heading_anchor` | Two headings share a slug (within-file or cross-file) | Yes | [Duplicate anchor](#duplicate_heading_anchor) | +| `empty_file` | Zero-byte markdown file | No | [Empty file](#empty_file) | +| `circular_link` | Local-link cycle A → B → A | No | [Circular link](#circular_link) | +| `unicode_replacement_chars`| `U+FFFD` present in decoded content | No | [U+FFFD](#unicode_replacement_chars) | +| `baseline_sha_mismatch` | `--baseline` sha differs from current tree sha | No | [Baseline drift](#baseline_sha_mismatch) | + +## Runbook + +### bom_detected + +**What:** A file (`docs/.md` or root `*.md`) begins with `EF BB BF` +(UTF-8 BOM), `FF FE` (UTF-16 LE BOM), or `FE FF` (UTF-16 BE BOM). Render +sniffers/repo viewers display these as mojibake. + +**Recovery:** `python scripts/recover_docs_build.py --apply` re-encodes the +file as UTF-8 without a BOM. + +**Prevention:** Configure your editor: VS Code → `"files.encoding": "utf8"`, +`"files.insertFinalNewline": true`. + +### crlf_line_endings + +**What:** File contains `\r\n` line endings (typical from a Windows editor +without `core.autocrlf = input`). Causes unstable diffs and merge noise. + +**Recovery:** `python scripts/recover_docs_build.py --apply` normalises all +line endings to `\n`. + +**Prevention:** `git config --global core.autocrlf input`. For the repo, +add a `.gitattributes` rule: `*.md text eol=lf`. + +### trailing_whitespace + +**What:** Line ends with one or more spaces or tabs. Picked up as annoying +diff noise on otherwise identical edits. + +**Recovery:** `python scripts/recover_docs_build.py --apply` strips trailing +whitespace per detected line. + +**Prevention:** Most editors have a "Trim Trailing Whitespace on Save" +setting — enable it. + +### missing_final_newline + +**What:** File's last character is not `\n`. POSIX requires a trailing +newline; some deploy tooling concatenates files with `cat` and a missing +newline glues adjacent files together. + +**Recovery:** `python scripts/recover_docs_build.py --apply` appends a +single newline. + +**Prevention:** Configure your editor: VS Code `"files.insertFinalNewline": true`. + +### duplicate_heading_anchor + +**What:** Two ATX headings at the same level produce the same slug. Within +a file → ambiguous in-page navigation. Across files → ambiguous deep link +target (GitHub / static site generators resolve to the first match). + +**Recovery:** `python scripts/recover_docs_build.py --apply` renames the +duplicate occurrence(s) (first is preserved) by appending `( +)` to the heading text — e.g. `## Configuration` becomes `## +Configuration (csrf-protection 1)`. + +**Prevention:** Disambiguate headings with topic-specific prefixes +(`CSRF Configuration` vs `Cache Configuration` vs `Model Configuration`). + +### empty_file + +**What:** Zero-byte markdown file. Almost always a `git mv` mistake or +an empty new-doc stub that was never filled in. The deploy pipeline +renders an empty page that links to nothing. + +**Recovery:** Manual — the util **does not** auto-delete because the file +may be intentionally empty (a placeholder for future content). Delete the +file (`rm docs/empty.md`) or fill it with the intended content. + +**Detection:** `python scripts/recover_docs_build.py --check` flags the +file; the JSON plan carries `{"mode": "empty_file", "file": ""}`. + +### circular_link + +**What:** A local-link cycle was detected in the docs graph +(`docs/a.md` → `docs/b.md` → `docs/a.md`). A user navigating such a chain +lands on a page that links back to the start, with no escape. Self-loops +(`docs/a.md` → `docs/a.md`) are also flagged. + +**Recovery:** Manual — the util **does not** auto-edit doc content to break +cycles. Inspect the chain, identify the unintended direction, and either +change one of the link targets to a different relevant page or convert it +to an external-link reference. + +**Detection:** The JSON plan reports one failure per detected cycle +**starting at each start node**. For a 2-file cycle A → B → A you will see +two failures (`A → B → A` started from A; `B → A → B` started from B). Both +refer to the same underlying cycle; fixing one resolves both. + +### unicode_replacement_chars + +**What:** UTF-8 decode of file content produced `U+FFFD` replacement +characters, indicating byte corruption (e.g. an editor saved the file as +Latin-1, then re-opened it as UTF-8). Common after Windows-save-as-ANSI or +after a `git filter-branch` encoding rewrite gone wrong. + +**Recovery:** Manual — the util reports the count and the file but does +not attempt to recover (any text-based auto-fix would lose the original +characters). Inspect the file byte-by-byte +(`xxd docs/.md | head -100`) and recover from `git log -p -- docs/.md` +if possible. If not, retyping the affected section is usually faster than +salvaging. + +### baseline_sha_mismatch + +**What:** The user passed `--baseline ` pointing at a previous deploy +metrics snapshot (the `--write-baseline` output from issue #929). The +current tree's canonical `sha256` of all concatenated bytes does not match. +This signals **content drift** between the last recorded deploy and now. + +**Recovery:** Manual — this is informational. To investigate: +```bash +git log -p -- docs/ '*.md' | less +``` +If the drift was intentional (expected docs edits), refresh the baseline: +```bash +python scripts/collect_docs_metrics.py --write-baseline .docs.metrics.baseline.json +``` +If the drift was NOT intentional, identify the suspect commit (`git log +--oneline -- docs/`) and `git revert ` it. + +**Detection:** Pass `--baseline` to `recover_docs_build.py` so the drift +failure is included in the plan: + +```bash +python scripts/recover_docs_build.py \ + --baseline .docs.metrics.baseline.json \ + --check +``` + +## CI integration + +`.github/workflows/docs-regression.yml` adds two new steps after the +existing markdown-lint and link-check: + +1. `python scripts/recover_docs_build.py --check` — fails the build on + any failure, repairable or manual-action. +2. `python -m pytest tests/test_recover_docs_build.py -q` — keep the util + itself under regression. + +To auto-repair in CI (rarely advisable, but supported for tightly +controlled repos), swap step 1 for: + +```bash +python scripts/recover_docs_build.py --apply --check +git diff --exit-code || ( \ + git config user.name 'docs-recovery-bot' && \ + git config user.email 'docs-recovery-bot@users.noreply.github.com' && \ + git commit -am 'chore(docs): auto-recovered build failures' && \ + git push \ +) +``` + +## Tests + +```bash +python -m pytest tests/test_recover_docs_build.py -v +``` + +The suite covers every failure-mode detector, every auto-repair, the +idempotence of the apply pipeline, side-effect isolation across untouched +files, and the CLI surface (default scan, `--check`, `--apply`, +`--apply --check`, `--json`, `--baseline` match + mismatch + missing +keys, plus a non-existent-root failure case). + +## Related + +- [`docs/dedupe-docs.md`](dedupe-docs.md) — the issue #928 dedupe gate. +- [`docs/observability-metrics.md`](observability-metrics.md) — the issue #929 metrics collector. +- The failure catalogue above is the canonical reference for alerts + raised against the metrics emitted by issue #929 — pair the + observability alerts with this runbook to triage them. + +## Issue + +Resolves [issue #930](https://github.com/imDarshanGK/localmind/issues/930) — +*"Add failure recovery to build and deploy docs"*. diff --git a/scripts/recover_docs_build.py b/scripts/recover_docs_build.py new file mode 100644 index 00000000..76a89af7 --- /dev/null +++ b/scripts/recover_docs_build.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +""" +Recover docs build — detect and optionally repair failure modes in the +build & deploy docs tree. + +This is the failure-recovery companion to `scripts/dedupe_docs.py` (issue +#928 → PR #1009) and `scripts/collect_docs_metrics.py` (issue #929 → PR +#1013). Together the three utilities cover: + +- #928 dedupe gate (fail build on duplicate anchors/headings/ref-links) +- #929 observability metrics (instrument the build, drift detection) +- #930 failure recovery (this file) — detect documented failure modes and + offer automated recovery actions, with a dry-run plan mode + +Documented failure modes (see `docs/failure-recovery.md` for descriptions): + +| Tag | Detected by | Auto-repairable | +| -------------------------- | ------------------------------------ | --------------- | +| bom_detected | File starts with UTF-8/16 BOM | Yes (strip) | +| crlf_line_endings | File contains \\r\\n | Yes (LF) | +| trailing_whitespace | Any line ends with spaces/tabs | Yes (strip) | +| missing_final_newline | File content does not end with '\\n' | Yes (append) | +| duplicate_heading_anchor | Two headings share the same slug | Yes (rename) | +| empty_file | Zero-byte markdown file | No (manual) | +| baseline_sha_mismatch | --baseline sha differs from current | No (git-level) | +| circular_link | ./a.md -> ./b.md -> ./a.md | No (manual) | +| unicode_replacement_chars | 'U+FFFD' present in content | No (manual) | + +Exit codes (CI-friendly): +- 0 — no failures detected (or `--apply` repaired everything cleanly) +- 1 — failures detected in `--check` mode (CI gate) +- 2 — I/O error or invalid arguments + +Usage: + python scripts/recover_docs_build.py # scan + print plan + python scripts/recover_docs_build.py --check # CI gate (exit 1) + python scripts/recover_docs_build.py --apply # execute auto-repairs + python scripts/recover_docs_build.py --apply --check # repair + verify clean + python scripts/recover_docs_build.py --baseline # also detect sha drift + python scripts/recover_docs_build.py --json # machine-readable plan + +The implementation intentionally avoids third-party dependencies so it can +run in any Python 3.11+ CI runner without an extra `pip install`. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Iterable + +REPO_ROOT_DEFAULT = Path(__file__).resolve().parent.parent +DOCS_DEFAULT_GLOBS = ("docs/**/*.md", "README.md", "*.md") + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$", re.MULTILINE) +INLINE_LINK_RE = re.compile(r"(? dict[str, Any]: + return asdict(self) + + +@dataclass +class RecoveryPlan: + """Aggregated plan across all scanned files.""" + + failures: list[Failure] = field(default_factory=list) + applied_repairs: list[dict[str, Any]] = field(default_factory=list) + baseline_sha256: str | None = None + current_sha256: str | None = None + + @property + def total_failures(self) -> int: + return len(self.failures) + + @property + def unrepairable_failures(self) -> list[Failure]: + return [f for f in self.failures if not f.auto_repairable] + + @property + def repairable_failures(self) -> list[Failure]: + return [f for f in self.failures if f.auto_repairable] + + def is_clean(self) -> bool: + return self.total_failures == 0 + + def to_dict(self) -> dict[str, Any]: + return { + "failures": [f.to_dict() for f in self.failures], + "applied_repairs": self.applied_repairs, + "baseline_sha256": self.baseline_sha256, + "current_sha256": self.current_sha256, + "total_failures": self.total_failures, + "total_repairable": len(self.repairable_failures), + "total_unrepairable": len(self.unrepairable_failures), + } + + +def slugify(text: str) -> str: + """GitHub-style slug: lowercase, strip punctuation, hyphen-join.""" + slug = re.sub(r"[^\w\s.\-]", "", text.lower()) + slug = re.sub(r"\.", "-", slug) + slug = re.sub(r"[\s_]+", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-") + return slug or "section" + + +def collect_docs(root: Path, globs: Iterable[str]) -> list[Path]: + """Return sorted list of markdown files matching the globs.""" + found: set[Path] = set() + for pattern in globs: + for path in root.glob(pattern): + if path.is_file(): + found.add(path.resolve()) + return sorted(found) + + +def extract_headings(content: str, file: str) -> list[tuple[int, int, str]]: + """Return [(line, level, text), ...] for ATX headings.""" + hits: list[tuple[int, int, str]] = [] + for m in HEADING_RE.finditer(content): + level = len(m.group(1)) + text = m.group(2).strip() + anchor_pos = m.start(2) + line = content.count("\n", 0, anchor_pos) + 1 + hits.append((line, level, text)) + return hits + + +def detect_failures( + root: Path, files: list[Path], baseline_sha: str | None +) -> tuple[RecoveryPlan, dict[Path, str]]: + """Inspect every file and populate a RecoveryPlan. + + Returns the plan plus a map of `{path: normalised content}` so callers + can apply repairs without re-reading from disk. + """ + plan = RecoveryPlan(baseline_sha256=baseline_sha) + contents: dict[Path, str] = {} + + byte_buffer = bytearray() + + for path in files: + raw = path.read_bytes() + byte_buffer.extend(raw) + rel = str(path.relative_to(root)) + has_bom = any(raw.startswith(bom) for bom in BOMS) + + # Decode — tolerate UTF-8/16 + replacement chars for safe processing. + text = raw.decode("utf-8", errors="replace") + if has_bom: + plan.failures.append( + Failure( + mode=FAILURE_BOM, + file=rel, + auto_repairable=True, + detail="Byte-order mark detected; strip and re-encode as UTF-8.", + ) + ) + # Strip BOM for content-level inspections so we see what the deploy would see. + if text.startswith("\ufeff"): + text = text[1:] + contents[path] = text + + if len(raw) == 0: + plan.failures.append( + Failure( + mode=FAILURE_EMPTY_FILE, + file=rel, + auto_repairable=False, + detail="Zero-byte markdown file is almost always a mistake; remove or fill it.", + ) + ) + + if "\r\n" in text: + plan.failures.append( + Failure( + mode=FAILURE_CRLF, + file=rel, + line=1, + auto_repairable=True, + detail="File uses CRLF line endings; normalise to LF for stable diffs.", + ) + ) + + # Trailing whitespace — count lines containing ' \t' at the end. + for idx, line in enumerate(text.split("\n"), start=1): + if line.rstrip(" \t") != line: + plan.failures.append( + Failure( + mode=FAILURE_TRAILING_WS, + file=rel, + line=idx, + auto_repairable=True, + detail="Line has trailing spaces/tabs; strip to reduce diff noise.", + ) + ) + + if text and not text.endswith("\n"): + plan.failures.append( + Failure( + mode=FAILURE_MISSING_NEWLINE, + file=rel, + line=text.count("\n") + 1, + auto_repairable=True, + detail="File is missing the final newline; append one for POSIX compliance.", + ) + ) + + if "\ufffd" in text: + # UTF-8 replacement char — indicates a decode failure mid-content. + count = text.count("\ufffd") + plan.failures.append( + Failure( + mode=FAILURE_UNICODE_REPLACEMENT, + file=rel, + auto_repairable=False, + detail=f"{count} U+FFFD replacement char(s); decode failed — inspect bytes.", + ) + ) + + headings = extract_headings(text, rel) + # Within-file duplicate anchors (same level + slug). + seen: dict[tuple[int, str], int] = {} + for line, level, heading_text in headings: + key = (level, slugify(heading_text)) + if key in seen: + plan.failures.append( + Failure( + mode=FAILURE_DUPLICATE_ANCHOR, + file=rel, + line=line, + auto_repairable=True, + detail=( + f"Duplicate H{level} heading '{heading_text}' " + f"(slug '{key[1]}', first at line {seen[key]}); " + "rename with `( )` suffix." + ), + ) + ) + else: + seen[key] = line + + # Cross-file duplicate anchors — same slug on the same level across files. + by_anchor: dict[tuple[int, str], list[tuple[str, int, str]]] = {} + for path in files: + rel = str(path.relative_to(root)) + for line, level, heading_text in extract_headings(contents[path], rel): + by_anchor.setdefault((level, slugify(heading_text)), []).append( + (rel, line, heading_text) + ) + for (level, slug), hits in by_anchor.items(): + files_with = {h[0] for h in hits} + if len(files_with) > 1: + # Only report once per (level, slug), with the first occurrence as + # the canonical one and the rest as the duplicates. + canonical, *dupes = hits + for dupe_rel, dupe_line, heading_text in dupes: + plan.failures.append( + Failure( + mode=FAILURE_DUPLICATE_ANCHOR, + file=dupe_rel, + line=dupe_line, + auto_repairable=True, + detail=( + f"Cross-file duplicate H{level} anchor '{slug}' " + f"(canonical: {canonical[0]}:{canonical[1]}); " + "update the heading text or add a `(file basename)` suffix." + ), + ) + ) + + # Circular links within docs (only local-link graph is walked). + plan.failures.extend(_detect_circular_links(root, files, contents)) + + # Compute the current sha and compare against baseline if provided. + current_sha = hashlib.sha256(bytes(byte_buffer)).hexdigest() + plan.current_sha256 = current_sha + if baseline_sha and baseline_sha != current_sha: + plan.failures.append( + Failure( + mode=FAILURE_BASELINE_DRIFT, + file="(tree)", + auto_repairable=False, + detail=( + f"Tree sha256 mismatch — baseline={baseline_sha[:12]}…, " + f"current={current_sha[:12]}…. Inspect via `git log -p -- docs/` for content drift." + ), + ) + ) + + return plan, contents + + +def _detect_circular_links( + root: Path, files: list[Path], contents: dict[Path, str] +) -> Iterable[Failure]: + """Walk the local-link graph and yield circular-link failure reports. + + A "local link" is one whose target is a relative path (not http(s)://, + not an #anchor, not an `mailto:`). We resolve links against the source + file's directory. Depth-first traversal with an explicit stack; we cap + the chain length at 8 hops to avoid pathological blowups. + """ + by_path: dict[Path, list[Path]] = {} + for path in files: + text = contents[path] + local_links: list[Path] = [] + for m in INLINE_LINK_RE.finditer(text): + url = m.group(2).strip() + if url.startswith(("http://", "https://", "mailto:", "#")): + continue + if url.startswith("/"): + continue # absolute — out of scope + target = (path.parent / url).resolve() + if target in files: + local_links.append(target) + by_path[path] = local_links + + max_depth = 8 + for start in files: + stack: list[tuple[Path, list[Path]]] = [(start, [start])] + while stack: + node, chain = stack.pop() + for nxt in by_path.get(node, []): + # Direct self-loop A -> A. + if nxt == node: + rel = node.relative_to(root) + yield Failure( + mode=FAILURE_CIRCULAR_LINK, + file=str(rel), + auto_repairable=False, + detail=f"Circular local-link chain (self): {rel} -> {rel}", + ) + continue + # Cycle back to the chain's start node. Only report when the + # chain has length >= 2 to avoid double-reporting with the + # self-loop case (which is already yielded above). + if nxt == chain[0] and len(chain) > 1: + rel_chain = " -> ".join( + str(p.relative_to(root)) for p in chain + [nxt] + ) + yield Failure( + mode=FAILURE_CIRCULAR_LINK, + file=str(chain[0].relative_to(root)), + auto_repairable=False, + detail=f"Circular local-link chain: {rel_chain}", + ) + continue + if nxt in chain: + continue # already visited on this branch + if len(chain) >= max_depth: + continue + stack.append((nxt, chain + [nxt])) + + +def apply_repairs( + root: Path, plan: RecoveryPlan, contents: dict[Path, str] +) -> list[dict[str, Any]]: + """Apply every auto-repairable failure to the file system. + + Returns an audit log of `{file, mode, action}` records. + + Idempotent — running twice leaves the tree unchanged. + """ + log: list[dict[str, Any]] = [] + + # Group failures by (file, mode) so we can apply each repair once per file. + by_file_mode: dict[tuple[str, str], list[Failure]] = {} + for f in plan.repairable_failures: + by_file_mode.setdefault((f.file, f.mode), []).append(f) + + # BOM strip — also serves to fix CRLF in one pass (we re-encode as UTF-8 LF). + for (file_rel, mode), _ in by_file_mode.items(): + if mode != FAILURE_BOM: + continue + path = root / file_rel + new_text = contents[path].lstrip("\ufeff").lstrip("\ufffe") + path.write_text(new_text, encoding="utf-8", newline="\n") + contents[path] = new_text + log.append( + { + "file": file_rel, + "mode": mode, + "action": "stripped BOM, re-encoded as UTF-8 LF", + } + ) + + # CRLF normalisation (covers files that had CRLF without BOM). + for (file_rel, mode), _ in by_file_mode.items(): + if mode != FAILURE_CRLF: + continue + path = root / file_rel + text = contents[path] + new_text = text.replace("\r\n", "\n").replace("\r", "\n") + if new_text != text: + path.write_text(new_text, encoding="utf-8", newline="\n") + contents[path] = new_text + log.append( + {"file": file_rel, "mode": mode, "action": "normalised CRLF to LF"} + ) + + # Trailing whitespace strip. + by_file_trailing: dict[str, set[int]] = {} + for (file_rel, mode), fails in by_file_mode.items(): + if mode != FAILURE_TRAILING_WS: + continue + by_file_trailing.setdefault(file_rel, set()).update(f.line for f in fails) + for file_rel, lines in by_file_trailing.items(): + path = root / file_rel + text = contents[path] + ls = text.split("\n") + for idx in lines: + if 1 <= idx <= len(ls): + ls[idx - 1] = ls[idx - 1].rstrip(" \t") + new_text = "\n".join(ls) + if new_text != text: + path.write_text(new_text, encoding="utf-8", newline="\n") + contents[path] = new_text + log.append( + { + "file": file_rel, + "mode": FAILURE_TRAILING_WS, + "action": f"stripped trailing whitespace on {len(lines)} line(s)", + } + ) + + # Append final newline. + for (file_rel, mode), _ in by_file_mode.items(): + if mode != FAILURE_MISSING_NEWLINE: + continue + path = root / file_rel + text = contents[path] + if text and not text.endswith("\n"): + new_text = text + "\n" + path.write_text(new_text, encoding="utf-8", newline="\n") + contents[path] = new_text + log.append( + {"file": file_rel, "mode": mode, "action": "appended final newline"} + ) + + # Rename duplicate within-file headings with `( )` suffix. + by_file_anchor: dict[str, list[tuple[int, str]]] = {} + for (file_rel, mode), fails in by_file_mode.items(): + if mode != FAILURE_DUPLICATE_ANCHOR: + continue + # Each failure has its line + the original heading text captured. + for f in fails: + # Strip the heading-text out of the detail for safe parsing. + # Failure.detail follows the format: + # "Duplicate H heading '' (slug '', first at line ); ..." + # or + # "Cross-file duplicate H anchor '' ..." + # Both carry the affected line on the Failure itself. + # Parse the original heading text by inspecting the file at the line. + by_file_anchor.setdefault(file_rel, []).append((f.line, "")) + + for file_rel, line_list in by_file_anchor.items(): + path = root / file_rel + text = contents[path] + lines = text.split("\n") + stem = Path(file_rel).stem + for idx, (line_no, _) in enumerate(line_list, start=1): + target_idx = line_no - 1 + if 0 <= target_idx < len(lines): + m = re.match(r"^(#{1,6}\s+)(.+?)(\s*#*\s*)$", lines[target_idx]) + if m: + old_text = m.group(2).strip() + new_text = f"{old_text} ({stem} {idx})" + lines[target_idx] = f"{m.group(1)}{new_text}{m.group(3)}" + log.append( + { + "file": file_rel, + "mode": FAILURE_DUPLICATE_ANCHOR, + "action": f"renamed heading at line {line_no}: '{old_text}' -> '{new_text}'", + } + ) + new_text = "\n".join(lines) + if new_text != text: + path.write_text(new_text, encoding="utf-8", newline="\n") + contents[path] = new_text + + return log + + +def format_plan(plan: RecoveryPlan, root: Path) -> str: + """Render a human-readable plan summary.""" + if plan.is_clean(): + return "Docs build is healthy. No failures detected." + + lines: list[str] = [] + lines.append(f"Detected {plan.total_failures} failure(s):") + lines.append(f" - {len(plan.repairable_failures)} auto-repairable") + lines.append(f" - {len(plan.unrepairable_failures)} manual (no auto-repair)") + lines.append("") + + mode_groups: dict[str, list[Failure]] = {} + for f in plan.failures: + mode_groups.setdefault(f.mode, []).append(f) + + for mode in sorted(mode_groups): + fails = mode_groups[mode] + tag = "AUTO" if any(f.auto_repairable for f in fails) else "MANUAL" + lines.append(f"[{tag}] {mode} — {len(fails)} occurrence(s):") + for f in fails: + loc = f.file if not f.line else f"{f.file}:{f.line}" + lines.append(f" - {loc}") + if f.detail: + lines.append(f" {f.detail}") + lines.append("") + + if plan.baseline_sha256 and plan.current_sha256: + if plan.baseline_sha256 != plan.current_sha256: + lines.append( + "Baseline sha256 mismatch detected — see FAILURE_BASELINE_DRIFT above." + ) + else: + lines.append("Baseline sha256 matches current tree (no drift).") + lines.append("") + + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Detect and repair docs build failure modes." + ) + parser.add_argument( + "--root", type=Path, default=REPO_ROOT_DEFAULT, help="Repo root" + ) + parser.add_argument( + "--glob", + action="append", + default=None, + dest="globs", + help="Glob pattern to scan (repeatable). Defaults to 'docs/**/*.md README.md *.md'.", + ) + parser.add_argument( + "--baseline", + type=Path, + default=None, + help="Path to a JSON metrics snapshot (issue #929 --write-baseline output) " + "for sha256 drift detection.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit 1 if failures are detected (CI gate).", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Apply auto-repairs in-place. Idempotent.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON plan instead of human-readable text.", + ) + + args = parser.parse_args(argv) + globs = tuple(args.globs) if args.globs else DOCS_DEFAULT_GLOBS + root = args.root.resolve() + + if not root.is_dir(): + print(f"error: root is not a directory: {root}", file=sys.stderr) + return 2 + + baseline_sha: str | None = None + if args.baseline: + if not args.baseline.is_file(): + print(f"error: baseline not found: {args.baseline}", file=sys.stderr) + return 2 + baseline_payload = json.loads(args.baseline.read_text(encoding="utf-8")) + baseline_sha = baseline_payload.get("docs_sha256_total") + if not baseline_sha: + print( + "error: baseline JSON does not contain 'docs_sha256_total'", + file=sys.stderr, + ) + return 2 + + try: + files = collect_docs(root, globs) + plan, contents = detect_failures(root, files, baseline_sha) + except OSError as exc: + print(f"error: failed to inspect docs: {exc}", file=sys.stderr) + return 2 + + if args.apply: + plan.applied_repairs = apply_repairs(root, plan, contents) + if plan.applied_repairs: + print(f"Applied {len(plan.applied_repairs)} repair(s):") + for r in plan.applied_repairs: + print(f" - {r['file']} [{r['mode']}]: {r['action']}") + # Re-inspect to verify repairs landed. + files = collect_docs(root, globs) + plan, _ = detect_failures(root, files, baseline_sha) + if not plan.is_clean() and args.check: + unrep = plan.unrepairable_failures + if unrep: + print( + f"warning: {len(unrep)} unrepairable failure(s) remain — manual review required.", + file=sys.stderr, + ) + + if args.json: + print(json.dumps(plan.to_dict(), indent=2)) + else: + print(format_plan(plan, root)) + + if args.check and not plan.is_clean(): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_recover_docs_build.py b/tests/test_recover_docs_build.py new file mode 100644 index 00000000..7da847d2 --- /dev/null +++ b/tests/test_recover_docs_build.py @@ -0,0 +1,528 @@ +"""Tests for `scripts/recover_docs_build.py`. + +Coverage: + +- BOM detection (utf-8 / utf-16 LE / utf-16 BE) +- CRLF line endings +- Trailing whitespace +- Missing final newline +- Duplicate headings: + - within a file + - across files (cross-file anchor collision) +- Empty file (zero bytes) +- Unicode replacement chars (U+FFFD) +- Circular local-link chain detection +- Baseline sha256 mismatch +- Apply (--apply) mode: + - strip BOMs + - normalise CRLF to LF + - strip trailing whitespace + - append final newline + - rename duplicate within-file headings with (` `) suffix + - idempotence (second pass is clean) + - side-effect isolation (untouched files stay byte-identical) +- CLI surface: + - exit codes for default scan, --check, --apply, --apply --check, + --baseline, missing root, missing baseline + - --json emits a parseable plan +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / "scripts" / "recover_docs_build.py" +) +assert SCRIPT_PATH.is_file(), f"Expected script at {SCRIPT_PATH}" + +sys.path.insert(0, str(SCRIPT_PATH.parent)) + +import recover_docs_build as rc # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def docs_root(tmp_path: Path) -> Path: + (tmp_path / "docs").mkdir() + return tmp_path + + +def write(rel: str, content: str, root: Path) -> Path: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + return path + + +def write_bytes(rel: str, data: bytes, root: Path) -> Path: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +# --------------------------------------------------------------------------- +# slugify +# --------------------------------------------------------------------------- + + +class TestSlugify: + def test_basic_lower_hyphenation(self): + assert rc.slugify("API Reference") == "api-reference" + + def test_keeps_dots_as_separators(self): + assert rc.slugify("v2.0") == "v2-0" + + def test_strips_punctuation(self): + assert rc.slugify("What's New?!") == "whats-new" + + def test_empty_yields_section(self): + assert rc.slugify("") == "section" + + +# --------------------------------------------------------------------------- +# Failure detection +# --------------------------------------------------------------------------- + + +class TestBom: + def test_utf8_bom_flagged_as_repairable(self, docs_root: Path): + write_bytes("docs/a.md", b"\xef\xbb\xbf# BOMMED\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + bom_fails = [f for f in plan.failures if f.mode == rc.FAILURE_BOM] + assert len(bom_fails) == 1 + assert bom_fails[0].auto_repairable + + def test_utf16_le_bom_flagged(self, docs_root: Path): + write_bytes( + "docs/a.md", b"\xff\xfe" + "# title\n".encode("utf-16-le"), docs_root + ) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert any(f.mode == rc.FAILURE_BOM for f in plan.failures) + + def test_no_bom_is_clean(self, docs_root: Path): + write("docs/a.md", "# Clean\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_BOM for f in plan.failures) + + +class TestCrlf: + def test_crlf_detected(self, docs_root: Path): + # Use bytes to ensure CRLF survives write_text's newline transformation. + write_bytes("docs/a.md", b"# Title\r\n\r\nPara.\r\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + crlf = [f for f in plan.failures if f.mode == rc.FAILURE_CRLF] + assert len(crlf) == 1 + assert crlf[0].auto_repairable + + def test_lf_is_clean(self, docs_root: Path): + write_bytes("docs/a.md", b"# Title\n\nPara.\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_CRLF for f in plan.failures) + + +class TestTrailingWhitespace: + def test_trailing_spaces_flagged(self, docs_root: Path): + write("docs/a.md", "# Title \n\nPara.\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + ws = [f for f in plan.failures if f.mode == rc.FAILURE_TRAILING_WS] + assert len(ws) == 1 + assert ws[0].line == 1 + + def test_clean_lines_no_failure(self, docs_root: Path): + write("docs/a.md", "# Title\n\nPara.\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_TRAILING_WS for f in plan.failures) + + +class TestMissingFinalNewline: + def test_missing_newline_flagged(self, docs_root: Path): + write_bytes("docs/a.md", b"# No newline at EOF", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + mn = [f for f in plan.failures if f.mode == rc.FAILURE_MISSING_NEWLINE] + assert len(mn) == 1 + + def test_with_newline_clean(self, docs_root: Path): + write("docs/a.md", "# Has newline\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_MISSING_NEWLINE for f in plan.failures) + + +class TestDuplicateAnchors: + def test_within_file_duplicate_heading(self, docs_root: Path): + write("docs/a.md", "# Title\n\n## Section\n\n## Section\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + anchor_fails = [ + f for f in plan.failures if f.mode == rc.FAILURE_DUPLICATE_ANCHOR + ] + assert len(anchor_fails) == 1 + assert anchor_fails[0].line == 5 + assert "Within-file" not in anchor_fails[0].detail # implicit + + def test_cross_file_duplicate_anchor(self, docs_root: Path): + write("docs/a.md", "## Section\n", docs_root) + write("docs/b.md", "## Section\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + cross = [ + f + for f in plan.failures + if f.mode == rc.FAILURE_DUPLICATE_ANCHOR and "Cross-file" in f.detail + ] + assert len(cross) == 1 + + def test_unique_anchors_no_failure(self, docs_root: Path): + write("docs/a.md", "# A\n## Alpha\n", docs_root) + write("docs/b.md", "# B\n## Beta\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_DUPLICATE_ANCHOR for f in plan.failures) + + +class TestEmptyFile: + def test_empty_file_flagged(self, docs_root: Path): + write_bytes("docs/empty.md", b"", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + ef = [f for f in plan.failures if f.mode == rc.FAILURE_EMPTY_FILE] + assert len(ef) == 1 + assert not ef[0].auto_repairable + + +class TestUnicodeReplacement: + def test_replacement_char_flagged(self, docs_root: Path): + # Inject a U+FFFD by writing a bad-utf8 sequence (0xC3 0x28 is invalid utf-8). + write_bytes("docs/a.md", b"# Title with bad \xc3\x28 byte\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + ur = [f for f in plan.failures if f.mode == rc.FAILURE_UNICODE_REPLACEMENT] + assert len(ur) == 1 + assert not ur[0].auto_repairable + + def test_clean_utf8_no_failure(self, docs_root: Path): + write("docs/a.md", "# Title with中文\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_UNICODE_REPLACEMENT for f in plan.failures) + + +class TestBaselineDrift: + def test_match_no_drift(self, docs_root: Path): + write("docs/a.md", "# Title\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + # Use the same tree's sha as the baseline → no drift. + plan2, _ = rc.detect_failures( + docs_root, files, baseline_sha=plan.current_sha256 + ) + assert not any(f.mode == rc.FAILURE_BASELINE_DRIFT for f in plan2.failures) + + def test_mismatch_flagged(self, docs_root: Path): + write("docs/a.md", "# Title\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha="deadbeef" * 16) + drift = [f for f in plan.failures if f.mode == rc.FAILURE_BASELINE_DRIFT] + assert len(drift) == 1 + assert not drift[0].auto_repairable + + +class TestCircularLinks: + def test_self_link_is_circular(self, docs_root: Path): + write("docs/a.md", "# A\n[self](a.md)\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + circ = [f for f in plan.failures if f.mode == rc.FAILURE_CIRCULAR_LINK] + assert len(circ) >= 1 + + def test_two_file_cycle(self, docs_root: Path): + write("docs/a.md", "# A\n[next](b.md)\n", docs_root) + write("docs/b.md", "# B\n[back](a.md)\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + circ = [f for f in plan.failures if f.mode == rc.FAILURE_CIRCULAR_LINK] + assert len(circ) >= 1 + + def test_acyclic_links_clean(self, docs_root: Path): + write("docs/a.md", "# A\n[next](b.md)\n", docs_root) + write("docs/b.md", "# B\n[home](README.md)\n", docs_root) + write("README.md", "# Home\n[note](docs/a.md)\n", docs_root) + files = rc.collect_docs(docs_root, ("**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_CIRCULAR_LINK for f in plan.failures) + + def test_external_links_are_excluded(self, docs_root: Path): + # http(s)://... and #anchors must not enter the local-link graph. + write("docs/a.md", "# A\n[ex](https://example.com)\n[top](#top)\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert not any(f.mode == rc.FAILURE_CIRCULAR_LINK for f in plan.failures) + + +# --------------------------------------------------------------------------- +# Apply mode (--apply) +# --------------------------------------------------------------------------- + + +class TestApply: + def test_strips_bom(self, docs_root: Path): + path = write_bytes("docs/a.md", b"\xef\xbb\xbf# Title\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + log = rc.apply_repairs(docs_root, plan, contents) + assert path.read_bytes() == b"# Title\n" + assert any(r["mode"] == rc.FAILURE_BOM for r in log) + + def test_normalises_crlf(self, docs_root: Path): + path = write_bytes("docs/a.md", b"# Title\r\n\r\nPara.\r\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + assert path.read_bytes() == b"# Title\n\nPara.\n" + + def test_trailing_whitespace_stripped(self, docs_root: Path): + path = write_bytes("docs/a.md", b"# Title \n\nPara. \n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + assert path.read_bytes() == b"# Title\n\nPara.\n" + + def test_appends_final_newline(self, docs_root: Path): + path = write_bytes("docs/a.md", b"# No newline at EOF", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + assert path.read_bytes() == b"# No newline at EOF\n" + + def test_renames_duplicate_heading(self, docs_root: Path): + path = write("docs/a.md", "# Title\n\n## Section\n\n## Section\n", docs_root) + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + text = path.read_text(encoding="utf-8") + assert "## Section" in text # first occurrence unchanged + assert "## Section (a 1)" in text # duplicate renamed + + def test_idempotent(self, docs_root: Path): + # Seed all four auto-repairable deficiencies in one file. + write_bytes( + "docs/a.md", + b"\xef\xbb\xbf# Title\r\n\r\n## Section\r\n\r\n## Section ", + docs_root, + ) + + # Pass 1. + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + + # Re-inspect and re-apply (idempotence check). + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan2, contents2 = rc.detect_failures(docs_root, files, baseline_sha=None) + log2 = rc.apply_repairs(docs_root, plan2, contents2) + + # Second pass should not record applied repairs (no change). + # Note: the cross-file duplicate-heading check is independent of the + # file content; only within-file is touched here. So the only + # remaining legitimate "failure" is any cross-file issue — there are + # none in this isolated fixture, so log2 must be empty. + assert log2 == [] + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan3, _ = rc.detect_failures(docs_root, files, baseline_sha=None) + assert plan3.is_clean() + + def test_side_effect_isolation(self, docs_root: Path): + untouched = write("docs/clean.md", "# Unique Title\n", docs_root) + original = untouched.read_bytes() + # Plant a messy file alongside. + write_bytes("docs/messy.md", b"\xef\xbb\xbf# Messy \r\n", docs_root) + + files = rc.collect_docs(docs_root, ("docs/**/*.md",)) + plan, contents = rc.detect_failures(docs_root, files, baseline_sha=None) + rc.apply_repairs(docs_root, plan, contents) + assert untouched.read_bytes() == original + + +# --------------------------------------------------------------------------- +# RecoveryPlan helpers +# --------------------------------------------------------------------------- + + +class TestPlanHelpers: + def test_total_failures(self): + plan = rc.RecoveryPlan( + failures=[ + rc.Failure(mode=rc.FAILURE_BOM, file="a.md"), + rc.Failure(mode=rc.FAILURE_CRLF, file="b.md"), + ] + ) + assert plan.total_failures == 2 + assert len(plan.repairable_failures) == 0 + assert len(plan.unrepairable_failures) == 2 + + def test_repairable_partition(self): + plan = rc.RecoveryPlan( + failures=[ + rc.Failure(mode=rc.FAILURE_BOM, file="a.md", auto_repairable=True), + rc.Failure( + mode=rc.FAILURE_EMPTY_FILE, file="b.md", auto_repairable=False + ), + ] + ) + assert len(plan.repairable_failures) == 1 + assert len(plan.unrepairable_failures) == 1 + assert plan.total_failures == 2 + + def test_is_clean(self): + assert rc.RecoveryPlan().is_clean() + plan = rc.RecoveryPlan(failures=[rc.Failure(mode="x", file="a.md")]) + assert not plan.is_clean() + + def test_to_dict_has_required_fields(self): + plan = rc.RecoveryPlan(baseline_sha256="aaa", current_sha256="bbb") + d = plan.to_dict() + assert "failures" in d + assert "applied_repairs" in d + assert "baseline_sha256" in d + assert "current_sha256" in d + assert "total_failures" in d + assert "total_repairable" in d + assert "total_unrepairable" in d + + +# --------------------------------------------------------------------------- +# CLI subprocess interface +# --------------------------------------------------------------------------- + + +class TestCli: + def run_cli( + self, args: list[str], cwd: Path | None = None + ) -> subprocess.CompletedProcess: + cmd = [sys.executable, str(SCRIPT_PATH), *args] + return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, check=False) + + def test_default_scan_prints_plan(self, docs_root: Path): + write_bytes("docs/a.md", b"\xef\xbb\xbf# BOM\n", docs_root) + result = self.run_cli(["--root", str(docs_root)]) + assert result.returncode == 0 + assert "bom_detected" in result.stdout + + def test_check_returns_one_on_failure(self, docs_root: Path): + write_bytes("docs/a.md", b"\xef\xbb\xbf# BOM\n", docs_root) + result = self.run_cli(["--root", str(docs_root), "--check"]) + assert result.returncode == 1 + + def test_check_returns_zero_on_clean(self, docs_root: Path): + write("docs/a.md", "# Clean\n", docs_root) + result = self.run_cli(["--root", str(docs_root), "--check"]) + assert result.returncode == 0 + + def test_apply_repairs_bom(self, docs_root: Path): + path = write_bytes("docs/a.md", b"\xef\xbb\xbf# Title\n", docs_root) + result = self.run_cli(["--root", str(docs_root), "--apply", "--check"]) + assert result.returncode == 0 + assert path.read_bytes() == b"# Title\n" + + def test_json_emits_valid_plan(self, docs_root: Path): + write_bytes("docs/a.md", b"\xef\xbb\xbf# Title\n", docs_root) + result = self.run_cli(["--root", str(docs_root), "--json"]) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert "failures" in payload + assert payload["total_failures"] == 1 + + def test_baseline_mismatch_exit_one(self, docs_root: Path, tmp_path: Path): + write("docs/a.md", "# Title\n", docs_root) + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text( + json.dumps({"docs_sha256_total": "deadbeef" * 16}) + "\n", + encoding="utf-8", + ) + result = self.run_cli( + [ + "--root", + str(docs_root), + "--baseline", + str(baseline_path), + "--check", + ] + ) + assert result.returncode == 1 + + def test_baseline_match_exit_zero(self, docs_root: Path, tmp_path: Path): + write("docs/a.md", "# Title\n", docs_root) + # First: read current sha via JSON. + scan = self.run_cli(["--root", str(docs_root), "--json"]) + payload = json.loads(scan.stdout) + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text( + json.dumps({"docs_sha256_total": payload["current_sha256"]}) + "\n", + encoding="utf-8", + ) + result = self.run_cli( + [ + "--root", + str(docs_root), + "--baseline", + str(baseline_path), + "--check", + ] + ) + assert result.returncode == 0 + + def test_missing_root_returns_two(self, tmp_path: Path): + missing = tmp_path / "nonexistent" + result = self.run_cli(["--root", str(missing)]) + assert result.returncode == 2 + + def test_missing_baseline_returns_two(self, docs_root: Path, tmp_path: Path): + missing_baseline = tmp_path / "no-such.json" + result = self.run_cli( + [ + "--root", + str(docs_root), + "--baseline", + str(missing_baseline), + ] + ) + assert result.returncode == 2 + + def test_baseline_missing_sha_key_returns_two( + self, docs_root: Path, tmp_path: Path + ): + bad_baseline = tmp_path / "bad.json" + # JSON without docs_sha256_total key. + bad_baseline.write_text('{"total_files": 1}\n', encoding="utf-8") + result = self.run_cli( + [ + "--root", + str(docs_root), + "--baseline", + str(bad_baseline), + ] + ) + assert result.returncode == 2 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"]))