CMake template clenaups and script adaptations

This commit is contained in:
alejo committed 2026-09-12 15:29:06 -03:00
1 parent 3087fcea8a
commit 9248925b77
19 files changed
+361 -170

No files matched your search

+170 -128
View File
@@ -1,13 +1,35 @@
# CMake module-system demo
# Hot Reload
A minimal C++20 project showing a per-module CMake setup: each module owns
a tiny, self-contained `CMakeLists.txt`, the root `CMakeLists.txt`
discovers modules automatically, three ways to bring in third-party code
(vendored, installed, or fetched) stay out of your warnings and your
compile database, every module gets its own browsable documentation via a
pinned Doxygen that's entirely opt-in (never required just to build),
Ninja itself is pinned and fetched the same way with a graceful fallback
if it's unavailable, and every output path is configured in one place.
A cross-platform, cross-compiler C/C++ library that patches a running
process's own code in place, so a new build of a function takes effect
immediately, without restarting -- see
[`source/HotReload/README.md`](source/HotReload/README.md) for what it
does, what it guarantees, and its honest limitations. This file covers
the project around it: the CMake module system, its tooling, and how
the examples and tests fit together.
```sh
scripts/setup.sh # optional, one-time -- see "Setup" below
scripts/build.sh Release -DBUILD_EXAMPLES=ON
./out/build/bin/examples/hotreload_demo_host
```
## What's here
| | |
|---|---|
| [`source/HotReload/`](source/HotReload/README.md) | The library itself: `hotreload_lib`, a `STATIC` library with a plain C API (`hot_reload.h`) over a C++ implementation |
| [`source/examples/HotReloadDemo/`](source/examples/HotReloadDemo) | A scripted, deterministic demo: a host + two builds of one plugin, exercising every guarantee the library makes |
| [`source/examples/HotReloadLiveDemo/`](source/examples/HotReloadLiveDemo/README.md) | An interactive demo: a host that stays running, and one plugin file you edit and rebuild yourself |
| [`source/tests/HotReloadTests/`](source/tests/HotReloadTests) | An automated `ctest` suite covering the library end to end, including a regression test for a real bug found while building this |
The rest of this file is about the project *around* that: a per-module
CMake setup adapted from a generic template (each module owns a tiny,
self-contained `CMakeLists.txt`; the root `CMakeLists.txt` discovers
them automatically), `scripts/` for the everyday commands, and the
code-style tooling. None of it is specific to hot-reloading -- if
you're only here for the library, `source/HotReload/README.md` is the
one to read.
## Layout
@@ -21,15 +43,12 @@ cmake/DoxygenPin.cmake shared version/URL/hash parameters for the pinne
cmake/FetchDoxygen.cmake standalone downloader (run via `cmake -P`, not during normal configure)
cmake/NinjaPin.cmake shared version/URL/hash parameters for the pinned Ninja
cmake/FetchNinja.cmake standalone downloader (run via `cmake -P`, not during normal configure)
cmake/HotReload.cmake hot_reload_enable_patchable_functions() -- see source/HotReload/README.md
source/Core/CMakeLists.txt library module "core_lib" (SHARED), depends on fmt
source/HelloApp/CMakeLists.txt executable module "hello_exe", depends on vendored tiny_ansi
source/FarewellApp/CMakeLists.txt executable module "farewell_exe", depends on vendored tiny_case
source/HotReload/CMakeLists.txt library module "hotreload_lib" (STATIC) -- see source/HotReload/README.md
cmake/HotReload.cmake hot_reload_enable_patchable_functions() / hot_reload_restrict_exports()
source/HotReload/ the library -- library module "hotreload_lib" (STATIC)
source/examples/HotReloadDemo/ worked example: a host + two builds of one hot-reloadable plugin
source/examples/HotReloadLiveDemo/ interactive: a persistent host + one plugin file you edit yourself
vendor/tiny_ansi/ header-only vendored dependency (no build of its own)
vendor/tiny_case/ vendored dependency with its own CMakeLists.txt
source/tests/HotReloadTests/ the automated test suite
source/tests/UndefinedSymbolFixture/ a fixture the test suite loads, deliberately broken -- see its own comment
scripts/setup.sh, setup.bat one-time: fetch pinned Ninja + Doxygen, check tooling, activate git hook
scripts/build.sh, build.bat configure + build -- prefers a pinned Ninja, falls back gracefully
scripts/run.sh, run.bat run a module by its CMake target name
@@ -39,8 +58,8 @@ scripts/refactor.sh, refactor.bat auto-fix formatting/naming across this projec
.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below)
.editorconfig, .gitattributes -- same
.gitignore Visual Studio template + a block auto-synced from Configuration.cmake
c-api/.clang-tidy naming override template for a C API subtree -- now also applied for
real at source/HotReload/src/c-api/ and source/HotReload/include/hot_reload/
c-api/.clang-tidy naming override template for a C API subtree -- applied for real at
source/HotReload/src/c-api/ and source/HotReload/include/hot_reload/
.githooks/pre-commit formatting/lint pre-commit hook
cpp-style-guide.md, TOOLING.md the style guide itself + tooling docs
```
@@ -49,21 +68,20 @@ Everything generated -- the build tree, install output, docs, and reserved
packaging output -- lives under one `out/` directory (see "Output paths"
below); nothing under `out/` is source-controlled.
Each module's `CMakeLists.txt` is (conventionally) a couple of lines:
Each module's `CMakeLists.txt` is (conventionally) one line:
```cmake
# source/Core/CMakeLists.txt
add_dependency(fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.2
FIND_PACKAGE_ARGS NAMES fmt
)
add_lib_module(core_lib TYPE SHARED OUTPUT_NAME "demo_core" DEPENDS fmt::fmt)
# source/HotReload/CMakeLists.txt
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
```
A module declares its own dependencies in its own `CMakeLists.txt` -- same
"minimal tool intervention" principle as module registration: nothing to
add in the root file.
A module declares its own dependencies in its own `CMakeLists.txt` --
same "minimal tool intervention" principle as module registration:
nothing to add in the root file. This project's own modules don't
currently need any third-party dependency, so the table in "Three ways
to bring in a dependency" below is showing you machinery this project
isn't using itself right now -- it's still there, exercised, and ready
if you add a module that needs it.
## Output paths (`Configuration.cmake`)
@@ -134,11 +152,16 @@ normal reconfigure doesn't spuriously touch its contents or mtime.
## Three ways to bring in a dependency (`cmake/Dependencies.cmake`)
| Helper | For | Example |
Not currently used by any module in this project (`hotreload_lib` and
everything under `source/examples/`/`source/tests/` are entirely
self-contained -- no network access is needed to build any of it), but
fully exercised machinery, ready for a module that does need one:
| Helper | For | Usage |
|---|---|---|
| `add_vendor_header_only(NAME [INCLUDE_DIR <dir>])` | A buildless header-only drop under `vendor/<NAME>/include` | `vendor/tiny_ansi/` |
| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/<SUBDIR>` | `vendor/tiny_case/` |
| `add_dependency(NAME <FetchContent_Declare args>)` | An installed package (`FIND_PACKAGE_ARGS ...`) with a fetch-from-source fallback | `fmt` in `source/Core/` |
| `add_vendor_header_only(NAME [INCLUDE_DIR <dir>])` | A buildless header-only drop under `vendor/<NAME>/include` | `add_vendor_header_only(some_header_lib)` |
| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/<SUBDIR>` | `add_vendor_subdirectory(some_vendored_lib)` |
| `add_dependency(NAME <FetchContent_Declare args>)` | An installed package (`FIND_PACKAGE_ARGS ...`) with a fetch-from-source fallback | `add_dependency(fmt GIT_REPOSITORY ... FIND_PACKAGE_ARGS NAMES fmt)` |
All three are safe to call more than once for the same name (e.g. two
modules that need the same dependency) and mark the dependency's headers
@@ -153,10 +176,9 @@ code you don't control. Their targets are also dropped from
found. Omit `FIND_PACKAGE_ARGS` to always fetch. Requires network access
the first time a dependency isn't already installed.
A module `DEPENDS` on whatever target the chosen path defines (`fmt::fmt`,
`tiny_case`, `tiny_ansi`, ...) exactly like depending on another module --
`add_lib_module`/`add_exe_module` don't need to know or care which of the
three supplied it.
A module `DEPENDS` on whatever target the chosen path defines exactly
like depending on another module -- `add_lib_module`/`add_exe_module`
don't need to know or care which of the three supplied it.
## Documentation (automatic per module, pinned Doxygen, entirely opt-in)
@@ -191,11 +213,11 @@ Once set up:
```bash
scripts/docs.sh # build docs for every module, prints where to open it
scripts/docs.sh core_lib # build docs for just one module (docs_<target>)
scripts/docs.sh hotreload_lib # build docs for just one module (docs_<target>)
```
```bat
scripts\docs.bat
scripts\docs.bat core_lib
scripts\docs.bat hotreload_lib
```
Output goes to `out/docs/<module>/html/index.html` (a sibling of
@@ -205,15 +227,8 @@ Documentation is **not** built as part of a normal `scripts/build.sh` --
it's opt-in, the same way tests and examples are.
**Writing docs for a module** is just normal Doxygen comments in that
module's headers -- e.g. `source/Core/include/core/Greeting.hpp`:
```cpp
/// Builds a greeting for @p name.
///
/// @param name the person (or thing) being greeted.
/// @return a formatted greeting string, e.g. "Hello, Ale! (from demo_core)".
std::string makeGreeting(const std::string& name);
```
module's headers -- e.g. `source/HotReload/include/hot_reload/hot_reload.h`,
which is already written this way throughout.
Even with zero comments, a module's docs are still generated and useful --
file lists, function/class signatures, and (if [Graphviz](https://graphviz.org/)
@@ -222,7 +237,7 @@ itself, is still detected rather than pinned, since it's optional) call
graphs are produced regardless, since `DOXYGEN_EXTRACT_ALL` is on by
default. Comments just make it richer. Drop a `README.md` in a module's
own directory and it's automatically used as that module's Doxygen main
page.
page -- `source/HotReload/README.md` already does this.
**Configuring it** -- nothing is required, but everything is overridable:
@@ -266,19 +281,24 @@ cmake -G Ninja -S . -B out/build -DCMAKE_CXX_COMPILER=clang++ -DDIST_TARGET=linu
cmake --build out/build
```
Verified, not assumed, throughout this project's own development: every
module here (the library, both example demos, and the test suite) was
built and its tests/demos run to completion under both GCC and Clang.
## Code style & tooling
This project follows [`cpp-style-guide.md`](cpp-style-guide.md) (tabs,
Allman braces, `PascalCase` file names, `camelCase` functions/variables,
`m_`/`s_`/`g_` member/static/global prefixes -- see the file itself for
the full rules and rationale), enforced by the tooling described in
[`TOOLING.md`](TOOLING.md):
`m_`/`s_`/`g_` member/static/global prefixes, plus a distinct Part II
style -- `snake_case`, library-prefixed names -- for any C API boundary;
see the file itself for the full rules and rationale), enforced by the
tooling described in [`TOOLING.md`](TOOLING.md):
| File | Enforces |
|---|---|
| `.clang-format` | All formatting: tabs, Allman braces, pointer alignment, include order, line length |
| `.clang-tidy` | Naming conventions and a subset of language-feature rules, for this project's own C++ code |
| `c-api/.clang-tidy` | A template naming override (`snake_case`) for a C API subtree, if/when this project has one -- see below |
| `c-api/.clang-tidy` | A naming override (`snake_case`) for a C API subtree -- see below for where this is actually applied |
| `.editorconfig` | Baseline tabs/whitespace for editors that don't run clang-format live |
| `.gitattributes` | Forces LF line endings for source files on every OS |
| `.githooks/pre-commit` | Runs clang-format + clang-tidy on staged files before each commit; warns and skips (never blocks) if a tool isn't installed |
@@ -286,34 +306,48 @@ the full rules and rationale), enforced by the tooling described in
**Fixing violations automatically**, instead of hunting them down by hand:
```bash
scripts/refactor.sh # every file under source/ and c-api/ by default
scripts/refactor.sh source/Core # just one file or directory
scripts/refactor.sh # every file under source/ and c-api/ by default
scripts/refactor.sh source/HotReload # just one file or directory
```
```bat
scripts\refactor.bat
scripts\refactor.bat source\Core
scripts\refactor.bat source\HotReload
```
Runs `clang-format -i` (always) and `clang-tidy --fix` (if
`compile_commands.json` exists -- build once first if it doesn't) across
the given scope. The pre-commit hook only *checks*; this is the "just fix
it" counterpart -- review the result with `git diff` afterward, same as
any auto-formatter. This is exactly how this project's own code
(`source/`) was brought into compliance when the toolkit was integrated --
see the renames noted further down.
any auto-formatter.
**Be especially careful reviewing `git diff` on a file with more than one
architecture/OS branch** (`#if defined(__x86_64__) ... #elif ... #endif`,
several of which exist under `source/HotReload/src/` -- see
`Architecture.cpp`, `ModuleHandle.cpp`, `MemoryProtection.cpp`,
`SymbolTable.cpp`). `clang-tidy` only ever analyzes the ONE branch active
for whichever platform you're running it on -- it cannot see, and will
not touch, the source text inside the other, inactive `#elif` branches at
all. Verified directly while building this project: running `--fix` on
`Architecture.cpp` from this x86-64 Linux machine renamed a local
constant inside the x86-64 branch only, then updated a later line shared
by *all three* architecture branches to match the new name -- silently
breaking the build for the other two architectures, which still declared
the old name. Caught only by reviewing the diff and separately
cross-compiling every branch, which is exactly the review step this
paragraph is asking you to actually do on a file shaped like this, not
skip.
With no argument, only `source/` and `c-api/` are ever scanned -- `out/`,
`.cache/`, `vendor/`, and `.git/` are never walked, so this can't
accidentally spend minutes reformatting/analyzing a fetched dependency's
entire source tree (verified against `out/build/_deps/fmt-src/`
specifically, since that's what triggered this exact problem before the
fix). Passing an explicit scope takes it as given: `refactor.sh` still
excludes `vendor`/`out`/`.cache`/`.git` defensively if your scope happens
to overlap with them; `refactor.bat` does not (its Windows batch
equivalent of that filter turned out to be unreliable to verify, so it
was removed rather than shipped uncertain -- pick a scope that doesn't
overlap with those directories, which is the normal case anyway, e.g.
`source\Core`).
`.cache/`, and `.git/` are never walked, so this can't accidentally spend
minutes reformatting/analyzing a fetched dependency's entire source tree
(this project doesn't currently have one under `out/build/_deps/`, but the
exclusion is unconditional, not conditional on one existing). Passing an
explicit scope takes it as given: `refactor.sh` still excludes
`out`/`.cache`/`.git` defensively if your scope happens to overlap with
them; `refactor.bat` does not (its Windows batch equivalent of that filter
turned out to be unreliable to verify, so it was removed rather than
shipped uncertain -- pick a scope that doesn't overlap with those
directories, which is the normal case anyway, e.g. `source\HotReload`).
**One-time setup per clone** to activate the hook -- `scripts/setup.sh` /
`setup.bat` does this for you (see "Setup" below) along with fetching the
@@ -330,42 +364,18 @@ this project already generates and copies it to the repo root on every
build (see above), so as long as you've built at least once, the hook's
clang-tidy check works with no extra setup.
Two adjustments made when integrating the toolkit into this specific
project (not part of the toolkit as provided -- see `TOOLING.md` in this
repo for the toolkit's own documentation of everything else):
- **`vendor/` is excluded** from the pre-commit hook's staged-file check
(`git diff ... ':!vendor/*'`). Vendored code (`cmake/Dependencies.cmake`)
isn't this project's code, so it isn't held to this style guide -- same
reasoning as its `compile_commands.json` and warning-flag exclusions
elsewhere in this project.
- **`.clang-tidy` gained one option**, `LocalConstantCase: camelBack`. The
style guide's own text says only *global/static* `const`/`constexpr`
values are "constants" for naming purposes -- everything else stays
under the `camelCase` Local Variables rule. Without this option, a plain
local `const` (e.g. `const std::string name = ...;` inside a function)
silently fell through to the general `PascalCase` constant rule instead,
which would have flagged completely ordinary code. Verified against a
small isolated test file: with the fix, a local `const` is accepted in
`camelCase` while a `static const`/`constexpr` still correctly requires
`PascalCase`.
`c-api/.clang-tidy` is included as a template, positioned exactly as the
toolkit ships it (`c-api/.clang-tidy`) -- this project doesn't currently
have a C API (Part II) surface. If you add one, move this file to that
subtree's root (`c-api/`, `public-include/`, wherever your `extern "C"`
headers live); `clang-tidy` always uses the closest `.clang-tidy` file up
the directory tree, so files under that directory automatically pick up
the C API's `snake_case` naming instead of this project's `camelCase`.
This project's own code was reformatted and renamed to comply when the
toolkit was integrated -- e.g. `Greeting.hpp`/`.cpp` (was `greeting.h`/
`.cpp`) and `makeGreeting`/`makeFarewell` (was `make_greeting`/
`make_farewell`) in `source/Core/`. `main.cpp` in each app module became
`Main.cpp` for the same reason (`PascalCase` file names) -- the style
guide doesn't call out an entry-point exception, so none was assumed;
rename back to lowercase if you'd rather treat that specific convention
as an exception.
**This project genuinely has a C API subtree**, unlike the generic
template this was adapted from: `source/HotReload/include/hot_reload/`
(the public header) and `source/HotReload/src/c-api/` (its `extern "C"`
implementation) each carry a copy of `c-api/.clang-tidy`, positioned at
the root of the subtree they apply to -- `clang-tidy` always uses the
closest `.clang-tidy` file up the directory tree, so files under either
directory pick up `snake_case` naming instead of the rest of this
project's `PascalCase`/`camelCase`, while `source/HotReload/src/`'s own
Part I (C++) files, one level up, keep inheriting this repo-root
`.clang-tidy`'s rules exactly as normal. See either copy's own header
comment for the full explanation of how clang-tidy's directory-scoped
config resolution makes that work.
## How module discovery works
@@ -388,19 +398,32 @@ Any immediate child directory that contains its own `CMakeLists.txt` gets
need them, not auto-built by default). This uses `CONFIGURE_DEPENDS`, so
the *next build* reconfigures on its own after you add or remove a module
directory -- no manual `cmake` re-run needed. A module may `DEPENDS` on a
module discovered later in this list; order doesn't affect linking (CMake
resolves target names across the whole project at generate time --
verified, not assumed).
module discovered later in this list, or reference one via
`add_dependencies()`/a generator expression before that other module's
own `CMakeLists.txt` has even run -- order doesn't affect linking or
target existence (CMake resolves target names, and dependency edges to
not-yet-declared targets, across the whole project at generate time --
verified directly with an isolated, minimal reproduction while building
this project, not assumed).
A module directory can itself contain more than one CMake target when its
shape doesn't fit the "one target per module" default -- see
`source/examples/HotReloadDemo/PluginV1/CMakeLists.txt`'s own comment for
a real example (a hot-reloadable plugin needs to exist as two separate
builds of the same exported names, which `add_lib_module` doesn't assume)
and `source/examples/HotReloadDemo/CMakeLists.txt` for the small grouping
file that pattern needs, one level up, purely so discovery (which only
looks one level deep into `source/examples/`) finds it at all.
## Requirements
CMake >= 3.25, a C++20 compiler (GCC/Clang/MSVC), `git` (for
FetchContent's `GIT_REPOSITORY`-based dependencies like `fmt`), and
network access the first time you configure (for `fmt`, cached afterward
under `out/build/_deps`). Notably **not** in this list: Ninja and Doxygen
are both pinned and fetched automatically (see "Setup" below), not system
dependencies, and `scripts/build.sh` warns rather than fails if Ninja
isn't available at all (see "Build" below).
CMake >= 3.25, a C++20 compiler (GCC/Clang/MSVC). **No network access is
needed to build this project** -- every module here is self-contained,
with no `add_dependency()`-fetched third-party library anywhere (see
"Three ways to bring in a dependency" above). Ninja and Doxygen are both
pinned and fetched automatically (see "Setup" below) if you want them,
not system dependencies, and `scripts/build.sh` warns rather than fails
if Ninja isn't available at all (see "Build" below).
[Graphviz](https://graphviz.org/) is optional for Doxygen call graphs, and
[clang-format](https://clang.llvm.org/docs/ClangFormat.html)/
[clang-tidy](https://clang.llvm.org/extra/clang-tidy/) are optional for
@@ -409,6 +432,11 @@ above) -- both are detected, not pinned, since they're editor/workflow
tools rather than part of the build itself. `scripts/setup.sh` (below)
checks for both and prints install guidance if either is missing.
To actually try hot-reloading something (rather than just build the
library), you'll also want a way to rebuild one file while another
process keeps running -- covered in
[`source/examples/HotReloadLiveDemo/README.md`](source/examples/HotReloadLiveDemo/README.md).
## Setup
Optional, and separate from building (see "Requirements", "Build", and
@@ -443,14 +471,24 @@ Safe to re-run any time -- each step is a no-op if there's nothing to do
## Build
```bash
scripts/build.sh # Release (default)
scripts/build.sh Debug # or Debug / RelWithDebInfo / MinSizeRel
scripts/build.sh # Release (default), library + tests only if BUILD_TESTS was set before
scripts/build.sh Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=ON # + the automated tests + both demos
```
```bat
scripts\build.bat
scripts\build.bat Debug
scripts\build.bat Release -DBUILD_TESTS=ON -DBUILD_EXAMPLES=ON
```
`BUILD_TESTS` and `BUILD_EXAMPLES` both default to `OFF` (see
`CMakeLists.txt`) -- plain `scripts/build.sh` with no extra arguments
builds only `hotreload_lib` itself. Anything after the build type is
passed straight through to `cmake`'s configure step (tested directly:
`scripts/build.sh Debug -DBUILD_EXAMPLES=ON -DBUILD_TESTS=ON` builds the
library, both demos, and the test suite together), and CMake caches `-D`
options across reconfigures, so passing them once and calling
`scripts/build.sh` plain afterward for incremental builds keeps them set
-- no need to repeat them on every call.
Picks a generator in this order, each falling back to the next:
1. **A pinned Ninja**, if `scripts/setup.sh`/`setup.bat` has fetched one --
@@ -474,19 +512,21 @@ a Visual Studio generator on Windows) instead.
## Run
```bash
scripts/run.sh hello_exe
scripts/run.sh farewell_exe -- Ale
scripts/run.sh hotreload_demo_host
scripts/run.sh hotreload_live_demo_host
scripts/run.sh # no args: lists available targets
```
```bat
scripts\run.bat hello_exe
scripts\run.bat farewell_exe -- Ale
scripts\run.bat hotreload_demo_host
scripts\run.bat hotreload_live_demo_host
```
`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's
`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at
configure time, so these scripts never need editing when a module is
added, removed, or renamed.
added, removed, or renamed. Requires `-DBUILD_EXAMPLES=ON` to have been
set at configure time for either of the two hot-reload targets above,
since they live under `source/examples/`.
## Install
@@ -497,14 +537,16 @@ scripts/install.sh
scripts\install.bat
```
Always use the install script (or pass `--component demo` yourself if
Always use the install script (or pass `--component hotreload` yourself if
calling `cmake --install` directly). A vendored/fetched dependency's own
`CMakeLists.txt` usually has `install()` rules of its own with no
component tag; installing without `--component` would also run those,
typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`).
Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT
demo` and always filtering on it is what keeps `cmake --install` scoped to
just this project's own artifacts.
hotreload` and always filtering on it is what keeps `cmake --install`
scoped to just this project's own artifacts -- currently just
`hotreload_lib` itself, since examples and tests are opt-in, dev-time
targets rather than something you'd install.
## Adding a module
@@ -517,7 +559,7 @@ just this project's own artifacts.
```cmake
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS core_lib)
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS hotreload_lib)
```
`scripts/run.sh my_tool_exe` picks it up automatically once it's built,