lefthook.yml
Rama main · réplica de lectura del repositorio original.
# 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