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
available on mainplanned
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" });
packageis 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.pathis 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: onegriplint-yazicoversyazi.toml,keymap.toml, andtheme.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:
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"}
pathsis the module's config source set, post-tree()expansion; repo files are linted in place.tool_versioncomes 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'sE0xxrange 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.