A semantics-preserving sweep that makes each package export only what other
packages actually use. Repo-agnostic; drive it with moon ide analyze.
- Measure, don't guess.
moon ide analyze <pkg>prints every exported symbol (values, methods, struct fields, enum variants) withusage: N (M in test). Real usage =N − M. It also flagspub(all)/pub(open)that can narrow topub. Script the sweep: run it over every package first and rank packages by zero-real-usage count — fix the worst first. Two count semantics to internalize:- Counts are cross-package only. Same-package references never count,
so a symbol used ten times in its own package still shows
usage: 0. Those are the easiest wins: droppuband nothing else moves (the definition stays used, so no unused-symbol warning fires). - Blessed exports still list — filter on the annotation. A symbol
kept in
exports.mbtis NOT dropped from the output; it appears with, in exports.mbtappended to its usage comment. A sweep script that strips or ignores everything after//(this round's did) re-surfaces every deliberately kept export as a zero-usage candidate and drowns the ranking in already-classified keeps. Parse the annotation and subtract those rows first.
- Counts are cross-package only. Same-package references never count,
so a symbol used ten times in its own package still shows
- Classify each zero-real symbol — the deny-warn gate forces exactly
three outcomes:
- Test-only usage (
N > 0, N == M) → first check HOW consumers import the package:import { ... } for "test"in theirmoon.pkgmeans the package is a test kit by design — exempt it wholesale (e.g. atestkit/). Otherwise decide seam vs incident: a symbol many packages' tests lean on (a store seeder, a fixture constructor, a policy probe) is a deliberate test seam →exports.mbt, documented. A getter poked by one adjacent test is incidental → removepuband move that test into the package (whitebox/inline). Test usage is never API justification by itself. - No usage at all (
N == 0), plausibly intentional (documented tunables, one of a symmetric family, protocol surface) → keeppubbut move the block toexports.mbtin that package. The file IS the convention: everything in it is exported on purpose despite no internal consumer. - No usage at all, vestigial → delete the block. (Making it
privinstead just trades an over-export for an unused-symbol warning.)
- Test-only usage (
- Verify per package, not per sweep:
moon check --deny-warn, run the package's tests, thenmoon infoand check the.mbtidiff shrank the way you intended. The.mbtidiff is the review artifact.
-
Cross-workspace consumers are invisible.
moon ide analyzeonly sees the current workspace. A package consumed by a sibling module (anothermoon.work, a JS app module, a desktop shell) shows zero usage here while being load-bearing there. Union usage from every workspace that imports it before shrinking — and put the symbols such a consumer needs inexports.mbtwith a comment naming that consumer. (The analyzer keeps listing them, annotated, in exports.mbt— see step 1; filter on that marker in the sweep script.) -
Target-only consumers are invisible too — including to the narrowability flags. A JS-only package errors out of
moon ide analyze("no package directories found in input paths") AND its usages of everything else are missing from the counts and from the(all) or (open) can be removedhints. Real case:ParsedLogwas flagged narrowable while the js-only viz app constructs it with a struct literal — narrowing compiles clean under nativemoon check --deny-warn(which never builds the js app) and breaks the bundle build. Grep js/web consumers for every candidate, and verify withmoon check --target js(or the app's bundle build) after any narrowing a js package might construct. -
Published modules: if the module is on a registry, everything
pubis potentially someone's dependency. Get the owner's explicit OK that in-repo usage is the yardstick (here: yes). -
Blackbox tests break at compile time when their symbol goes priv — that's the signal, not a problem. Convert those tests rather than keeping the export.
-
Struct fields: don't bother marking fields
privon a plainpubstruct — the 2026 toolchain flags the modifier as redundant there (deny-warn fails). Field-level shrink only matters forpub(all)structs, where external construction needs every labeled field; check construction sites before narrowing those. -
Shrinking surfaces dead code beyond visibility. Once a constructor goes package-private,
unused_default_valueand friends start firing — e.g. a parameter default that no caller ever exercised (an upstream wrapper owned the real default). Treat these warnings as findings: delete the dead default (make the parameter required), don't suppress the warning. -
Analyzer notes are hints, not proofs.
(all) or (open) can be removedand usage counts can miss reflection-ish uses (derive output consumed as JSON keys, wire contracts). Compile + full tests after each package. -
Dead packages: a package whose every export is unused may itself be unimported — check
moon.pkgimporters; deleting the package beats shrinking it. -
pub impl(trait impls, incl. derive-adjacent Show/ToJson/FromJson) with zero external usage usually demote to plainimpl— in-package and derive-chain uses don't needpub. Two exceptions: (a) round-trip impls on durable-record types are protocol surface — keep them (exports.mbt); (b) a trait impl whose trait OBJECT crosses the package boundary cannot demote — the consumer package invoking through&Traitneeds the conformance public ("implementation of method X is private" is the compiler telling you this). Derive-generated impls have no block to edit; their visibility follows the type — skip them. -
Enum variants and
pub(all)struct fields are not individually shrinkable — act on the analyzer's narrowability flag for the whole type, and skip per-variant/per-field rows unless the type is plainpub. -
Make edit tooling fail on no-change. A regex that silently matches nothing turns a whole category of edits into no-ops that still report success (this sweep's
pub(all)narrowing was a no-op for three batches before the residual-stats check caught it). Count changed files/lines, not matched declarations — and re-run the analyzer at the end as an independent audit of what actually changed.
Pilot one leaf package end-to-end (smallest blast radius, validates the test-conversion mechanics), then sweep families bottom-up (leaf packages before the packages that import them), committing per family so review and bisection stay tractable.