diff --git a/.agents/skills/usethis-agents/SKILL.md b/.agents/skills/usethis-agents/SKILL.md index 9a35dc4c..f901aa69 100644 --- a/.agents/skills/usethis-agents/SKILL.md +++ b/.agents/skills/usethis-agents/SKILL.md @@ -32,11 +32,11 @@ When you need to add reference material about code to agent configuration: ### Sync blocks -Content between `` and `` markers is verified by the `check-doc-sync` hook. To update synced content: +Content between `` and `` markers is auto-fixed by the `fix-doc-sync` hook. To update synced content: 1. Modify the source (e.g. add a docstring to a module, or update a skill's description). 2. Run the relevant export hook to regenerate the docs file. -3. Copy the updated content into the sync block, or let prek handle it during commit. +3. Let prek handle the sync block update during commit (the `fix-doc-sync` hook runs automatically). ### Skills registry diff --git a/.agents/skills/usethis-qa-static-checks/SKILL.md b/.agents/skills/usethis-qa-static-checks/SKILL.md index 2594c2a6..c21c31c8 100644 --- a/.agents/skills/usethis-qa-static-checks/SKILL.md +++ b/.agents/skills/usethis-qa-static-checks/SKILL.md @@ -26,7 +26,7 @@ Note that we are interested in both errors and warnings from these tools - we sh ## When to run these checks -Before submitting changes for review, **always** run these static checks. This applies to **every** change, no matter how small — including documentation-only changes, skill file edits, and configuration updates. Hooks like `check-doc-sync` and `export-functions` validate generated files that can go out of sync even from non-code changes. Skipping static checks is a common cause of avoidable CI failures. +Before submitting changes for review, **always** run these static checks. This applies to **every** change, no matter how small — including documentation-only changes, skill file edits, and configuration updates. Hooks like `fix-doc-sync` and `export-functions` validate generated files that can go out of sync even from non-code changes. Skipping static checks is a common cause of avoidable CI failures. **Run static checks repeatedly until they pass.** After fixing any failure — or after making any further change for any reason — you must re-run **all** static checks again from scratch, even if you ran them moments ago. A single passing run is not enough if changes have been made since that run. It is expected and normal to invoke this skill multiple times in a loop until every check passes cleanly with no further modifications. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 246e79f8..df22a8c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,14 +87,6 @@ repos: always_run: true pass_filenames: false priority: 0 - - id: check-doc-sync - name: check-doc-sync - entry: uv run --frozen --offline hooks/check-doc-sync.py - args: ["AGENTS.md"] - language: system - always_run: true - pass_filenames: false - priority: 0 - id: check-banned-words name: check-banned-words entry: uv run --frozen --offline hooks/check-banned-words.py @@ -127,6 +119,16 @@ repos: always_run: true pass_filenames: false priority: 0 + - repo: local + hooks: + - id: fix-doc-sync + name: fix-doc-sync + entry: uv run --frozen --offline hooks/fix-doc-sync.py + args: ["AGENTS.md"] + language: system + always_run: true + pass_filenames: false + priority: 1 - repo: local hooks: - id: deptry diff --git a/hooks/check-doc-sync.py b/hooks/check-doc-sync.py deleted file mode 100644 index 8c881803..00000000 --- a/hooks/check-doc-sync.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Check that sync blocks in markdown files match their source files. - -Scans markdown files for comment pairs of the form: - - ...content... - - -and verifies the content between the markers matches the referenced file, -ignoring leading/trailing whitespace differences around the block boundaries. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -# Pattern matches and captures the path. -_SYNC_START = re.compile(r"^$") -# Pattern matches and captures the path. -_SYNC_END = re.compile(r"^$") -# Pattern matches a markdown fenced code block opening (e.g. ```text). -_CODEBLOCK_FENCE = re.compile(r"^```\w*$") - - -def _strip_codeblock(text: str) -> str: - """Strip a surrounding markdown fenced code block, if present.""" - stripped = text.strip() - lines = stripped.splitlines() - if ( - len(lines) >= 2 - and _CODEBLOCK_FENCE.match(lines[0]) - and lines[-1].strip() == "```" - ): - return "\n".join(lines[1:-1]) - return stripped - - -def _find_sync_blocks(text: str) -> list[tuple[str, str]]: - """Return (source_path, actual_content) pairs for every sync block.""" - blocks: list[tuple[str, str]] = [] - lines = text.splitlines(keepends=True) - i = 0 - while i < len(lines): - start_match = _SYNC_START.match(lines[i].strip()) - if start_match: - source_path = start_match.group(1) - content_lines: list[str] = [] - i += 1 - found_end = False - while i < len(lines): - end_match = _SYNC_END.match(lines[i].strip()) - if end_match and end_match.group(1) == source_path: - found_end = True - break - content_lines.append(lines[i]) - i += 1 - if not found_end: - print( - f"ERROR: No closing marker found for sync:{source_path}", - file=sys.stderr, - ) - blocks.append((source_path, "")) - else: - blocks.append((source_path, "".join(content_lines))) - i += 1 - return blocks - - -def main() -> int: - if len(sys.argv) < 2: - print("Usage: check-doc-sync.py [ ...]", file=sys.stderr) - return 1 - - failed = False - for filepath in sys.argv[1:]: - path = Path(filepath) - if not path.is_file(): - print(f"ERROR: {path} not found.", file=sys.stderr) - failed = True - continue - - text = path.read_text(encoding="utf-8") - blocks = _find_sync_blocks(text) - - for source_path, actual_content in blocks: - source = Path(source_path) - if not source.is_file(): - print( - f"ERROR: Source file {source} referenced in {path} not found.", - file=sys.stderr, - ) - failed = True - continue - - expected = source.read_text(encoding="utf-8") - - if _strip_codeblock(actual_content) != expected.strip(): - print( - f"ERROR: Content in {path} between sync:{source_path} markers " - f"is out of sync with {source}.", - file=sys.stderr, - ) - failed = True - - if failed: - return 1 - - print("All sync blocks are up to date.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/hooks/fix-doc-sync.py b/hooks/fix-doc-sync.py new file mode 100644 index 00000000..95e60dc6 --- /dev/null +++ b/hooks/fix-doc-sync.py @@ -0,0 +1,158 @@ +"""Fix sync blocks in markdown files to match their source files. + +Scans markdown files for comment pairs of the form: + + ...content... + + +and replaces the content between the markers with the referenced file's content, +preserving any markdown fenced code block wrapper. Exits with code 1 if any +files were modified (following the pre-commit autofix convention). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Pattern matches and captures the path. +_SYNC_START = re.compile(r"^$") +# Pattern matches and captures the path. +_SYNC_END = re.compile(r"^$") +# Pattern matches a markdown fenced code block opening (e.g. ```text). +_CODEBLOCK_FENCE = re.compile(r"^```\w*$") + + +def _detect_codeblock_fence(text: str) -> str: + """Return the opening fence line if text is wrapped in a code block, else ''.""" + stripped = text.strip() + lines = stripped.splitlines() + if ( + len(lines) >= 2 + and _CODEBLOCK_FENCE.match(lines[0]) + and lines[-1].strip() == "```" + ): + return lines[0] + return "" + + +def _build_replacement(actual_content: str, expected: str) -> str: + """Build the replacement content for a sync block, preserving code fences.""" + fence = _detect_codeblock_fence(actual_content) + if fence: + return f"\n{fence}\n{expected}\n```\n\n" + return f"\n{expected}\n\n" + + +def _collect_block( + lines: list[str], start: int, source_path: str +) -> tuple[list[str], int, bool]: + """Collect content lines from start until the matching end marker. + + Returns (content_lines, end_index, found_end). + """ + content_lines: list[str] = [] + idx = start + while idx < len(lines): + end_match = _SYNC_END.match(lines[idx].strip()) + if end_match and end_match.group(1) == source_path: + return content_lines, idx, True + content_lines.append(lines[idx]) + idx += 1 + return content_lines, idx, False + + +def _fix_file(path: Path) -> bool: + """Fix sync blocks in a single file. Returns True if modifications were made.""" + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + new_lines: list[str] = [] + modified = False + i = 0 + + while i < len(lines): + start_match = _SYNC_START.match(lines[i].strip()) + if not start_match: + new_lines.append(lines[i]) + i += 1 + continue + + # Found a sync start marker. + source_path_str = start_match.group(1) + new_lines.append(lines[i]) # Keep the start marker line. + i += 1 + + content_lines, end_idx, found_end = _collect_block(lines, i, source_path_str) + + if not found_end: + new_lines.extend(content_lines) + i = end_idx + print( + f"ERROR: No closing marker found for sync:{source_path_str}", + file=sys.stderr, + ) + continue + + source = Path(source_path_str) + if not source.is_file(): + new_lines.extend(content_lines) + new_lines.append(lines[end_idx]) + i = end_idx + 1 + print( + f"ERROR: Source file {source} referenced in {path} not found.", + file=sys.stderr, + ) + continue + + expected = source.read_text(encoding="utf-8").strip() + actual_content = "".join(content_lines) + replacement = _build_replacement(actual_content, expected) + + if actual_content != replacement: + modified = True + new_lines.extend(replacement.splitlines(keepends=True)) + else: + new_lines.extend(content_lines) + + new_lines.append(lines[end_idx]) # Keep the end marker line. + i = end_idx + 1 + + if modified: + path.write_text("".join(new_lines), encoding="utf-8") + + return modified + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: fix-doc-sync.py [ ...]", file=sys.stderr) + return 1 + + any_modified = False + failed = False + + for filepath in sys.argv[1:]: + path = Path(filepath) + if not path.is_file(): + print(f"ERROR: {path} not found.", file=sys.stderr) + failed = True + continue + + was_modified = _fix_file(path) + if was_modified: + print(f"Fixed sync blocks in {path}.") + any_modified = True + + if failed: + return 1 + + if any_modified: + return 1 + + print("No sync blocks needed updating.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())