Appearance
Reading and Modernizing an Unfamiliar Project
An unfamiliar native repository is a set of contracts accumulated over time. Modernization succeeds when it first makes those contracts observable, then changes one dimension at a time. Rewriting build files or replacing every raw pointer before reproducing the product discards evidence.
Begin with read-only reconnaissance
Find the authoritative entry points:
text
README and contributor instructions
CMakeLists.txt, Makefile, solution/workspace, or build scripts
package manifests and lockfiles
CI workflows
public headers and exported symbols
executables' main functions
tests and test data
release/install scriptsSearch for compiler standard flags, platform macros, generated files, submodules, vendored code, and environment assumptions. Determine the repository root and whether the worktree already contains user changes before editing.
Draw the target graph. Which executables link which libraries? Which headers are public? Which code generates source? Which shared libraries or plugins load at runtime? A directory tree alone does not answer these questions.
Reproduce the documented build unchanged
Record tool versions, configure command or preset, build configuration, target architecture, dependency source, and test command. Preserve the first failure output. If a tool is missing, distinguish installation from source defects.
Do not begin by upgrading the standard, generator, dependencies, and warnings together. A faithful baseline tells you whether later failure is a regression or an existing condition.
If only one developer machine builds the project, capture its environment into documentation, presets, manifests, or CI before attempting cleanup.
Classify failures by stage
- Configuration: toolchain or dependency discovery, unsupported options.
- Preprocessing/compilation: missing declarations, language mode, macros, type rules.
- Linking: missing or duplicate symbols, ABI/runtime mismatch, order.
- Loading: missing/incompatible shared objects or plugins.
- Runtime: ownership, bounds, concurrency, domain behavior.
- Packaging: absent headers, exports, runtime files, metadata.
This prevents a common anti-pattern: adding global include/library paths until an unrelated phase happens to pass.
Establish characterization tests
Before refactoring poorly understood code, capture externally important behavior: CLI status and output, file formats, public API results, error mapping, and known edge cases. Characterization tests can preserve strange behavior temporarily; label whether it is intentional compatibility or a suspected bug.
Add a clean install/consumer smoke test for libraries. Archive representative artifacts and symbol lists if ABI stability matters.
Increase diagnostic visibility gradually
Start with compiler warnings that can be made clean without semantic churn. Apply first-party flags only. Add AddressSanitizer/UndefinedBehaviorSanitizer and a separate ThreadSanitizer configuration where supported. Run tests under optimized builds.
Baseline static-analysis findings and choose high-signal checks. Do not conceal thousands of findings with one global suppression or land thousands of mechanical fixes without behavioral review.
Every discovered invalid behavior deserves a focused fix and, where practical, a regression test. Sanitizer findings outrank style modernization.
Modernize ownership at seams
Inventory functions that create, borrow, retain, resize, and destroy. In C, document ownership and consolidate cleanup. Introduce opaque handles or caller-provided buffers where they clarify interfaces.
In C++, replace direct resource ownership with RAII wrappers. Prefer values and unique_ptr; introduce shared_ptr only when ownership is genuinely shared. Convert one boundary at a time so callers cannot silently mix old and new destruction rules.
Views such as span and string_view improve bounds visibility but can expose latent dangling lifetimes. Adopt them after owner lifetimes are understood.
Update the language standard deliberately
Inventory current modes and extensions across all targets. First make code warning-clean under the existing standard. Then change the standard selection in the authoritative build and compile across supported toolchains without simultaneously adopting new syntax.
After the mode change is stable, introduce improvements that pay for themselves:
- C99/C11 declarations, fixed-width types, static assertions, and atomics where supported;
- C++ scoped enums,
nullptr, rule-of-zero classes, smart pointers, range loops; - C++17
optional,variant, filesystem, andstring_viewwith lifetime review; - C++20 concepts,
span, ranges, andjthreadwhere deployment supports them.
Modules and coroutines are architectural/toolchain projects, not search-and-replace upgrades.
Refactor build logic around targets
If moving to CMake, first encode the existing artifact graph faithfully. Do not redesign directory structure, dependency versions, compiler flags, and library boundaries in the same commit.
Replace global includes and definitions with target usage requirements. Add presets for supported workflows. Model imported dependencies as targets. Preserve platform-native projects only when they remain an intentional source of truth; otherwise generate or open CMake rather than maintaining two drifting graphs.
Modernize dependencies with provenance
List direct and transitive packages, source URLs/registries, patches, licenses, and ABI variants. Remove unused dependencies before changing managers. Choose vcpkg, Conan, system packages, or vendoring according to distribution and target needs.
Lock immutable inputs, establish binary-cache trust, and test offline or clean resolution. Treat package-manager migration as a supply-chain and build-reproducibility change, not mere convenience.
Preserve ABI intentionally
Before changing public class layout, inline functions, compiler runtime, exception mode, allocator boundary, or symbol visibility, decide whether existing binaries must continue working. If yes, use ABI comparison tools and consumer fixtures. If no, communicate the required rebuild and version boundary.
A stable C facade can shield consumers while C++ internals evolve. It still needs explicit calling, layout, threading, and allocation contracts.
Keep changes reviewable
A productive sequence is:
- document and automate the current build;
- add tests and CI without changing behavior;
- enable diagnostics and fix concrete defects;
- clarify ownership and public boundaries;
- update the standard mode;
- adopt modern language/library facilities;
- simplify or migrate build/dependency tooling;
- verify packaging and supported matrices;
- measure performance before and after.
Each checkpoint should build and test. Preserve a bisectable history where the repository permits it. The sequence improves reproducibility, ownership visibility, diagnostic coverage, and packaging without making newer syntax the goal by itself.
A final audit
Before declaring the modernization complete, ask:
- Can a clean machine follow one documented configure/build/test path?
- Are compiler, language mode, SDK, and dependency sources visible?
- Do CI jobs cover declared targets rather than accidental ones?
- Are public ownership and error contracts documented?
- Do sanitizer and optimized test configurations pass?
- Can an external consumer use the installed library?
- Are runtime dependencies packaged and loadable?
- Are ABI breaks and required rebuilds explicit?
- Were performance-sensitive changes measured?
Optional prompts
Explain: Why should the existing build be reproduced before converting it to CMake?
Answer: The old build is evidence of the artifact graph and platform contracts. Without a baseline, conversion failures cannot be distinguished from existing defects, and hidden requirements may be lost.
Explain: Why separate raising the C++ standard mode from adopting new features?
Answer: It isolates compatibility changes caused by the compiler's interpretation from behavioral and design changes, making failures and review much easier to attribute.
Debug: After replacing raw pointers with string_view, tests intermittently fail. What should be audited?
Answer: Owner lifetime and invalidation. A view adds bounds but no ownership; temporaries, moved strings, or reallocated containers may leave it dangling.