diff options
| -rwxr-xr-x | .claude/hooks/validate-el.sh | 114 | ||||
| l--------- | .claude/rules/commits.md | 1 | ||||
| -rw-r--r-- | .claude/rules/elisp-testing.md | 157 | ||||
| -rw-r--r-- | .claude/rules/elisp.md | 75 | ||||
| l--------- | .claude/rules/testing.md | 1 | ||||
| l--------- | .claude/rules/verification.md | 1 | ||||
| -rw-r--r-- | .claude/settings.json | 74 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rwxr-xr-x | githooks/pre-commit | 50 |
9 files changed, 3 insertions, 473 deletions
diff --git a/.claude/hooks/validate-el.sh b/.claude/hooks/validate-el.sh deleted file mode 100755 index d6999ac..0000000 --- a/.claude/hooks/validate-el.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# Validate and test .el files after Edit/Write/MultiEdit. -# PostToolUse hook: receives tool-call JSON on stdin. -# -# On success: exit 0 silent. -# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude -# sees a structured error in its context, THEN exit 2 to block the tool -# pipeline. stderr still echoes the error for terminal visibility. -# -# Phase 1: check-parens + byte-compile -# Phase 2: for non-test .el files, run matching tests/test-<stem>*.el - -set -u - -# Emit a JSON failure payload and exit 2. Arguments: -# $1 — short failure type (e.g. "PAREN CHECK FAILED") -# $2 — file path -# $3 — emacs output (error body), always sent to Claude in additionalContext -# $4 — optional compact terminal echo; when set, the terminal shows this -# instead of the full $3 (Claude still gets the full $3). Used by the -# test runner so a failing suite prints a short summary to the pane -# rather than dumping every ERT backtrace. -fail_json() { - local ctx - ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \ - | jq -Rs .)" - cat <<EOF -{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}} -EOF - printf '%s: %s\n%s\n' "$1" "$2" "${4:-$3}" >&2 - exit 2 -} - -# 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 - fail_json "PAREN CHECK FAILED" "$f" "$output" - fi - ;; - *.el) - if ! output="$(emacs --batch --no-site-file --no-site-lisp \ - -L "$PROJECT_ROOT" \ - -L "$PROJECT_ROOT/modules" \ - -L "$PROJECT_ROOT/tests" \ - -L "$PROJECT_ROOT/themes" \ - --eval '(package-initialize)' \ - "$f" \ - --eval '(check-parens)' \ - --eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then - fail_json "VALIDATION FAILED" "$f" "$output" - fi - ;; -esac - -# --- Phase 2: test runner --- -# Determine which tests (if any) apply to this edit. Works for projects with -# source at root, in modules/, or elsewhere — stem-based test lookup is the -# common pattern. -tests=() -case "$f" in - */init.el|*/early-init.el) - : # Phase 1 handled it; skip test runner - ;; - "$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") - ;; - *.el) - # Any other .el under the project — find matching tests by stem - stem="$(basename "${f%.el}")" - mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort) - ;; -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 "$PROJECT_ROOT/themes" \ - --eval '(package-initialize)' \ - -l ert "${load_args[@]}" \ - --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then - # Terminal gets a compact summary (the run tally + the failing test names); - # Claude still gets the full backtrace via additionalContext. Keeps the - # pane from drowning in ERT stack frames on every red test. - summary="$(printf '%s\n' "$output" \ - | grep -E '^Ran [0-9]+ tests|unexpected results:|^[[:space:]]+FAILED' || true)" - [ -n "$summary" ] && summary="${summary}"$'\n'"(full backtrace in Claude's context)" - fail_json "TESTS FAILED ($count test file(s))" "$f" "$output" "$summary" - fi -fi - -exit 0 diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md deleted file mode 120000 index 3e746ed..0000000 --- a/.claude/rules/commits.md +++ /dev/null @@ -1 +0,0 @@ -/home/cjennings/code/rulesets/claude-rules/commits.md
\ No newline at end of file diff --git a/.claude/rules/elisp-testing.md b/.claude/rules/elisp-testing.md deleted file mode 100644 index 7c3a9ef..0000000 --- a/.claude/rules/elisp-testing.md +++ /dev/null @@ -1,157 +0,0 @@ -# Elisp Testing Rules - -Applies to: `**/tests/*.el` - -Implements the core principles from `testing.md`. All rules there apply here — -this file covers Elisp-specific patterns. - -## Framework: ERT - -Use `ert-deftest` for all tests. One test = one scenario. - -## File Layout - -- `tests/test-<module>.el` — tests for `<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 scoped to one module -- `tests/testutil-*.el` — cross-module helpers (shared fixtures, generic mocks, filesystem helpers); name them for what they help with - -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. - -### Measuring it — `make coverage-summary` - -The bundle ships a coverage summary at `.claude/scripts/coverage-summary.el` and a Makefile fragment (`coverage-makefile.txt`) with `coverage` and `coverage-summary` targets. After `make coverage` writes an undercover SimpleCov report, `make coverage-summary` prints a per-file table and a unit-weighted project number. - -The number to watch is the missing-file count. A module no test loads never appears in the SimpleCov report, so a line-weighted total skips it silently — the suite looks healthier than it is. The summary counts every `modules/*.el` on disk that's absent from the report as 0%, so an untested module drags the project number down where you can see it. Copy the fragment's targets into your own Makefile to adopt it; the bundle never edits your Makefile. - -## 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. - -## Interactive vs Internal — Split for Testability - -When a function mixes business logic with user interaction, split it: - -- **Internal** (`cj/--foo`) — pure logic. All parameters explicit. No prompts, - no UI. Deterministic and trivially testable. -- **Interactive wrapper** (`cj/foo`) — thin layer that reads user input and - delegates to the internal. - -```elisp -(defun cj/--move-buffer-and-file (dir &optional ok-if-exists) - "Move the current buffer's file into DIR. Overwrite if OK-IF-EXISTS." - ...) - -(defun cj/move-buffer-and-file () - "Interactive wrapper: prompt for DIR, delegate." - (interactive) - (let ((dir (read-directory-name "Move to: "))) - (cj/--move-buffer-and-file dir))) -``` - -Test the internal directly with parameter values — no `cl-letf` on -`read-directory-name`, `yes-or-no-p`, etc. The wrapper gets a smoke test or -nothing — Emacs already tests its own prompts. The internal also becomes -reusable by other Elisp code without triggering UI. - -## 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. - -## Batch-Mode Reproducibility - -Tests must pass under `emacs --batch` — the headless, scriptable path that CI and the `make` targets use. `--batch` is the source of truth, not an interactive session. - -- Don't depend on interactive-session state: window configuration, frame parameters, `this-command`, minibuffer activity, or anything a running editor accumulates. A test that passes in a live Emacs but fails (or hangs) under `--batch` is broken. -- Don't block on a prompt. `--batch` has no one to answer `y-or-n-p` or `read-string`, so an unmocked prompt either errors or stalls the run. Test the internal directly (see *Interactive vs Internal* above) or `cl-letf` the prompt. -- Keep tests deterministic: no reliance on test execution order, wall-clock time (mock `current-time`), or environment that differs between the developer's machine and CI. - -## Isolating Emacs State - -A test must not read or mutate the developer's real Emacs config. Bind a throwaway environment so the run is hermetic regardless of who runs it. - -- Bind `user-emacs-directory` (and, when relevant, `user-init-file`) to a temp directory so package state, `custom-file` writes, caches, and auto-save files land in the sandbox rather than the developer's `~/.emacs.d`. -- Control `load-path` explicitly. Add only the project's own directories; don't lean on whatever happens to be installed in the developer's session. -- Depend only on the project's declared dependencies. A test that passes because some unrelated package is installed on this machine will fail on a clean checkout or in CI. - -```elisp -(ert-deftest test-foo-writes-to-sandbox () - "Normal: writes under an isolated user-emacs-directory." - (let* ((sandbox (make-temp-file "elisp-test-" t)) - (user-emacs-directory (file-name-as-directory sandbox))) - (unwind-protect - (progn - (cj/--foo) - (should (file-exists-p (expand-file-name "foo.cache" user-emacs-directory)))) - (delete-directory sandbox t)))) -``` - -## Byte-Compile and Native-Comp Warnings - -A clean compile is part of green. Byte-compile warnings (free variables, wrong argument counts, unused lexical bindings, obsolete-function calls) flag real defects, so treat them as failures rather than noise. - -This can be enforced in the test run by binding `byte-compile-error-on-warn` to `t` and compiling the modules under test, optionally extending to native compilation where `native-comp-async-report-warnings-errors` is available. - -Keep the native-comp half conditional. Native compilation exists only on builds with the `native-compile` feature (Emacs 28+ compiled with it); older or non-native builds lack `native-comp-*` variables and `native-compile` entirely. Gate on the feature so the suite still runs everywhere: - -```elisp -(when (and (fboundp 'native-comp-available-p) (native-comp-available-p)) - ;; native-comp-specific checks here - ) -``` - -Make the warnings-as-errors gate opt-in or version-aware rather than absolute — a warning that's clean on the project's pinned Emacs may differ across versions, and a hard failure on every build penalizes contributors on a different Emacs than the maintainer's. - -## 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/.claude/rules/elisp.md b/.claude/rules/elisp.md deleted file mode 100644 index ea9bdc2..0000000 --- a/.claude/rules/elisp.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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 -- Edit cohesively, then verify parens/byte-compile right away. For nontrivial Elisp, land a function as one complete, coherent change rather than dribbling it in over many tiny partial edits — incremental fragments accumulate subtle paren mismatches. Run the paren-balance and byte-compile checks immediately after editing, whatever editing mechanism the environment uses. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md deleted file mode 120000 index 23c3a14..0000000 --- a/.claude/rules/testing.md +++ /dev/null @@ -1 +0,0 @@ -/home/cjennings/code/rulesets/claude-rules/testing.md
\ No newline at end of file diff --git a/.claude/rules/verification.md b/.claude/rules/verification.md deleted file mode 120000 index ac32768..0000000 --- a/.claude/rules/verification.md +++ /dev/null @@ -1 +0,0 @@ -/home/cjennings/code/rulesets/claude-rules/verification.md
\ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 9ab9f12..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "attribution": { - "commit": "", - "pr": "" - }, - "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" - } - ] - } - ] - } -} @@ -17,3 +17,6 @@ .claude/ /chime-autoloads.el /tests/tests-autoloads.el +CLAUDE.md +githooks/ +/coverage-makefile.txt diff --git a/githooks/pre-commit b/githooks/pre-commit deleted file mode 100755 index 909cde2..0000000 --- a/githooks/pre-commit +++ /dev/null @@ -1,50 +0,0 @@ -#!/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 |
