What changed, and why
froot's dead-code loop knew one shape of dead weight: an unused dependency. It dropped the dep from the manifest and let the repo's own CI confirm the build no longer needed it. This change finishes the loop out to dead code, the rest of the MHE dead-code catalog, with no new loop, gate, or schedule. The same dead-code loop now also deletes whole unused files and un-exports symbols no other module imports.
The interesting part is where the new lines are not. The state machine, the workflows, the merge gate, and the durable schedule did not move. The change adds two work-item kinds the spine carries, one pure source transform, and a few lines of knip parsing the adapter was already discarding. These are froot's first edits to source rather than a manifest, so they double as the proof that a source-editing work item fits the existing chassis.
Blast radius stays small by construction: every change is still a PR a human approves (trust is propose-only by default), CI is still the only oracle, and a dead export is un-exported, not deleted. That last choice is the safe response to what the analyzer actually claims, and the rest of this guide explains why.
Two new work-item kinds
A bump and a removal were the whole work-item union. Now there are four. DeadFile is a module nothing imports; DeadExport is a symbol exported but imported by no other module. They are separate kinds, not one shape with an optional symbol, so each is valid by construction: a file deletion has no symbol, an un-export must name exactly one.
Each kind is frozen and self-describing; subject is its human identifier (a path, a symbol).
src/froot/domain/dead_source.py · 97 lines
⋯ 33 lines hidden (lines 1–33)
⋯ 22 lines hidden (lines 61–82)
That subject property is small but load-bearing. The spine's logs and labels used to read .package off every work item; a file or an export has no package. Giving every kind a subject lets the spine stay generic instead of growing a branch per kind. The union itself just widens, and kind routes it.
The discriminated union widens a third and fourth time.
src/froot/domain/work.py · 31 lines
⋯ 22 lines hidden (lines 1–22)
The action: a file unlinked, an export stripped
The open-PR activity already branched per kind to apply a bump or a removal. Two arms join it. A dead file is deleted from the checkout; a dead export has its line rewritten. Neither goes through the package manager — deleting a file is language-agnostic, and the un-export is a pure text edit — so both are small helpers the activity calls directly. The push that follows stages whatever changed (git add -A), and CI runs against the result.
The two source actions. The export edit re-validates the line before writing.
src/froot/workflow/activities.py · 921 lines
⋯ 136 lines hidden (lines 1–136)
⋯ 751 lines hidden (lines 171–921)
The action dispatch: four kinds, one push, CI the oracle for all of them.
src/froot/workflow/activities.py · 921 lines
⋯ 459 lines hidden (lines 1–459)
⋯ 448 lines hidden (lines 474–921)
The signal knip already had
The cheapest part of the change. knip maps the whole import graph and reports unused files and exports in the same JSON run the adapter was already making for unused dependencies — it just parsed the dependency section and dropped the rest. Two small parsers read the files array and the per-file exports (and types, which un-export identically). Then the exports are narrowed against the real source in the checkout, and only the editable ones survive.
Two parsers over the same knip JSON; defensive, never a raise.
src/froot/adapters/npm.py · 431 lines
⋯ 169 lines hidden (lines 1–169)
⋯ 209 lines hidden (lines 223–431)
Narrowing reads the flagged line and keeps only what the transform can edit; it counts the drops.
src/froot/adapters/npm.py · 431 lines
⋯ 224 lines hidden (lines 1–224)
⋯ 168 lines hidden (lines 264–431)
One run, three kinds. The dropped-export count is logged, not swallowed.
src/froot/adapters/npm.py · 431 lines
⋯ 350 lines hidden (lines 1–350)
⋯ 20 lines hidden (lines 412–431)
The veto routes by kind
Every flagged item still passes a safe-to-remove veto at the signal, before a workflow ever starts — "not imported" is not "dead" for a dependency (think pytest, eslint) and it is not "dead" for source either (a framework entry loaded by convention, a route or plugin file). The loop now routes a dependency to the dependency judge and a file or export to the source judge; only a clean verdict becomes a PR.
One loop, two vetoes, dispatched by kind; survivors carry the judge's reasoning into the PR body.
src/froot/loops/dead_code.py · 92 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 17 lines hidden (lines 76–92)
The source veto is a fourth thin agent over the shared assessment shape, a distinct prompt.
src/froot/adapters/model_judge.py · 282 lines
⋯ 271 lines hidden (lines 1–271)
How it's verified
The transform is the riskiest piece, so it carries a table of cases: every form it must edit, and every form it must refuse. The action is proven end to end on a real temp checkout — the export is stripped, its body and the still-exported sibling untouched — and the signal is proven to surface all three kinds from one knip body.
The un-export contract, kept and refused forms side by side.
tests/test_dead_source.py · 69 lines
⋯ 41 lines hidden (lines 1–41)
The action edits the real file in the checkout, nothing else.
tests/test_activities.py · 934 lines
⋯ 460 lines hidden (lines 1–460)
⋯ 448 lines hidden (lines 487–934)
The full suite is green: 503 tests (34 new), both determinism gates clean, ruff and mypy clean. Run against a throwaway git repo, the loop produces exactly the diff a reviewer would want: the dead file deleted whole, the unused export's export stripped, its body and the still-used sibling left alone.