fold/CLAUDE.md

10 KiB
Raw Permalink Blame History

CLAUDE.md — fold code conventions

Operating contract for Claude (and any other LLM-driven contributor) working in this repo. Complements AGENTS.md (which covers backend/router architecture). This file is the structural contract: how to keep code readable, testable, and modular as the codebase grows.

Core principle

No new monoliths. Split before they grow.

The encoder grew to 2800+ lines in a single file because each contributor added "just one more arm" to the dispatch chain. Each addition was locally reasonable; the cumulative result is unreviewable. Fight this drift on every PR.


File-size budgets

Artifact Soft cap Hard cap Action at hard cap
Header (.hpp) 300 lines 500 lines Split by responsibility
Source (.cpp) 800 lines 1500 lines Split by responsibility
Single function 50 lines 150 lines Extract helpers
Single class 300 lines 600 lines Split into composed components
Switch/dispatch arm 30 lines 80 lines Extract to named helper

If a file is already over the hard cap (e.g. src/symbolic/encoder.cpp at 2800 lines), every PR that touches it must:

  1. Not increase the line count beyond what's strictly needed for the functional change.
  2. Carry along at least one extraction (one helper, one dispatch arm, one data structure moved to its own file) as scout-rule cleanup. The extraction goes in the same PR as the functional change.

When extracting from an oversized file, target the anonymous-namespace helper functions first (low risk, no API change) before moving public types.


Function structure

Dispatch functions

Functions that select behavior by type/kind/opcode (encodeExpr, encodeBinary, applyStmt, etc.) should be pure switches that delegate to per-arm helpers:

// GOOD
z3::expr encodeExpr(ir::Expr e, const ASTContext& ctx, SymbolicEnv& env) {
    switch (e.kind) {
        case Kind::DeclRef:    return encodeDeclRef(e, ctx, env);
        case Kind::BinaryOp:   return encodeBinaryArm(e, ctx, env);
        case Kind::Call:       return encodeCallArm(e, ctx, env);
        // ...
    }
}

// BAD — bodies inline, function grows unbounded
z3::expr encodeExpr(...) {
    if (kind == DeclRef) { /* 30 lines */ }
    if (kind == BinaryOp) { /* 150 lines */ }
    if (kind == Call) { /* 200 lines */ }
}

A dispatch function's cyclomatic complexity should be O(number of cases), not O(number of cases × average case complexity). If a single arm is over 30 lines, extract it.

Helper extraction rule of thumb

Before duplicating a 4-line idiom, extract it. Three sites of the same pattern → helper. Two sites → judgment call (usually leave it).

Before extracting, decide if the helper is internal (anonymous namespace in the same .cpp, never tested directly) or shared (exposed in a header, unit-testable). Default to internal; promote only when a second TU needs it or when the logic is complex enough to deserve targeted tests.


Layering

Strict layer rules (no upward includes)

fold::ir          // dialect-agnostic types (Symbol, Expr, SourceLoc, ...)
   ↑
fold::report      // Finding, Location, structured output
   ↑
fold::symbolic    // SymbolicEnv, encoder, theory; consumes ir + report
   ↑
fold::detect      // detectors that subclass SafetyObserver
   ↑
fold::cli         // scan runner, MCP, CLI wiring

Headers in lower layers MUST NOT include headers from higher layers. The compiler will catch most of these via include-what-you-use; the human cost is treating "I just need access to FooDetector from SymbolicEnv" as a code smell.

Frontend abstraction (the M30 work)

fold::ir types (Symbol, Expr, Call, MemberRef, SourceLoc) are frontend-agnostic by convention. Their header files include no clang headers. Clang-aware implementations live in *_clang.cpp files (see src/symbolic/ir_expr_clang.cpp, src/symbolic/clang_location_mapper.cpp).

A future Rust frontend constructs ir::Expr{Kind::DeclRef, rust_handle} directly without going through the clang path. Detectors and hooks must not break this invariant.


Testability

Test-first for new modules

A new file in src/symbolic/ or src/detect/ requires:

  • Unit tests in tests/unit/test_<module>.cpp
  • At least one positive test (intended behavior fires)
  • At least one negative test (related-but-different input does NOT fire)
  • Wired into tests/unit/meson.build

Don't add a detector without a test file. Don't add a transformation pass without a counter-example test.

Test-by-extraction for legacy modules

When extracting from a monolith, the extracted helper gains a unit test in the same PR. If extracting encodeArraySubscript from encodeExpr, also add test_encode_array_subscript.cpp with at least one happy-path case. This converts "we're all-green" (integration coverage) into "all-green + targeted helper coverage" (extraction enables regression isolation).

Validation cadence on the hot path

Encoder and detector changes are precision-sensitive: unit tests can pass while curl/yams audit findings drift by hundreds. After any encoder or detector edit:

  1. ninja -C build/release — must succeed
  2. meson test -C build/release — must be fully green (Fail: 0)
  3. For changes touching encodeExpr / encodeBinary / encodeUnary / applyStmt / SymbolicEnv / SafetyObserver: re-run a curl null-deref audit and diff finding counts against the last known-good baseline. Document any delta.

What "green" means

meson test -C build/release returning Fail: 0 is necessary but not sufficient. The full bar is:

  • Build green with -Werror-equivalent warnings inspected
  • All tests pass
  • For symbolic-encoder changes: spot-check finding count parity on at least one OSS target (curl is the canonical baseline)
  • No // TODO left in code for "I'll handle this in a follow-up" — either do it now or write a tracked issue

API surface discipline

When adding a public API

Before adding to a header (include/fold/...):

  1. Is one caller enough? If only one TU needs it, keep it in the .cpp anonymous namespace.
  2. Does it deserve a test? Public APIs need targeted unit tests.
  3. Is the name self-documenting? compute() is a smell; compute<x>() for some specific x is the right name.
  4. Does it leak frontend types? A header in fold/ir/ must not name clang::*; a header in fold/detect/ may name clang because detectors are clang-frontend-coupled today, but each new such header makes a Rust frontend harder.

When adding an observer hook

SafetyObserver hooks are a contract that every detector implements. Each new hook is:

  • A signature update across 7+ detectors
  • A new clang→IR conversion site in the encoder
  • A risk of subtle ordering bugs

Before adding a hook, ask: can this be derived from an existing hook + SymbolicEnv state? If yes, don't add. If no, add it with parameter types already abstract (ir::Expr, ir::Symbol, ir::SourceLoc) — never with clang::* types.


Editing oversized files (the encoder rule)

src/symbolic/encoder.cpp is 2800+ lines. When a change is necessary there:

DO

  • Add new logic as a named helper in the same file, called from one dispatch site. Don't inline new logic into an existing 100-line arm.
  • Use the existing encodeBinaryOperatorArm / encodeCallExprArm pattern for any new big-body arm: forward-declare in an anonymous namespace near the top, define after encodeExpr.
  • Carry along one extraction with the functional change (scout rule): pull one anonymous-namespace helper into a sibling .cpp file, or split the file along a natural boundary if the helper is large.

DON'T

  • Add a new dispatch arm directly to the legacy if-chain — it's empty now; use the switch(ir::Expr::Kind) pattern.
  • Grow encodeBinaryOperatorArm or encodeCallExprArm beyond their current size. If a new case is needed, factor sub-helpers first.
  • Add new clang dyn_cast chains. Detector-side dispatch should go through ir::Expr::Kind and the typed accessors (binaryOpcode(), declRefSymbol(), etc.).

Planned splits (for future PRs)

  • encoder.cpp should eventually become:
    • encoder_dispatch.cpp — encodeExpr switch + small leaf arms
    • encoder_binary.cpp — encodeBinary + encodeBinaryOperatorArm
    • encoder_unary.cpp — encodeUnary + encodeUnaryOperatorArm
    • encoder_call.cpp — encodeCallExprArm + library models
    • encoder_stmt.cpp — applyStmt + DeclStmt handling
    • encoder_types.cpp — coerceToBv, coerceToType, type-system glue
    • encoder_runtime.cpp — small utility helpers (freshUnknown, markEncoded, recordPrecision)

Each split is a separate PR with explicit test parity verification.


Naming

  • Anonymous-namespace helper functions: camelCase, descriptive of intent (encodeArraySubscriptArm, not handleArraySubscript).
  • Public types: PascalCase (SymbolicEnv, SafetyObserver).
  • Constants: kPascalCase (kBitvecWidth, kMaxModeledCopyBytes).
  • Member fields: trailing underscore (current_, definitely_non_null_).
  • IR types: in fold::ir namespace, short names (Expr, Symbol, SourceLoc) — they're the most-used vocabulary.

Why this file exists

The M30 refactor (Phases A–D3) made the detector + observer surface frontend-agnostic. But the encoder itself is still 2800 lines of mostly-clang code that grew because nobody enforced size budgets. The refactor cost ~12 sessions of careful work to undo.

Don't repeat that. New code should be split as it's written. Old code should shrink with every PR that touches it.

If you're an LLM working in this repo: when you're about to add code, ask "would a human reviewer be happy reading this 6 months from now?" If the answer is "they'd skim past it because the file is too big," extract first.