Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/usethis-agents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ When you need to add reference material about code to agent configuration:

### Sync blocks

Content between `<!-- sync:path/to/file -->` and `<!-- /sync:path/to/file -->` markers is verified by the `check-doc-sync` hook. To update synced content:
Content between `<!-- sync:path/to/file -->` and `<!-- /sync:path/to/file -->` 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

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/usethis-qa-static-checks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 10 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
114 changes: 0 additions & 114 deletions hooks/check-doc-sync.py

This file was deleted.

158 changes: 158 additions & 0 deletions hooks/fix-doc-sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Fix sync blocks in markdown files to match their source files.

Scans markdown files for comment pairs of the form:
<!-- sync:path/to/file -->
...content...
<!-- /sync:path/to/file -->

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 <!-- sync:some/path --> and captures the path.
_SYNC_START = re.compile(r"^<!--\s*sync:(\S+)\s*-->$")
# Pattern matches <!-- /sync:some/path --> and captures the path.
_SYNC_END = re.compile(r"^<!--\s*/sync:(\S+)\s*-->$")
# 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> [<file> ...]", 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())
Loading