Software Engineering

Git & GitHub for source code tracking

The first practice you must adopt is revision control. As you can see from https://github.com/IHPCSS/software-engineering, development of even a small project goes through many iterations, and without revision control you would repeatedly face the problem of trying to fix errors you introduced yourself. With revision control you can simply step back to a known-good version instead.

For a large and very active project like GROMACS, we run our own server where every single commit is tracked, and all developers continuously rebase their changes onto the current state of the main branch. Another common approach (used e.g. on GitHub) is the pull request model: a developer sends a PR, and the maintainer reviews and merges it. Either model works.

If you are just starting out, we strongly recommend creating an account at https://github.com and putting your repositories there. GitHub has plenty of documentation on how to set up an empty repository or import existing code.

Issue Tracking

Even for a small project with a single developer, it is valuable to have a list where you can note bugs, features, and other considerations. GitHub includes an integrated issue tracker — click the “Issues” tab and create a new one. Each issue gets a unique number, and you can close it automatically from a commit message:

Fixes #14

A file README.md in the top-level directory is shown as the default page for anyone visiting the GitHub repository. It is a good place for a brief description and status badges (see below).

CMake for Build Configuration

We use CMake (https://cmake.org) for build configuration — it works on Linux, macOS, and Windows, and has excellent support for MPI, OpenMP, CUDA, and many other HPC tools.

The canonical out-of-source build workflow is:

cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel

We require CMake 3.20 or later. Modern CMake uses imported targets for dependencies instead of raw compiler flags. For example:

target_link_libraries(laplace_lib PUBLIC MPI::MPI_CXX)

This automatically propagates the right include paths, compile flags, and linker flags to anything that links against laplace_lib — you never touch CMAKE_CXX_FLAGS directly.

Useful CMake variables

CMAKE_BUILD_TYPE

Release (default, with optimisation), Debug (with assertions and debug symbols), or RelWithDebInfo.

BUILD_TESTS

ON (default) to compile and register the GoogleTest unit tests.

BUILD_COVERAGE

ON to instrument the build for gcov/lcov coverage reporting (requires GCC or Clang and a Debug build).

BUILD_DOCS

ON to enable the Sphinx and Doxygen documentation targets.

MPI / OPENMP / OPENACC

ON to search for and link against the respective parallelisation library.

To list all available options interactively, run ccmake <path-to-source>.

Multiple build directories

Rather than reconfiguring the same directory, keep separate build directories for different configurations:

cmake -B build-debug   -DCMAKE_BUILD_TYPE=Debug   -DBUILD_TESTS=ON
cmake -B build-release -DCMAKE_BUILD_TYPE=Release
cmake -B build-mpi     -DCMAKE_BUILD_TYPE=Release -DMPI=ON

A change to one source file only requires a recompile in the affected directory.

GitHub Actions for Continuous Integration

Every push to GitHub automatically triggers a CI pipeline defined in .github/workflows/build-cmake.yml. The pipeline runs a matrix of configurations: GCC and Clang, each in Debug and Release mode, on Ubuntu 24.04. This catches problems that only appear with a specific compiler or optimisation level before they reach the main branch.

The matrix is defined in the workflow file as:

strategy:
  fail-fast: false
  matrix:
    compiler:   [gcc, clang]
    build_type: [Debug, Release]

fail-fast: false ensures that all four combinations run even if one fails — you see the full picture rather than stopping at the first error.

A separate coverage job runs after the matrix succeeds. It builds with -DBUILD_COVERAGE=ON and uploads the resulting coverage report to Codecov (see Code Coverage below).

The CI status is shown as a badge in the README.md file so that anyone visiting the repository immediately sees whether the current code passes all checks.

To add CI to your own project, copy .github/workflows/build-cmake.yml into your repository, adjust the compiler and build-type matrix to match your requirements, and push. GitHub Actions is free for public repositories.

Code Coverage

Code coverage measures the fraction of your source lines that are actually executed by the test suite. 100% line coverage means every line of production code was reached by at least one test; it does not guarantee correctness, but it does guarantee that untested code paths cannot silently break.

This project uses gcov (the GCC coverage tool) and lcov (a front-end that collects gcov data and renders an HTML report). The CMake build integrates both through the coverage target:

cmake -B build -DBUILD_TESTS=ON -DBUILD_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target coverage
open build/coverage-report/index.html

cmake/Coverage.cmake auto-detects the gcov version that matches the compiler (e.g. gcov-14 when using g++-14) and passes it to lcov via --gcov-tool, avoiding the version-mismatch error that occurs when the system gcov is a different version from the compiler.

The coverage job in CI uploads the coverage.info file to Codecov (https://codecov.io), which hosts an annotated HTML report and provides the coverage badge shown in README.md. Codecov highlights which lines are covered (green) and which are not (red), making it easy to identify gaps in the test suite.

The codecov.yml configuration file (in .github/) excludes the vendored GoogleTest sources from the report so the badge reflects coverage of our code only.

General Documentation with Sphinx

The documentation you are reading right now is generated by Sphinx, which reads plain-text reStructuredText (.rst) files from the docs subdirectory. The raw files are readable in any editor, and Sphinx can produce HTML, PDF (via LaTeX), and other formats.

If Sphinx is installed locally:

cmake -B build -DBUILD_DOCS=ON
cmake --build build --target sphinx-html
open build/docs/html/index.html

You do not need a local Sphinx installation: ReadTheDocs (https://readthedocs.org) integrates with GitHub and rebuilds the documentation automatically on every push. Connect ReadTheDocs to your repository once, and anyone can read the current documentation at a link like https://software-engineering.readthedocs.io/.

The badge in README.md shows the status of the last ReadTheDocs build.

Code Documentation with Doxygen

Sphinx provides high-level narrative documentation. Doxygen parses the C++ source code and generates reference documentation — class hierarchies, function signatures, parameter descriptions, and cross-references — directly from the /// and /** */ comments in the header files.

If Doxygen is installed:

cmake -B build -DBUILD_DOCS=ON
cmake --build build --target doxygen
open build/docs/doxygen/html/index.html

All three library headers (grid.h, boundary_conditions.h, solver.h) are fully documented with Doxygen comments, including the memory layout contract, class invariants, and extension points for MPI and CUDA.

Unit Tests with GoogleTest

Continuous integration tells you the code compiles. Unit tests tell you it also works correctly.

The key idea is to design your code as small, independent modules with a clear interface, and test each module in isolation. If the tests are small and cover every code path, the CI system can tell you exactly which 20–30 lines introduced a regression — before you have even opened the source code.

This project uses GoogleTest, which is vendored under src/external/googletest/ so you do not need to install it separately. Tests are organised in src/laplace/tests/, one file per library source:

grid.cpp

Tests for the Grid class: construction invariants, memory layout contract, write() success and failure paths.

boundary_conditions.cpp

Tests for ConstantBC and CornerHeatBC: edge values, corner ownership, interior cells left untouched.

solver.cpp

Tests for solve(): uniform boundary conditions (converges in one sweep), single-interior-cell exact solution, convergence within max_iter, SolverResult field consistency.

To build and run the tests:

cmake -B build -DBUILD_TESTS=ON
cmake --build build --target tests
ctest --test-dir build --output-on-failure

Or to build and run in one step:

cmake --build build --target check

The current test suite achieves 100% line and branch coverage of all three library source files, verified by gcov/lcov (see Code Coverage).

Static Analysis with cppcheck

Unit tests verify what the code does; static analysis tools inspect how the code is written without executing it. They catch a different class of problems: out-of-bounds array access, use of uninitialised variables, shadowed identifiers, and patterns that are technically legal C++ but almost certainly bugs.

cppcheck (https://cppcheck.sourceforge.io) is a lightweight static analyser that understands C++ without needing a compilation database. Run it on the library sources directly:

find src -name "*.cpp" \
    -not -path "*/external/*" \
    -not -path "*/tests/*" | \
xargs cppcheck \
    --std=c++23 \
    --enable=warning,style,performance,portability \
    --suppress=missingIncludeSystem \
    --error-exitcode=1 \
    -I src

The -not -path "*/tests/*" exclusion is necessary because cppcheck cannot parse the TEST(...) macros that GoogleTest defines — they look like syntax errors without the GoogleTest headers in the compilation context.

The CI job runs this automatically on every push and fails the build if cppcheck reports any finding. The --error-exitcode=1 flag is what makes the job fail; without it, cppcheck exits 0 even when it finds problems.

Static Analysis with clang-tidy

clang-tidy (https://clang.llvm.org/extra/clang-tidy/) is a more powerful analyser that works from the same AST that the compiler builds. Because it understands the code exactly as the compiler does — including template instantiations, macros, and implicit conversions — its diagnostics are both precise and exhaustive.

clang-tidy needs a compilation database (compile_commands.json) to know how each file was compiled. CMake generates one automatically:

cmake -B build -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_TESTS=ON

Then run clang-tidy across all project sources:

find src -name "*.cpp" -not -path "*/external/*" | sort | \
    xargs clang-tidy -p build

The checks are configured in .clang-tidy at the repository root. The file enables the bugprone-*, modernize-*, performance-*, and readability-* check families, then disables a handful of checks that would be noisy or unhelpful for HPC code (such as readability-magic-numbers and modernize-use-trailing-return-type). WarningsAsErrors: '*' ensures that every finding is treated as a build error, mirroring the -Werror compiler flag.

Because clang-tidy reads compile_commands.json, it also analyses the unit-test files and sees the GoogleTest headers — unlike cppcheck, it has the full compilation context and can check test code correctly.

Runtime Sanitisers

Compiler warnings and static analysis find bugs without running the code. Runtime sanitisers go further: they instrument the compiled binary with extra checks that fire during execution, catching bugs that can only be observed at runtime — memory corruption, use-after-free, data races.

Sanitisers are controlled by a single CMake variable:

cmake -B build -DCMAKE_BUILD_TYPE=Debug \
      -DBUILD_TESTS=ON -DSANITIZE=address
cmake --build build --target tests
ctest --test-dir build --output-on-failure

cmake/Sanitizers.cmake passes -fsanitize=<value> -fno-omit-frame-pointer to both the compiler and the linker, so every translation unit in the project is instrumented.

AddressSanitizer

-DSANITIZE=address enables AddressSanitizer (ASan), which detects:

  • Buffer overflows (stack, heap, and globals)

  • Use-after-free and use-after-return

  • Use of uninitialised stack memory (partially, without MSan)

  • Memory leaks (via LeakSanitizer, enabled by default)

ASan adds roughly 2× memory overhead and a 2× runtime overhead, which is acceptable for a Debug build in CI. Enable leak detection explicitly when running tests:

ASAN_OPTIONS=abort_on_error=1:detect_leaks=1 \
    ctest --test-dir build --output-on-failure

ThreadSanitizer

-DSANITIZE=thread enables ThreadSanitizer (TSan), which detects data races — concurrent reads and writes to the same memory location without synchronisation. This is particularly relevant when extending the solver with OpenMP or MPI shared-memory communication.

ASan and TSan cannot be combined in the same binary; the CI pipeline runs them in separate jobs:

TSAN_OPTIONS=abort_on_error=1 \
    ctest --test-dir build --output-on-failure

Sanitisers require Clang or GCC. The CI jobs use Clang 18, which produces the clearest stack traces and has the most mature sanitiser runtime. Neither ASan nor TSan can be used with -DCMAKE_BUILD_TYPE=Release and link-time optimisation enabled, because LTO can optimise away the instrumentation; always pair sanitisers with a Debug build.

Source Code Organisation

software-engineering/
├── .clang-tidy              # clang-tidy check configuration
├── .github/
│   ├── codecov.yml          # Codecov reporting configuration
│   └── workflows/
│       └── build-cmake.yml  # GitHub Actions CI pipeline
├── cmake/
│   ├── CompilerFlags.cmake  # Warning flags for GCC, Clang, Intel
│   ├── Coverage.cmake       # gcov/lcov coverage support
│   ├── Sanitizers.cmake     # ASan / TSan runtime sanitiser support
│   └── TestMacros.cmake     # add_unit_test() helper macro
├── docs/                    # Sphinx source (this documentation)
│   └── doxygen/             # Doxygen configuration
├── src/
│   ├── external/
│   │   └── googletest/      # Vendored GoogleTest (not our code)
│   ├── laplace/             # Library: Grid, BoundaryConditions, Solver
│   │   ├── grid.h / .cpp
│   │   ├── boundary_conditions.h / .cpp
│   │   ├── solver.h / .cpp
│   │   └── tests/           # Unit tests (one file per source)
│   │       ├── grid.cpp
│   │       ├── boundary_conditions.cpp
│   │       └── solver.cpp
│   └── program/             # Executable (main.cpp)
└── CMakeLists.txt

The library (laplace_lib) is compiled as a static library so that the test executables can link against it independently of the laplace executable. This is a deliberate software engineering choice: it means the solver logic is testable in isolation without running the full program.

Header and implementation files are co-located in src/laplace/ rather than separated into include/ and src/ directories. The include/ separation is the right choice for installable libraries (where users install headers separately from binaries), but for a self-contained teaching project it adds indirection without benefit.