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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ jobs:
doc-consistency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.x"
- name: Check doc consistency
run: python3 scripts/check_docs.py
- name: Run tests
run: python3 -m unittest discover -s tests -v
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ When proposing a change to `create-dev-loop.md`, the generated template, or a ph

## Testing changes

CI (`.github/workflows/ci.yml`) runs `scripts/check_docs.py`, which mechanically enforces two of the rules above — every `{{placeholder}}` in the template has a Step 4 substitution-table row, and README's "What it does" list stays 1:1 with the Steps — plus checks that relative links between the repo's own docs resolve. It catches doc-drift, not behavior; there is no automated test of what `/create-dev-loop` actually generates. Validate behavioral changes by running `/create-dev-loop` against a real repo and confirming:
CI (`.github/workflows/ci.yml`) runs `scripts/check_docs.py`, which mechanically enforces two of the rules above — every `{{placeholder}}` in the template has a Step 4 substitution-table row, and README's "What it does" list stays 1:1 with the Steps — plus checks that relative links between the repo's own docs resolve. It catches doc-drift, not behavior; there is no automated test of what `/create-dev-loop` actually generates. CI also runs `tests/test_check_docs.py`, fixture-based unit tests of `check_docs.py` itself (so a silently-broken check doesn't read as "0 errors → pass"); add cases there when you change its logic. Validate behavioral changes by running `/create-dev-loop` against a real repo and confirming:
1. The generated skill file compiles (no unresolved `{{placeholders}}` remain)
2. The slash command link resolves correctly
3. The reported summary accurately reflects the target repo
Expand All @@ -72,3 +72,4 @@ Validate by running `/create-dev-loop` against any real repository you maintain
| `CONTRIBUTING.md` | Restated conventions and the validation checklist still match this file (`CLAUDE.md`) |
| `SECURITY.md` | The trust model still matches what Step 2 of `create-dev-loop.md` actually reads from the target repo |
| `.github/PULL_REQUEST_TEMPLATE.md` | Doc-sync checkboxes and test-plan guidance match the current CI scope and this file (`CLAUDE.md`) |
| `.github/ISSUE_TEMPLATE/*.md` | Restated conventions and Step names still match this file (`CLAUDE.md`) |
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ Running `/create-dev-loop` in any repo runs seven Steps (mapped 1:1 to Steps 1

The generated skill drives a 10-phase loop: triage → work selection → implementation → PR → review → address comments → doc check → merge → self-audit → repeat.

## Installation

Register `create-dev-loop.md` itself as a slash command by symlinking it into `~/.claude/commands/`:

```
mkdir -p ~/.claude/commands
ln -s "$(pwd)/create-dev-loop.md" ~/.claude/commands/create-dev-loop.md
```

This is a one-time setup step for this repo's own product, distinct from Step 5, which registers each *generated* skill the same way.

## Usage

```
Expand Down
8 changes: 8 additions & 0 deletions create-dev-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Creates a tailored `dev-loop` Claude skill for the current repository by explori

## Steps

**Steps at a glance:** [1 — Identify](#1--identify-the-repository) ·
[2 — Explore](#2--explore-the-repository) ·
[3 — Write the skill file](#3--write-the-skill-file) ·
[4 — Fill in the placeholders](#4--fill-in-the-placeholders) ·
[5 — Register the skill](#5--register-the-skill) ·
[6 — Create a private GitHub repo](#6--create-a-private-github-repo-for-the-skill) ·
[7 — Record in a personal catalog](#7--record-the-skill-in-a-personal-catalog-optional)

### 1 — Identify the repository

Confirm the working directory is inside a git repository:
Expand Down
3 changes: 3 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# scripts/

CI-support scripts. See [`CLAUDE.md`](../CLAUDE.md) and [`CONTRIBUTING.md`](../CONTRIBUTING.md) for conventions; `check_docs.py` is documented in `CLAUDE.md`'s "Testing changes" section.
130 changes: 130 additions & 0 deletions tests/test_check_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Fixture-based smoke tests for scripts/check_docs.py.

Each of check_docs.py's three checks appends to a module-level `errors`
list rather than returning a value, so these tests reset that list before
each call and assert on its contents afterward.
"""
import sys
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import check_docs # noqa: E402


class ExtractTemplateBodyTests(unittest.TestCase):
def setUp(self):
check_docs.errors = []

def test_extracts_fenced_body(self):
text = "before\n```markdown\nline one\nline two\n```\nafter"
self.assertEqual(check_docs.extract_template_body(text), "line one\nline two")
self.assertEqual(check_docs.errors, [])

def test_missing_opening_fence_errors(self):
body = check_docs.extract_template_body("no fence here at all")
self.assertEqual(body, "")
self.assertTrue(any("opens the generated-skill template" in e for e in check_docs.errors))

def test_missing_closing_fence_errors(self):
body = check_docs.extract_template_body("```markdown\nunterminated")
self.assertEqual(body, "")
self.assertTrue(any("closing" in e for e in check_docs.errors))


class CheckPlaceholdersTests(unittest.TestCase):
def setUp(self):
check_docs.errors = []

def _table(self, *names):
rows = "\n".join(f"| `{n}` | ... |" for n in names)
return f"### 4 — Fill in the placeholders\n\n{rows}\n\n### 5 — Register\n"

def test_all_placeholders_declared_passes(self):
body = "Hello {{NAME}}, {{#if FLAG}}shown{{/if}}"
text = self._table("NAME", "FLAG")
check_docs.check_placeholders(text, body)
self.assertEqual(check_docs.errors, [])

def test_missing_placeholder_row_errors(self):
body = "Hello {{NAME}} and {{UNDECLARED}}"
text = self._table("NAME")
check_docs.check_placeholders(text, body)
self.assertEqual(len(check_docs.errors), 1)
self.assertIn("{{UNDECLARED}}", check_docs.errors[0])

def test_compound_row_covers_both_placeholders(self):
body = "{{GITHUB_OWNER}}/{{GITHUB_REPO}}"
text = self._table("GITHUB_OWNER/REPO")
check_docs.check_placeholders(text, body)
self.assertEqual(check_docs.errors, [])

def test_missing_table_section_errors(self):
check_docs.check_placeholders("no such section here", "{{NAME}}")
self.assertTrue(any("Fill in the placeholders" in e for e in check_docs.errors))


class CheckReadmeStepsSyncTests(unittest.TestCase):
def setUp(self):
check_docs.errors = []

def test_matching_steps_passes(self):
cdl_text = "### 1 — Identify\n...\n### 2 — Explore\n..."
readme_text = "## What it does\n1. **Identify**\n2. **Explore**\n\n## Usage\n"
check_docs.check_readme_steps_sync(cdl_text, readme_text)
self.assertEqual(check_docs.errors, [])

def test_mismatched_steps_errors(self):
cdl_text = "### 1 — Identify\n...\n### 2 — Explore\n..."
readme_text = "## What it does\n1. **Identify**\n\n## Usage\n"
check_docs.check_readme_steps_sync(cdl_text, readme_text)
self.assertEqual(len(check_docs.errors), 1)
self.assertIn("out of sync", check_docs.errors[0])

def test_missing_what_it_does_section_errors(self):
check_docs.check_readme_steps_sync("### 1 — Identify\n...", "## Usage\nno such section\n")
self.assertTrue(any("What it does" in e for e in check_docs.errors))


class CheckLocalLinksTests(unittest.TestCase):
def setUp(self):
check_docs.errors = []
self._orig_root = check_docs.REPO_ROOT

def tearDown(self):
check_docs.REPO_ROOT = self._orig_root

def test_resolvable_link_passes(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "TARGET.md").write_text("target")
(root / "SOURCE.md").write_text("[link](TARGET.md)")
check_docs.REPO_ROOT = root
check_docs.check_local_links()
self.assertEqual(check_docs.errors, [])

def test_broken_link_errors(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "SOURCE.md").write_text("[link](DOES_NOT_EXIST.md)")
check_docs.REPO_ROOT = root
check_docs.check_local_links()
self.assertEqual(len(check_docs.errors), 1)
self.assertIn("broken relative link", check_docs.errors[0])

def test_external_and_anchor_only_links_ignored(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "SOURCE.md").write_text(
"[web](https://example.com)\n[mail](mailto:a@example.com)\n[anchor](#section)"
)
check_docs.REPO_ROOT = root
check_docs.check_local_links()
self.assertEqual(check_docs.errors, [])


if __name__ == "__main__":
unittest.main()
Loading