Skip to content

feat(rules): add a command rule kind for dynamic checks - #60

Merged
wenzowski merged 1 commit into
mainfrom
claude/next-ready-ticket-wko3ot
Aug 7, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/next-ready-ticket-wko3ot

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

What

Resolves CLOUD-89: adds RuleKind::Command, the sanctioned escape hatch for
rules no static shape can express — an exit-code predicate (0 passes,
non-zero is a violation) that never parses the command's output for meaning,
which would make it a judge (CLOUD-93).

Also dogfoods the engine: batten.toml gains a real rule, so Batten now gates
its own repository (consumer #1 in practice, not just in principle).

Invocation contract

Follows the hk idiom — glob-as-gate decoupled from glob-as-argv:

  • The glob gates first: no match skips the rule without spawning (§4
    "cheap when irrelevant").
  • A bare {{files}} argument expands in place to the matched paths; a
    template omitting it runs once and self-discovers.
  • Matched paths are batched under a documented argv bound (MAX_FILES_BYTES,
    sized under Windows' ~32 KiB command line), so a large match set cannot
    overflow. Batching preserves order, which keeps findings byte-stable (§6).
  • The template is split on whitespace and executed directly, never through a
    shell
    , so what runs is exactly what a reviewer reads (§9: rules "name a
    command already on the operator's PATH").
  • A command that cannot run (missing binary) is a config error → exit 2,
    never a silent pass.

Consuming CLOUD-170

spawns_processes() is true for this kind, so it is routed automatically:
enforce runs it, check refuses it (exit 2, naming batten enforce).
The gate written in #58 was vacuous then — it binds now, with no edit to it.

The schema tension #54 left

Per-kind fields are Options on the flat struct, with kind/field agreement
validated explicitly — a #[serde(flatten)] enum would silently defeat
deny_unknown_fields. A field belonging to another kind is an error, never
ignored, so a rule can't half-apply.

Finding::line becomes optional: a command's exit code condemns a batch, not a
line, so it reports the rule's glob rather than inventing a line number.

Tests

7 new unit gates (exit-code mapping, no-match-never-spawns, missing binary,
{{files}} substitution, self-discovering form runs once, batching bound +
order, kind/field agreement) and 5 end-to-end over the compiled binary
(check refuses and points at enforce; enforce maps 0/non-zero; missing
binary → 2; unmatched glob → no spawn). 46 unit + 17 e2e green; mise run verify green, rebased on latest main.

Deferred (unchanged scope)

Surfacing the command's stdout needs the bounded, pointer-only drain that
CLOUD-82 owns, so streams are discarded here rather than emitted unbudgeted.
The fix side stays with CLOUD-90; output-string promotion with CLOUD-117.

Refs CLOUD-89. Builds on CLOUD-12 (#54) and CLOUD-170 (#58).

Some rules cannot be expressed as a static banned shape. Add
RuleKind::Command: an exit-code predicate that runs a configured `run`
template — 0 passes, non-zero is a violation. It never parses the
command's output for meaning, which would make it a judge (CLOUD-93).

Invocation contract (the hk idiom: glob-as-gate decoupled from
glob-as-argv):

* The glob gates first — no match skips the rule without spawning (§4).
* A bare {{files}} argument expands in place to the matched paths; a
  template omitting it runs once and self-discovers.
* Matched paths are batched under a documented argv bound so a large
  match set cannot overflow; batching preserves order, keeping findings
  byte-stable.
* The template is split on whitespace and executed directly, never
  through a shell, so what runs is exactly what a reviewer reads (§9).
* A command that cannot run (missing binary) is a config error, exit 2 —
  never a silent pass.

Consumes CLOUD-170's decision rather than re-litigating it:
spawns_processes() is true, so the kind runs only under `enforce` and
`check` refuses it.

Resolves the schema tension #54 left: per-kind fields are Options on the
flat struct, with kind/field agreement validated explicitly, because a
`#[serde(flatten)]` enum would defeat deny_unknown_fields. A field
belonging to another kind is an error, never ignored.

Findings gain an optional line: a command's exit code condemns a batch,
not a line, so it reports the rule's glob without inventing one.

Dogfood: batten.toml gains a conflict-marker rule, so Batten now gates
its own repository with its own engine.

Refs CLOUD-89.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4v4gg2DRyfPAR2fXCQ3ZZ
@linear-code

linear-code Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
CLOUD-89 Add a `command` rule kind for dynamic checks

Why
Some rules cannot be expressed as static banned-shape patterns. The rule engine needs a command-backed rule kind that shells out against matched files and converts exit code into a violation.

Scope
This is the sanctioned bridge for dynamic rule families such as entity-grep-style disjointness checks.

Acceptance

  • Rules can declare kind = "command"
  • Command-backed rules can target a file glob and exit non-zero on violation
  • The mechanism is documented as the sanctioned escape hatch for dynamic rules

Refinement — Ready (invocation contract pinned, hk idiom)

Decision: a command-kind check is an exit-code predicate; it does not parse output for meaning (that would be a judge — CLOUD-93). Batten surfaces the command's own stdout under the token budget, and the command is responsible for keeping that stdout pointer-only (CLOUD-92).

File-passing follows jdx's hk idiom (see this repo's hk.pkl), which decouples glob-as-gate from glob-as-argv rather than choosing argv vs stdin vs self-discovery:

  • A rule declares kind = "command", a glob, and a run template.
  • The glob first acts as a gate: it intersects the change set and decides whether the step runs at all. Empty match → skip without spawning (§4 "cheap when irrelevant").
  • The run template optionally interpolates a {{files}} placeholder at the exact position the author wants the matched paths. If the template omits {{files}}, the command self-discovers (glob still gates) — exactly how hk.pkl's fmt/lint/test steps use mise run … --all while commit-msg interpolates {{commit_msg_file}}.
  • The runner batches {{files}} expansion to stay under argv length limits; batches are independent and a non-zero exit in any batch is a violation.
  • Exit 0 = pass, non-zero = violation (Batten exit 1, §7); a command that cannot run (missing binary, non-exec) is a config error (exit 2, §7), never a silent pass.
  • Effect (§5): per CLOUD-170's decision — do not assume read here, since a command-spawning kind is exactly what reopens the check verb's effect classification. The optional fix side is out of scope for this issue (CLOUD-90).

Acceptance (adds to the above):

  • A rule whose glob matches nothing is skipped without spawning, asserted by test.
  • A run template containing {{files}} receives the matched paths, in a documented stable order, byte-stable for identical input; a template omitting {{files}} runs once and self-discovers.
  • Matched-path expansion is batched under a documented argv-length bound (large match sets do not overflow argv), asserted by test.
  • A command exiting non-zero yields Batten exit 1; a missing binary yields exit 2.

Note: output-string promotion (tool exits 0 but its stdout/stderr carries a warning) is deliberately not here — that is batten exec output predicates, CLOUD-117, which builds on this machinery and emits warn-severity findings honored by the shared fail_on_warning setting (CLOUD-49).


Re-scoped against the landed engine (PR #54, CLOUD-12).

The engine, RuleKind dispatch, glob selection, pointer-only findings, and exit mapping now exist. CLOUD-89 shrinks to one RuleKind variant + one match arm — no longer a foundational build. Re-pointed: blockedBy CLOUD-12 (was CLOUD-47/48, both stale). The invocation contract above (glob-gates, optional {{files}}, batching) still holds and layers onto #54's glob file-selection.

Prerequisite decision (owned elsewhere): the effect-model soundness question — a Read-classified check that can spawn arbitrary processes once this kind exists — is tracked as CLOUD-170 and blocks this issue. Consume its decision; do not re-litigate it here.

Tension #54 hands to this issue:

  • Schema shape. feat(check): rule and check engine with a static forbid kind #54's Rule is a flat struct with all-required fields and deny_unknown_fields (#[serde(flatten)] deliberately avoided). A command kind needs run, not pattern, so per-kind fields must be modeled without a flatten that would defeat deny_unknown_fields. This is the core implementation problem of the variant.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 7, 2026 00:53
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit a2293e5 into main Aug 7, 2026
8 checks passed
@wenzowski
wenzowski deleted the claude/next-ready-ticket-wko3ot branch August 7, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants