# `Tack.RulesCheck`
[🔗](https://github.com/giggleton/pica/blob/v0.1.0/dev/tack/rules_check.ex#L1)

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 `--tighten` only 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/**/*.ex` files 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.

# `budget`

```elixir
@type budget() :: %{required(String.t()) =&gt; non_neg_integer()}
```

The budget file's keys and their ceilings.

# `expanded`

```elixir
@type expanded() :: %{required(String.t()) =&gt; %{required(glob()) =&gt; [String.t()]}}
```

Rules-file path → each of its globs → the files that glob matched.

# `glob`

```elixir
@type glob() :: String.t()
```

A `paths:` entry from a rules file's frontmatter.

# `inputs`

```elixir
@type inputs() :: %{
  claude_md_lines: non_neg_integer(),
  expanded: expanded(),
  lib_files: [String.t()],
  budget: budget()
}
```

Everything `evaluate/1` needs, gathered by the task.

# `report`

```elixir
@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.

# `evaluate`

```elixir
@spec evaluate(inputs()) :: report()
```

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.

# `globs`

```elixir
@spec globs(String.t()) :: [glob()]
```

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 `[]`.

# `orphan_globs`

```elixir
@spec orphan_globs(expanded()) :: [{String.t(), glob()}]
```

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.

# `parse_budget`

```elixir
@spec parse_budget(String.t()) :: budget()
```

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.

# `render_budget`

```elixir
@spec render_budget(budget()) :: String.t()
```

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.

# `tighten`

```elixir
@spec tighten(budget(), %{required(String.t()) =&gt; 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.

# `uncovered`

```elixir
@spec uncovered([String.t()], expanded()) :: [String.t()]
```

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
