The crate move left the guides describing a layout that no longer exists: bear as the owner of semantic analysis, a build script it no longer has, and compiler YAML under crates/bear. Routing that sends a reader to the wrong directory is worse than no routing, since it reads as current. The new crates/semantic/CLAUDE.md states the constraints that are easy to violate rather than listing files: the dependency boundary that is the reason the crate exists, the deliberate public API, why the testing feature is not cfg(test), and that compiler definitions are data. docs/rationale/workspace-crate-layout.md records the durable reasoning for the split and the config boundary, including the rejected alternatives, the accepted costs, and the deferred question of folding the application library into the driver. Two sibling rationale entries described the serde deserializer this work removed; both now describe startup resolution instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.7 KiB
The analysis core is its own crate, not a module
Context
Bear does two separable things. It intercepts a build, and it decides what an intercepted execution means: whether it is a compiler invocation, and what its arguments are. The second half, compiler recognition and flag parsing, used to be a module inside the same library crate as the command-line arguments, the configuration schema, the modes of operation, and the output writers.
The module already had the right shape. What it did not have was a
boundary anyone could rely on. Inside one crate, every pub item is
visible to every other module, and use crate::config from the analysis
code compiles without complaint. It did compile: the analysis core
imported four configuration types, including the whole top-level config
struct. Nothing could have stopped that edge from
growing, because a module boundary is a convention and conventions are
enforced by review attention.
The awkward part was the configuration edge itself. An as: entry in
the configuration file names a compiler family, so somewhere a
user-written string has to become a compiler identity, and the set of
valid identities is generated from the compiler definition YAML. Before
the split, that resolution happened in the configuration schema's serde
deserializer, which meant the schema layer had to include generated
codegen output to know the family set.
Decision
Compiler recognition and flag parsing live in their own library crate,
which owns the compiler definition YAML and the codegen build script
that turns it into tables. It depends on the interception primitives and
nothing from the application layer: no configuration types, no clap,
no command-line arguments, and no serde.
The configuration schema depends on nothing. It stores a configured
as: value as the string the user wrote. The analysis core owns the
resolution of that string onto a compiler identity, next to the
generated data it validates against. The application layer is the only
place that sees both, so it is the single conversion point, and it
converts once at startup before any build runs.
Consequences
What the crate boundary buys, none of which a module boundary can provide:
- The compiler enforces the dependency direction. A back-edge from the application into the analysis core is a dependency cycle and a build error, not a review finding.
- The public API is deliberate. The crate's
lib.rsstates what dependents may touch, and internals staypub(crate). Widening the surface is now a visible diff rather than an accident of visibility. - Dependencies are scoped and auditable. The manifest lists what analysis actually needs, which turned out to exclude serde entirely. It cannot quietly grow a dependency on the configuration stack, because that is a manifest line a reviewer sees.
- Build and test units are smaller. Editing application code no longer reruns compiler codegen or recompiles the analysis core, and the analysis crate's own test loop builds no application code.
- Routing is simpler: one guide per role, instead of one file explaining which subdirectories belong to which layer.
Accepted costs: one more manifest, and a change that crosses the
boundary now touches two crates. Inside a single workspace with no
published versions that is a wider diff, not a compatibility problem.
The other cost is diagnostic. Because the schema no longer validates
as: at deserialization time, an unknown spelling is reported after the
configuration echo rather than before it, and a launcher spelling such
as ccache now echoes back verbatim instead of being normalized to
wrapper. Both were judged improvements, or at worst neutral.
The rejected alternatives for the configuration boundary each preserved the coupling in a different shape:
- Keep the compiler-family type in the configuration schema, with its serde derive. This is the smallest diff and the reason the coupling existed. It requires the schema layer to include codegen output so it can name the family set, which is exactly the dependency the split removes. The schema layer would then also have to be rebuilt whenever a compiler YAML file changed.
- Have the schema's validator call a parse function in the analysis core. This keeps fail-fast validation at load time, but it points the schema layer at the analysis core, which is the same edge running the other way. A schema is a description of a file format; it should not need the analysis core to be linked in order to be understood.
- Mirror the type in the schema with
serde(remote). This puts the serde derive on the application side while the real type stays in the analysis core, but it works by hand-writing a shadow copy of the type that must track the original. The family set is generated data, so there is no fixed variant list to mirror in the first place, and the shadow copy still needs the generated ids to validate against.
The chosen split wins on all three counts at once: the schema depends on nothing, the analysis core owns resolution next to the generated data that gives it meaning, and the conversion happens in exactly one place that is named in the code rather than hidden in a deserializer.
The analysis crate carries a testing cargo feature that compiles its
command factories and comparison helpers into the library. This exists
because a dependency's cfg(test) code is not compiled when a
dependent's tests are built, and the output tests in the application
layer are written against those factories. An unconditional public
module would ship test factories in release builds; a second copy of the
factories in the dependent would drift from the original. The feature is
off by default and enabled only as a dev-dependency, and the module is
gated on "test or feature" so the crate's own fast test loop still
covers it.
Still open, deferred rather than refused: the application library and the driver binary are separate packages with no other consumer between them, and the shell-completions generator is a third package that exists only to read the command-line definitions it does not own. Folding the library into the driver, and the generator into whichever package owns those definitions, is a reasonable next step. Nothing in this layout blocks it, and the analysis crate is unaffected either way.
References
- No requirement. The split changed no user-observable contract; the two diagnostic deltas above are the whole visible difference.
- Related rationale:
compiler-as-no-aliases(the one-spelling-per-familyas:contract whose mechanism this relocated) andcompiler-family-definition(why the family set is generated data rather than a hand-written enum). - Crate roles and per-area routing: the root
CLAUDE.md.