Skip to content

Latest commit

 

History

History
301 lines (221 loc) · 18.8 KB

File metadata and controls

301 lines (221 loc) · 18.8 KB

Validators — full reference

What validators are

Validators are squiggle-on-save for the LLM. After every mutating op — edit, replace, replace_lines, paste, vim — supertool runs the matching validators against the result file. If one fails and rollback_on_fail: true is set, the file reverts atomically to its pre-edit state and the model gets an immediate error receipt with the parse error, line number, and column. No broken files sitting around. No "edit succeeded, discovered it three turns later."

The model retries with real information instead of hallucinating a fix.

How they hook in

Each validator entry declares a hooks_into array listing the mutating ops it should run after:

"hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"]

Validators are declared per-file-type in .supertool.json under validators, keyed by validator name. Each entry matches files via a match glob:

{
  "validators": {
    "jsonlint": {
      "cmd": "python3 {supertool_dir}/validators/jsonlint/jsonlint.py {file}",
      "match": "*.json",
      "hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"],
      "rollback_on_fail": true,
      "timeout": 10
    }
  }
}

Multiple entries for the same language are fine — *.yml and *.yaml need separate entries, for example.

You can also invoke any validator explicitly, without an edit, via the validate op:

./supertool 'validate:src/Foo.php'        # all matching validators
./supertool 'validate:src/Foo.php:phplint' # specific validator

Graceful skip

When the underlying toolchain is missing (e.g. stylelint not installed, terraform not on PATH), the validator wrapper warns and exits 0. Supertool stays fully usable in any repo without pre-installed dependencies. No validator failure blocks an unrelated edit.

Bundled validators

Enable any of these by copying the relevant entry from .supertool.example.json into your project's .supertool.json. Each ships as a thin Python wrapper that delegates to the real tool and handles graceful skip when the tool is absent.

Language / format Validator name Requires Notes
PHP phplint php on PATH Uses php -l
XML xmllint libxml2 (xmllint CLI) Reports line + column on parse error
JSON jsonlint stdlib only No external dep — always available
YAML (.yml) yaml-check PyYAML (pip install pyyaml) Separate entry needed for .yaml
YAML (.yaml) yaml-check-yaml PyYAML (pip install pyyaml) Identical logic, different glob
INI inilint stdlib only No external dep
Python py-compile stdlib only Uses py_compile — syntax only, not type
Python (types) pyright pyright (npm install -g pyright) Real type-check via pyright --outputjson
Bash bash-check bash on PATH Uses bash -n
JavaScript node-check node on PATH Uses node --check
CSS / SCSS stylelint stylelint npm package Config from project .stylelintrc
TOML tomllint stdlib (3.11+) or tomli Falls back to tomli on 3.10
Markdown markdownlint markdownlint CLI npm install -g markdownlint-cli
Ruby ruby-check ruby on PATH Uses ruby -c
Dockerfile hadolint hadolint on PATH Catches both syntax and best-practice lint
TypeScript (.ts) tsc-check tsc (npm install -g typescript) --noEmit — no output files written
TypeScript (.tsx) tsc-check-tsx tsc (npm install -g typescript) Separate entry needed for .tsx
Go gofmt-check gofmt (ships with Go) Fails on formatting diff, not just syntax
Terraform terraform-check terraform CLI Uses terraform validate
Rust cargo-check cargo (ships with Rust) Uses cargo check — full type resolution
PHP — static phpstan phpstan binary (PATH or via PHPSTAN_BIN env) Env-configured. See README
PHP — mess phpmd phpmd binary (PATH or via PHPMD_BIN env) Env-configured. See README
PHP — style psr phpcs binary (PATH or via PSR_BIN env) Env-configured. See README
Git git-status git binary Reports working-tree delta (+N -N <state>) post-edit
Prettier — check prettier-check prettier binary (PATH or via PRETTIER_BIN env) Env-configured read-only formatting check. See README

Adding your own

Any tool that exits non-zero on bad input works. Three lines of JSON and a new language is supported.

Example for Elixir:

"elixir-check": {
  "cmd": "elixir -c {file}",
  "match": "*.ex",
  "hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"],
  "rollback_on_fail": true,
  "timeout": 10
}

The {file} placeholder is replaced with the absolute path to the file being validated. {supertool_dir} is also available for pointing at bundled wrapper scripts. See validators/SCHEMA.md for the full adapter contract if you want structured error output (line numbers, error categories, fix suggestions). PRs for languages you edit regularly are welcome.

Rollback semantics

rollback_on_fail controls whether the file reverts when validation fails:

Value When to use
true Syntax errors, parse failures — a broken file is worse than no edit
false Style/lint warnings where the edit itself is valid — warn but keep the file

true is the right default for most validators. A PHP file with a missing } is not a partially-edited file — it's a broken file. Rollback gives the model a clean retry surface instead of a corrupt starting point.

false makes sense for opinionated linters like hadolint or markdownlint where violations are informational and the file is still structurally valid.

Parallel execution — validators use the project's parallel setting. When multiple validators match a single file (e.g. a .ts file matching both tsc-check and stylelint), they run concurrently. The first failure triggers rollback if rollback_on_fail is set; remaining validators still complete.

Field reference

Full list of .supertool.json validator config fields:

Field Notes
cmd Shell command. {file} → target path. {supertool_dir} → supertool install dir.
match Glob filter on the target path (default *).
exclude Glob (or list of globs) to skip even when match hits — e.g. "*tests/*". Per-validator on purpose: phpunit must still scan tests.
hooks_into Op names to wrap (subset of edit, replace, replace_lines, paste, vim).
rollback_on_fail Restore pre-edit file content if the validator's count went up or ok flipped to false.
resolve Shell cmd returning an alternate target path (e.g. source-file → test-file).
timeout Seconds. Default 60.
opt_in If true, validator only runs on explicit request via the validate op.
tier "fast" (default) or "slow". slow defers the validator to end-of-call, deduped by (validator, path) — runs once per unique pair regardless of how many ops touched the file. See Slow tier.
env Optional {KEY: VAL} block merged into the subprocess environment. Values are coerced to strings. Useful for pointing wrappers at a project-local binary or config without touching the system environment.

env — usage

Pass tool-specific config without shell exports:

"phpstan": {
  "cmd": "python3 {supertool_dir}/validators/phpstan/phpstan.py {file}",
  "match": "*.php",
  "hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"],
  "rollback_on_fail": false,
  "timeout": 60,
  "env": {
    "PHPSTAN_BIN": "./vendor/bin/phpstan",
    "PHPSTAN_CONFIG": "phpstan.neon",
    "PHPSTAN_LEVEL": "8"
  }
}

The env block is merged on top of the inherited process environment (os.environ | spec.env), so unset keys fall through to whatever the shell already has.

Slow tier — defer to end of call

Heavy validators (phpstan, phpunit, rector, tsc, cargo-check) often take 5–30s per run. In a multi-op batch — three edits on the same file — running them after each op is pure waste: same file content gets re-analyzed N times.

Set "tier": "slow" on the validator and supertool defers it: queued during the batch, drained once at end-of-call, deduped by (validator, path) so each pair runs exactly once regardless of how many ops touched it.

"phpstan": {
  "cmd": "python3 {supertool_dir}/validators/phpstan/phpstan.py {file}",
  "match": "*.php",
  "hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"],
  "tier": "slow",
  "rollback_on_fail": false,
  "timeout": 60
}

Behavior:

  • Single-op calls always run inline, regardless of tier — defer only kicks in for multi-op batches where dedup pays off.
  • Failures don't roll back prior ops. Slow-tier results arrive after every op has already committed; mirrors the deferred-formatter contract. Keep rollback_on_fail: false on slow validators.
  • Output appears under a [validators-deferred] header, separate from the per-op [validators] block, so it's clear which results came from the dedup pass.
  • Default is "fast" — omitting the field preserves the original per-op behavior. Zero change for users who don't opt in.

Use slow for anything where a single analysis pass on the final file content is enough. Use fast (default) when you want per-op rollback semantics (syntax linters, json/yaml/xml parsers — these are cheap anyway).

Caching

Results are auto-cached at ~/.cache/supertool/validators/, keyed on sha256(file_content) + name + cmd. Validators skip re-running when the file hasn't changed since the last pass.

Disable caching per-call with the SUPERTOOL_NO_VALIDATOR_CACHE=1 env var, or per-project with "validator_cache": false in .supertool.json.

TTL. Entries expire validator_cache_ttl_hours after they're written (default 24; set 0 to disable expiry). The key only hashes file content, so an entry can outlive changes it can't see — an updated validator adapter, a changed rector.php, or a transient engine failure a clean re-run would now pass. Expiry is on access: a stale entry is treated as a miss, re-runs, and is rewritten with a fresh timestamp. No cron needed.

Engine failures are never cached. The core cache filter is generic: it excludes a result whose error code is non-deterministic (MCP transport errors, non-zero adapter exits) and never inspects tool-specific message text (per SCHEMA.md: "Validator core never parses tool-specific output"). Real findings (PHPStan types, rector.refactor suggestions) are deterministic and stay cached. Message-level engine glitches — rector's System error: ... reflection failures and Call to a member function toMutatingScope() on null — are dropped at the source by the adapter, configured per-mcp via the validators.rector.engine_glitches prop in .supertool.json (a JSON list of case-sensitive substrings the adapter reads straight from .supertool.json; built-in defaults apply when the prop is absent). Add a new signature there — no code change. This prevents a transient warm-daemon hiccup from freezing a failure that replays on every later run (the June-2026 2100-poisoned-entries incident). See validators/rector-mcp/rector-mcp.py.

Manual run

Run validators explicitly against any file without an edit op:

./supertool 'validate:src/Foo.php'                    # all matching validators
./supertool 'validate:src/Foo.php:phplint,phpstan'    # filtered to named validators
./supertool 'validate:a.php,b.php,c.php'              # list form — many files, config loaded once
./supertool 'validate:a.php,b.php:@syntax'            # only parser/compiler validators

Useful for a pre-commit sweep or spot-checking a file you didn't edit this session.

List form — pass a comma-separated path list (validate:f1,f2,…[:tool_filter]) to validate several files in one invocation; the config is loaded once for the whole batch and each path is independently security-checked at dispatch. Single-file validate:PATH is unchanged.

@syntax filter — the special filter @syntax selects only validators that declare "syntax": true in their spec (the parser/compiler tier), keeping that scope in config rather than a hardcoded caller list. Falls back to the bundled name list for older configs that predate the flag.

Output example

After an edit that breaks PHP syntax with rollback_on_fail: true:

[validators]
phplint : 0 → 1        (+1)   ✗
  + L42 parse  Parse error: syntax error, unexpected token "{" in ... on line 42

[rolled back] phplint regressed; file restored

The model gets a clean retry surface with the exact line and error — no broken file left behind.

Adapter contract

Custom validators must conform to the adapter contract in validators/SCHEMA.md. Each adapter takes one file arg and prints a JSON object on stdout with a standardised shape (ok, count, errors). The bundled adapters (validators/phplint/, validators/xmllint/, etc.) are the reference implementations.

Two optional output fields worth knowing: source_context (array of source lines centered on the error, rendered indented under each error in verbose mode) and diff (unified diff string, rendered as a fenced block — useful for tools like Rector that produce a suggested patch). Full spec in SCHEMA.md.

resolve — map a source file to its real target

By default a validator runs against the file the op touched. The optional resolve key lets it run against a different file derived from that one — the canonical case is "edited a source file, run its test." resolve is a shell cmd that takes the edited file ({file}) and prints the path to run instead:

"phpunit": {
  "cmd": "... {file}",
  "match": "*.php",
  "hooks_into": ["edit", "replace", "replace_lines", "paste", "vim"],
  "resolve": "bash .claude/scripts/resolve_test.sh {file}",
  "tier": "slow"
}

Contract — supertool reads stdout only:

  • Non-empty stdout → that path becomes the validator's target.
  • Empty stdout → the validator is skipped for this op (nothing sensible to run).

The exit code is ignored by the validator path. That is deliberate, and it's what makes the advisory below possible.

advice — config-driven post-op hints

Autonomous flows that write only through supertool bypass the editor/git-hook reminders to do follow-up work (write a test, regenerate the XSD, …). The top-level advice block emits non-blocking [advice] lines after a mutating op when a rule matches:

// .supertool.json
{
  "advice": {
    "newTest": {
      "hooks_into": ["paste"],
      "match": "*.php",
      "when": "new-file",
      "resolveFromValidator": true,
      "message": "new class without test"
    },
    "newComponent": {
      "hooks_into": ["edit", "paste"],
      "match": "*.php",
      "contains": "extends \\w*ComponentBase|implements \\w*IComponent",
      "message": "XSD/cache regen likely (dvsi_xsd + dvsi_clearcache)"
    }
  }
}

Each rule is gated by (all optional):

field meaning default
hooks_into ops that trigger the rule all mutating (edit, paste, replace, replace_lines, vim)
match path glob *
when new-file | existing-file | always — gated on whether the file existed before the op always
contains regex tested against the content the op added (lines present after but not before) — fires only when the op introduces the pattern, not when the file already held it — (no content gate)
resolve a subprocess (a source→target resolver) emitting a would-be target
resolveFromValidator reuse the first resolve cmd declared on a validator instead of duplicating it false
message the line shown; {target}/{path}/{op} interpolate. A message with no {target} gets — consider <target> appended when a resolver produced one ""

A paste that creates a new *.php file with no resolvable sibling test (the newTest rule above) appends:

[advice]
ℹ new class without test — consider tests/unit/SiX/FooTest.php

The resolve contract

A resolve/resolveFromValidator rule reuses the same resolve cmd declared on a validator, so the source→target path logic lives in exactly one place. Because the advisory needs the would-be target (which doesn't exist yet) while the validator path needs stdout to stay empty on a miss, the resolver signals the two cases on different channels:

case stdout stderr exit
target exists target path 0
no target (empty) would-be target 3

stdout stays empty on a miss → the validator still skips, unchanged. The miss target rides on stderr, flagged by exit 3 → only the advisory reads it. Advisory only: it never blocks the write.

Format-on-save

See formatters.md — formatters run after every edit, before validators, normalizing whitespace and style before the safety check runs.