diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-19 11:57:23 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-19 11:57:23 -0500 |
| commit | 18fcaf9f27d03849487078b30f667c3b574e6554 (patch) | |
| tree | 67e3ede717fbf9fbfe10c46042451e15abeeb91f | |
| parent | f8c593791ae051b07dba2606c18f1deb7589825e (diff) | |
| download | rulesets-18fcaf9f27d03849487078b30f667c3b574e6554.tar.gz rulesets-18fcaf9f27d03849487078b30f667c3b574e6554.zip | |
feat: add per-project language bundles + elisp ruleset
Introduces a second install mode alongside the existing global symlinks:
per-project language bundles that copy a language-specific Claude Code
setup (rules, hooks, settings, pre-commit) into a target project.
Layout additions:
languages/elisp/ - Emacs Lisp bundle (rules, hooks, settings, CLAUDE.md)
scripts/install-lang.sh - shared install logic
Makefile additions:
make help - unified help text
make install-lang LANG=<lang> PROJECT=<path> [FORCE=1]
make install-elisp PROJECT=<path> [FORCE=1] (shortcut)
make list-languages - show available bundles
Elisp bundle contents:
- CLAUDE.md template (seed on first install, preserved on update)
- .claude/rules/elisp.md, elisp-testing.md, verification.md
- .claude/hooks/validate-el.sh (check-parens, byte-compile, run matching tests)
- .claude/settings.json (permission allowlist, hook wiring)
- githooks/pre-commit (secret scan + staged-file paren check)
- gitignore-add.txt (append .claude/settings.local.json)
Hooks use \$CLAUDE_PROJECT_DIR with a script-relative fallback, so the
same bundle works on any machine or clone path. Install activates git
hooks via core.hooksPath=githooks automatically. Re-running install is
idempotent; CLAUDE.md is never overwritten without FORCE=1.
| -rw-r--r-- | Makefile | 35 | ||||
| -rw-r--r-- | README.org | 86 | ||||
| -rw-r--r-- | languages/elisp/CLAUDE.md | 66 | ||||
| -rw-r--r-- | languages/elisp/claude/hooks/validate-el.sh | 79 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/elisp-testing.md | 78 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/elisp.md | 75 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/verification.md | 42 | ||||
| -rw-r--r-- | languages/elisp/claude/settings.json | 70 | ||||
| -rwxr-xr-x | languages/elisp/githooks/pre-commit | 50 | ||||
| -rw-r--r-- | languages/elisp/gitignore-add.txt | 3 | ||||
| -rwxr-xr-x | scripts/install-lang.sh | 97 |
11 files changed, 680 insertions, 1 deletions
@@ -2,8 +2,27 @@ SKILLS_DIR := $(HOME)/.claude/skills RULES_DIR := $(HOME)/.claude/rules SKILLS := c4-analyze c4-diagram debug add-tests respond-to-review review-pr fix-issue security-check RULES := $(wildcard claude-rules/*.md) +LANGUAGES := $(notdir $(wildcard languages/*)) -.PHONY: install uninstall list +.PHONY: help install uninstall list \ + install-lang list-languages install-elisp + +help: + @echo "rulesets — Claude Code skills, rules, and language bundles" + @echo "" + @echo " Global install (symlinks into ~/.claude/):" + @echo " make install - Install skills and rules globally" + @echo " make uninstall - Remove the symlinks" + @echo " make list - Show install status" + @echo "" + @echo " Per-project language rulesets:" + @echo " make install-lang LANG=<lang> PROJECT=<path> [FORCE=1]" + @echo " make install-elisp PROJECT=<path> [FORCE=1] (shortcut)" + @echo " make list-languages - Show available language bundles" + @echo "" + @echo " FORCE=1 overwrites an existing CLAUDE.md (other files always overwrite)." + @echo "" + @echo "Available languages: $(LANGUAGES)" install: @mkdir -p $(SKILLS_DIR) $(RULES_DIR) @@ -77,3 +96,17 @@ list: echo " - $$name"; \ fi \ done + +# --- Per-project language rulesets --- + +list-languages: + @echo "Available language rulesets (languages/):" + @for lang in $(LANGUAGES); do echo " - $$lang"; done + +install-lang: + @test -n "$(LANG)" || { echo "ERROR: set LANG=<language> (try: make list-languages)"; exit 1; } + @test -n "$(PROJECT)" || { echo "ERROR: set PROJECT=<path>"; exit 1; } + @bash scripts/install-lang.sh "$(LANG)" "$(PROJECT)" "$(FORCE)" + +install-elisp: + @$(MAKE) install-lang LANG=elisp PROJECT="$(PROJECT)" FORCE="$(FORCE)" diff --git a/README.org b/README.org new file mode 100644 index 0000000..f46eef2 --- /dev/null +++ b/README.org @@ -0,0 +1,86 @@ +#+TITLE: Rulesets +#+AUTHOR: Craig Jennings + +Claude Code skills, rules, and per-language project bundles. + +* Layout + +| Directory | Purpose | +|-----------------+------------------------------------------------------------------| +| =claude-rules/= | Generic rules symlinked into =~/.claude/rules/= (apply globally) | +| =<skill>/= | Skill directories symlinked into =~/.claude/skills/= | +| =languages/= | Per-language project bundles (rules + hooks + settings) | +| =scripts/= | Install helpers | + +* Two install modes + +** Global (machine-wide) + +Symlinks skills and generic rules into =~/.claude/=. Run once per machine. + +#+begin_src bash +make install # symlink skills and rules into ~/.claude/ +make uninstall # remove the symlinks +make list # show what's installed +#+end_src + +Skills and generic rules apply to every Claude Code session on this machine. + +** Per-project language bundles + +Copies a language-specific ruleset into a target project. Re-run to refresh. + +#+begin_src bash +make install-elisp PROJECT=~/projects/my-elisp-thing +# or, explicit: +make install-lang LANG=elisp PROJECT=~/projects/my-elisp-thing + +make list-languages # show available bundles +#+end_src + +What gets installed: +- =.claude/rules/*.md= — project-scoped rules (language-specific + verification) +- =.claude/hooks/= — PostToolUse validation scripts +- =.claude/settings.json= — permission allowlist + hook wiring +- =githooks/= — git hooks (activated via =core.hooksPath=) +- =CLAUDE.md= — seeded on first install only (use =FORCE=1= to overwrite) +- =.gitignore= — appends personal-override entries (deduped) + +The install is re-runnable. Running it again refreshes files in place; personal +tweaks live in =.claude/settings.local.json= and are not touched. + +* Available languages + +| Language | Path | Notes | +|----------+------------------+----------------------------------------------| +| elisp | =languages/elisp/= | Emacs Lisp — ERT, check-parens, byte-compile | + +Add more by creating =languages/<name>/= with the same structure. + +* Bundle structure + +Each language bundle under =languages/<lang>/= follows: + +#+begin_example +languages/<lang>/ +├── CLAUDE.md # project instructions template (seed only) +├── claude/ # copied into <project>/.claude/ +│ ├── rules/*.md +│ ├── hooks/*.sh +│ └── settings.json +├── githooks/ # copied into <project>/githooks/ +│ └── pre-commit +└── gitignore-add.txt # lines appended to <project>/.gitignore +#+end_example + +* Design principles + +- *Authoritative source*: =.claude/= and =githooks/= overwrite on every install. + If you edit them in-project, your changes will be lost on next install. Put + per-project customizations in =.claude/settings.local.json= (gitignored) or + project-specific files outside =.claude/=. +- *CLAUDE.md is precious*: it's the one file with project-specific prose, so + install never overwrites it unless =FORCE=1=. +- *Portable paths*: hooks use =$CLAUDE_PROJECT_DIR= (Claude Code sets it) with + a script-relative fallback. No hardcoded usernames or paths. +- *Idempotent*: re-running install is always safe. No state beyond file contents. diff --git a/languages/elisp/CLAUDE.md b/languages/elisp/CLAUDE.md new file mode 100644 index 0000000..9b95e4f --- /dev/null +++ b/languages/elisp/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +## Project + +Elisp project. Customize this section with your own description, layout, and conventions. + +**Typical layout:** +- `init.el`, `early-init.el` — entry points (Emacs config projects) +- `modules/*.el` — feature modules +- `tests/test-*.el` — ERT unit tests +- `tests/testutil-*.el` — shared test fixtures and mocks + +## Build & Test Commands + +If the project has a Makefile, document targets here. Common pattern: + +```bash +make test # All tests +make test-file FILE=tests/test-foo.el # One file +make test-name TEST=pattern # Match test names +make validate-parens # Balanced parens in modules +make validate-modules # Load all modules to verify they compile +make compile # Byte-compile (writes .elc) +make lint # checkdoc + package-lint + elisp-lint +``` + +Alternative build tools: `eldev`, `cask`, or direct `emacs --batch` invocations. + +## Language Rules + +See rule files in `.claude/rules/`: +- `elisp.md` — code style and patterns +- `elisp-testing.md` — ERT conventions +- `verification.md` — verify-before-claim-done discipline + +## Git Workflow + +- Conventional commit prefixes: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` +- Pre-commit hook in `githooks/` scans for secrets and runs `check-parens` on staged `.el` files +- Activate on fresh clone: `git config core.hooksPath githooks` + +## Problem-Solving Approach + +Investigate before fixing. When diagnosing a bug: +1. Read the relevant module and trace what actually happens +2. Identify the root cause, not a surface symptom +3. Write a failing test that captures the correct behavior +4. Fix, then re-run tests + +## Testing Discipline + +TDD is the default: write a failing test before any implementation. If you can't write the test, you don't yet understand the change. Details in `.claude/rules/elisp-testing.md`. + +## Editing Discipline + +A PostToolUse hook runs `check-parens` + `byte-compile-file` on every `.el` file after Edit/Write/MultiEdit. Byte-compile warnings (free variables, wrong argument counts) are signal — read them. + +Prefer Write over cumulative Edits for nontrivial new code. Small functions (under 15 lines) are near-impossible to get wrong; deeply nested code is where paren errors hide. + +## What Not to Do + +- Don't add features beyond what was asked +- Don't refactor surrounding code when fixing a bug +- Don't add comments to code you didn't change +- Don't create abstractions for one-time operations +- Don't commit `.env` files, credentials, or API keys — pre-commit hook catches common patterns but isn't a substitute for care diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh new file mode 100644 index 0000000..5fd4241 --- /dev/null +++ b/languages/elisp/claude/hooks/validate-el.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Validate and test .el files after Edit/Write/MultiEdit. +# PostToolUse hook: receives tool-call JSON on stdin. +# Silent on success; on failure, prints emacs output and exits 2 +# so Claude sees the error and can correct it. +# +# Phase 1: check-parens + byte-compile +# Phase 2: for modules/*.el, run matching tests/test-<stem>*.el + +set -u + +# Portable project root: prefer Claude Code's env var, fall back to deriving +# from this script's location ($project/.claude/hooks/validate-el.sh). +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" + +f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" +[ -z "$f" ] && exit 0 +[ "${f##*.}" = "el" ] || exit 0 + +MAX_AUTO_TEST_FILES=20 # skip if more matches than this (large test suites) + +# --- Phase 1: syntax + byte-compile --- +case "$f" in + */init.el|*/early-init.el) + # Byte-compile here would load the full package graph. Parens only. + if ! output="$(emacs --batch --no-site-file --no-site-lisp "$f" \ + --eval '(check-parens)' 2>&1)"; then + printf 'PAREN CHECK FAILED: %s\n%s\n' "$f" "$output" >&2 + exit 2 + fi + ;; + *.el) + if ! output="$(emacs --batch --no-site-file --no-site-lisp \ + -L "$PROJECT_ROOT" \ + -L "$PROJECT_ROOT/modules" \ + -L "$PROJECT_ROOT/tests" \ + "$f" \ + --eval '(check-parens)' \ + --eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then + printf 'VALIDATION FAILED: %s\n%s\n' "$f" "$output" >&2 + exit 2 + fi + ;; +esac + +# --- Phase 2: test runner --- +# Determine which tests (if any) apply to this edit. +tests=() +case "$f" in + "$PROJECT_ROOT/modules/"*.el) + stem="$(basename "${f%.el}")" + mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort) + ;; + "$PROJECT_ROOT/tests/testutil-"*.el) + stem="$(basename "${f%.el}")" + stem="${stem#testutil-}" + mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort) + ;; + "$PROJECT_ROOT/tests/test-"*.el) + tests=("$f") + ;; +esac + +count="${#tests[@]}" +if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then + load_args=() + for t in "${tests[@]}"; do load_args+=("-l" "$t"); done + if ! output="$(emacs --batch --no-site-file --no-site-lisp \ + -L "$PROJECT_ROOT" \ + -L "$PROJECT_ROOT/modules" \ + -L "$PROJECT_ROOT/tests" \ + -l ert "${load_args[@]}" \ + --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then + printf 'TESTS FAILED for %s (%d test file(s)):\n%s\n' "$f" "$count" "$output" >&2 + exit 2 + fi +fi + +exit 0 diff --git a/languages/elisp/claude/rules/elisp-testing.md b/languages/elisp/claude/rules/elisp-testing.md new file mode 100644 index 0000000..fcad9de --- /dev/null +++ b/languages/elisp/claude/rules/elisp-testing.md @@ -0,0 +1,78 @@ +# Elisp Testing Rules + +Applies to: `**/tests/*.el` + +## Framework: ERT + +Use `ert-deftest` for all tests. One test = one scenario. + +## File Layout + +- `tests/test-<module>.el` — tests for `modules/<module>.el` +- `tests/test-<module>--<helper>.el` — tests for a specific private helper (matches `<module>--<helper>` function naming) +- `tests/testutil-<module>.el` — fixtures and mocks for one module +- `tests/testutil-general.el`, `testutil-filesystem.el`, `testutil-org.el` — cross-module helpers + +Tests must `(require 'module-name)` before the testutil file that stubs its internals, unless documented otherwise. Order matters — a testutil that defines a stub can be shadowed by a later `require` of the real module. + +## Test Naming + +```elisp +(ert-deftest test-<module>-<function>-<scenario> () + "Normal/Boundary/Error: brief description." + ...) +``` + +Put the category (Normal, Boundary, Error) in the docstring so the category is grep-able. + +## Required Coverage + +Every non-trivial function needs at least: +- One **Normal** case (happy path) +- One **Boundary** case (empty, nil, min, max, unicode, long string) +- One **Error** case (invalid input, missing resource, failure mode) + +Missing a category is a test gap. If three cases look near-identical, parametrize with a loop or `dolist` rather than copy-pasting. + +## TDD Workflow + +Write the failing test first. A failing test proves you understand the change. Assume the bug is in production code until the test proves otherwise — never fix the test before proving the test is wrong. + +For untested code, write a **characterization test** that captures current behavior before you change anything. It becomes the safety net for the refactor. + +## Mocking + +Mock at boundaries: +- Shell: `cl-letf` on `shell-command`, `shell-command-to-string`, `call-process` +- File I/O when tests shouldn't touch disk +- Network: URL retrievers, HTTP clients +- Time: `cl-letf` on `current-time`, `format-time-string` + +Never mock: +- The code under test +- Core Emacs primitives (buffer ops, string ops, lists) +- Your own domain logic — restructure it to be testable instead + +## Idioms + +- `cl-letf` for scoped overrides (self-cleaning) +- `with-temp-buffer` for buffer manipulation tests +- `make-temp-file` with `.el` suffix for on-disk fixtures +- Tests must run in any order; no shared mutable state + +## Running Tests + +```bash +make test # All +make test-file FILE=tests/test-foo.el # One file +make test-name TEST=pattern # Match by test name pattern +``` + +A PostToolUse hook runs matching tests automatically after edits to a module, when the match count is small enough to be fast. + +## Anti-Patterns + +- Hardcoded timestamps — generate relative to `current-time` or mock +- Testing implementation details (private storage structure) instead of behavior +- Mocking the thing you're testing +- Skipping a failing test without an issue to track it diff --git a/languages/elisp/claude/rules/elisp.md b/languages/elisp/claude/rules/elisp.md new file mode 100644 index 0000000..e641058 --- /dev/null +++ b/languages/elisp/claude/rules/elisp.md @@ -0,0 +1,75 @@ +# Elisp / Emacs Rules + +Applies to: `**/*.el` + +## Style + +- 2-space indent, no tabs +- Hyphen-case for identifiers: `cj/do-thing`, not `cj/doThing` +- Naming prefixes: + - `cj/name` — user-facing functions and commands (bound to keys, called from init) + - `cj/--name` — private helpers (double-dash signals "internal") + - `<module>/name` — module-scoped where appropriate (e.g., `calendar-sync/parse-ics`) +- File header: `;;; foo-config.el --- brief description -*- lexical-binding: t -*-` +- `(provide 'foo-config)` at the bottom of every module +- `lexical-binding: t` is mandatory — no file without it + +## Function Design + +- Keep functions under 15 lines where possible +- One responsibility per function +- Extract helpers instead of nesting deeply — 5+ levels of nesting is a refactor signal +- Prefer named helpers over lambdas for anything nontrivial +- No premature abstraction — three similar lines beats a clever macro + +Small functions are the single strongest defense against paren errors. Deeply nested code is where AI and humans both fail. + +## Requires and Loading + +- Every `(require 'foo)` must correspond to a loadable file on the load-path +- Byte-compile warnings about free variables usually indicate a missing `require` or a typo in a symbol name — read them +- Use `use-package` for external (MELPA/ELPA) packages +- Use plain `(require 'foo-config)` for internal modules +- For optional features, `(when (require 'foo nil t) ...)` degrades gracefully if absent + +## Lexical-Binding Traps + +- `(boundp 'x)` where `x` is a lexical variable always returns nil. Bind with `defvar` at top level if you need `boundp` to work, or use the value directly. +- `setq` on an undeclared free variable is a warning — use `let` for locals or `defvar` for module-level state +- Closures capture by reference. Avoid capturing mutating loop variables in nested defuns. + +## Regex Gotchas + +- `\s` is NOT whitespace in Emacs regex. Use `[ \t]` or `\\s-` (syntax class). +- `^` in `string-match` matches after `\n` OR at position 0 — use `(= (match-beginning 0) start)` for positional checks when that matters. +- `replace-regexp-in-string` interprets backslashes in the replacement. Pass `t t` (FIXEDCASE LITERAL) when the replacement contains literal backslashes. + +## Keybindings + +- `keymap-global-set` for global; `keymap-set KEYMAP ...` for mode-local +- Group module-specific bindings inside the module's file +- Autoload cookies (`;;;###autoload`) don't activate through plain `(require ...)` — use the form directly, not an autoloaded wrapper + +## Module Template + +```elisp +;;; foo-config.el --- Foo feature configuration -*- lexical-binding: t -*- + +;;; Commentary: +;; One-line description. + +;;; Code: + +;; ... code ... + +(provide 'foo-config) +;;; foo-config.el ends here +``` + +Then `(require 'foo-config)` in `init.el` (or a config aggregator). + +## Editing Workflow + +- A PostToolUse hook runs `check-parens` and `byte-compile-file` on every `.el` save +- If it blocks, read the error — don't retry blindly +- Prefer Write over repeated Edits for nontrivial new code; incremental edits accumulate subtle paren mismatches diff --git a/languages/elisp/claude/rules/verification.md b/languages/elisp/claude/rules/verification.md new file mode 100644 index 0000000..8993736 --- /dev/null +++ b/languages/elisp/claude/rules/verification.md @@ -0,0 +1,42 @@ +# Verification Before Completion + +Applies to: `**/*` + +## The Rule + +Do not claim work is done without fresh verification evidence. Run the command, read the output, confirm it matches the claim, then — and only then — declare success. + +This applies to every completion claim: +- "Tests pass" → Run the test suite. Read the output. Confirm all green. +- "Linter is clean" → Run the linter. Read the output. Confirm no warnings. +- "Build succeeds" → Run the build. Read the output. Confirm no errors. +- "Bug is fixed" → Run the reproduction steps. Confirm the bug is gone. +- "No regressions" → Run the full test suite, not just the tests you added. + +## What Fresh Means + +- Run the verification command **now**, in the current session +- Do not rely on a previous run from before your changes +- Do not assume your changes didn't break something unrelated +- Do not extrapolate from partial output — read the whole result + +## Red Flags + +If you find yourself using these words, you haven't verified: + +- "should" ("tests should pass") +- "probably" ("this probably works") +- "I believe" ("I believe the build is clean") +- "based on the changes" ("based on the changes, nothing should break") + +Replace beliefs with evidence. Run the command. + +## Before Committing + +Before any commit: +1. Run the test suite — confirm all tests pass +2. Run the linter — confirm no new warnings +3. Run the type checker — confirm no new errors +4. Review the diff — confirm only intended changes are staged + +Do not commit based on the assumption that nothing broke. Verify. diff --git a/languages/elisp/claude/settings.json b/languages/elisp/claude/settings.json new file mode 100644 index 0000000..cca0eaa --- /dev/null +++ b/languages/elisp/claude/settings.json @@ -0,0 +1,70 @@ +{ + "permissions": { + "allow": [ + "Bash(make)", + "Bash(make help)", + "Bash(make targets)", + "Bash(make test)", + "Bash(make test *)", + "Bash(make test-all)", + "Bash(make test-unit)", + "Bash(make test-integration)", + "Bash(make test-file *)", + "Bash(make test-name *)", + "Bash(make validate-parens)", + "Bash(make validate-modules)", + "Bash(make compile)", + "Bash(make lint)", + "Bash(make profile)", + "Bash(emacs --batch *)", + "Bash(emacs -Q --batch *)", + "Bash(git status)", + "Bash(git status *)", + "Bash(git diff)", + "Bash(git diff *)", + "Bash(git log)", + "Bash(git log *)", + "Bash(git show)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git branch)", + "Bash(git branch -v)", + "Bash(git branch -a)", + "Bash(git branch --list *)", + "Bash(git remote)", + "Bash(git remote -v)", + "Bash(git remote show *)", + "Bash(git ls-files *)", + "Bash(git rev-parse *)", + "Bash(git cat-file *)", + "Bash(git stash list)", + "Bash(git stash show *)", + "Bash(jq *)", + "Bash(date)", + "Bash(date *)", + "Bash(which *)", + "Bash(file *)", + "Bash(ls)", + "Bash(ls *)", + "Bash(wc *)", + "Bash(du *)", + "Bash(readlink *)", + "Bash(realpath *)", + "Bash(basename *)", + "Bash(dirname *)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-el.sh" + } + ] + } + ] + } +} diff --git a/languages/elisp/githooks/pre-commit b/languages/elisp/githooks/pre-commit new file mode 100755 index 0000000..909cde2 --- /dev/null +++ b/languages/elisp/githooks/pre-commit @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pre-commit hook: secret scan + paren validation on staged .el files. +# Use `git commit --no-verify` to bypass for confirmed false positives. + +set -u + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# --- 1. Secret scan --- +# Patterns for common credentials. Scans only added lines in the staged diff. +SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])' + +secret_hits="$(git diff --cached -U0 --diff-filter=AM \ + | grep '^+' | grep -v '^+++' \ + | grep -iEn "$SECRET_PATTERNS" || true)" + +if [ -n "$secret_hits" ]; then + echo "pre-commit: potential secret in staged changes:" >&2 + echo "$secret_hits" >&2 + echo "" >&2 + echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2 + echo "bypass with: git commit --no-verify" >&2 + exit 1 +fi + +# --- 2. Paren check on staged .el files --- +staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)" + +if [ -n "$staged_el" ]; then + paren_fail="" + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$f" ] || continue + if ! out="$(emacs --batch --no-site-file --no-site-lisp "$f" \ + --eval '(check-parens)' 2>&1)"; then + paren_fail="${paren_fail}${f}: +${out} + +" + fi + done <<< "$staged_el" + + if [ -n "$paren_fail" ]; then + printf 'pre-commit: paren check failed:\n\n%s' "$paren_fail" >&2 + exit 1 + fi +fi + +exit 0 diff --git a/languages/elisp/gitignore-add.txt b/languages/elisp/gitignore-add.txt new file mode 100644 index 0000000..d570607 --- /dev/null +++ b/languages/elisp/gitignore-add.txt @@ -0,0 +1,3 @@ +# Claude Code — personal overrides (not part of the ruleset) +/.claude/settings.local.json +/.claude/.cache/ diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh new file mode 100755 index 0000000..f9b7a31 --- /dev/null +++ b/scripts/install-lang.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Install a language ruleset into a target project. +# Usage: install-lang.sh <language> <project-path> [force] +# +# Copies the language's ruleset files into the project. Re-runnable +# (authoritative source overwrites). CLAUDE.md is preserved unless +# force=1, to avoid trampling project-specific customizations. + +set -euo pipefail + +LANG="${1:-}" +PROJECT="${2:-}" +FORCE="${3:-}" + +if [ -z "$LANG" ] || [ -z "$PROJECT" ]; then + echo "Usage: $0 <language> <project-path> [force]" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$REPO_ROOT/languages/$LANG" + +if [ ! -d "$SRC" ]; then + echo "ERROR: no ruleset for language '$LANG' (expected $SRC)" >&2 + exit 1 +fi + +if [ ! -d "$PROJECT" ]; then + echo "ERROR: project path does not exist: $PROJECT" >&2 + exit 1 +fi + +# Resolve to absolute path +PROJECT="$(cd "$PROJECT" && pwd)" + +echo "Installing '$LANG' ruleset into $PROJECT" + +# 1. .claude/ — rules, hooks, settings (authoritative, always overwrite) +if [ -d "$SRC/claude" ]; then + mkdir -p "$PROJECT/.claude" + cp -rT "$SRC/claude" "$PROJECT/.claude" + if [ -d "$PROJECT/.claude/hooks" ]; then + find "$PROJECT/.claude/hooks" -type f -name '*.sh' -exec chmod +x {} \; + fi + echo " [ok] .claude/ installed" +fi + +# 2. githooks/ — pre-commit etc. +if [ -d "$SRC/githooks" ]; then + mkdir -p "$PROJECT/githooks" + cp -rT "$SRC/githooks" "$PROJECT/githooks" + find "$PROJECT/githooks" -type f -exec chmod +x {} \; + if [ -d "$PROJECT/.git" ]; then + git -C "$PROJECT" config core.hooksPath githooks + echo " [ok] githooks/ installed, core.hooksPath=githooks" + else + echo " [ok] githooks/ installed (not a git repo — skipped core.hooksPath)" + fi +fi + +# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1 +if [ -f "$SRC/CLAUDE.md" ]; then + if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then + echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)" + else + cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md" + echo " [ok] CLAUDE.md installed" + fi +fi + +# 4. .gitignore — append missing lines (deduped, skip comments) +if [ -f "$SRC/gitignore-add.txt" ]; then + touch "$PROJECT/.gitignore" + added=0 + while IFS= read -r line || [ -n "$line" ]; do + # Skip blank lines and comments in the source file + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + # Only add if not already present + if ! grep -qxF "$line" "$PROJECT/.gitignore"; then + # If this is the first line we're adding, prepend a header + if [ "$added" -eq 0 ]; then + printf '\n# --- %s ruleset ---\n' "$LANG" >> "$PROJECT/.gitignore" + fi + echo "$line" >> "$PROJECT/.gitignore" + added=$((added + 1)) + fi + done < "$SRC/gitignore-add.txt" + if [ "$added" -gt 0 ]; then + echo " [ok] .gitignore: $added line(s) added" + else + echo " [skip] .gitignore entries already present" + fi +fi + +echo "" +echo "Install complete." |
