fold/docs/internal/perf-audit/fold-on-fold.md
2026-05-27 10:36:57 -04:00

8.1 KiB

Fold-on-fold static-analysis pass (2026-05-23)

Output of running fold against its own source tree:

fold --quiet scan \
  --compile-db build/release/compile_commands.json \
  --detectors clone,dead-statement,equiv \
  --clone-type 3 --clone-similarity 0.8 \
  --processes 4 \
  --format json --output docs/internal/perf-audit/fold-on-fold-2026-05-23.json

Wall: ~9.7 min over 134 TUs.

Totals: 639 findings — 487 clone, 152 dead-statement, 0 equiv.

The equiv-detector slot caught nothing because equiv only fires on function pairs whose signatures match exactly AND that pass z3 input/ output equivalence; the recently scout-rule-extracted helpers don't collide on signature.

Top duplication clusters worth refactoring

mixHash across the security detectors

Sites: src/detect/sign_conversion.cpp:154, div_by_zero.cpp:*, int_truncation.cpp:*, int_overflow.cpp:*, null_deref.cpp:*.

5+ identical copies of the FNV-1a-style mix function inside per- detector anonymous namespaces. Refactor target: lift to a single helper in src/ir/normalize.cpp (already has mix64) or src/symbolic/theory.hpp. Each detector loses ~6 lines.

supportsParam and collectConjuncts

Multiple anonymous-namespace copies across detectors. The Phase-B v2 migration moved some helpers but missed these. Refactor target: extract to src/symbolic/z3_helpers.cpp (sister to z3_model_support.{hpp,cpp} that already exists).

onFunctionEnd boilerplate

DivByZeroObserver::onFunctionEnd, IntOverflowObserver::onFunctionEnd, OOBObserver::onFunctionEnd, etc. all do the same solver_ = nullptr; current_fn_ = nullptr; cleanup. Refactor target: a small mixin helper or a base-class SafetyObserverBase providing the default implementation.

ir::*::valid / operator bool / operator== boilerplate

include/fold/ir/call.hpp:23-29 and friends — Call, MemberRef, Symbol, Expr all spell out their own identical valid(), operator bool, operator==, operator!=, asClangX(). Five copies of each. Refactor target: common CRTP base or just a macro; the opaque-handle shape repeats deliberately but the accessor boilerplate shouldn't.

Test-fixture duplication

tests/unit/test_oob_access.cpp:* (60 hits), test_null_deref.cpp:* (51 hits), test_int_truncation.cpp:* (7 hits) all show massive cross-cluster matches. Judgment call: these are parameterised detector tests — the duplication is the point of fixture coverage. Not a refactor target. Could be reframed as Catch2 GENERATE() tables if test count keeps growing, but that's stylistic.

Dead-statement findings (152 → 16 after fix)

Initial finding: 152 dead-statement reports. Spot-checks of the top files (incremental_cache.cpp, call_graph.cpp, def_use.cpp, r2_backend.cpp, static_ptr_asymmetry.cpp) all showed the same shape: the flagged statement was inside a range-for body. Plain for (int x : v) was fine; for (const auto& [k, v] : map) was not.

Root cause: clang's CFG for a range-for whose loop variable is a structured binding leaves the body block without an incoming edge from the loop's condition check. The reachability walk in DeadStatementVisitor then marks every body statement unreachable. Reproducer in 7 lines of C++.

Fix: commit 8895d50 adds a parent-map walk; if the anchor's ancestor chain contains a CXXForRangeStmt, suppress the finding. Regression test dead-statement: range-for + structured binding body is reachable pins it.

Result: 152 → 16 findings on fold's own source (90% reduction).

Remaining 16 are all "defensive return after exhaustive enum switch"

The 16 surviving findings are the same idiom across 13 files:

const char* findingKindName(FindingKind k) {
    switch (k) {
        case FindingKind::Clone: return "clone";
        // ... every enum value covered ...
        case FindingKind::FuzzCrash: return "fuzz-crash";
    }
    return "unknown";   // ← flagged
}

When every enum case returns (no default), clang's CFG does not insert a synthetic edge to the post-switch block, so the trailing return "unknown" is genuinely unreachable. The detector is technically correct; the engineer wrote the return defensively in case a future enum addition forgets a case.

Three resolutions worth considering:

  1. Leave as documented exception. The defensive return is graceful — returns a placeholder rather than UB. Cost is a few FPs on tight C++ codebases that use the same idiom.
  2. Replace with __builtin_unreachable() / std::unreachable() at fold's own sites. Removes the dead code, signals intent, improves codegen. Cost: UB at runtime if exhaustiveness breaks later.
  3. Add a second filter to dead_statement.cpp for trailing statements after exhaustive enum switches. Cost: complex AST analysis (find the surrounding switch, verify it covers every enum case, etc.) and risks suppressing real dead code.

Recommendation: (1) is fine for fold-as-a-tool; the 16 remaining findings are a known false-positive idiom worth documenting in user-facing release notes. Sites in fold's own source: finding.cpp (3), theory.cpp (5), ir_expr_clang.cpp (1), corpus_writer.cpp (1), harness_emitter.cpp (2), clone.cpp (1), triage.cpp (1), int_overflow.cpp (1), test_cross_tu_equiv.cpp (1).

What this confirms vs. denies

Confirms:

  • The 5 security detectors share substantial duplicated mechanics (mixHash, supportsParam, onFunctionEnd, hashQuery cache shape). Hypothesised in the perf-audit plan; visible in the data.
  • The ir::* opaque types have unnecessary per-type boilerplate.

Denies:

  • equiv-detector finding nothing means the prior scout-rule extractions DID move shared logic out — the per-detector remaining duplication is in small helpers, not whole functions.

Pointer

Full JSON: docs/internal/perf-audit/fold-on-fold-2026-05-23.json (639 findings, ~50 KB). Profile: fold-on-fold-2026-05-23-profile.json.

2026-05-23 late-session pass (after audit + cache + composite-walker)

Rule Count
fold.clone 81
fold.dead-statement 14
fold.equiv 1
fold.null-deref 22 (new; not in earlier passes)

The 22 null-deref findings on fold itself are mostly the same shape: a parameter is null-checked early via a compound condition (e.g. if (!rd || rd->isImplicit()) return true;), then the parameter is passed to a helper function that dereferences it. The encoder doesn't propagate the !rd → false arm of the || into a non-null fact on the post-early-return path; downstream the helper sees an unknown pointer and flags the deref.

Example: src/detect/class_dup.cpp:186-193. Parameter rd is null-checked at line 186 (if (!rd || ...)), passed to eligibleMethodCount(rd) at line 193 which derefs param 0.

What this surfaces

The compound-guard pattern (if (!x || other) return;) is real and worth recognising syntactically. cfg_helpers::nullCheckVar now handles NULLCHECK || other and NULLCHECK && other (symmetric AND case for if (x != nullptr && other) shapes). Three unit tests under [m22-extension] pin the contract. The existing SMT path predicate already handled most in-function derefs after these guards; the syntactic flag is mainly defense in depth + faster precondition discharge at call sites.

The 22 null-deref findings on fold itself are NOT eliminated by that change. They come from a separate gap: fold analyzes static file-local helpers as standalone entry points, so static int methods(const R* r) { return r->v; } emits a nullable-deref finding on r->v regardless of whether the only callers in the TU prove r non-null. Two routes to fix that:

  1. Skip standalone analysis for file-static helpers whose every call site discharges the parameter non-null precondition.
  2. Derive a stronger required-precondition summary from the callee body and suppress the deref-site finding when every call site satisfies it.

Both are detector-side work, larger scope, filed for a future null-deref precision refresh. Tight well-typed codebases tend to produce a long tail of these context-sensitive parameter-deref FPs — they are domain-invariant, not signal.