diff --git a/Makefile b/Makefile index 001d2a8..8a43e1b 100644 --- a/Makefile +++ b/Makefile @@ -30,11 +30,19 @@ STOW_FLAGS := --no-folding -t "$$HOME" .PHONY: help help: ## show this help - @grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' - + @# Two patterns: `##@ Group` opens a section, `## text` after a target + @# documents it. Both are parsed out of this file, so a target is described + @# where it is defined and a target with no `##` stays out of the list. + @# ponytail: escapes inline, no tty check — `make help | less` shows the + @# codes as text. Gate on `test -t 1` the day anyone pipes this. + @awk 'BEGIN {FS = ":.*##"; print "usage: make \n"} \ + /^##@/ { printf "\n\033[1m%s:\033[0m\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_.-]+:.*##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +##@ Quality assurance # -# QA — every target here is read-only: it must not touch the machine it runs -# on, because CI runs the lot on macOS and Ubuntu on every pull request. +# Every target here is read-only: it must not touch the machine it runs on, +# because CI runs the lot on macOS and Ubuntu on every pull request. # .PHONY: qa qa-deps lint bin-test @@ -71,10 +79,23 @@ bin-test: ## run every *.test.sh — self-contained, no machine state touched echo "== $$t"; bash "$$t" || exit 1; \ done +.PHONY: claude-settings-test +claude-settings-test: ## check the stowed Claude settings parse and stay machine-independent + @# It is one file for every machine, so a path that only exists on one is a + @# bug the other machine finds silently — the setting is simply ignored + @# there. $$HOME instead, and anything genuinely local (a client checkout, a + @# per-machine socket) goes in ~/.claude/settings.local.json, which is not + @# tracked. That file is also why this repo being public is survivable. + @python3 -c 'import json;json.load(open("shared/.claude/settings.json"))' + @grep -nE '"[^"]*/(Users|home)/' shared/.claude/settings.json \ + && { echo "^ only true on one machine - use \$$HOME, or move it to ~/.claude/settings.local.json"; exit 1; } \ + || true + +##@ Security # -# Security — separate from qa because it is the same answer on every OS, so CI -# runs it once instead of once per matrix leg, and because it needs Docker, -# which qa deliberately does not. +# Separate from qa because it is the same answer on every OS, so CI runs it once +# instead of once per matrix leg, and because it needs Docker, which qa +# deliberately does not. # # Pinned: an unpinned scanner turns someone else's rule release into a red build @@ -154,10 +175,11 @@ semgrep-mirror: ## copy a semgrep version into my GHCR (once per version bump) @# There is no bash or shell pack in the registry (p/bash and p/shell both @# 404), so `make lint` remains what checks the scripts themselves. +##@ Apps and packages # -# Apps and packages — the Brewfile on every machine, plus whatever the distro -# has to supply itself. Called `apps` rather than `brew` because Homebrew is -# only most of it: the GUI half on Linux comes from vendor apt repos. +# The Brewfile on every machine, plus whatever the distro has to supply itself. +# Called `apps` rather than `brew` because Homebrew is only most of it: the GUI +# half on Linux comes from vendor apt repos. # .PHONY: apps apps-test @@ -184,21 +206,7 @@ apps-test: ## parse the Brewfile without installing anything @command -v brew >/dev/null || { echo "brew not installed - skipped"; exit 0; }; \ HOMEBREW_NO_AUTO_UPDATE=1 brew bundle list --file Brewfile >/dev/null -.PHONY: claude-settings-test -claude-settings-test: ## check the stowed Claude settings parse and stay machine-independent - @# It is one file for every machine, so a path that only exists on one is a - @# bug the other machine finds silently — the setting is simply ignored - @# there. $$HOME instead, and anything genuinely local (a client checkout, a - @# per-machine socket) goes in ~/.claude/settings.local.json, which is not - @# tracked. That file is also why this repo being public is survivable. - @python3 -c 'import json;json.load(open("shared/.claude/settings.json"))' - @grep -nE '"[^"]*/(Users|home)/' shared/.claude/settings.json \ - && { echo "^ only true on one machine - use \$$HOME, or move it to ~/.claude/settings.local.json"; exit 1; } \ - || true - -# -# Dotfiles ($HOME) via GNU stow -# +##@ Dotfiles ($HOME) via GNU stow .PHONY: stow restow unstow stow-backup stow-test stow-backup: @@ -256,9 +264,7 @@ stow-test: ## stow and unstow every package in the repo into a throwaway $HOME @t=$$(mktemp -d); trap 'rm -rf "$$t"' EXIT; HOME=$$t $(MAKE) -s stow unstow @# The conflict-backup half is bin/stow/backup.test.sh, which bin-test runs. -# -# Shell -# +##@ Shell .PHONY: shell shell: ## source this repo's .bashrc fragment from ~/.bashrc (idempotent) @@ -272,8 +278,9 @@ shell: ## source this repo's .bashrc fragment from ~/.bashrc (idempotent) && echo "NOTE: an older copy of the fragment is still pasted into ~/.bashrc - delete that block, it shadows the repo" \ || true +##@ JetBrains IDEs # -# JetBrains IDEs — see bin/jetbrains/README.md for why this is a script, not stow +# See bin/jetbrains/README.md for why this is a script, not stow # .PHONY: jetbrains @@ -284,8 +291,9 @@ jetbrains: ## set the JVM options this repo owns in every JetBrains config dir @echo @echo "plugins: open $(CURDIR) in the IDE and accept the 'required plugins' prompt" +##@ macOS System Settings # -# macOS System Settings — the panes stow cannot reach, see bin/macos/defaults.sh +# The panes stow cannot reach, see bin/macos/defaults.sh # .PHONY: macos macos-touchid @@ -314,9 +322,7 @@ macos-touchid: ## authenticate sudo with Touch ID (root-owned, so its own target cat "$$f"; \ sudo -k; echo "now run any sudo command - it should ask for a fingerprint" -# -# GIT config -# +##@ GIT config .PHONY: git git-config-test git-config-format git: ## set up this machine: hook in .gitconfig, set email, set up commit signing diff --git a/shared/.claude/CLAUDE.md b/shared/.claude/CLAUDE.md index 7e27c64..f464838 100644 --- a/shared/.claude/CLAUDE.md +++ b/shared/.claude/CLAUDE.md @@ -3,8 +3,8 @@ Every rule lives in [`rules/`](rules/), one file each. This file is the index. A rule there loads unconditionally unless it declares `paths:` frontmatter, which scopes it to the files it is about. A rule earns that scoping only when its trigger is a file path *and* breaking it shows up in a diff — GitHub Actions -is the only one so far. The rest override a default I would otherwise fall back -to, so they have to be in context before the mistake, not after. +and Makefiles are the two so far. The rest override a default I would otherwise +fall back to, so they have to be in context before the mistake, not after. | Rule | Applies when | |------|--------------| @@ -14,6 +14,7 @@ to, so they have to be in context before the mistake, not after. | [Commit messages](rules/commit-messages.md) | writing a commit | | [Git history](rules/git-history.md) | amend, squash, rebase, force-push | | [GitHub Actions](rules/github-actions.md) | writing under `.github/` — scoped, loads itself | +| [Makefiles](rules/makefiles.md) | writing a `Makefile` — scoped, loads itself | | [Voice](rules/voice.md) | background, not a rule: the plugin that reads answers aloud | The folder name is not a preference: `rules/` and `CLAUDE.md` are the only two diff --git a/shared/.claude/rules/makefiles.md b/shared/.claude/rules/makefiles.md new file mode 100644 index 0000000..682de6b --- /dev/null +++ b/shared/.claude/rules/makefiles.md @@ -0,0 +1,57 @@ +--- +paths: + - "**/Makefile" + - "**/makefile" + - "**/GNUmakefile" + - "**/*.mk" +--- + +# Makefiles + +Plain `make` must answer "what can I do here?", so every Makefile gets a `help` +target, and `help` is the **first target in the file** — Make's default goal is +the first target, which is what makes the bare `make` print it. Set +`.DEFAULT_GOAL := help` as well, not instead: it is what keeps the bare `make` +answering the day someone adds a target above `help` without noticing that +position was load-bearing. + +`help` parses the Makefile rather than repeating it, so a target is described +where it is defined and the list cannot drift from the targets. Two markers: +`## text` at the end of a target line documents that target, `##@ Group` opens a +section. The colon after a heading is printed, not written — nine source lines +cannot each remember it. + + .PHONY: help + help: ## show this help + @awk 'BEGIN {FS = ":.*##"; print "usage: make \n"} \ + /^##@/ { printf "\n\033[1m%s:\033[0m\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_.-]+:.*##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + + ##@ Quality assurance + lint: ## parse every shell file without running it + +Bold the heading, colour the target name: 30 targets in one column is the thing +the grouping was meant to fix, and the section titles have to win over the rows +under them. The escapes go inline in the format string rather than through +`tput`, and there is no `test -t 1` gate — piping `make help` into `less` shows +the codes as text, which is a cost worth one line of awk rather than five. + +The parser is deliberately naive — it assumes `##@ ` with the space, one `##` +per line, and a target name inside 22 columns. Widen it when a line in the file +actually breaks it, not in advance. + +Group targets by what they are *for*, not by what they call — checks, packages, +dotfiles, deploy — and keep the file in that order, because the section a target +sits under is the section it prints under. Spell the group name out: the heading +is read by someone who does not yet know what `QA` stands for here, which is the +whole reason they ran `make`. + +Prefix a target with its group when the bare name would collide or read +ambiguously across groups. Four checks here each want the name `test`, so they +become `apps-test`, `stow-test`, `git-config-test` and `claude-settings-test`. +Prefix for that reason only: `stow`, `restow` and `unstow` are unambiguous as +they are. + +A target with no `##` is deliberately unlisted — a prerequisite of another +target is not something to reach for by hand. Every target that is not a file is +`.PHONY`.