Mistakes Cuttlefish Catches

On every build, Cuttlefish reads your code and checks it against the board you’re targeting. It looks for the kinds of mistakes that are easy to make with hardware but hard to spot by eye — and that the C++ compiler won’t warn you about. When it finds one, it points at the line and suggests a fix.


The Kinds of Mistakes It Catches

Here are the most common ones, in plain terms:

  • Using a pin for something it can’t do. For example, asking a pin for a fading “PWM” signal when that pin doesn’t support it.
  • Two things on the same pin or bus. Like using the same physical pin twice through different names, or forgetting to release a shared bus.
  • Slow code that freezes the screen. A delay() inside your main loop pauses everything — including any display you’ve drawn.
  • Risky code inside an interrupt. Interrupts (the code that runs when a pin suddenly changes) have to finish fast. Long delays or bus calls there can lock up the board.
  • Running low on memory. Boards like the Arduino Uno have very little memory. Cuttlefish estimates how much your program will use and warns you before it silently corrupts.
  • Variables shared with interrupts. A value an interrupt writes and your main loop reads needs special handling — Cuttlefish adds that for you automatically.

A Few Examples

Wrong pin type

import { A0 } from '@typecad/board-arduino-uno';

const pin = A0.asOutput();
pin.pwm();  // ERROR: A0 does not support PWM on Arduino Uno

The message names the pin, what you asked for, and which pins do support it.

Same pin used twice

import { A4, SDA } from '@typecad/board-arduino-uno';

const analog = A4.asInput();
const i2c = SDA;  // WARNING: A4 and SDA are the same physical pin

A delay that freezes your screen

function loop() {
  delay(1000);  // WARNING: Blocking delay inside loop() freezes async/UI
  readSensor();
}
// Hint: Use millis() comparisons or Async.sleep(ms) instead.

Cuttlefish knows that loop() has to return quickly, so it only flags delay() there — not in one-time setup code.

A variable shared with an interrupt

let flag = false;

function setup() {
  D2.asInput().onFalling(() => { flag = true; });
}

function loop() {
  if (flag) { /* ... */ flag = false; }
}
// INFO: 'flag' is written in an interrupt handler and read in main code
//       — emitted with the right marking so the compiler doesn't cache it.

Cuttlefish marks flag correctly for you. This fixes a whole class of bugs where a loop seems to ignore a value an interrupt just set.


Running the Checks

The checks run automatically every time you build. For a full written report, add --diagnostics:

cuttlefish build --diagnostics

This writes diagnostics.md and diagnostics.json listing every check, how the pins and buses are used, and the memory estimate.


⚙️ Advanced details — the full list of checks

Cuttlefish runs about 20 checks in sequence during every build. Each one produces a diagnostic with a severity (error, warning, or info), a message, and usually a suggested fix. The orchestrator is runProgramValidations().

#CheckSeverityWhat it catches
1Pin CapabilitieserrorUsing pin features it doesn’t support (e.g., .pwm() on non-PWM pin)
2Peripheral ValidationwarningPeripheral usage conflicts with board data
3Unsafe PinswarningPins that interfere with critical peripherals
4Resource AnalysisinfoOverall resource usage, pin/peripheral allocation
5Pin Alias ConflictswarningSame physical pin used via different aliases
6PWM Timer SharinginfoPWM pins sharing the same hardware timer
7Timer0 PWM TiminginfoTimer0 conflict with millis()/micros()
8Pulldown SupportwarningPull-down requested on unsupported pins
9Interrupt Safetywarning/infoUnsafe operations inside interrupt handlers, reentrancy risk
10ADC RangewarningReads beyond ADC resolution
11Unit SuspicionwarningSuspicious numeric values (wrong units)
12Pin Mode ConfigwarningReads/writes without prior mode configuration
13Peripheral OwnershiperrorUsing peripherals without taking ownership
14Ownership Analysiserror/warningUse-after-move, borrow violations, letconst promotion
15Try-Catch ValidationerrorTry-catch on platforms with -fno-exceptions
16Heap Allocationwarning/info/errornew on AVR, heap awareness on ESP32, new Array<E>() on AVR
17Volatile InferenceinfoGlobals shared between an interrupt and main code auto-marked volatile
18Reentrancy DetectionwarningFunctions called from both an interrupt and the main thread
19Memory Budgetwarning/errorEstimated SRAM usage vs board budget (stack/heap collision)
20Blocking Delay in Loopwarningdelay() inside loop() that freezes async/UI rendering
⚙️ Advanced details — pins, timers, and interrupts

Pin capability validation

Each pin on a board has a defined set of capabilities. The validator checks every HAL method call against the board definition:

import { A0 } from '@typecad/board-arduino-uno';

const pin = A0.asOutput();
pin.pwm();  // ERROR: A0 does not support PWM on Arduino Uno

The diagnostic includes the pin name, the requested capability, and suggests which pins do support it.

Peripheral pin conflicts

Many pins serve dual roles. On Arduino Uno:

  • D13 is both a digital pin and SPI clock (SCK)
  • A4 is both analog input and I2C data (SDA)
  • D0/D1 are used by Serial (UART0)

Using a pin for GPIO while its peripheral is active produces a conflict diagnostic.

Pin alias conflicts

The same physical pin can be referenced by different names:

import { A4, SDA } from '@typecad/board-arduino-uno';

const analog = A4.asInput();
const i2c = SDA;  // WARNING: A4 and SDA are the same physical pin

PWM timer sharing

On AVR, PWM pins share hardware timers. The validator detects when multiple PWM pins share the same timer:

// D9 and D10 both use Timer1 on ATmega328P
const led1 = D9.asOutput();
const led2 = D10.asOutput();
led1.pwm(128);
led2.pwm(64);
// INFO: "D9, D10 share timer1 on Arduino Uno. Duty cycle can differ per pin,
//        but timer-wide PWM settings are shared across that group."

Timer0 PWM timing conflict

Timer0 drives millis() and micros() on AVR. Using PWM on D5 or D6 (Timer0-backed pins) can interfere with timing:

D5.asOutput().pwm(128);
// INFO: D5 uses Timer0 which also drives millis()/micros().
//        PWM frequency changes may affect timing accuracy.

The timer ID is detected from the board definition’s .timer field or by parsing AVR OC register naming (OC0Atimer0).

Interrupt (ISR) safety

The validator scans interrupt-handler callbacks for operations that are unsafe in interrupt context. Detection covers user-written lambdas (D2.onFalling(() => {...})) as well as HAL-authoring callback() directives — Cuttlefish tracks which callbacks are bound to interrupts across the whole program:

OperationSeverityReason
delay()warningBlocks the CPU in interrupt context
delayMicroseconds()warningBlocks and should be avoided in interrupts
Serial.print()infoMay not work correctly in interrupt context
Serial.println()infoMay not work correctly in interrupt context
Serial.write()infoMay not work correctly in interrupt context
Serial.read()infoMay not work correctly in interrupt context
I2C0.* / I2C1.*warningI2C operations can cause lockups in interrupt context
SPI0.* / SPI1.*infoSPI operations may cause issues in interrupt context

Platforms can customize this list via PlatformStrategy.isrUnsafeOperations().

Example

D2.asInputPullUp().onFalling(() => {
  delay(100);               // warning: delay() blocks the CPU
  Serial.println(" ISR!");  // info: Serial.println() may not work correctly
  I2C0.write(0x50, buf);    // warning: I2C0.write - I2C operations can cause lockups
});

Duplicate handler detection

Attaching multiple handlers to the same pin is also flagged:

D2.asInputPullUp().onFalling(() => { /* handler 1 */ });
D2.asInputPullUp().onFalling(() => { /* handler 2 */ });
// WARNING: Duplicate interrupt handler on pin D2

Automatic volatile inference

The classic embedded bug: a global flag set in an interrupt and polled in loop() becomes an infinite loop under -Os because the compiler caches it in a register. Cuttlefish knows which callbacks are interrupts and which globals they write — so it auto-marks shared variables volatile without any annotation:

let flag = false;

function setup() {
  D2.asInput().onFalling(() => { flag = true; });
}

function loop() {
  if (flag) { /* ... */ flag = false; }
}
// INFO: 'flag' is written in an interrupt handler and read in main code
//       — emitted as `volatile` to prevent register caching.

The emitted C++ declares volatile bool flag = false; automatically. This eliminates a class of bugs the C++ compiler cannot detect — “this function runs in interrupt context” is a call-graph property, not a function property.

Reentrancy detection

If a function is called from both an interrupt and main-thread code, an interrupt firing mid-execution can corrupt local state:

function process() { /* ... */ }

function setup() {
  D2.asInput().onFalling(() => { process(); });  // called from interrupt
}

function loop() {
  process();  // also called from main — reentrancy risk
}
// WARNING: 'process' is called from both an interrupt handler and main-thread code.

Heap allocation detection

Cuttlefish detects new expressions in any position — assignments, returns, call arguments, nested sub-expressions — not just variable declarations:

class Sensor { constructor() {} }

function setup() {
  this.sensor = new Sensor();  // detected in assignment
  process(new Sensor());       // detected in call argument
  return new Sensor();         // detected in return
}

Severity scales with the target:

  • AVR/megaAVRwarning: the heap is ~1.5–1.8 KB on a Uno; heavy or churning allocation risks fragmentation. new Array<E>(n) is an error (it would need std::vector, which AVR cannot host).
  • ESP32 and othersinfo: these targets have more RAM and a real heap, but allocations inside loop() churn the heap over time. The diagnostic suggests PSRAM for large buffers.

Memory budget validation

On AVR, the 2 KB SRAM is shared by globals, heap, and stack with no memory protection — a collision corrupts memory silently. Cuttlefish compares its static memory estimate against the board’s SRAM budget:

// 900 int32_t values = 3600 bytes on a 2048-byte SRAM target
const buf: int32_t[] = [/* ... 900 zeros ... */];
// WARNING: Estimated SRAM usage is 176% of the 2.0 KB budget
//          (statics 3.53 KB + stack ~0.03 KB = ~3.56 KB).
//          This exceeds available SRAM and will likely corrupt memory at runtime.

The estimate accounts for global variables, struct sizes, string literals, unsized arrays (element count recovered from the initializer), and call-stack depth. The board’s SRAM size is resolved from the board definition or an architecture-specific fallback.

ADC range validation

Reads beyond the board’s ADC resolution are flagged:

import { A0 } from '@typecad/board-arduino-uno';

const value = A0.asInput().readAnalog();
if (value > 1023) { /* ... */ }
// WARNING: Arduino Uno ADC is 10-bit (0-1023). Values above 1023 are unreachable.

Validation is board-data-driven — returns no diagnostic when board data is missing.

⚙️ Advanced details — ownership and bus rules

Ownership analysis

The ownership validator implements Rust-inspired rules for transpile-time validation. It is opt-in — if no ownership types appear in the program, only const-suggestion checks run.

Use-after-move

let buf: Owned = new Buffer(64);
let other = buf;       // ownership transferred
buf.read();            // ERROR: 'buf' was moved and cannot be used again
// Hint: const buf_ref: Shared = buf; // add this before the move

Assign to immutable borrow

const ref: Shared = data;
ref = newData;         // ERROR: Cannot assign to 'ref' — it is an immutable borrow
// Hint: Change to Mutable if reassignment is needed

Owned copy warning

let buf: Owned = new Buffer(64);
let copy = buf;        // INFO: Non-primitive Owned creates a C++ copy
// Hint: const copy: Shared = buf; // borrow by reference instead

Return local reference

function getData(): Shared {
  const local: Owned = new Buffer(64);
  return local;        // ERROR: Returning borrow of local variable — dangling reference
}

Const promotion

When the whole-program reassignment analysis proves a let is never written after initialization, Cuttlefish automatically emits it as const in C++ — enabling ROM placement and constant folding:

let x = 42;            // INFO: 'x' is never reassigned — emitted as `const`
// Emitted: const int x = 42;

This is information the C++ compiler cannot recover across translation units. The inverse demotion (const → non-const when a member is mutated) also applies automatically.

Borrow mismatch

function process(data: Mutable) { /* ... */ }
const readonly: Shared = buffer;
process(readonly);     // ERROR: Cannot pass Shared to Mutable parameter

Peripheral ownership

When you opt into bus ownership by calling take(), all subsequent bus access must occur within a taken section:

Double take

I2C0.take();
I2C0.take();           // ERROR: Double-take — I2C0 is already owned
// Hint: Call 'I2C0.release()' before taking it again

Unowned release

I2C0.release();        // WARNING: I2C0.release() called but bus was not taken
// Hint: Ensure you call 'I2C0.take()' before releasing

Unowned access

I2C0.take();
I2C0.release();
I2C0.write(0x50, data); // ERROR: Unowned access — I2C0 used without being taken
// Hint: Call 'const bus = I2C0.take();' and use the returned object

Leak detection

SPI0.take();
// ... end of program
// WARNING: SPI0 was taken but never released
// Hint: Call 'SPI0.release()' when finished

Bus names recognized: I2C0/I2C1, SPI0/SPI1, UART0, Wire, Serial.