Skip to content
agent-groundsPublic

About

A pre-commit guard that keeps source files small enough to stay cheap for agents to read — without ever rewriting your code.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

fissile

crates.io license: MIT

A pre-commit guard that keeps source files small enough to stay cheap for agents to read — without ever rewriting your code.

Large files are an invisible tax on every AI-assisted workflow. A 4,000-line module gets dragged into context whenever an agent needs one function inside it, and you pay for that in tokens, latency, and attention — on every run, forever. Reviewers see diffs, not totals, so a file that crossed a sensible size years ago keeps gaining a line at a time and nothing pushes back at the moment the bloat is introduced (§GND-001-fissile).

fissile is that feedback loop. At commit time it measures staged files against per-repo, per-file-type budgets and flags the ones that have outgrown them. It only measures and reports — it never edits your code, so how to split a flagged file is always your call. Each overflow can carry a short, project-owned message that names the local split: the destination module, the owner, the extraction pattern.

It knows the difference between file types, because that is the whole point: a 2,000-line generated SQL dump is fine, a 2,000-line hand-written module is not, and a checked-in PNG is usually a mistake.

Why not an existing tool?

Tool What it protects
large-file hooks the repo from accidental blobs
linter line rules one language's local style
bundle-size checks shipped artifacts
PR-size gates reviewer throughput
fissile the source layout itself — its cost to read

The difference is the message. A generic "file too large" stops a bad commit; fissile also names the architectural move that makes the next commit better.

Install

cargo install fissile

One small static binary, no runtime, no Python/Node toolchain. Prebuilt per-platform binaries (Linux x86_64/aarch64, macOS Intel/Apple silicon, Windows x86_64/aarch64) ship with every GitHub release, each archive with a .sha256 beside it. To run the unreleased tip:

cargo install --git https://github.com/agent-grounds/fissile

Quickstart

fissile init            # writes .agent-grounds/fissile.toml, AGENTS.md, and the git hook
wrote ./.agent-grounds/fissile.toml
wrote ./AGENTS.md
wrote ./.git/hooks/pre-commit
next:
1. Review .agent-grounds/fissile.toml: the source rule budgets common code extensions; add this repo's languages or tune the limits.
2. Commit a change to see the pre-commit hook run fissile check --staged.
3. Run fissile audit once and add justified exceptions with fissile exception add.
see AGENTS.md for what agents are told; the findings carry the rest.

That is it. The installed hook runs fissile check --staged on every commit. That command checks index bytes and Git history: with the default bounded grace, the first four continuous over-soft edits warn and a history-proven fifth blocks. Explicit paths and plain check are working-tree snapshots, so their soft findings stay advisory even when they happen to contain the same bytes (§FS-004-check-audit.1.4).

When a hook manager owns pre-commit instead, configure it to run the staged command without passing filenames. For pre-commit, the local hook is (§FS-002-init.6):

- repo: local
  hooks:
    - id: fissile
      name: fissile
      entry: fissile check --staged
      language: system
      pass_filenames: false

Checking a whole branch

After a rebase, or in a pull-request job where nothing is staged, ask the hook's question of every commit since a base instead (§FS-004-check-audit.1.5):

$ fissile check --base origin/main

It judges the files changed between the base and HEAD, as they stand at HEAD, under the current checkout's config, and counts soft edits once per commit the way the hook did — splitting the count into the edits before the base and those on the branch (§FS-004-check-audit.1.4). It is an endpoint verdict: a violation repaired before HEAD passes. The base must be an ancestor of HEAD, and fissile never fetches. In CI, check out full history and pass the event's base commit rather than origin/main, which can move between the event and the checkout:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: fissile check --base "${{ github.event.pull_request.base.sha }}"

On a pull request's merge ref, fissile check --base HEAD^1 names the same base.

The starter config ships sensible defaults — a byte budget on everything, a line budget on common source extensions (.rs, .go, .py, .ts, .js, …) wherever they live, and a markdown budget — all editable in place.

The two tiers

Every rule carries two limits, because one threshold is a false economy:

  • soft — warns, exit 0 for the first four continuous staged edits with the default grace. A history-proven fifth edit is promoted to blocking but remains soft debt, so its remedy is still to split or record a soft exception (§FS-004-check-audit.1.4).
  • hard — blocks, exit 1. No override flag, no # fissile: allow comment. The only way past is a justified exception (below).

They also say different things. A rule carries a message per severity, so a warning can ask for a split next time you are in the file while a block says what has to happen before more code lands — and each names its own way out when no honest split exists (§DF-003-severity-guidance).

Example output

Findings are grouped: the guidance is printed once, and the files it applies to are listed under it, largest first.

$ fissile check
hard: 1 file over the 550-line budget [rule: source, message: split-source-hard]
  Must split before more code lands here: move cohesive groups of items into
  sibling modules along seams that already exist. Do not invent an abstraction,
  flatten a boundary, or shuffle lines to get under the limit — a worse design
  under budget is not the goal.
  If you cannot see a split that leaves the architecture intact, stop and ask a
  human. The only other way past this gate is a human-reviewed exception, whose
  reason names the constraint that makes splitting illegal (--kind structural)
  or the boundary that is missing and what must exist first (--kind deferred):
  fissile exception add <path> --severity hard --rule source --kind <kind>
    src/orders.rs: 620 non-blank lines (budget 550; an exception here would accept 700)

soft: 2 files over the 350-line budget [rule: source, message: split-source-soft]
  Should split, next time you are in one of these files: move a cohesive group
  of items into a sibling module. Split along a responsibility seam, never at
  the line count, and never break apart code that belongs together.
  If no split leaves the design better than it is now, record why instead of
  forcing one — not what the file contains, which is what this finding already
  said. Name the constraint that makes splitting illegal (--kind structural), or
  the boundary that is missing and what must exist first (--kind deferred,
  --until naming what retires it):
  fissile exception add <path> --severity soft --rule source --kind <kind>
    src/util.rs: 410 non-blank lines (budget 350; an exception here would accept 500)
    src/billing.rs: 372 non-blank lines (budget 350; an exception here would accept 400)

hint: fissile measure <path>... reports size and headroom for the files you split into.
# exit 1

The header states the severity, the crossed limit, and the rule and message that own the budget; every file line leads with the path and, for line rules, names the counting basis and budget so editors and agents know what to change. It also names the ceiling an exception add with no --max would record for that file, so a reader choosing to record the overflow rather than split reaches for a number with headroom instead of copying the measurement. Twelve files under one rule cost one copy of the guidance, not twelve. A passing run prints exactly ok.

The shipped messages are deliberately generic and carry no citation — they know nothing about your layout, and an ID from fissile's docs would resolve nowhere in your repo. Rewriting them to name real destinations, and to cite your own architecture, is the first edit worth making.

--format json is the agent surface — one flat record per finding, ungrouped:

[{"path":"src/orders.rs","unit":"lines","actual":620,"limit":550,"severity":"hard","rule_id":"source","message_id":"split-source-hard","message":"Must split before more code lands here: ...","exception_would_accept":700}]

The schema is published under schema/ and validated against emitted output.

Audit an existing repo

Adopting against a large codebase? audit inventories the whole repo without blocking anyone, so you can see the surface before turning the hook on:

$ fissile audit --top 5
hard: 1 file over the 550-line budget [rule: source, message: split-source-hard]
  Must split before more code lands here: ...
    src/orders.rs: 620 non-blank lines (budget 550; an exception here would accept 700)

soft: 2 files over the 350-line budget [rule: source, message: split-source-soft]
  Should split, next time you are in one of these files: ...
    src/util.rs: 410 non-blank lines (budget 350; an exception here would accept 500)
    src/billing.rs: 372 non-blank lines (budget 350; an exception here would accept 400)

exceptions:
  structural (never expires): 3
  deferred (carrying debt): 32

top lines:
  620 src/orders.rs
  410 src/util.rs

The two exception counts are deliberately not one total: three files nobody will ever split and thirty-two waiting on work are different facts about a codebase.

check already names an exact-path entry whose file the commit removes, so a leftover entry surfaces in the diff that killed it. Add --stale-exceptions for the rest of the inventory — globs matching nothing, ceilings that have drifted far above the file they still accept, which is the ratchet slipping back, and ceilings the file has grown up to exactly, which pass today and fail on the next unrelated commit:

loose ceilings:
  docs/file-size-agent-exceptions.toml: src/orders.rs accepts 700 lines, now 421 — retune to 500
  docs/file-size-agent-exceptions.toml: src/router.rs accepts 519 lines, now 519 — no headroom; retune to 600

Add --rule-coverage to find rules and messages that match nothing.

Both of those are read in a loop — edit the config, run again, look again — so --only cuts the report down to the sections you are reading:

$ fissile audit --only coverage
rule coverage:
  rules matching no file: legacy
  files only under catch-all: scripts/release.sh
  unused messages: none

The sections are the seven keys the JSON output publishes — findings, silenced, exceptions, top, stale, loose, coverage — and they print in that order however you name them. Naming one is what asks for it, so --only coverage needs no --rule-coverage; --only top still needs --top <N>, which carries the count. Selection reaches the screen and nothing else: a repository with a hard overflow still exits non-zero with its findings off screen (§FS-004-check-audit.2).

A script needs neither flag: fissile audit --format json --rule-coverage | jq .coverage returns the same section, and JSON is the surface agents should reach for (§GOAL-004-token-thrift).

How big is this file?

check reports a measurement only when a file is over budget, and the count is fissile's own — comments count, blank lines do not — so wc -l cannot answer "is there room for this here?". measure answers it for any file, passing or not, and never fails a build:

$ fissile measure src/orders.rs src/util.rs
src/orders.rs 620 lines [source] soft 350 hard 550 hard-accepted 700 — 80 to hard-accepted
src/util.rs 410 lines [source] soft 350 hard 550 — 140 to hard

The clause after the dash is the room left before whichever threshold binds first — a limit, or the ceiling an exception records. It is room you can actually use: 0 to hard means the file is at the limit and the next line is the first one that fails the commit, because a limit fires above the limit. That is the number you need before deciding whether the new test goes in this file or a new one.

What does this repo enforce?

check and audit speak in findings, so a passing tree tells you nothing about its budgets. limits prints the rules themselves — every one the config declares, in the order it declares them, with no file in hand:

$ fissile limits
source [src/**/*.rs] lines soft 350 hard 550
config-toml [**/*.toml] lines soft 180 hard 300

--format json carries the rest of each rule — its priority, its message ids, and how it counts a line — so a documented limit can be generated from the config or compared against it in CI instead of copied into prose that nothing checks:

{"rules":[{"id":"source","include":["src/**/*.rs"],"unit":"lines","soft":350,"hard":550,"priority":0,"soft_message":"split-source-soft","hard_message":"split-source-hard","count_blank_lines":false,"count_comment_lines":true}]}

It reads the config and not the exception registries, so it still answers in a tree whose registry check and audit refuse to load (§FS-010-limits).

Justified exceptions

A file you have decided to keep large gets a written reason in a registry — not a silent ignore comment. exception add appends the entry for you:

fissile exception add src/orders.rs --severity hard --rule source \
  --kind deferred --until "the pricing module exists" \
  --reason "Missing boundary: pricing has no module of its own, so the rate
table, the discount rules, and the invoice writer are all reachable only from
here. Splitting today just moves private helpers behind a new file."

A hard exception is the only way past a stop-the-line gate, so it is a person's to record: off a terminal the command refuses and names the soft-severity route instead, which leaves the finding standing. A script that adds one legitimately passes --force (§DF-008-hard-severity-needs-a-terminal).

[[exceptions]]
path = "src/orders.rs"
match = "exact"
rules = ["source"]
kind = "deferred"
max_accepted = { value = 620, unit = "lines" }
until = "the pricing module exists"
reason = """
Missing boundary: pricing has no module of its own, so the rate table, the
discount rules, and the invoice writer are all reachable only from here.
Splitting today just moves private helpers behind a new file.
"""

The hard block is now silenced — but only up to max_accepted, which is the measurement rounded up to the configured [exceptions.bump] step (100 lines by default). A ceiling reads as a decision — this file may run to 700 lines — rather than as whatever the file happened to measure the day someone wrote the entry, and an ordinary edit no longer trips it. Grow the file past it and the finding returns. The soft warning still nudges the agent, because this entry is deferred: there is a split to keep asking for.

Silencing that warning takes a second entry, in the soft registry — and it holds no argument of its own, since what to accept, why, and what retires it were all decided above. --shadows-hard says where the argument lives instead of copying it:

fissile exception add src/orders.rs --severity soft --rule source --shadows-hard

The twin it writes carries shadows = "hard", the kind copied from the entry it points at, and a max_accepted of its own — the one number the pair is allowed to disagree about, so hard debt to 620, warn again above 400 still works. Delete the hard entry and the twin stops loading with it, which is what keeps the two from drifting apart (§FS-003-exceptions.2.3).

--kind is the field that keeps the registry honest, because a reason answers one of two questions and they are not the same question (§DF-004-exception-kind):

the claim until retires when
structural splitting is illegal — name the constraint indefinite never
deferred a boundary is missing — name it and what must exist first the condition someone builds it

Without the split, both collapse into "this file is large because …", which describes the file and claims nothing a reviewer can disagree with. audit counts the two separately, so accepted permanently and carrying debt never show up as one number.

Moving a ceiling

When the file outgrows its ceiling, the reason usually still holds and only the number is wrong. exception retune moves it — in either direction, at either severity, leaving the reason, kind, and until untouched:

$ fissile exception retune src/orders.rs --severity hard --rule source
docs/file-size-human-exceptions.toml: src/orders.rs 700 -> 800 lines

It picks the number the same way add does, so a ceiling never becomes a fossil of one commit, and the diff is the single line that changed. Lowering works the same way and is how you retire a loose ceiling audit reports. State the number yourself with --max <N> --unit lines and it is written as stated; the step rounds only what the tool measured (§DF-010-stated-ceilings-are-exact).

Removing an entry

When the file is split, the path is gone, or the rule's limit moves up past the ceiling, there is no number left to move and the entry itself should go:

$ fissile exception remove src/orders.rs --severity hard --rule source
docs/file-size-human-exceptions.toml: removed src/orders.rs (accepted up to 800 lines)

It addresses the entry exactly as retune does, and it will not delete one that is still silencing a finding — the refusal names the file and the limit that would report it. It is also the way out of a registry a raised limit has made invalid, which every other command aborts on before it measures anything (§FS-009-exception-remove).

The kind also decides what a hard entry silences. A structural one silences the soft warning for the overflow it accepts as well: splitting is illegal, so the warning names work nobody may do and no amount of work can clear it. One entry makes a file over the hard limit quiet — no second entry in the soft registry repeating the same rationale (§FS-003-exceptions.3).

Overlapping entries

One overflow at one severity takes one rationale, so two entries in the same registry that both cover a file, rule and unit are a registry defect, not a choice between them. The refusal names the measured file where the overlap was met, then the two entries to edit — the first two that match, in registry order (§FS-003-exceptions.3):

$ fissile check
fissile check: docs/soft-exceptions.toml: more than one exception matches src/big.rs for lines rule rust-source; matching entries include path = "src/**" and path = "src/b*.rs"; remove or narrow overlapping entries so only one covers this file, rule and unit in this registry

Search the registry for those path values rather than for the measured file: a glob entry never spells it. Delete whichever entry is redundant, or narrow one of the patterns so it no longer reaches the file, and run the command again — a third overlapping entry is reported once the first pair is resolved.

Use as a library

use fissile::{Budget, Checker, MessageTemplate, Rule, Selector, Unit, measure_text};

let checker = Checker::new(vec![Rule::new(
    "rust-modules",
    Selector::Extension("rs".into()),
    Budget::new(Unit::Lines, Some(200), Some(400)),
    MessageTemplate::new(
        "split-rust-module",
        "Move cohesive helpers from {path} into the nearest owned module.",
    ),
)])?;

let file = measure_text("src/lib.rs", "fn main() {}\n");
assert!(checker.check(&file)?.is_empty());
# Ok::<(), fissile::FissileError>(())

A registry defect is returned as an ExceptionError whose Display is the message the CLI prints. ExceptionError::MultipleMatches carries the colliding entries' path values as patterns: Box<[String; 2]> beside the measured path (§FS-003-exceptions.4). That field arrived in 0.11: code that builds the variant or destructures it without .. has to name it.

Configuration

A single versioned TOML file at .agent-grounds/fissile.toml — data, not a plugin surface. Budgets are set per extension, per glob, and per unit (bytes, lines, or tokens); each rule names a message template. Full schema in docs/functional-spec/FS-001-config.md, with a worked sample at examples/fissile.toml.

A rule can subtract paths from only its own scope while every other applicable unit and rule remains active (§FS-001-config.3.4):

[[rules]]
id = "citable-spec"
include = ["docs/**/*.md"]
exclude = ["docs/changelog/*.md"]
unit = "lines"
soft = 750
hard = 2000
message = "split-doc"

Use [[rules]].exclude when a path should leave one budget, [scan].exclude when fissile should not scan or budget the path at all, and an exception when the rule still applies but an overflow is accepted with a written reason and ceiling. Omitting a rule exclusion, or writing exclude = [], is unchanged from earlier version-1 configs.

How it fits together

  • init — config, exception registries, the managed AGENTS.md block, and the git hook (§FS-002-init).
  • check --staged — the index-and-history commit gate, including bounded soft-edit promotion (§FS-004-check-audit.1.4).
  • check --base <ref> — the same gate over the commits since <ref>, measured at HEAD (§FS-004-check-audit.1.5).
  • check [<paths>...] — working-tree snapshot checks; explicit paths and the plain scan do not inspect or spend Git history (§FS-004-check-audit.1.4).
  • measure — what fissile counts for a file, and the headroom left (§FS-007-measure).
  • audit — the whole-repo inventory and migration surface (§FS-004-check-audit).
  • exception add — append a justified oversized-file exception (§FS-005-exception-add).
  • exception retune — move the ceiling an entry already records (§FS-008-exception-retune).
  • exception remove — delete an entry that accepts nothing (§FS-009-exception-remove).

This repo is grounded with grund: the §ID markers above point at the specs and goals that justify each behavior. Install the same version as CI with cargo install grund --version 0.18.0 --locked (§AR-001-ci.2).

About

A pre-commit guard that keeps source files small enough to stay cheap for agents to read — without ever rewriting your code.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages