Skip to content

Commit e283861

Browse files
committed
Fix sanitizer, package consumer, and static analysis CI
- isolate allocator probes from ThreadSanitizer - disable unit tests in the installed-package job - export the C++20 requirement to downstream consumers - resolve clang-tidy diagnostics without weakening checks - document sanitizer coverage and package verification
1 parent c8d7910 commit e283861

16 files changed

Lines changed: 449 additions & 254 deletions

.github/workflows/ci.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ jobs:
121121
# downstream.
122122
- name: Build and install
123123
run: |
124-
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PWD/staged"
124+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
125+
-DMOTIONKIT_BUILD_TESTS=OFF \
126+
-DCMAKE_INSTALL_PREFIX="$PWD/staged"
125127
cmake --build build -j "$(nproc)"
126128
cmake --install build
127129
@@ -136,12 +138,26 @@ jobs:
136138
target_link_libraries(consumer PRIVATE motionkit::core)
137139
EOT
138140
cat > /tmp/consumer/main.cpp <<'EOT'
139-
#include <cassert>
140-
#include "motionkit/core/se3.hpp"
141+
#include "motionkit/core/frame_graph.hpp"
141142
int main() {
142143
using namespace motionkit;
143144
const SE3 pose(SO3::rotZ(1.0), Vec3{1.0, 2.0, 3.0});
144-
assert((pose.inverse() * pose).isApprox(SE3{}, 1e-12, 1e-12));
145+
if (!(pose.inverse() * pose).isApprox(SE3{}, 1e-12, 1e-12)) {
146+
return 1;
147+
}
148+
FrameGraph frames;
149+
const auto base = frames.declareRoot("base");
150+
if (!base) {
151+
return 2;
152+
}
153+
const auto tcp = frames.declareFrame("tcp", base.value, pose);
154+
if (!tcp) {
155+
return 3;
156+
}
157+
const auto resolved = frames.lookup(base.value, tcp.value);
158+
if (!resolved || !resolved.value.isApprox(pose, 1e-12, 1e-12)) {
159+
return 4;
160+
}
145161
return 0;
146162
}
147163
EOT

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ else()
1313
endif()
1414

1515
option(MOTIONKIT_BUILD_TESTS "Build unit tests" ${MOTIONKIT_IS_TOP_LEVEL})
16+
option(MOTIONKIT_BUILD_ALLOCATION_TESTS
17+
"Build global-allocation probe tests" ON)
1618
option(MOTIONKIT_BUILD_BENCHMARKS "Build micro-benchmarks" OFF)
1719
option(MOTIONKIT_ENABLE_WERROR "Treat warnings as errors" ON)
1820

@@ -49,6 +51,7 @@ target_include_directories(motionkit_core
4951
PUBLIC
5052
$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
5153
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
54+
target_compile_features(motionkit_core PUBLIC cxx_std_20)
5255
target_link_libraries(motionkit_core PRIVATE motionkit::warnings)
5356
set_target_properties(motionkit_core PROPERTIES
5457
OUTPUT_NAME motionkit-core

CMakePresets.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"cacheVariables": {
5050
"CMAKE_BUILD_TYPE": "Debug",
5151
"CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer",
52-
"CMAKE_EXE_LINKER_FLAGS": "-fsanitize=thread"
52+
"CMAKE_EXE_LINKER_FLAGS": "-fsanitize=thread",
53+
"MOTIONKIT_BUILD_ALLOCATION_TESTS": "OFF"
5354
}
5455
},
5556
{
@@ -116,4 +117,4 @@
116117
"configurePreset": "tsan"
117118
}
118119
]
119-
}
120+
}

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ Eigen, no KDL, no Pinocchio — the algorithms are the point.
2424
| WP-06 | Hand-eye, TCP and base-frame calibration | Planned |
2525
| WP-12 | CUDA batch IK and collision checking | Planned |
2626

27-
77 tests, all passing, under GCC and Clang in Debug and Release, plus ASan,
28-
UBSan and TSan.
27+
77 tests, all passing under GCC and Clang in Debug and Release. ASan and UBSan
28+
exercise the full suite. TSan exercises the 73 ordinary tests; the four
29+
allocator-interposition tests run in a dedicated executable and are excluded
30+
from TSan because both the tests and the sanitizer runtime replace the global
31+
allocation functions.
2932

3033
---
3134

@@ -40,7 +43,9 @@ cmake --build --preset debug
4043
ctest --preset debug
4144
```
4245

43-
Other presets: `release`, `asan`, `tsan`, `tidy`.
46+
Other presets: `release`, `asan`, `tsan`, `tidy`. The `tsan` preset intentionally
47+
runs 73 tests: the four tests that instrument global allocation are a test-harness
48+
incompatibility with TSan, not an exemption for production code.
4449

4550
Before pushing, run the formatter -- CI enforces it:
4651

@@ -162,11 +167,11 @@ test wrong.
162167
|---|---|
163168
| GCC + Clang × Debug + Release | `-Wconversion` and `-Wold-style-cast` fire on different constructs per compiler |
164169
| `-Werror` with `-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow` | Silent narrowing in a pose pipeline is a field failure, not a warning |
165-
| ASan + UBSan, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
166-
| TSan | Ahead of the threaded executor in WP-08 |
170+
| ASan + UBSan on all 77 tests, `-fno-sanitize-recover=all` | A UBSan finding fails the build rather than printing a note |
171+
| TSan on the 73 ordinary tests | Ahead of the threaded executor in WP-08; the four allocator-interposition tests are excluded because TSan defines the same global allocation hooks |
167172
| clang-tidy, `--warnings-as-errors=*` | Rule set and exclusions justified in ADR-0002 |
168-
| clang-format `--dry-run --Werror` | Formatting is not a review topic |
169-
| **install + downstream consumer compile** | Caught a real bug on first run: the exported target was `motionkit::motionkit_core` while in-tree consumers used the `motionkit::core` alias. Every `find_package` downstream would have failed, and no unit test could have seen it |
173+
| `scripts/format.sh --check` with clang-format 18 | Formatting is not a review topic, and CI runs the same check developers run |
174+
| **install with repository tests off + downstream consumer compile and run** | Exercises only the installed package contract; it caught a real bug on first run when the exported target was `motionkit::motionkit_core` but consumers used `motionkit::core` |
170175

171176
---
172177

@@ -176,6 +181,12 @@ Unit tests assert known values; the interesting ones assert **properties** over
176181
thousands of uniformly sampled rotations from a fixed seed — a property test you
177182
cannot replay is a flake, not a test.
178183

184+
Four allocation tests are instrumentation rather than ordinary unit tests. They
185+
run in their own executable because their global `operator new`/`operator delete`
186+
replacements affect an entire process. That target alone suppresses GNU's
187+
`-Wmismatched-new-delete` diagnostic: the `malloc`/`free` pairing is deliberate
188+
and is the mechanism being tested. The warning remains enabled everywhere else.
189+
179190
- **Group axioms**: associativity, inverse, composition matching matrix product
180191
- **Invariants**: stored quaternion is always unit and canonical; `matrix()` is always in SO(3)
181192
- **Round trips**: quaternion ↔ matrix ↔ rotation vector ↔ RPY
@@ -188,4 +199,4 @@ cannot replay is a flake, not a test.
188199

189200
## Licence
190201

191-
Apache-2.0.
202+
Apache-2.0.

docs/adr/0004-verify-the-installed-package-in-ci.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,32 @@ project weeks later, where it would have looked like the consumer's fault.
3636

3737
CI runs an `install-consumer` job that:
3838

39-
1. Builds and installs the package into a staging prefix.
39+
1. Configures with `MOTIONKIT_BUILD_TESTS=OFF`, then builds and installs the
40+
package into a staging prefix.
4041
2. Generates a fresh, minimal CMake project that has never seen the source tree.
4142
3. Configures it with `CMAKE_PREFIX_PATH` pointing at the staging prefix, using
4243
only `find_package(motionkit REQUIRED)` and the public target name.
4344
4. Compiles it, **and runs the resulting binary**.
4445

4546
Step 4 matters: a package can configure and link correctly and still ship
4647
headers that do not match the installed library.
48+
The consumer uses explicit failure return codes rather than `assert`, because
49+
the Release build disables assertions.
4750

48-
The consumer source deliberately uses the exact snippet published in the
49-
README. If the README example stops working, CI fails.
51+
The consumer deliberately uses the public include and CMake target published in
52+
the README. If the documented `find_package` and link contract stops working,
53+
CI fails.
54+
55+
The consumer does not set `CMAKE_CXX_STANDARD`, and it includes the frame-graph
56+
API whose defaulted comparison requires C++20. The installed `motionkit::core`
57+
target propagates its `cxx_std_20` requirement, so the package must put a
58+
downstream compiler into a compatible language mode itself.
59+
60+
Disabling the repository tests is part of the boundary, not an optimisation.
61+
Top-level motionkit builds enable tests by default, which would fetch GoogleTest
62+
and compile test-only instrumentation before the installed package is exercised.
63+
Those checks belong to the build-and-test jobs. The install-consumer job should
64+
fail only when building, installing or consuming the package fails.
5065

5166
## Consequences
5267

@@ -56,6 +71,8 @@ README. If the README example stops working, CI fails.
5671
- Header install coverage is checked implicitly — a header omitted from
5772
`install(DIRECTORY ...)` fails this job and nothing else.
5873
- The documented usage example cannot silently rot.
74+
- Test-only dependencies and instrumentation cannot make the package-consumer
75+
gate fail before it reaches the installed artifact.
5976

6077
**Negative**
6178

docs/adr/0005-frame-graph-is-a-tree.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,5 +156,21 @@ nothrow and sized forms pointing at the runtime's implementation, so memory
156156
obtained one way is released the other. AddressSanitizer catches it immediately —
157157
`alloc-dealloc-mismatch (operator new vs free)`, raised from inside GoogleTest's
158158
own `stable_sort`, which allocates a temporary buffer. All eight `new` forms and
159-
all twelve `delete` forms now route through `malloc`/`free` so the pairing holds
159+
all twelve `delete` forms now use matching allocation families (`malloc`/`free`,
160+
or `_aligned_malloc`/`_aligned_free` for aligned MSVC forms) so the pairing holds
160161
whichever form the standard library reaches for.
162+
163+
Those replacements have executable-wide linkage, so the four allocation checks
164+
live in a dedicated test target rather than changing allocation for every unit
165+
test. The 73 ordinary tests remain in the main test executable.
166+
167+
The allocation target is intentionally not built by the TSan preset. The Clang
168+
TSan runtime provides its own strong global allocation symbols, and linking a
169+
second set is a duplicate-symbol error before any test can run. TSan still covers
170+
the library and all 73 ordinary tests; only the incompatible instrumentation is
171+
excluded.
172+
173+
GNU also diagnoses the deliberate custom `new`/`delete` plumbing as
174+
`-Wmismatched-new-delete`. That warning is suppressed only for the dedicated
175+
allocation target. It remains enabled, and promoted to an error, for the library
176+
and every ordinary test target.

include/motionkit/core/frame_graph.hpp

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-License-Identifier: Apache-2.0
22
#pragma once
33

4+
#include <cstddef>
45
#include <cstdint>
56
#include <string>
67
#include <string_view>
@@ -49,7 +50,9 @@ struct Expected {
4950
FrameError error{FrameError::None};
5051

5152
constexpr explicit operator bool() const noexcept { return error == FrameError::None; }
52-
constexpr bool hasValue() const noexcept { return error == FrameError::None; }
53+
[[nodiscard]] constexpr bool hasValue() const noexcept {
54+
return error == FrameError::None;
55+
}
5356
};
5457

5558
/// Opaque handle to a frame in one FrameGraph.
@@ -61,15 +64,15 @@ class FrameId {
6164
public:
6265
constexpr FrameId() noexcept = default;
6366

64-
constexpr bool valid() const noexcept { return index_ != kInvalidIndex; }
67+
[[nodiscard]] constexpr bool valid() const noexcept { return index_ != kInvalidIndex; }
6568
constexpr bool operator==(const FrameId& other) const noexcept = default;
6669

6770
/// Index into the owning graph, for diagnostics only.
68-
constexpr std::uint32_t index() const noexcept { return index_; }
71+
[[nodiscard]] constexpr std::uint32_t index() const noexcept { return index_; }
6972

7073
private:
7174
friend class FrameGraph;
72-
static constexpr std::uint32_t kInvalidIndex = 0xFFFFFFFFu;
75+
static constexpr std::uint32_t kInvalidIndex = 0xFFFFFFFFU;
7376

7477
explicit constexpr FrameId(std::uint32_t index) noexcept : index_(index) {}
7578

@@ -127,7 +130,7 @@ class FrameGraph {
127130
FrameError setTransform(FrameId frame, const SE3& parent_T_frame) noexcept;
128131

129132
/// The transform from `frame` to its parent.
130-
Expected<SE3> transformToParent(FrameId frame) const noexcept;
133+
[[nodiscard]] Expected<SE3> transformToParent(FrameId frame) const noexcept;
131134

132135
/// Returns `a_T_b`: the pose of frame `b` expressed in frame `a`.
133136
///
@@ -151,33 +154,33 @@ class FrameGraph {
151154
/// and `FrameGraphRealtime.LowestCommonAncestorBeatsRoutingThroughTheRoot`.
152155
///
153156
/// Allocation-free and non-throwing.
154-
Expected<SE3> lookup(FrameId a, FrameId b) const noexcept;
157+
[[nodiscard]] Expected<SE3> lookup(FrameId a, FrameId b) const noexcept;
155158

156159
/// Finds a frame by name. Linear scan: intended for setup and diagnostics,
157160
/// not for the hot path. Resolve names to FrameIds once and keep the handles.
158-
Expected<FrameId> find(std::string_view name) const noexcept;
161+
[[nodiscard]] Expected<FrameId> find(std::string_view name) const noexcept;
159162

160163
/// Name of a frame, or an empty view for an unknown handle.
161-
std::string_view name(FrameId frame) const noexcept;
164+
[[nodiscard]] std::string_view name(FrameId frame) const noexcept;
162165

163166
/// Parent of `frame`, or an invalid FrameId if it is a root.
164-
FrameId parent(FrameId frame) const noexcept;
167+
[[nodiscard]] FrameId parent(FrameId frame) const noexcept;
165168

166169
/// Number of ancestors between `frame` and its root; a root has depth 0.
167-
Expected<std::uint32_t> depth(FrameId frame) const noexcept;
170+
[[nodiscard]] Expected<std::uint32_t> depth(FrameId frame) const noexcept;
168171

169-
std::size_t size() const noexcept { return nodes_.size(); }
170-
bool empty() const noexcept { return nodes_.empty(); }
172+
[[nodiscard]] std::size_t size() const noexcept { return nodes_.size(); }
173+
[[nodiscard]] bool empty() const noexcept { return nodes_.empty(); }
171174

172175
private:
173176
struct Node {
174177
std::string name;
175-
FrameId parent{};
176-
SE3 parent_T_this{};
178+
FrameId parent;
179+
SE3 parent_T_this;
177180
std::uint32_t depth{0};
178181
};
179182

180-
bool isKnown(FrameId frame) const noexcept {
183+
[[nodiscard]] bool isKnown(FrameId frame) const noexcept {
181184
return frame.valid() && frame.index_ < nodes_.size();
182185
}
183186

include/motionkit/core/se3.hpp

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: Apache-2.0
22
#pragma once
33

4+
#include <array>
5+
46
#include "motionkit/core/so3.hpp"
57
#include "motionkit/core/types.hpp"
68

@@ -19,30 +21,31 @@ class SE3 {
1921
SE3(const SO3& rotation, const Vec3& translation) noexcept
2022
: r_(rotation), t_(translation) {}
2123

22-
static SE3 fromTranslation(const Vec3& t) noexcept { return SE3(SO3{}, t); }
23-
static SE3 fromRotation(const SO3& r) noexcept { return SE3(r, Vec3{}); }
24+
static SE3 fromTranslation(const Vec3& t) noexcept { return {SO3{}, t}; }
25+
static SE3 fromRotation(const SO3& r) noexcept { return {r, Vec3{}}; }
2426

25-
const SO3& rotation() const noexcept { return r_; }
26-
const Vec3& translation() const noexcept { return t_; }
27+
[[nodiscard]] const SO3& rotation() const noexcept { return r_; }
28+
[[nodiscard]] const Vec3& translation() const noexcept { return t_; }
2729

28-
SE3 inverse() const noexcept;
30+
[[nodiscard]] SE3 inverse() const noexcept;
2931
SE3 operator*(const SE3& rhs) const noexcept;
3032

3133
/// Transforms a point: rotate, then translate.
3234
Vec3 operator*(const Vec3& point) const noexcept;
3335

3436
/// Transforms a free vector (a direction or velocity): rotation only.
35-
Vec3 rotateVector(const Vec3& v) const noexcept { return r_ * v; }
37+
[[nodiscard]] Vec3 rotateVector(const Vec3& v) const noexcept { return r_ * v; }
3638

3739
/// Row-major 4x4 homogeneous matrix, for interop with CAD and vision stacks
3840
/// that speak matrices rather than quaternions.
39-
std::array<Scalar, 16> matrix() const noexcept;
41+
[[nodiscard]] std::array<Scalar, 16> matrix() const noexcept;
4042

41-
bool isApprox(const SE3& other, Scalar linear_tol, Scalar angular_tol) const noexcept;
43+
[[nodiscard]] bool isApprox(const SE3& other, Scalar linear_tol,
44+
Scalar angular_tol) const noexcept;
4245

4346
private:
44-
SO3 r_{};
45-
Vec3 t_{};
47+
SO3 r_;
48+
Vec3 t_;
4649
};
4750

4851
} // namespace motionkit

0 commit comments

Comments
 (0)