Linters

Config linters check your config files against the tool's own schema before anything is staged. A typo'd key in yazi.toml deploys cleanly and fails at tool runtime, in a different terminal, an hour later — linters close that gap at check time, where the error points at the offending line.

Linters are data: every shipped linter is a versioned data pack embedded in grip (crates/griplint/packs, key tables as TOML) checked by the in-crate engine in-process — no venv, no provisioning, no plugin lifecycle, and the golden corpus replays byte-exact against the reference implementation. lint = "name" travels in the IR; grip check and grip apply run the linter, so linting works anywhere the binary runs — CI included — with no runtime to provision for the embedded packs. A new structured-format linter is a data PR, not a package. External griplint-* executables keep the protocol path for exotic formats.

Status: the engine is shipped and every pack below runs in-process — the set is what exists today and what's queued, sorted by github stars.

Coverage

23 available 29 planned the long tail

available on mainplanned

helixreference alacritty starship yazi mise jj atuin glow superfile zola bottom git-cliff broot ruff rio harlequin television procs bacon claude-code zed gh-dash tuicr deno lazygit ghostty lazydocker Hyprland zellij kitty k9s btop glances delta niri biome fastfetch oh-my-posh gitui rofi lsd pre-commit tmuxinator posting eza waybar lf taskwarrior dunst newsboat editorconfig zathura

Available means a data pack on main in crates/griplint/packs. Every planned tool has an open tracking issue; a pack is a versioned key table reviewed against upstream docs, with a weekly freshness watch.

Using a linter

For first-party packs: nothing to register — lint = "helix" runs the embedded pack. External plugins register in env.toml; opt in from the module either way.

[linters.yazi]
package = "owner/griplint-yazi@1.2.0"   # plugin-store ref — provisioned, sha256-verified

[linters.internal]
path = "/opt/bin/griplint-internal"     # explicit executable — the out-of-tree form
export default module("yazi", { /* … */ lint: "yazi" });
  • package is a plugin-store ref "owner/repo@tag": grip downloads the release binary, verifies it against the mandatory sha256 sidecar, and receipts it into $GRIPSACK_HOME/plugins/ — the tag is the pin. path is the explicit executable for development.
  • lint = "<name>" must resolve against the registry: an unregistered name is a hard eval error with the module-line span, never a silent skip. There is deliberately no PATH-discovery fallback — discovery is how configs end up silently unlinted on one machine and linted on another.
  • lint= is the entire opt-in; there are no per-entry file lists. The linter owns the tool's layout knowledge: one griplint-yazi covers yazi.toml, keymap.toml, and theme.toml, and ignores what it doesn't recognize.

The command surface

The core lints after eval and sema, so every command that evaluates gets diagnostics for free:

command behavior
grip check eval + IR sema + linters, then stop: render every diagnostic, exit code = validity, zero side effects — the tight config-editing loop and the CI gate for your dotfiles repo
grip plan check + lockfile + diff vs the current generation — "what would change" includes "and by the way, this is broken"
grip apply an error-severity lint diagnostic fails eval before anything stages

Linters are static shape (does this key exist in yazi 25.x); verify contracts are runtime smoke (does the deployed thing work). A mode that shells out to the tool itself belongs to the verify side — the two stay apart.

The lint flow

a typo'd key fails grip check with a span — before anything is staged, before the tool ever runs:

grip check — eval + sema + linters, zero side effects
error[griplint-helix/A01]: unknown key [editor] 'scrollof'
  --> /home/you/env/configs/helix/config.toml:4:1
   |
 4 | scrollof = 5
   | ^ not a real key
  help: did you mean 'scrolloff'?

Writing a linter

A linter is a plain executable speaking NDJSON over stdio — one request per module–linter pair:

{"op": "lint", "paths": ["configs/yazi/yazi.toml", "configs/yazi/keymap.toml"],
 "tool_version": "25.5.31"}
  • paths is the module's config source set, post-tree() expansion; repo files are linted in place.
  • tool_version comes from the host lockfile pin — the lock that pins the binary also pins the config schema. Unpinned, the linter uses its latest schema and warns.
  • Diagnostics come back in the shared shape — same snippet, colors, and codespacing as every other gripsack diagnostic. Codes are namespaced griplint-<tool>/<code> (e.g. griplint-yazi/A01); the core's E0xx range stays reserved. Plugins serialize; only the core renders — there is no second renderer.
  • Unknown files are ignored, never errors. A linter that chokes on a layout it doesn't recognize is a bad linter, not a bad config.
  • Death is never silent: a linter that crashes, hangs, or emits garbage surfaces as a diagnostic, not a missing check. The contract made executable: griplint-conformance and gripfetch-conformance — run your plugin against the suite and the envelope stops being prose.

Where linters live

In the gripsack repo as data: crates/griplint/packs — one TOML pack per tool, keyed by tool version, with the golden fixture corpus beside it, read by the in-crate engine (plan/0012); exotic formats (RON, KDL, custom) stay external griplint-* executables over the protocol above, forever. The tables are community-maintained, and the north star is DefinitelyTyped: the tool's owners eventually own their linter, and gripsack just provides the envelope and the renderer.

Full design: plan/0010, plan/0011, and plan/0012.