agents/docs/cpp-standards.md
fl:: namespace instead of std::fl/type_traits.hfl::vector<T> for dynamic arrays. The implementation is in src/fl/stl/vector.h.fl::net Namespace ConventionFiles in src/fl/net/ follow a two-level namespace pattern:
fl::net
fl::net::OTAfl::net::<module> sub-namespace
fl::net::ota::Servicefl::net::<module>
fl::net::http::*, fl::net::ble::*When moving a type into a sub-namespace, drop the module prefix from the name since the namespace already provides context:
OTAService → fl::net::ota::ServiceBleStatusInfo → fl::net::ble::StatusInfoBleTransportState → fl::net::ble::TransportState| File | Primary type (fl::net) | Sub-namespace (fl::net::<mod>) |
|---|---|---|
ota.h | OTA | ota::Service |
rpc.h | (none — transport aliases) | rpc::StreamClient, rpc::StreamServer |
http.h | (none — facade) | http::Server, http::Response, http::fetch_get, … |
ble.h | (none — collection) | ble::TransportState, ble::StatusInfo, ble::createTransport, … |
An API object is a public-facing wrapper header that lives alongside a directory of the same name containing implementation details. Users only ever #include the API header.
File layout:
src/fl/stl/fixed_point.h ← API object (public interface)
src/fl/stl/fixed_point/ ← implementation directory
s16x16.h ← concrete type
base.h ← shared internals
...
Rules:
foo.h is the public API; foo/ holds everything behind it.fixed_point_impl<IntBits, FracBits, Sign>) selects the right concrete type at compile time. Invalid combinations fail via an undefined primary template.fl::sin(), fl::floor()) are SFINAE-gated to the wrapper type, giving users a natural calling convention.s0x32 * s16x16 → s16x16) are defined at the bottom, after all types are visible.Exemplar: src/fl/stl/fixed_point.h wrapping src/fl/stl/fixed_point/
src/platforms/ (e.g., int.h, io_arduino.h) that route to platform-specific implementations via coarse-to-fine detection. See src/platforms/README.md for details.src/platforms/**): Header files typically do NOT need platform guards (e.g., #ifdef ESP32). Only the .cpp implementation files require guards. When the .cpp file is guarded from compilation, the header won't be included. This approach provides better IDE code assistance and IntelliSense support.
header.h: No platform guards (clean interface)header.cpp: Has platform guards (e.g., #ifdef ESP32 ... #endif)#ifdef ESP32 to both header and implementation files (degrades IDE experience).cpp.hpp files)A .cpp.hpp file is a compile-time component router that assembles a complete feature from sparse, per-platform fragments with null/no-op fallbacks.
Key properties:
.cpp.hpp file examines platform defines and #includes the right fragments. It composes a complete system from whatever pieces the current platform offers.Why .cpp.hpp instead of .cpp or .h?
.cpp) — so it can't be a normal .h (would cause multiple-definition linker errors).#included from exactly one translation unit (like .hpp) — not compiled on its own.// IWYU pragma: private to enforce single inclusion.Naming convention (future standard):
component.impl.cpp.hpp — The router file. Contains the #if/#elif dispatch logic that selects which platform fragment to include. Included from exactly one translation unit.component_<platform>.impl.hpp — Platform-specific implementation fragments that the router includes (e.g., coroutine_esp32.impl.hpp, coroutine_wasm.impl.hpp).The names make roles explicit: .impl.cpp.hpp = "implementation router, include me once." _<platform>.impl.hpp = "platform fragment, the router includes me."
Structure of a .impl.cpp.hpp router:
// IWYU pragma: private
// Platform detection
#include "platforms/arm/is_arm.h"
#include "platforms/esp/is_esp.h"
#include "platforms/wasm/is_wasm.h"
#if defined(FL_IS_WASM)
#include "platforms/wasm/feature_wasm.impl.hpp"
#elif defined(FASTLED_STUB_IMPL)
#include "platforms/stub/feature_stub.impl.hpp"
#elif defined(FL_IS_ESP32)
#include "platforms/esp/32/feature_esp32.impl.hpp"
#else
// Fallback: null/no-op implementation
#include "platforms/shared/feature_null.impl.hpp"
#endif
Current exemplar: src/platforms/coroutine.impl.cpp.hpp.
_noop.hpp)The dispatcher's #else branch needs a target — that's the no-op implementation. Platforms without hardware support for a feature get an implementation that satisfies the same API signature with empty / zero-returning bodies, so user code compiles unconditionally on any platform.
Naming and location:
src/platforms/shared/ with the suffix _noop.hpp — e.g., pin_noop.hpp, memory_noop.hpp, simd_noop.hpp, codec/h264_noop.hpp._null.hpp, _stub.hpp, or _dummy.hpp. The keyword is noop — this is what to grep for to find the convention.File contents:
// IWYU pragma: private (header is only included via the dispatcher, never directly).inline to stay ODR-safe if the header is reached from multiple TUs.fl::platforms:: namespace (not fl:: — keeps the public surface clean).0, false, nullptr, or empty span, but documented sentinel values are also valid when the API contract requires them (e.g., pin_noop.hpp::needsPwmIsrFallback returns true because the no-op platform genuinely needs the ISR fallback path; setPwmFrequencyNative returns -4 as the documented "not supported" error code). Never assert, throw, or call FL_WARN. A no-op is a no-op — the whole point is that user code keeps running on unsupported platforms.FL_<COMPONENT>_HAS_<FEATURE> capability macros (see Type 3 below), the no-op header must not define any of the boolean capability flags — so #ifdef FL_<COMPONENT>_HAS_<FEATURE> evaluates to false on the unsupported platform. It must still define any numeric properties (e.g., #define FL_WATCHDOG_PERSIST_BYTES 0) so user code that reads them compiles.Stub-only variant (_stub_noop.h):
When the no-op only makes sense for the host/stub build (because it pretends to be a real OS primitive that no real MCU exposes), put it in src/platforms/stub/ with the _stub_noop.h suffix instead — e.g., mutex_stub_noop.h, thread_stub_noop.h, semaphore_stub_noop.h. Rule of thumb:
shared/<x>_noop.hpp — fallback that any unsupported platform may include via the dispatcher.stub/<x>_stub_noop.h — only the host/stub build consumes it.Current exemplars: src/platforms/shared/memory_noop.hpp, src/platforms/shared/pin_noop.hpp, src/platforms/shared/simd_noop.hpp, src/platforms/shared/codec/h264_noop.hpp.
fl::span<T> has implicit conversion constructors - you don't need explicit fl::span<T>(...) wrapping in function calls. Example:
verifyPixels8bit(output, leds) (implicit conversion)verifyPixels8bit(output, fl::span<const CRGB>(leds, 3)) (unnecessary explicit wrapping)fl::span auto-converts from containers with data()/size(). Pass the container directly:
AudioSample(data, timestamp) or fl::span<const fl::i16> s = myVector;AudioSample(fl::span<const fl::i16>(data.data(), data.size()), timestamp)fl::span<T> or fl::span<const T> for function parameters and return types unless a copy of the source data is required:
fl::span<const uint8_t> getData() (zero-copy view)void process(fl::span<const CRGB> pixels) (accepts arrays, vectors, etc.)std::vector<uint8_t> getData() (unnecessary copy)fl::span<const T> for read-only views to prevent accidental modificationPlatform Identification Naming Convention:
FL_IS_<PLATFORM><_OPTIONAL_VARIANT>FL_IS_STM32, FL_IS_STM32_F1, FL_IS_STM32_H7, FL_IS_ESP_32S3FASTLED_STM32_F1, FASTLED_STM32 (missing FL_IS_ prefix)FL_STM32_F1, IS_STM32_F1 (incorrect prefix pattern)Detection and Usage:
FL_IS_ARM, FL_IS_STM32, FL_IS_ESP32 and their variantsFASTLED_STM32_HAS_TIM5, FASTLED_STM32_DMA_CHANNEL_BASED (not platform IDs)#ifdef FL_IS_STM32 or #ifndef FL_IS_STM32#if defined(FL_IS_STM32) or #if !defined(FL_IS_STM32)#define FL_IS_STM32 (no value)#if FL_IS_STM32 or #if FL_IS_STM32 == 1#define FL_IS_STM32 1 (do not assign values)FASTLED_USE_PROGMEM, FASTLED_ALLOW_INTERRUPTS)FASTLED_STM32_GPIO_MAX_FREQ_MHZ 100, FASTLED_STM32_DMA_TOTAL_CHANNELS 14)#define FASTLED_USE_PROGMEM 1 or #define FASTLED_USE_PROGMEM 0#define FASTLED_STM32_GPIO_MAX_FREQ_MHZ 100#if FASTLED_USE_PROGMEM or #if FASTLED_USE_PROGMEM == 1#define FASTLED_USE_PROGMEM (missing value - ambiguous default behavior)For a subsystem (e.g., Watchdog, Audio, Codec) that has multi-tier platform support, document each platform's capabilities with FL_<COMPONENT>_HAS_<FEATURE> flags. These let user code compile-time gate optional features without doing platform detection itself.
Spelled-out names — no acronyms in macro identifiers. The macro name and the public API name share a single vocabulary. If the API is FastLED.watchdog() then the macros are FL_WATCHDOG_*, not FL_WDT_*. If the API is FastLED.audio() then the macros are FL_AUDIO_*. Acronyms in macro names cost grepability and force readers to learn a second name for the same thing.
FL_WATCHDOG_HAS_WINDOW_MODE, FL_WATCHDOG_PERSIST_BYTES, FL_AUDIO_HAS_I2S, FL_CODEC_HAS_H264FL_WDT_HAS_WINDOW_MODE (abbreviation hides the component), FASTLED_WATCHDOG_WINDOW_MODE (use FL_ for newer per-component flags), WATCHDOG_HAS_WINDOW_MODE (missing prefix)Boolean capability flags:
#define FL_WATCHDOG_HAS_WINDOW_MODE (no value)#if defined(FL_WATCHDOG_HAS_WINDOW_MODE) or #ifdef FL_WATCHDOG_HAS_WINDOW_MODENumeric properties (sizes, limits, capacities):
#define FL_WATCHDOG_PERSIST_BYTES 16#if FL_WATCHDOG_PERSIST_BYTES >= 8static_assert(FL_WATCHDOG_PERSIST_BYTES >= 8, "...") to enforce minimums the unified API promises.Where they live: Defined in the per-platform *.impl.hpp (or the component's public header) that implements the feature. The _noop.hpp fallback defines none of the boolean flags (so #ifdef checks correctly evaluate false on unsupported platforms) and always defines a zero/default for every numeric property the unified API exposes (so #if-on-numeric-value still compiles).
Distinction from Type 1 (FL_IS_*): Type 1 says "I am running on platform X." Type 3 says "this component has feature Y on this platform." A single platform may carry many Type 3 flags from independent components.
fl/stl/compiler_control.hFL_DBG("message" << var) for debug prints (easily stripped in release builds)FL_WARN("message" << var) for warnings (persist into release builds)fl::printf, fl::print, fl::println - prefer FL_DBG/FL_WARN macros instead<< operator, NOT printf-style formatting__cxa_guard Conflicts__cxa_guard_* function calls. If Teensy's <new.h> is included after the compiler sees the static, the signatures conflict..cpp.hpp files): Include <new.h> early on Teensy 3.x to declare the guard functions before use:
// Teensy 3.x compatibility: Include new.h before function-local statics
#if defined(__MK20DX128__) || defined(__MK20DX256__) || defined(__MK64FX512__) || defined(__MK66FX1M0__)
#include <new.h>
#endif
src/fl/async.cpp.hpp (includes <new.h> early to prevent conflicts)__guard* signature.cpp file
src/platforms/shared/spi_hw_1.{h,cpp} for the correct patternci/lint_cpp/test_no_static_in_headers.py for critical directories (src/platforms/shared/, src/fl/, src/fx/)// okay static in header comment if absolutely necessary (use sparingly)onBeginFrame() / show() must wait for poll() == READY before starting a new frame — use a simple while (poll() != READY) loopfl::task::run() — never busy-spin without yielding. Use fl::task::run(250, fl::task::ExecFlags::SYSTEM) inside wait loops to yield to the OS scheduler (FreeRTOS vTaskDelay(0) on ESP32, std::this_thread::yield() on host). This prevents watchdog timeouts and starvation of WiFi/BT/system tasks. Include fl/task/executor.h.
fl::yield(), vTaskDelay(), or taskYIELD() — fl::task::run() is the unified APIExecFlags::SYSTEM (OS yield only, minimal overhead)ExecFlags::ALL (also pumps coroutines and scheduled tasks)show() or onBeginFrame(). The poll() method drives the state machine to READY; callers just wait for it.onEndFrame() may wait for READY or DRAINING — after show() kicks off DMA, it's fine to return once DMA is running (DRAINING). onBeginFrame() will ensure READY before the next frame.src/fl/channels/README.md → "DMA Wait Pattern" sectionint mCount;, fl::string mName;, bool mIsEnabled;int count;, fl::string name;, bool isEnabled;CFastLED)Core Principle: Any new global / library-wide configuration setter MUST be exposed as a public method on the CFastLED god instance in src/FastLED.h. The implementation may live as a free function in the fl:: namespace for ADL / testability, but the documented user-facing entry point is FastLED.setX(...), not fl::set_x(...).
Rationale: CFastLED is the discoverable surface — users see FastLED.setBrightness(), FastLED.setMaxRefreshRate(), FastLED.setPowerModel() in every sketch and expect every other knob to live there too. Free-function-only configuration ratchets the API surface into a second, undocumented place that users won't find and that drifts out of sync with the god instance.
Exemplar (src/FastLED.h:1455):
// Free function in fl:: namespace — does the work, testable in isolation
namespace fl { void set_power_model(const PowerModelRGB& m) noexcept; }
// Public entry point on CFastLED — thin delegator, this is what users call
class CFastLED {
inline void setPowerModel(const PowerModelRGB& model) {
set_power_model(model);
}
};
Rules:
fl::set_* / fl::enable_* / fl::disable_* / fl::use_* free function that mutates library-wide state without a matching CFastLED::setX() / enableX() wrapper.
fl::set_input_gamut(&profile, fl::InputGamut::Rec709); as the documented call siteFastLED.setInputGamut(&profile, fl::InputGamut::Rec709); (god instance), with a fl::set_input_gamut(...) free function backing itinline one-liner that delegates — no logic, no validation, no error handling. The free function holds all behavior.fl:: must ship with a CFastLED wrapper. A small transitional allowlist (GRANDFATHERED_NAMES in ci/lint_cpp/public_settings_pattern_checker.py) exempts pre-existing bare setters (e.g. fl::set_input_gamut #2710, fl::enable_rgbw_colorimetric_lut, fl::set_rgbww_colorimetric_profile) until their wrappers land. Entries are removed as each name is wrapped — the goal is an empty allowlist. New additions do NOT get grandfathered.Check Process:
src/fl/**/*.h whose name matches ^set_|^enable_|^disable_|^use_ and that mutates a static / global / namespace-scope variable, grep src/FastLED.h for a CFastLED method that delegates to it.ci/lint_cpp/public_settings_pattern_checker.py. The checker carries a shrinking GRANDFATHERED_NAMES allowlist for legacy bare setters; remove a name from the list once its CFastLED wrapper is merged.Where the rule does NOT apply:
fl:: that operate on caller-owned objects without touching global state (fl::fill_solid(span, color)).fl::detail:: / anonymous-namespace functions — not public API.Core Principle: Any public fl::set_* API that stores a configuration profile / settings object in process-wide (or static / namespace-scope) state MUST store by value (copy / move) and never retain a caller-owned pointer. The parameter SHOULD be const T& (or T&&); a const T* parameter is permitted only as a signal for nullable-reset (nullptr means "revert to default") and only if the implementation copies the pointed-to value into a value-typed slot on non-null and clears that slot on null. Storing the raw pointer (sActive = profile) is always a deterministic use-after-scope bug when callers pass a stack temporary like auto p = make_profile(); fl::set_x(&p); /* p goes out of scope */.
Rules:
namespace { const RgbcctProfile* sActive = nullptr; }
void set_rgbww_colorimetric_profile(const RgbcctProfile* p) FL_NOEXCEPT {
sActive = p; // caller's lifetime, BOOM if they pass a stack temporary
}
namespace { RgbcctProfile sActive = kRgbwwDefaultProfile; }
void set_rgbww_colorimetric_profile(const RgbcctProfile& p) FL_NOEXCEPT {
sActive = p; // copy, lifetime owned by library
}
const T* allowed when null means "reset"):
namespace { RgbcctProfile sActive = kRgbwwDefaultProfile; }
void set_rgbww_colorimetric_profile(const RgbcctProfile* p) FL_NOEXCEPT {
sActive = (p != nullptr) ? *p : kRgbwwDefaultProfile; // copy on non-null, reset on null
}
const T& to the owned copy, not a pointer that could be nullptr.fl::shared_ptr<T> or fl::unique_ptr<T> — never a raw const T*.Rationale: The colorimetric / power-model / driver-config setters are the only place this rule fires in the current codebase, but every one of them got a use-after-scope CodeRabbit finding (#2554, #2560, #2588, #2683, #2682) before the rule was written. The Public Settings Pattern (above) tells you where the setter lives (god instance); this rule tells you how it stores the value. The nullable-reset exception above accommodates the established set_rgbww_colorimetric_profile(const T*) shape — pointer parameter, value-typed storage — which is correct and idiomatic for "pass nullptr to revert."
Check Process:
fl::set_* setter that stores into a static / namespace-scope variable, verify the storage is value-typed (T, not const T*).const T* AND the storage is const T*, flag as HIGH severity (pointer storage = use-after-scope).const T* but the storage is T and the body copies on non-null + resets on null, that's the allowed nullable-reset shape — no violation.Core Principle: If set_X(T* obj, ...) writes into the fields of a caller-owned *obj, and any subsystem caches values derived from *obj keyed only on (obj_ptr, ...), then the setter MUST bump obj->mCacheVersion (or call obj->invalidate()) and the cache key MUST include that version. Otherwise the cache returns stale data the next time the same pointer is passed in.
Rules:
void set_input_gamut(DiodeProfile* p, InputGamut g) FL_NOEXCEPT {
apply_gamut(p, g); // rewrites p->input_xy_*
// …no version bump; ProfileCache keyed on (p, cct) still returns stale M_src
}
void set_input_gamut(DiodeProfile* p, InputGamut g) FL_NOEXCEPT {
apply_gamut(p, g);
++p->mCacheVersion; // forces any derived-value cache to rebuild
}
// and:
struct CacheKey { const DiodeProfile* p; u32 version; int cct; };
*p in place; bumps p->mCacheVersion so derived caches rebuild on next access."DiodeProfile that ships as const to all consumers and is replaced wholesale (set_profile(const T&)) eliminates this entire class of bug — see Singleton-Stored Configuration rule above.Rationale: Drove four CodeRabbit findings in the survey window (#2554, #2589, #2707, #2711) and is the root cause of the colorimetric "looks right in tests but stale in production" class. Tests typically construct one profile per test case so the cache never hits — the bug only shows up in real applications that mutate one shared profile across frames.
Check Process:
fl::set_*(T* obj, ...) where the body mutates *obj, search the codebase for a cache keyed on obj (typical names: ProfileCache, kCache, sCachedDerived).Core Principle: When a PR changes a numeric bound, enum range, struct shape, or semantic of a public API surface, it MUST update every consumer of that contract in the same PR: (a) the producer / setter, (b) the validator, (c) unit tests, (d) docs (agents/docs/*, AutoResearch JSON-RPC reference, RPC handlers), and (e) any matching debug-metrics / stats struct. Partial sweeps cause silent producer/consumer skew.
Rules:
examples/AutoResearch/AutoResearchRemote.cpp bumps laneSizes max from 8 to 16, but src/fl/channels/validation.cpp.hpp still rejects > 8 — silent rejection at runtime.available() / empty() / size() semantics without updating the docstring AND every caller that compares against constants.Rationale: This is a broadening of the existing "API Unit Change" rule (which was scoped to _ms / _us / _bytes suffixes). The recurring CodeRabbit pattern (#2621, #2648, #2669, #2682) is more general — any contract change, not just unit changes, needs the same fanout.
Check Process (PR-level):
src/, examples/, tests/, and agents/docs/.fl::span for Callback Typedefs and Small Fixed-Size ArraysCore Principle: The general fl::span rule (above) covers function parameters but is repeatedly missed for two specific shapes: (a) fl::function<...> callback / handler typedefs, and (b) small fixed-size array parameters like const float xy[2]. These slip through because they don't look like the canonical void f(const u8*, size_t) pattern.
Rules:
(ptr, size) shape.
using write_bytes_handler_t = fl::function<size_t(const u8*, size_t)>;using write_bytes_handler_t = fl::function<size_t(fl::span<const u8>)>;void set_input_gamut(DiodeProfile* p, InputGamut g, const float white_xy[2]);void set_input_gamut(DiodeProfile* p, InputGamut g, fl::span<const float, 2> white_xy);void set_white_point(DiodeProfile* p, fl::vec2f white_xy);fl::span<u8, 4> (or 5) over five separate u8* parameters.Scoped exception — deferred legacy migrations: A (ptr, size) callback typedef MAY be temporarily retained in a legacy layer when migrating it would force a synchronized rewrite of every caller and the migration is being deferred to a follow-up PR. The exception requires (a) a comment at the typedef site referencing the migration plan / tracking issue, (b) naming the legacy location (current example: src/fl/stl/cstdio.h retains raw pointer+length handler signatures intentionally), and (c) the exception is timeboxed — it must be revisited at the next layer-wide migration window. Permanent divergence is not permitted; the goal is "all-or-nothing per-layer sweep when the engineering budget allows."
Rationale: The base span rule prevents most of the pattern but CodeRabbit caught these two shapes in #2560, #2683, #2711. Just adding the two shapes to the canonical examples will close most remaining gaps. The scoped exception accommodates the real-world case where touching every caller in one PR isn't tractable; without it the rule pushes engineers toward unprincipled mixed APIs.
fl::atomic or Critical Section, Never Bare volatileCore Principle: Any field that is written from an ISR callback context and read from task context (or vice versa), or written from two task contexts without other locking, MUST be either an fl::atomic<T> with explicit memory_order, or guarded by portENTER_CRITICAL_ISR/portEXIT_CRITICAL_ISR (ISR side) and portENTER_CRITICAL/portEXIT_CRITICAL (task side). Plain volatile only prevents the compiler from caching the load — it does NOT provide atomicity, ordering, or visibility across cores.
Rules:
int / bool / volatile int for state shared with an ISR.
volatile int mPendingTransmits = 0;
// ISR: --mPendingTransmits; (not atomic on dual-core, no ordering)
// task: while (mPendingTransmits) {} (no acquire fence)
fl::atomic<int> mPendingTransmits{0};
// ISR: mPendingTransmits.fetch_sub(1, fl::memory_order_release);
// task: while (mPendingTransmits.load(fl::memory_order_acquire) > 0) {}
// ISR side:
portENTER_CRITICAL_ISR(&mLock);
mTransmitting = false;
mNextDescriptor = next;
portEXIT_CRITICAL_ISR(&mLock);
// task side:
portENTER_CRITICAL(&mLock);
bool tx = mTransmitting;
portEXIT_CRITICAL(&mLock);
std::atomic<T> — FastLED targets AVR / older embedded toolchains that don't have <atomic>. Use fl::atomic<T> from fl/stl/atomic.h. (See the project-wide rule: prefer fl:: over std:: except for low-level metaprogramming traits.)mTransmitting = false in refill() while a chunk-done ISR is still pending corrupts the next frame's state.Rationale: ESP32 / RP2040 / dual-core MCUs make this a hardware-correctness issue, not just a style preference. Caught only twice in the survey window (#2682, #2703), but both were high-severity races that would have shipped without manual review.
Check Process:
IRAM_ATTR callback or static void IRAM_ATTR on_* ISR, verify it's either fl::atomic<T> or guarded by a portMUX_TYPE critical section.volatile on these fields is a violation.Core Principle: When summing channel values, brightness contributions, or any per-element accumulator whose total may exceed the destination type's max, you MUST clamp before the narrowing cast. static_cast<u8>(x) silently wraps at 256, which is defined behavior in C++ but almost always the wrong behavior.
Rules:
static_cast<u8> a value that can exceed 255.
u32 total_mW = red_mW + (white_mW + warm_white_mW) / 3;
u8 byte = static_cast<u8>(total_mW); // wraps if total > 255
u32 total_mW = red_mW + (white_mW + warm_white_mW) / 3;
u8 byte = static_cast<u8>(fl::min<u32>(255u, total_mW));
u8 byte = fl::qadd8(red_byte, white_byte); // saturates at 255
u16 narrowing — static_cast<u16>(x) where x is u32 and the source can exceed 65535 must clamp first.u8 byte = static_cast<u8>(rgb.r); when rgb.r is already a u8 typed via a different value path. The rule fires when there's been arithmetic that can overflow the destination type.Rationale: Sibling to the existing "Signed Integer Overflow (UB)" rule. Unsigned wrap is defined but produces silently-wrong color / power / brightness values. The signed-UB rule (above) does NOT catch this — u8 + u16 → wrap is fine according to the standard but is the bug pattern flagged twice in #2560 (first review ignored, second review re-flagged).
Core Principle: When tests/fl/foo/test_bar.cpp mirrors part of src/fl/foo/bar.cpp.hpp (e.g. to verify mathematical identities like Hermite-basis sum = 1), the test MUST call the production symbol directly. Re-declaring the algorithm as a local lambda or helper inside the test file means the test passes regardless of bugs introduced into the production implementation.
Rules:
// tests/fl/gfx/rgbw_colorimetric.cpp
FL_TEST_CASE("hermite basis sums to 1") {
auto h0 = [](float t) { return (1 - t)*(1 - t)*(1 + 2*t); };
auto h1 = [](float t) { return t*t*(3 - 2*t); };
// …asserts h0(t) + h1(t) == 1. Production hermite_basis is never called.
}
#include "fl/gfx/rgbw_colorimetric.h"
FL_TEST_CASE("hermite basis sums to 1") {
float out[2];
fl::gfx::hermite_basis(0.5f, out);
FL_CHECK_CLOSE(out[0] + out[1], 1.0f, 1e-6f);
}
*_mirror_for_test.cpp.hpp) and a static_assert / runtime check must compare the mirror's output against the production output on a sample input.inline algorithms can be tested by calling the production header directly — there's no link concern.Rationale: Caught in #2683, #2707, #2709. The bug pattern: a future regression in the production implementation (e.g. someone swaps the sign of a Hermite coefficient) passes all tests because the tests verify a local copy of the correct algorithm. The production code can silently drift.
Core Principle: Every C++ source file (.h, .cpp, .hpp, .cpp.hpp) and every Python source file in this repo MUST contain only 7-bit ASCII. No emoji in comments, no Unicode math symbols in identifiers or print statements, no curly-quote characters from copy/pasted documentation.
Rules:
// ⚠️ This runs in ISR context — keep it short// WARNING: This runs in ISR context — keep it shortprint("FastLED − Reference"), ρ = 0.5, Δ = abs(a - b)print("FastLED - Reference"), rho = 0.5, delta = abs(a - b)"smart quotes" with "straight quotes", — with - or --.*.md) are exempt — Unicode in human-reading docs is fine. This rule is for code only.Rationale: Source files are read by lots of tools (compilers, linters, AVR / RP2040 / Teensy toolchains with various Unicode story, terminal log dumps, grep, IDE search). Some toolchains choke; others render the glyphs as garbage in compile errors. Pure ASCII keeps the source legible in every context. Flagged in #2648 (C++ comments with emoji) and #2709 (Python with ×, −, ρ, ∆).
Check Process:
grep -rPn '[^\x00-\x7F]' src/ tests/ ci/ (excluding *.md).