# Lefthook config (https://lefthook.dev). Run `lefthook install` once, then # every git commit / push runs the hooks below. Anything that fails here # blocks the commit / push, so keep the pre-commit phase FAST (sub-second on # a typical change). Slow checks belong in pre-push or CI. # # Bypass (use sparingly, with a reason): `git commit --no-verify`. CI is the # backstop and will re-run everything. pre-commit: parallel: true commands: gofmt: # Lefthook globs match the whole path (not just basename), so "*.go" # would only catch Go files at the repo root. Use a recursive glob to # cover server/, agent/, internal/, test/, and anything else. # # The `glob` directive gates whether the hook RUNS, but `{staged_files}` # expands to every staged file in the commit, not just files matching # the glob. Pass `{staged_files}` through a Go-suffix filter before # handing it to gofmt so a mixed-language commit (e.g. Go + .rego + # .md) does not trip gofmt on the non-Go files with `illegal character` # errors that abort the hook before it can pass. glob: "**/*.go" run: | go_files=$(printf '%s\n' {staged_files} | grep -E '\.go$' || true) if [ -z "$go_files" ]; then exit 0; fi # tr '\n' '\0' + xargs -0 reads filenames null-delimited so a # filename containing a space (rare but legal) does not get # split into two arguments. Portable across BSD + GNU xargs. unformatted=$(printf '%s\n' "$go_files" | tr '\n' '\0' | xargs -0 gofmt -s -l) if [ -n "$unformatted" ]; then echo "gofmt -s would rewrite the following files. Running it now; re-stage and recommit:" echo "$unformatted" printf '%s\n' "$go_files" | tr '\n' '\0' | xargs -0 gofmt -s -w exit 1 fi stage_fixed: true eslint: # Scope eslint to the UI source tree; running it on everything scans node_modules needlessly. glob: "ui/src/**/*.{ts,tsx,js,jsx}" root: "ui/" run: npx eslint --fix --max-warnings=0 {staged_files} stage_fixed: true swiftformat: # Optional: only runs if swiftformat is installed (`brew install swiftformat`). glob: "extension/edr/**/*.swift" run: | if command -v swiftformat >/dev/null 2>&1; then swiftformat {staged_files} fi stage_fixed: true markdownlint: # Lint staged markdown via markdownlint-cli2 (config in .markdownlint-cli2.yaml). # Uses --fix for the safe rules; what cannot be auto-fixed (MD040 language tags) # still surfaces and blocks the commit. Prose wrapping + table style are NOT # markdownlint's job here; the prettier-md hook below owns those. # # npx caches the package after the first download, so steady-state cost is ~1s. glob: "**/*.md" run: | if command -v npx >/dev/null 2>&1; then npx -y -p markdownlint-cli2@0.18.1 markdownlint-cli2 --fix {staged_files} fi stage_fixed: true prettier-md: # Enforce the no-hard-wrap convention + aligned tables on staged markdown via # Prettier (config in .prettierrc.yaml, proseWrap: never; exclusions in # .prettierignore). --write reflows in place and stage_fixed re-stages, so a # wrapped paragraph is silently collapsed rather than blocking the commit. # # {staged_files} expands to every staged path regardless of glob, and Prettier # errors on files it has no parser for, so filter to .md before invoking (same # pattern as the gofmt hook above). --ignore-path keeps .prettierignore honored. glob: "**/*.md" run: | if command -v npx >/dev/null 2>&1; then md_files=$(printf '%s\n' {staged_files} | grep -E '\.md$' || true) if [ -z "$md_files" ]; then exit 0; fi printf '%s\n' "$md_files" | tr '\n' '\0' | xargs -0 npx -y prettier@3.3.3 --ignore-path .prettierignore --write fi stage_fixed: true dash-lint: # Mirror the CI "No em dashes" gate (.github/workflows/no-emdash.yml) at commit time, scoped to staged files like # every other hook here. tools/lint-no-emdash.sh bans em/en dashes (U+2014 / U+2013); tools/dash-lint bans the # spaced ASCII hyphen `' - '` in Markdown prose and in code comments + string literals. This closes the gap that let # PR #414's tasks.md em-dashes reach CI: `task lint:dashes` scans `git ls-files`, so the then-untracked new files # were invisible to a manual run, and no pre-commit hook covered the rule. {staged_files} expands to every staged # path regardless of glob; both tools filter internally (grep -I binary-skip / type-aware), so the raw list is safe. # `go run ./tools/dash-lint` compiles once and is cached, so steady-state cost is ~1s alongside the markdown hooks. # Capture each check's status into rc rather than relying on the block's last-command exit: lefthook takes the run # script's final exit code, so a chained `lint-no-emdash.sh` (em-dash) failure would otherwise be masked by a # passing `dash-lint` (spaced-hyphen) on the next line. Running both unconditionally also surfaces every violation # in one pass instead of stopping at the first. run: | files=$(printf '%s\n' {staged_files}) if [ -z "$files" ]; then exit 0; fi rc=0 printf '%s\n' "$files" | tr '\n' '\0' | xargs -0 bash tools/lint-no-emdash.sh || rc=1 printf '%s\n' "$files" | tr '\n' '\0' | go run ./tools/dash-lint || rc=1 exit $rc # Catch secrets before they land. `git secrets` or `trufflehog` could # fit here later. For now, a minimal regex guard against the obvious # patterns that shouldn't be in source. # # Reads from the INDEX (`git show :path`) rather than the working tree so # a developer who stages a secret and then edits it out of the working # tree before committing still gets caught. Falls back to the working # tree for files deleted from the index. no-secrets: glob: "**/*.{go,ts,tsx,js,swift,md,yml,yaml,json,sh}" exclude: - ".tool-versions" - "lefthook.yml" - "docs/adr/**" run: | matched=0 for f in {staged_files}; do content=$(git show ":$f" 2>/dev/null) || continue if printf '%s' "$content" | grep -nE 'AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then echo "Potential secret staged in $f" matched=1 fi done exit $matched # audit_events is append-only. Block any production Go file that # stages an UPDATE or DELETE against the table; the audit trail's # integrity is the whole point of the schema, and accidental # tampering is the regression this hook exists to catch. # The Go test at server/identity/internal/audit/append_only_lint_test.go # is the CI backstop for developers who bypass with --no-verify. # The audit package is NOT excluded (PR #120 review): the regex # is UPDATE/DELETE-specific so the legitimate INSERT in mysql.go # does not trip, and policing the package is the point. audit-append-only: glob: "**/*.go" exclude: - "**/*_test.go" run: | matched=0 for f in {staged_files}; do content=$(git show ":$f" 2>/dev/null) || continue if printf '%s' "$content" | grep -niE '\b(update[[:space:]]+audit_events|delete[[:space:]]+from[[:space:]]+audit_events)\b'; then echo "audit_events is append-only; UPDATE/DELETE not permitted in $f" matched=1 fi done exit $matched commit-msg: commands: no-agent-attribution: # Commits here are authored by the people who own them. An agent that helped write a change is a tool, and tools do not take # authorship credit. This refuses any Co-Authored-By trailer (CONTRIBUTING.md bans them for people and agents alike), an # agent session marker, and a "Generated with" line. The only trailer allowed is the one GitHub writes on a squash-merged # dependabot PR, which never passes through this hook anyway. # # This hook covers LOCAL commits only. A squash merge composed by GitHub from a PR body never reaches it, which is how the # four commits that prompted this gate got in, so the CI half in .github/workflows/no-agent-attribution.yml is the one that # closes that path. Keep both. run: tools/lint-no-agent-attribution.sh msg {1} pre-push: parallel: true commands: go-build: # Whole-module build as a cheap smoke test. Failures here almost # always mean a refactor left a broken import somewhere. The server # embeds server/ui/dist via `embed`; on a clean checkout that dir # doesn't exist, so seed a placeholder here (CI workflows do the # equivalent before their Go steps). run: | mkdir -p server/ui/dist && touch server/ui/dist/.gitkeep go build ./... go-lint-fast: # --fast-only enables the quick-running linters (under ~2s usually). # The full run still happens in CI and before release. Uses the custom # golangci-lint binary at tmp/golangci-lint-custom because .golangci.yml # enables the commentwrap plugin which the upstream binary cannot load; # falls back to upstream only with --no-config to avoid the unknown- # linter error, surfacing a clear stderr hint for the operator. run: | mkdir -p server/ui/dist && touch server/ui/dist/.gitkeep if [ -x tmp/golangci-lint-custom ]; then tmp/golangci-lint-custom run --fast-only ./... else echo "[lefthook] tmp/golangci-lint-custom missing; running upstream golangci-lint --no-config (no commentwrap)" >&2 echo "[lefthook] Run 'task lint:install' once to enable the full linter set including commentwrap." >&2 golangci-lint run --no-config --fast-only ./... fi arch-go: # Bounded-context architecture lint via arch-go. Local feedback in # ~3s; CI also has its own arch-go workflow as the backstop. Only # runs when Go files (or the rule config / Taskfile / arch-go.yml # itself) change, mirroring the .github/workflows/arch-go.yml gate. glob: "{**/*.go,arch-go.yml,Taskfile.yml,go.mod,go.sum}" run: | mkdir -p server/ui/dist && touch server/ui/dist/.gitkeep task lint:arch tsc: root: "ui/" run: npx tsc --noEmit # Post-checkout hook keeps UI deps in sync if package-lock.json changed. post-checkout: commands: npm-install-if-lock-changed: run: | if git diff --name-only HEAD@{1} HEAD 2>/dev/null | grep -q '^ui/package-lock.json$'; then echo "ui/package-lock.json changed; running npm ci" (cd ui && npm ci) fi