The checks behind mix tack.rules_check: the agent-facing documentation may not regrow
into the shape that rots, and the path-scoped area rules under .claude/rules/ may not silently
stop applying.
Three checks, two of them ratchets:
- CLAUDE.md's line budget. The always-loaded file is cross-cutting rules plus a status
map; a feature's narrative belongs in its area file. A checked-in budget caps the line count,
and
--tightenonly ever lowers it, so every slim is locked in and every growth is a red gate rather than a tendency. Raising the number is allowed — it is a visible one-line edit. - No orphan glob. Every
paths:entry in a rules file must match at least one file. The harness gives no signal when a glob matches nothing: the rule simply never loads. A move that empties a glob turns the gate red instead. - No newly uncovered lib file. The count of
lib/**/*.exfiles matched by no rules glob may not exceed its budget. A glob that still matches something hides the file that moved out from under it; this catches that case, since the moved file joins the uncovered set.
Pure — the reader is Mix.Tasks.Tack.RulesCheck
This module reads no file and calls no Mix function. The task expands each glob with
Path.wildcard/2 (the same *, **, ? and {a,b} family the harness's paths: frontmatter
uses, with dotfiles matched) and hands this module the results, the lib file list, the line count
and the budget text; it returns findings. That keeps the checks testable against fabricated trees.
Summary
Types
The budget file's keys and their ceilings.
Rules-file path → each of its globs → the files that glob matched.
A paths: entry from a rules file's frontmatter.
Everything evaluate/1 needs, gathered by the task.
What evaluate/1 measured and concluded.
Functions
Runs all three checks over already-gathered inputs.
The paths: globs declared in a rules file's YAML frontmatter.
Every {rules_file, glob} whose expansion is empty, in file then declaration order.
Parses the budget file: one key value per line, # comments and blank lines ignored.
Renders a budget back to the file's format, keys sorted, under a comment that says what the
numbers are for. render_budget(parse_budget(text)) is stable, so --tighten produces a
minimal diff.
budget with every ceiling lowered to what was measured, never raised.
The lib files no area glob's expansion contains, sorted.
Types
@type budget() :: %{required(String.t()) => non_neg_integer()}
The budget file's keys and their ceilings.
Rules-file path → each of its globs → the files that glob matched.
@type glob() :: String.t()
A paths: entry from a rules file's frontmatter.
@type inputs() :: %{ claude_md_lines: non_neg_integer(), expanded: expanded(), lib_files: [String.t()], budget: budget() }
Everything evaluate/1 needs, gathered by the task.
@type report() :: %{ findings: [String.t()], measured: %{ claude_md_lines: non_neg_integer(), uncovered_lib_files: non_neg_integer(), uncovered: [String.t()], orphans: [{String.t(), glob()}] } }
What evaluate/1 measured and concluded.
Functions
Runs all three checks over already-gathered inputs.
findings is empty when the gate should pass. Each finding is one sentence naming what was
measured, its ceiling, and the fix.
The paths: globs declared in a rules file's YAML frontmatter.
Reads only the block between the opening and closing --- fences, and only the - "…" items
under the paths: key. A file with no frontmatter or no paths: key declares no globs (it is an
unscoped rule, loaded at launch) and yields [].
Every {rules_file, glob} whose expansion is empty, in file then declaration order.
An orphan glob is a rule that never loads and a check that never runs.
Parses the budget file: one key value per line, # comments and blank lines ignored.
Unknown keys are kept, so a future ratchet needs no parser change; the two the checks read are
claude_md_lines and uncovered_lib_files. A missing key is a finding, not a default — a
ratchet with no ceiling enforces nothing.
Renders a budget back to the file's format, keys sorted, under a comment that says what the
numbers are for. render_budget(parse_budget(text)) is stable, so --tighten produces a
minimal diff.
@spec tighten(budget(), %{required(String.t()) => non_neg_integer()}) :: budget()
budget with every ceiling lowered to what was measured, never raised.
A key the budget lacks is added at its measured value, so a first run can seed the file.
The lib files no area glob's expansion contains, sorted.
These files load no area rules when read. The count is ratcheted, not forbidden: mix tasks and infrastructure modules are legitimately uncovered, and the check exists so that number cannot grow unnoticed.
A glob whose expansion covers more than four fifths of the lib files is a cross-cutting rule,
not an area, and does not count as coverage — otherwise one lib/**/*.ex glob would zero the
ratchet and every later move would pass. Such a glob still counts for the orphan check.