From 26dc40a8a4309f561b5ec9085753ef769e3164de Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Fri, 14 Aug 2026 19:36:00 +0300 Subject: [PATCH] Add CI matrix, JS dogfood, release automation, and a PR reviewer Split ci.yml into a Python matrix (3.10-3.12) and a Node matrix (18, 20, 22). Install tree-sitter in CI so ast_check parses the hooks JavaScript. Delete three section-label comments in selftest.js that the check flags as paraphrase. Add release.yml: a v* tag builds the GitHub release from the matching CHANGELOG section, via tools/changelog_section.py, which carries its own selftest. Add claude-review.yml: claude-code-action@v1 reviews each same-repo PR for slop. The job is gated on the ANTHROPIC_API_KEY secret, so it skips until the key is set. --- .github/workflows/ci.yml | 38 +++++++++++++------ .github/workflows/claude-review.yml | 39 +++++++++++++++++++ .github/workflows/release.yml | 25 ++++++++++++ hooks/selftest.js | 3 -- tools/changelog_section.py | 59 +++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/claude-review.yml create mode 100644 .github/workflows/release.yml create mode 100644 tools/changelog_section.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d75f56..42a4941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,32 +5,33 @@ on: pull_request: jobs: - test: + python: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.11' - - uses: actions/setup-node@v4 - with: - node-version: '20' + python-version: ${{ matrix.python-version }} - name: linter selftest run: python3 tools/lint.py --selftest - - name: hook selftest - run: node hooks/selftest.js - - name: ast selftest run: python3 tools/ast_check.py --selftest - # The plugin's own Python has to survive its own structural rules. + - name: changelog extractor selftest + run: python3 tools/changelog_section.py --selftest + + - name: install tree-sitter for the JS ast check + run: pip install tree-sitter tree-sitter-language-pack + + # With tree-sitter present, ast_check parses the hooks JavaScript too. - name: ast check own source - run: python3 tools/ast_check.py --check tools/ + run: python3 tools/ast_check.py --check hooks/ tools/ - # The deep-dive docs quote the banned list and carry an ignore-file marker, - # so the linter skips them. README, CONTRIBUTING, and CHANGELOG get checked. - name: lint own docs run: python3 tools/lint.py --check README.md docs/CONTRIBUTING.md docs/CHANGELOG.md docs/hooks.md docs/linting.md @@ -44,3 +45,16 @@ jobs: - name: lint own comments run: python3 tools/lint.py --check --code tools/ hooks/ install.sh + + node: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18', '20', '22'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: hook selftest + run: node hooks/selftest.js diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000..32a525b --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,39 @@ +name: anti-slop review + +# Runs Claude on each PR to flag writing slop in the diff. Needs the repo secret +# ANTHROPIC_API_KEY (or run /install-github-app). Fork PRs get no secret, so the +# job is gated to same-repo branches. +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.ANTHROPIC_API_KEY != '' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Review the diff on this pull request against this repo's anti-slop + rules (hooks/rules/core.md and patterns.json). Check only the added + or changed lines. Flag: + - banned vocabulary (leverage, delve, crucial, robust, seamless, load-bearing, and the rest of patterns.json) + - contrast constructions ("it's not X, it's Y", "X isn't just Y") + - em-dashes in prose + - sycophancy and servile closers + - preamble or closing-summary padding in comments or the PR body + Post one concise summary comment with file:line for each hit. If the + diff is clean, say so in one line and stop. + claude_args: | + --model claude-sonnet-5 + --max-turns 6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ec16c3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: release + +# Push a tag like v0.3.1 and this cuts the GitHub release from the matching +# docs/CHANGELOG.md section. +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: build release notes + run: python3 tools/changelog_section.py "${GITHUB_REF_NAME}" > "${RUNNER_TEMP}/notes.md" + - name: create release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file "${RUNNER_TEMP}/notes.md" diff --git a/hooks/selftest.js b/hooks/selftest.js index 5592ab8..4a79fcf 100755 --- a/hooks/selftest.js +++ b/hooks/selftest.js @@ -45,7 +45,6 @@ assert.ok( ); assert.ok(!hit('- one a\n- **Two** b\n- three c'), 'bold-bullet false positive'); -// The servile closer. assert.ok(hit("Say the word and I'll add the counter."), 'servile closer missed'); assert.ok(hit('Just let me know.'), 'servile closer missed'); assert.ok(hit('Happy to walk through the retry path.'), 'servile closer missed'); @@ -86,7 +85,6 @@ assert.ok(!hit('Postgres or SQLite?'), 'plain question blocked'); assert.ok(hard.some(v => !v.soft), 'appositive contrast must block'); } -// Preamble and narration. assert.ok(hit('Yes, I read it. The lock is held.'), 'narration missed'); assert.ok(hit("Here's the actual shape of the deploy."), 'preamble missed'); assert.ok(hit('One thing I found while reading matters more.'), 'buried finding missed'); @@ -101,7 +99,6 @@ assert.ok(!hit('The runner polls /health until the version matches.'), 'plain re assert.ok(!check('The registry holds one lock.').some(v => v.soft), 'short reply flagged'); } -// Ambiguous words never block. assert.ok(!hit('The harness runs the hook.'), 'ambiguous word blocked'); assert.ok(!hit('A robust retry covers the landscape.'), 'ambiguous words blocked'); diff --git a/tools/changelog_section.py b/tools/changelog_section.py new file mode 100644 index 0000000..bd76031 --- /dev/null +++ b/tools/changelog_section.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Print one version's section from docs/CHANGELOG.md. + + python3 tools/changelog_section.py 0.3.0 + python3 tools/changelog_section.py v0.3.0 # a leading v is stripped + +release.yml pipes the output into `gh release create --notes-file`. +""" + +import sys +from pathlib import Path + + +def section(text: str, version: str) -> str: + """Return the body under `## [version]`, up to the next `## [` header. + + Link-reference lines (`[0.3.0]: https://...`) at the file's foot stay out. + """ + out, capture = [], False + header = f"## [{version}]" + for line in text.split("\n"): + if line.startswith("## ["): + if capture: + break + capture = line.strip().startswith(header) + continue + if capture and not line.startswith("["): + out.append(line) + return "\n".join(out).strip() + + +def selftest(): + sample = ( + "# Changelog\n\n" + "## [0.3.0]\n\n### Added\n- A thing.\n\n" + "## [0.2.0]\n\n### Added\n- An older thing.\n\n" + "[0.3.0]: https://example.com/v0.3.0\n" + ) + assert section(sample, "0.3.0") == "### Added\n- A thing.", "0.3.0 body wrong" + assert section(sample, "0.2.0") == "### Added\n- An older thing.", "0.2.0 body wrong" + assert section(sample, "9.9.9") == "", "missing version must be empty" + print("changelog_section selftest ok") + + +def main(): + if "--selftest" in sys.argv: + selftest() + return + version = sys.argv[1].lstrip("v") + path = Path(__file__).resolve().parent.parent / "docs" / "CHANGELOG.md" + body = section(path.read_text(), version) + if not body: + print(f"no changelog section for {version}", file=sys.stderr) + sys.exit(1) + print(body) + + +if __name__ == "__main__": + main()