17.5 Maintaining a Compiler Codebase
A compiler may live for decades while languages, targets, teams, and build systems change around it. Maintainability begins with explicit stage boundaries and owned data. The parser should not reach into register allocation state; an optimizer should not mutate a global symbol table as a side effect. Each stage should advertise its accepted input invariants, produced output invariants, diagnostics, and failure behavior.
Immutable or persistent IR makes ownership and caching easier to reason about, although controlled mutation can be appropriate inside a pass. In either design, centralize changes through APIs that preserve use-def chains, control-flow edges, source locations, and analysis invalidation. Pass required services through explicit contexts rather than process-wide globals. Hidden global target settings, current-file pointers, or intern pools make parallel compilation and deterministic tests fragile.
Dependencies should point toward small stable abstractions. Frontends may lower into a shared IR interface; targets implement a backend contract; orchestration composes them. A “utilities” package that imports everything becomes an architectural bypass. Track dependency cycles, public API surface, and fan-out. Plugins need capability boundaries and versioned interfaces, since loading arbitrary in-process code turns every internal detail into an accidental compatibility promise.
Incremental work requires exact dependencies
An incremental compiler reuses a result only when all relevant inputs are unchanged. Cache keys may include source hashes, flags, target triple, language edition, imported interfaces, environment assumptions, and compiler build ID. Under-invalidation returns stale wrong code; over-invalidation is correct but slow. Model queries and their dependencies explicitly, record why a query reran, and test changes that affect public interfaces differently from private function bodies.
Observability makes architectural claims measurable. Attribute time and memory to passes, count IR growth, record cache hit reasons, and emit traces that connect a slow build to queries rather than anonymous threads. Performance tests should use representative workloads and fixed environments; a microbenchmark improvement can move cost into a later pass or increase generated code size.
Reproducibility is a release feature
A reproducible build produces equivalent artifacts from the same declared inputs. Pin compiler and dependency versions, use hermetic tools, normalize embedded paths, set or remove timestamps deliberately, stabilize map and filesystem iteration, and define build IDs from content rather than wall-clock state. Byte-for-byte identity is ideal for binaries; when signatures or packaging add approved variation, specify and compare the normalized payload.
CI should cover supported host/target pairs without multiplying every dimension blindly. Run fast presubmit checks, shard deterministic suites, and schedule slower sanitizer, fuzz, bootstrap, and cross-platform jobs. Cache only artifacts with complete keys and verify restored metadata. A flaky test is a defect with an owner and deadline; retries may collect evidence but must not convert repeated failure into success.
Release engineering extends beyond a green build. Produce provenance, checksums, debug symbols, source mappings, licenses, and a software bill of materials. Sign artifacts through controlled identities, test installation and rollback, and retain the exact toolchain required to reproduce them. Canary channels expose a release to a limited population while monitoring crash signatures, miscompilation reports, compile-time regressions, and diagnostic changes. Telemetry must respect privacy and be interpretable against a build ID.
Finally, design for removal. Feature flags, experimental IR forms, target hooks, and compatibility shims need owners and exit criteria. Document architectural decisions and migration paths near the code they constrain. Regularly delete dead paths after evidence shows they are unused. Maintenance is not freezing a compiler’s structure; it is creating boundaries, evidence, and release discipline that let the implementation evolve without making correctness unknowable.