Failure modes
The ugly paths, named on purpose (SPEC §9): what chaff does when an input is bad, ambiguous, or missing, rather than guessing quietly.
A declared entry is missing or unreadable
Exit 2, no score printed. A partial score is a wrong score, so chaff would rather tell you nothing than tell you a number computed from an incomplete set.
A glob matches nothing
A warning, with the pattern echoed back, not an error. An empty
.claude/rules/directory is normal, not a mistake.Unparseable markdown or invalid YAML frontmatter
That entry becomes
kind: unknown, excluded from the score denominator, and listed under “not analyzed” with the reason stated.Unrecognized JSON shape in a tool-defs entry
Skipped, with the sniffer’s reason printed. Never guessed.
An @-import target is missing, cyclic, or past 4 hops
The matching
import.*structural finding fires. A cycle is broken at the first repeat; each file is counted once regardless of how many times it is imported.An @-import sits inside a code span or fenced code block
Not an import. Matches Claude Code’s own documented behavior. Pinned by fixture both ways.
An @-import resolves outside the set root
Resolved and materialized, but marked
external: trueand counted separately from the resident total. Claude Code gates these behind an approval dialog, so they may not actually be resident in practice.A date-relative rule runs with no --today
Uses the system date and prints it. Committed fixtures always pin
--todayso their output stays reproducible forever.The calibration manifest is missing the requested model
The Claude-count band is omitted; ref tokens still print. A warning names the manifest date. chaff never extrapolates a ratio it has not measured.
--tokenizer anthropic is requested without a key, offline, or rate-limited
Falls back to
o200k_base, with a printed downgrade line and a non-zero warning count. Never a silent substitution.The set exceeds 5 MB or 500 entries
Hard stop, exit 4. This is a context set, not a codebase.
A file is too large for the browser Worker
Per-file 2 MB cap, the message names the file. The page never freezes the main thread while it decides.
Same input, different OS (CRLF/BOM/path separators)
Identical findings and identical byte offsets after normalization, pinned by the determinism eval.
A measured rule's evidence goes stale
CI fails. chaff never silently keeps claiming a behavioral delta it can no longer support with a fresh run.
Exit codes
Load-bearing for CI. 1 and 3 look similar (both mean “something is wrong”) but are deliberately distinct: different owner, different fix.
| Code | Meaning | Who fixes it |
|---|---|---|
| 0 | Clean. No findings at or above the gate. | Nobody. Nothing to do. |
| 1 | Findings at/above the gate (--fail-on, default enforced). | Whoever owns the context file: fix the finding, or ignore/fail-on it deliberately if it's a false positive. |
| 2 | Input/parse failure. A declared entry is missing or unreadable. | Whoever owns the config or context files: fix the path or the file. |
| 3 | Resident-token budget exceeded. | Whoever owns the budget: move content on-demand, or raise the budget deliberately. |
| 4 | Usage/config error: bad flag, invalid config, a budget with no model named, an unknown rule id. | Whoever invoked chaff: fix the CLI call or the config. |
| 5 | Internal error. A bug in chaff itself. | chaff’s maintainer. Please report it. |
Cost safety
The product itself makes zero model calls at any traffic level and has zero server functions, so the abuse-case numbers are zero and there is no rate limiter to design. The repo’s eval tooling (the admission pipeline) is the only key path in the whole codebase, and it is local-only with per-run and program-wide USD caps that never run in CI.