Tack.RulesCheck (pica v0.1.0)

Copy Markdown View Source

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.

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

budget()

@type budget() :: %{required(String.t()) => non_neg_integer()}

The budget file's keys and their ceilings.

expanded()

@type expanded() :: %{required(String.t()) => %{required(glob()) => [String.t()]}}

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

glob()

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

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

inputs()

@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()

@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

evaluate(map)

@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(rules_file_text)

@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(expanded)

@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(text)

@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(budget)

@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(budget, measured)

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

uncovered(lib_files, expanded)

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