What changed, and why
PR #20 made the dead-code loop un-export an unused symbol: strip the export, leave it module-private. That is the safe minimum. The fuller move is to delete a truly-dead symbol, and deletion needs an AST โ multi-line declarations, proper cleanup โ not a regex. froot's worker is Python and can't run a TypeScript AST, so the codemod runs where the deptry signal already runs: an e2b sandbox (Node + ts-morph).
The consequence is the interesting part. Until now the sandbox only ever read the checkout (deptry, a signal). This is the first time it mutates it and hands the edits back โ froot's first sandbox-as-action. That seam is the reason to care: it is the reusable plank the future fabrication loops ride. The agentic coding harness, when it comes, swaps the deterministic codemod for an LLM behind the same run-in-sandbox then apply-edits convention, on terrain that already works.
The module's own framing: action vs signal, and the degradation contract.
src/froot/adapters/codemod.py ยท 175 lines
โฏ 150 lines hidden (lines 26โ175)
The codemod
The whole AST decision is here. ts-morph loads only the flagged file (knip already guarantees no other module imports the symbol, so in-file references are all that can exist). If nothing in the file references the symbol, the whole declaration is removed; if it is still used in-file, only the export modifier is stripped. The changed file is emitted as a {path: content} JSON map on stdout โ the one thing that survives the sandbox teardown.
The validated ts-morph codemod: delete-if-dead, un-export-if-used-in-file.
src/froot/adapters/codemod.py ยท 175 lines
โฏ 43 lines hidden (lines 1โ43)
โฏ 89 lines hidden (lines 87โ175)
The sandbox-as-action seam
The sandbox runs a script and returns stdout; the workdir is discarded on teardown. So the action convention is: the script mutates its copy and prints the edits, and the worker applies them to its checkout (the one it will push). The script captures the checkout root, installs ts-morph off to the side so it never pollutes the tree, writes the codemod and the target, and runs it โ all install noise to stderr so stdout is only the JSON.
The sandbox script. WORK=$(pwd) is the extracted checkout; stdout stays clean.
src/froot/adapters/codemod.py ยท 175 lines
โฏ 87 lines hidden (lines 1โ87)
โฏ 71 lines hidden (lines 105โ175)
apply_export_codemod is the orchestration: build the script, run it in the sandbox, and on a clean run write each returned file back into the worker's checkout. It returns whether anything landed โ the signal the caller uses to decide between the codemod and the fallback.
The pure parser, then the run-then-apply orchestration; every non-clean path returns False.
src/froot/adapters/codemod.py ยท 175 lines
โฏ 115 lines hidden (lines 1โ115)
The action: codemod, or fall back
The open-PR activity's DeadExport arm tries the codemod first; if the sandbox is unconfigured (no key), errors, exits non-zero, or finds nothing, it falls back to the in-worker regex un-export from #20. So a missing sandbox never blocks the loop โ exactly the degradation the deptry signal already uses. CI is the oracle for whichever edit lands.
Prefer the codemod; fall back to the in-worker un-export.
src/froot/workflow/activities.py ยท 927 lines
โฏ 413 lines hidden (lines 1โ413)
โฏ 504 lines hidden (lines 424โ927)
How it's verified
The pure pieces (script builder, output parser) are fixture-tested. The orchestration is driven by a FakeSandbox: a canned edit is applied, and each failure mode (non-zero exit, empty output, a raising sandbox) returns False so the caller falls back. The action dispatch is tested both ways โ codemod success takes the codemod's edit, no-key takes the fallback's un-export.
The apply path: a canned sandbox edit lands in the checkout.
tests/test_codemod.py ยท 87 lines
โฏ 38 lines hidden (lines 1โ38)
โฏ 32 lines hidden (lines 56โ87)
The Node codemod itself is validated by running the exact shipped _CODEMOD_JS against fixtures โ a truly-dead symbol is deleted, one used in-file is un-exported, a missing one is a no-op. That is the same posture as deptry: the tool holds the integration truth, not the Python around it. Full suite green: 511 tests, both determinism gates, ruff and mypy.