SuperAPI AI Directory

hk

Testing & quality

View repository

Updated 10-15 20:27

About this skill

hk - Git Hook Manager

hk by jdx runs linters and formatters as git hooks with built-in parallelism, file locking (no race conditions), and staged-file-only operation (no separate lint-staged needed). Config is in Pkl - Apple's typed configuration language.

Mental Model

Every hk setup is three steps: detect what the project has → compose steps from tiers → wire the hooks in.

detect project type + tools
         ↓
compose hk.pkl (tiered steps)
         ↓
wire: mise.toml + .hk-hooks/ + prepare script

Setup Workflow

1. Detect

hk --version                    # get current version for amends URL
ls package.json go.mod Cargo.toml pyproject.toml flake.nix Makefile
cat mise.toml package.json      # existing tools, package manager, scripts

Identify:

  • Language(s) and framework
  • Package manager (pnpm/bun/npm/yarn for JS, cargo, go, pip, etc.)
  • Formatter already configured (prettier, biome, ruff, gofmt…)
  • Linter already configured (eslint, golangci-lint, ruff, clippy…)
  • Test runner (vitest, jest, go test, cargo test, pytest…)
  • Whether it's a team/shared repo, and whether branch protection should be hard server-side protection or advisory local hook protection

2. Choose steps (tiered)

Tier 1 - Universal (always add):

Step Builtin
trailing-whitespace Builtins.trailing_whitespace
newlines Builtins.newlines
check-merge-conflict Builtins.check_merge_conflict

Tier 2 - Common tools (add if relevant):

Step Builtin When
typos Builtins.typos Always (fast spell check)
gitleaks custom Always (secret detection)
rumdl Builtins.rumdl If *.md files exist

Tier 3 - Language-specific (see references/builtins-by-language.md):

Signal file Steps to add
package.json + biome.json/biome.jsonc biome (or ultracite), eslint
package.json (no biome) prettier, eslint
tsconfig.json typecheck (tsc/tsgo/astro check/svelte-check)
go.mod go_fmt, go_vet, golangci_lint, gomod_tidy
Cargo.toml cargo_fmt, cargo_clippy
pyproject.toml/requirements.txt ruff (format+lint), mypy
flake.nix/*.nix nix_fmt (nixfmt), deadnix
*.sh/*.zsh shfmt, shellcheck

Tier 4 - Project-specific (detect from config files):

Signal Step
commitlint.config.* exists commit-msg hook with commitlint
.dependency-cruiser.* or check:deps exists whole-graph architecture check
.yamllint* exists yamllint
Team/shared repo no-commit-to-branch (pre-commit), branch guard (pre-push). For advisory private-repo protection with owner opt-out, use the soft-protected pre-push asset below.
pnpm-lock.yaml exists pnpm build-script decision check - copy assets/pnpm-build-scripts-check.mjs (see below)
Test runner detected test step(s) - vitest/jest/go test/cargo test/pytest

3. Wire the hooks

Three files to create/update, plus optional extras:

  1. mise.toml - add hk, pkl, tool binaries
  2. hk.pkl - configuration
  3. .hk-hooks/pre-commit - tracked hook wrapper (runs hk run pre-commit -q; -q quiets every step on success - see references/output-noise.md)
  4. .hk-hooks/pre-push - optional, for push-time checks or branch guards. For advisory private-repo branch protection, copy from assets/soft-protected-branch-pre-push.sh.

Then:

chmod +x .hk-hooks/*
git config --local core.hooksPath .hk-hooks

And add to package.json prepare script (JS projects):

"prepare": "[ -n \"$CI\" ] && exit 0 || git config --local core.hooksPath .hk-hooks"

Wire core.hooksPath directly - don't use hk install here. hk install succeeds on modern hk/Git and wires hk's own generated hooks, silently diverging from the tracked .hk-hooks/ wrappers this skill sets up (the wrapper adds the HK=0 bypass, mise discovery, and -q). One wiring path, the tracked one. hk needn't be installed at prepare time - the wrapper discovers it at commit time and errors clearly if missing.

For non-JS projects, set core.hooksPath manually or via a Makefile setup target.

4. Validate

hk check --all      # verify all steps pass on existing files
hk validate         # verify hk.pkl is valid Pkl

Preferred Patterns

hk.pkl global settings

Always use these at the top (after the amends/import lines):

exclude = List("node_modules", "dist", ".next", ".git")  // add project-specific dirs
display_skip_reasons = List()   // suppress skip noise
terminal_progress = false        // disable OSC terminal-progress escape sequences (NOT stdout noise — see references/output-noise.md)

Always use these on the pre-commit hook:

["pre-commit"] {
    fix = true        // auto-fix and re-stage
    stash = "git"     // isolate staged changes
    steps { ... }
}

Binary file excludes

Always exclude binary/font files from trailing-whitespace, newlines, and typos:

local binary_excludes = List(
    "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.ico",
    "*.woff", "*.woff2", "*.ttf", "*.eot", "*.pdf", "*.zip"
)

["trailing-whitespace"] = (Builtins.trailing_whitespace) {
    exclude = binary_excludes
}

Keeping steps quiet - one flag on the hook wrapper

On hk ≥ 1.51.0 the quiet lever is hk run <hook> -q on the .hk-hooks/pre-commit wrapper. -q natively quiets every step on success: success → 0 bytes, failure → the failing step's full stdout+stderr survives (only hk's progress chrome is dropped). No per-step wrapping, no per-tool tiering - steps run their plain commands.

["vitest"] {
    check = "pnpm exec vitest run"   // chatty on success — silenced by wrapper-level -q
}

Never use --silent: it reaches 0 bytes on success too, but on failure it drops the diagnostics (you get only See .../output.log). -q is the only safe choice. -n, HK_LOG, RUST_LOG are no-ops on step success output. See references/output-noise.md for the mechanism, the TTY-vs-no-TTY nuance, and measured numbers.

Whole-graph checks

Some tools inspect the whole repo graph and should not receive {{files}}: dependency-cruiser, knip, supply-chain scanners, link checkers, full typechecks, and coverage gates. Wire them as ordinary steps with no glob when the check must always see the full graph. (Which supply-chain scan to run, and its block-vs-report severity split, is the supply-chain-hardening skill's call - this skill owns the wiring.)

Globless is not only about what the tool accepts. A globbed step does not run at all when the only staged change is a deletion: hk resolves the glob to zero files and the step never appears in the plan, while a globless step still runs (with "0 files"). Any invariant broken by removing a file - a link checker, a dead-reference check, a manifest-vs-tree gate - must therefore be globless, or it goes quiet in exactly the case it exists for.

For dependency-cruiser:

local depcruise_step = new Step {
    check = "pnpm --silent check:deps"
}

Use a package script so the long command and config path live with the JS project:

"check:deps": "depcruise src --config .dependency-cruiser.cjs --output-type err-long --no-progress --no-cache"

Prefer putting whole-graph checks in a full quality, check, CI, or pre-push hook. Promote to staged pre-commit only after measuring the step and confirming the added latency is acceptable for normal commits.

The .hk-hooks/pre-commit wrapper

This is the file git actually executes. It's tracked in git (unlike .git/hooks/). Don't capture hk's output - exec it so colour, progress, and failure diagnostics stream through. The -q flag drops only success chrome (0 bytes on a clean run); a failing step still streams its full stdout+stderr. The wrapper also adds an HK=0 bypass and discovers hk via mise when it isn't on PATH:

#!/bin/sh
# hk pre-commit hook — tracked wrapper. Streams hk output directly.

# HK=0 bypasses all hooks (mirrors `HK=0 git commit`).
if [ "${HK:-1}" = "0" ]; then
  exit 0
fi

# Find hk: on PATH, else via mise (covers shells without mise activated).
HK_BIN=""
if command -v hk >/dev/null 2>&1; then
  HK_BIN="$(command -v hk)"
elif command -v mise >/dev/null 2>&1; then
  HK_BIN="$(mise which hk 2>/dev/null || true)"
fi

if [ -z "$HK_BIN" ]; then
  echo "hk not found. Install tools with: mise install" >&2
  exit 1
fi

exec "$HK_BIN" run pre-commit -q "$@"

For hooks that only delegate to hk, use simpler wrappers:

#!/bin/sh
exec hk run commit-msg "$@"
#!/bin/sh
exec hk run pre-push "$@"

Soft-protected branch pre-push

Use this rarely: small/private/shared repos where server-side branch protection is unavailable or intentionally advisory, but collaborators should be steered away from direct pushes to main/master. Prefer server-side branch rules when they are available. This is not a security boundary: hooks are per clone, require core.hooksPath, and can be bypassed with --no-verify.

Copy assets/soft-protected-branch-pre-push.sh to .hk-hooks/pre-push and make it executable:

cp /path/to/skill/assets/soft-protected-branch-pre-push.sh .hk-hooks/pre-push
chmod +x .hk-hooks/pre-push
git config --local core.hooksPath .hk-hooks

Pattern:

  • Parse Git's pre-push stdin and block by remote_ref, not the current branch. Current-branch checks miss pushes like git push origin feature:main.
  • Default-block direct pushes to refs/heads/main and refs/heads/master.
  • Let owner clones opt out with repo-local config: git config --local hooks.allowMainPush true.
  • Keep one-off automation escape hatch explicit: HK_ALLOW_MAIN_PUSH=1 git push.
  • Document the advisory nature and opt-out in repo docs/agent instructions.

pnpm build-script decision check

A dependency with a lifecycle script (preinstall/install/postinstall, or a binding.gyp) needs a decision recorded in pnpm-workspace.yaml allowBuilds. Without one, pnpm 11 fails the install closed (ERR_PNPM_IGNORED_BUILDS) - but only where nothing masks its check. On a machine with a global ignoreScripts, the install is green, a cold reinstall is green, and the failure lands in CI or a platform build instead.

Copy assets/pnpm-build-scripts-check.mjs to .hk-hooks/ and glob the step on the files that can change the dependency tree:

["pnpm-build-scripts"] {
    glob = List("package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml")
    check = "node .hk-hooks/pnpm-build-scripts-check.mjs"
}

Pattern:

  • Read config, run nothing. The checker parses installed manifests and pnpm-workspace.yaml. pnpm has no detect-without-execute mode, and its own reporting (pnpm ignored-builds, .modules.yaml) is computed under the masking setting, so it reports the mask rather than the missing decision. The one local command that does reproduce CI - pnpm install --ignore-scripts=false - re-enables the scripts the posture blocks, so it is not a check.
  • Never brick what it can't evaluate: absent node_modules, or no pnpm project, warns and exits 0 (same posture as a typecheck step on a fresh clone).
  • --json for machine consumption; the human output caps the listing and reports the true total.
  • Whether a package gets true or false is the user's security decision - the supply-chain-hardening skill owns that call. This step only insists the decision exists.

Known limit: it reads the installed tree, so it sees the optional dependencies resolved for this platform. A postinstall that only ships in a linux-x64 package is invisible to any local check; only a CI job on the target platform closes that gap.

Pkl Syntax Reference

Required first lines

amends "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Builtins.pkl"

Always match the version in amends and import to the installed hk version (hk --version), and require hk ≥ 1.51.0 - the wrapper-level -q success quieting (see above) needs it. The skill installs hk = "latest", so fresh setups qualify; for an older pinned repo, upgrade hk.

Builtin step (use as-is)

["trailing-whitespace"] = Builtins.trailing_whitespace

Builtin step (with overrides)

["trailing-whitespace"] = (Builtins.trailing_whitespace) {
    exclude = List("*.png", "*.jpg")
    batch = true
}

Output controls

Run-level (preferred): flags on hk run <hook>.

Flag Effect
-q Quiet-on-success (hk ≥ 1.51.0): success → 0 bytes; failure keeps the failing step's full stdout+stderr. Put this on the wrapper.
--silent 0 bytes on success and on failure - drops diagnostics. Never use it.
-n / --no-progress No-op on step success output (touches progress rendering only).

Per-step: two knobs that trim hk's chrome - neither suppresses a command's own output (only wrapper-level -q does that):

["typecheck"] {
    check = "pnpm exec tsc --noEmit"
    output_summary = "stderr"   // end-of-run summary stream: "stderr" (default) | "stdout" | "combined" | "hide"
    hide = false                // true removes this step's status markers (the ✔/✖ lines)
}

Without -q, on failure hk prints the output twice (live stream + end summary), and output_summary = "hide" drops the duplicate summary but is only safe under head-keeping output truncation. Wrapper-level -q sidesteps this: it yields a single small failure copy, safe under both head- and tail-keeping truncation. See references/output-noise.md.

Custom step

["typecheck"] {
    glob = List("*.ts", "*.tsx")       // optional: only run when these files staged
    check = "pnpm exec tsc --noEmit"   // silent on success — no wrapper needed
    // fix = "command to auto-fix"     // optional
}

Template variables

Variable Value
{{files}} Space-separated list of staged files matching the step's glob
{{commit_msg_file}} Path to commit message file (commit-msg hook only)
{{workspace}} Directory containing workspace_indicator file
{{workspace_files}} Files relative to workspace directory
{{root}} Repo root. Inside a tests {} block it still points at the real root, not the sandbox - that is what lets before copy a checker in
{{tmp}} Per-test sandbox directory. tests {} only; using it auto-enables tmpdir

Multi-line inline script

["no-commit-to-branch"] {
    check = """
      branch=$(git rev-parse --abbrev-ref HEAD)
      if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
        echo "Direct commits to '$branch' are not allowed."
        exit 1
      fi
      """
}

Local variable (share steps across hooks)

local fast_steps = new Mapping<String, Step> {
    ["trailing-whitespace"] = Builtins.trailing_whitespace
    ["shfmt"] = (Builtins.shfmt) { batch = true }
}

hooks {
    ["pre-commit"] { fix = true; stash = "git"; steps = fast_steps }
    ["check"] { steps = fast_steps }
    ["fix"] { fix = true; stash = "git"; steps = fast_steps }
}

Sequential ordering with Groups

Steps within a group run in parallel; groups run sequentially:

steps {
    ["format"] = new Group {
        steps = new Mapping<String, Step> {
            ["prettier"] { ... }
            ["eslint"] { ... }
        }
    }
    ["validate"] = new Group {   // runs after format completes
        steps = new Mapping<String, Step> {
            ["typecheck"] { ... }
            ["test"] { ... }
        }
    }
}

Or use depends for fine-grained ordering:

["eslint"] {
    depends = List("prettier")   // waits for prettier to finish
    ...
}

mise.toml Additions

[tools]
hk = "latest"
pkl = "latest"        # required for hk.pkl parsing

# Add as needed based on detected steps:
typos = "latest"      # Tier 2: spell check
gitleaks = "latest"   # Tier 2: secret detection
rumdl = "latest"      # Tier 2: markdown lint (if .md files present)
yamllint = "latest"   # Tier 4: YAML lint (if .yamllint* present)

Maintenance

Add a new step

Insert into hk.pkl under the appropriate section. Check hk builtins for available built-ins, or write a custom step.

Update hk version

hk --version   # check current

Bump both URLs in hk.pkl to the installed version (minimum v1.51.0), e.g.:

amends "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.56.1/hk@1.56.1#/Builtins.pkl"

Bypass hooks temporarily

HK=0 git commit -m "wip"             # skip all hk hooks
HK_SKIP_STEPS=vitest git commit      # skip specific step

Debug a failing step

hk check -v                          # verbose output
hk check -v --step typecheck         # single step only
hk run pre-commit -v                 # simulate hook run

Local developer overrides

Create hk.local.pkl (gitignored) to override settings locally:

amends "./hk.pkl"
hooks {
    ["pre-commit"] {
        steps {
            ["vitest"] {
                check = "pnpm exec vitest run --testPathPattern=fast"
            }
        }
    }
}

Gotchas

Issue Fix
pkl: command not found Add pkl = "latest" to mise.toml, run mise install
amends version mismatch Match amends/import URL version to hk --version output
Builtins snake_case vs step names kebab-case Builtins.trailing_whitespace → ["trailing-whitespace"]
Hook runs but matches nothing Check glob patterns; use hk check -v to see file matching
Step fails when {{files}} holds nothing the tool handles A glob decides what the step runs on, not what the tool accepts: several exit non-zero on an empty target set rather than no-op. oxfmt errors "Expected at least one target file" when every passed path sits in its own ignorePatterns; oxlint does the same given no lintable file. Glob each step to what that tool actually handles, and keep lint and format as separate steps - a combined one globbing *.json fails on a JSON-only commit
Binary files fail spell check Add binary excludes to typos/trailing-whitespace/newlines steps
Git worktrees: hk install fails Automatic since v1.35.0; if using older version use .hk-hooks/ + core.hooksPath
Fix auto-stages wrong files Use explicit stage glob on the step, or ensure step glob covers fixed files
Noisy output on success Add -q to the pre-commit wrapper (hk run pre-commit -q, hk ≥ 1.51.0): 0 bytes on success, full failing-step output on failure. Never --silent (drops failure diagnostics). See references/output-noise.md
Hook runs in CI unnecessarily Add [ -n "$CI" ] && exit 0 to prepare script
hk.local.pkl uses amends not being honoured First line must be amends "./hk.pkl"
A builtin named in the docs does not resolve The builtin set is tied to the version in your amends/import URL, not to the installed hk. Check that tag's pkl/builtins/ before reaching for one - statix, for instance, is absent at 1.56.1 while deadnix, lychee, check_symlinks, check_case_conflict and hk_test are all present
hk --all seems to miss files It selects tracked files only. An untracked tree under a directory you excluded for size was never in scope, so the exclude may be hiding tracked files for nothing - check with hk check --all --stats before keeping it
vale fails on a deliberately-malformed frontmatter fixture Vale hard-errors (E201) on unparseable frontmatter rather than skipping the file, so test fixtures that are invalid on purpose have to be excluded from the step, the same way lint fixtures are
pinact fails whenever the machine is offline It resolves every action ref through the GitHub API (/repos/<owner>/<repo>/commits/<ref>) and has no offline mode, so an unreachable API is a hard failure (exit 1 on 3.10.1), identical to the one it reports for a genuinely unpinned action. Put it in CI, not pre-commit - the same reason zizmor runs --offline in the hook
Step tests write fixtures into the work tree A bare relative path with no tmpdir = true writes into the repo and leaves the file there. Use {{tmp}}/.... See references/testing-steps.md

References

  • references/builtins-by-language.md - step selection by ecosystem
  • references/complete-examples.md - full hk.pkl configs for different stacks
  • references/output-noise.md - how to keep steps quiet correctly (wrapper-level -q, hk's native controls, harness-truncation caveat)
  • references/testing-steps.md - tests {} and hk test: the fields, the {{tmp}} sandbox rule, testing a whole-repo checker with before + {{root}}, and what Builtins.hk_test drags in
  • assets/soft-protected-branch-pre-push.sh - copy to .hk-hooks/pre-push for advisory local branch protection with clone-local owner opt-out
  • tests/soft-protected-branch-pre-push.bats - behavioural tests for the advisory branch-protection asset
  • assets/pnpm-build-scripts-check.mjs - copy to .hk-hooks/ to fail a commit when a dependency's build script has no allowBuilds decision
  • tests/pnpm-build-scripts-check.bats - behavioural tests for the build-script decision checker
  • hk docs - official documentation
  • hk builtins - list all available built-in linters