Fix audit findings: soundness, coverage reporting, docs, and review follow-ups #1

Merged
trevon merged 18 commits from fix/audit-findings into master 2026-09-25 06:48:08 +00:00
Owner

Fixes the confirmed findings from an audit of fold's soundness, reporting and docs, then the findings from a code review of those fixes. There are 18 commits on master (b3da5a4): the 10 audit fixes, a README/docs commit, and 7 review fixes. Fixes come with a failing test written first unless noted.

Commits

76bc00f test(fanout): build the failure worker before the recovery tests

  • Problem: subprocess_fanout failed on a clean build because its helper binary was not a test dependency.
  • Fix: add depends: [fanout_failure_worker].

6adcc24 fix(detect): never prove post-loop conditions from a bounded unroll

  • Problem: dead-branch, always-condition and unreachable followed only the path on which the loop guard held for loop_unroll iterations. Early exits (a symbolic bound, break, goto) were dropped, so a condition after for (i < n) was "Proved" dead or constant from the loop having run exactly N times.
  • Fix: LoopUnrollExits (new loop_havoc.{hpp,cpp}, which also absorbs the three duplicated havoc helpers) marks the post-loop state as exact only when the unroll left through the loop's only exit. Otherwise the assigned variables are havocked and the entry path is restored.
  • Test: test_loop_exit_soundness.cpp covers symbolic-bound loops, a break loop, an exact constant loop as a control (still Proved), and the helpers.

67483ee fix(model): apply libc models only to C library callees

  • Problem: malloc/free/realloc and taint source/sink models matched the callee's bare identifier. mine::free(p); free(p); was a Proved double-free, and Shell::system(...) was a command-injection sink.
  • Fix: isCLibraryFunction/isCLibraryCallee require the resolved declaration to be global, in std, or extern "C", and never a member. Heuristic web-framework taint entries keep name matching (c_api = false).
  • Test: test_library_callee_identity.cpp (a namespaced free, a member free, a member system) with libc controls.

0556cc6 fix(report): keep Proved for exact bounded models only

  • Problem: docs/research/soundness.md defines proved as an exact bounded model, but double-free (heap flag), static-ptr-asymmetry (pattern), unused-function and dead-statement (AST facts), the stdlib copy check, and heap OOB/UAF (SAT without a precision check) all assigned Proved.
  • Fix:
    • double-free, static-ptr-asymmetry and the stdlib copy check report Heuristic; unused-function and dead-statement report Syntactic.
    • Heap OOB and UAF keep Proved only when the path carries no imprecision tag.
    • sizeof/alignof on complete non-VLA types is now an exact constant (typeTraitConstant), so malloc(sizeof(T)) paths can stay exact.
  • Test: test_proved_requires_exact_model.cpp.

c9811b7 fix(scan): report unanalyzed translation units and exit 2

  • Problem: a TU that failed to parse left no trace, and a scan of only broken files exited 0 with no findings. Logs also went to stdout, which corrupted SARIF/JSON written to stdout.
  • Fix:
    • Each TU yields an outcome: clean, compile-errors, frontend-failed, analysis-failed, timeout, crash or worker-exit. Workers carry it on file_finished.
    • SARIF gets invocations[0].executionSuccessful plus one toolExecutionNotifications entry per gap. Findings JSON gets an additive coverage object.
    • Any gap, or no TU analyzed, exits 2 after writing the report. CLI diagnostics go to stderr.
    • docs/USAGE.md and AGENTS.md document the fields and the exit code.
  • Test: test_scan_coverage.cpp, plus test_cli_paths [coverage] (clean TU exits 0; broken TU exits 2 and SARIF on stdout still parses).

46aaac4 fix(summary): key function summaries by USR, not qualified name

  • Problem: overloads shared one summary key. A cross-TU call to take(int*, long) (which null-checks) inherited take(int*, int)'s nonnull precondition and was reported as a null-deref. Lookups also spelled static functions differently from stores, so their summaries were never found.
  • Fix: summaryStoreKey() (USR via semanticFunctionId) is used for every clang-side store, lookup and dependency edge. Now that static callees' summaries are found, null-deref's summary path applies the same DEBUGASSERT and static-parameter-contract filters as its callee-deref path.
  • Test: test_summary_key_identity.cpp: the overload call was red before the fix; a control call to the dereferencing overload still reports.

5c352b2 fix(solver): fail closed when the portfolio re-check disagrees

  • Problem: with --portfolio, a SAT verdict stood even when the reporting solver's re-check timed out or disagreed, so findings were emitted with no model.
  • Fix: reconcilePortfolioRecheck() returns the verdict only when both solvers agree, and unknown otherwise. It is applied at all six sites (null-deref, div-by-zero, int-overflow).
  • Test: test_model_validation.

8a32f08 fix(int-overflow): render witnesses in the source type's width and sign

  • Problem: an int a witness printed the raw 64-bit symbol, e.g. a=2147483648.
  • Fix: each binding is printed truncated to its source width with its signedness (sourceIntTypesOf, formatSourceIntValue).
  • Test: test_int_overflow [witness].

f0fe006 fix(docs,tests): correct the pre-push hook claim, a dead link, MCP cleanup

  • Problem: the README advertised an OpenGrep pre-push hook that is not tracked, and prior-art.md linked to prior-art-full.md, which never existed. The MCP test TempDir destructor could throw and leave directories behind.
  • Fix: the README names only the tracked pre-commit hook, and the dead link is removed. TempDir uses the non-throwing remove_all. It stays under the working directory, because the MCP server rejects paths outside its workspace (moving it to the system temp directory broke three MCP tests).

c785a6a docs(positioning): drop claims the code and evidence do not support

  • Tiers: five tiers, including Latent. Proved now requires an exact model.
  • Removed claims: "100k LOC in minutes", "no other open-source analyzer / no commercial vendor", the CBMC-via-Z3/MathSAT description and its scaling claim, and "most analyzers stop at SAT".
  • Added: the Clang Static Analyzer's Z3 refutation, and AFLGo/SAVIOR as related work. The README is left for a separate docs pass.

2945e59 docs: trim the README and move the performance roadmap to docs/

  • Problem: the README listed 11 of fold's 17 detectors, never explained the confidence tiers, did not mention exit code 2 or coverage reporting, and spent half its length on a performance roadmap.
  • Fix: the README says what fold is, defines the five tiers, what "bounded" means after the loop-exit fix, a quick start, output formats and exit codes, and links; the detector list defers to docs/USAGE.md. The performance section moves to docs/architecture/performance.md, which now says the gold set is fold's own reviewed output matched by kind and file, so it catches drift rather than measuring precision/recall. The lint hook note moves to BUILD.md.

Review fixes

A code review of the commits above found 10 issues. Eight were fixed across the 7 commits below, most with a red test first. #6 was confirmed by reading the code, because a portfolio disagreement cannot be forced end to end; its test covers the new helper. #10 is a refactor. Two did not reproduce, and one half of #10 is deferred (see Deferred).

9e37fd0 fix(detect): model continue exits and memory stores in truncated loops (review #1, #7)

  • Problem: LoopUnrollExits treated continue as a non-exit, so a loop with only a continue escape was still "exact" and post-loop conditions were Proved. When the unroll was truncated, only assigned scalars were havocked; array elements, members and stores through pointers kept their pre-loop values, so if (a[0] == 0) after such a loop was Proved.
  • Fix: continue inside the analysed loop (not inside a nested loop) counts as an escape. LoopUnrollExits::havoc also havocs the base arrays/aggregates written by subscript or member stores; a store through a pointer, arrow or unknown base havocs all heap arrays and marks the path over-approximated. The dead-branch, always-condition and unreachable sites call exits.havoc.
  • Test: test_loop_exit_soundness [continue] and [memory] (array store and calloc'd buffer), plus an opaque-call loop guard for review #9.

964769c fix(oob): require an exact encoding before a constant-index OOB is Proved (review #3)

  • Problem: a constant-index stack-array OOB was Proved even on an over-approximated path; the heap and UAF paths already required an exact encoding.
  • Fix: emit takes exactEncoding(env) and assigns Proved only with it.
  • Test: test_proved_requires_exact_model [oob]: an exact control stays Proved; an opaque call on the path is now Heuristic (was Proved).

29a292c fix(oob,uaf): re-check portfolio verdicts on the reporting solver (review #2)

  • Problem: with --portfolio, OOB and UAF took the portfolio's verdict as final. The reporting solver was never checked, so OOB read its counterexample from an unchecked solver (i=0 under i > 5), and a SAT the reporting solver would not reproduce was still emitted.
  • Fix: symbolic::recheckPortfolioVerdict re-checks a decided verdict on the reporting solver and reconciles the two (disagreement or timeout -> unknown). It is applied at the four OOB and two UAF sites.
  • Test: test_proved_requires_exact_model [portfolio]: the counterexample must satisfy the path (was i=0, now i=6).

0008244 fix(encoder): a global C++ lookalike is not a C library function (review #8)

  • Problem: isCLibraryFunction accepted any global non-member function, so a project's void free(Obj*) overload was libc free: calling it twice was a double free, and reading after it a use-after-free.
  • Fix: a global function counts only if it is extern "C", in std, or declared in a system header. extern "C" also covers C translation units.
  • Test: test_library_callee_identity [global] (double-free and UAF 1 -> 0); test_encoder_callee_names flips the global C++ helper.

b8c997c fix(scan): report quarantined TUs as coverage gaps (review #5)

  • Problem: a TU skipped because a prior run quarantined it was invisible. Serial mode dropped it from the attempted set (a scan whose only TU was quarantined was complete and exited 0); fan-out called it frontend-failed.
  • Fix: the shard runner counts every requested TU and records skipped ones with gap reason quarantined; a worker emits a quarantined file_finished record so the parent classifies it the same way. docs/USAGE.md lists the reason.
  • Test: test_subprocess_fanout [quarantine] covers serial and fan-out.

ed48d44 fix(solver): never memoize a core from a portfolio disagreement (review #6)

  • Problem: null-deref, div-by-zero and int-overflow reconciled the portfolio re-check, but then memoized the re-check's unsat core (and read its model) from the raw re-check result. A portfolio SAT that the re-check called UNSAT became unknown, yet its core was reused as the assumptions for later queries on the same path. The disagreement was silent.
  • Fix: all six sites use recheckPortfolioVerdict and act only on the reconciled verdict. SolverStats::portfolio_disagreements counts disagreements and is merged, printed and exported in the detector profile; the sites trace UNKNOWN (portfolio re-check disagreed).
  • Test: test_model_validation [portfolio] (agreement, unknown, disagreement, merge).

fcf474e refactor(cli): one TU path normalizer for coverage, fan-out and quarantine (review #10, part)

  • Problem: normalizeClangTuPath, normalizeQuarantinePath and fan-out's normalizePath were three copies of one routine that must agree.
  • Fix: fold/cli/tu_path.hpp normalizeTuPath, used everywhere. No behaviour change.

Tests

  • meson test -C build/release: 140/140 pass after the review fixes (macOS arm64, LLVM 21, Apple clang for Conan deps).
  • scripts/lint.sh --ci: exit 0 (format clean; cppcheck reports only pre-existing advisory warnings).
  • rust_frontend's 500 ms fake-frontend timeout test flaked once at load average 22 during the audit fixes and passed on four reruns. It is timing-sensitive and unrelated to this PR.

Before / after

Before is a build of b3da5a4; after is the audit fixes (c785a6a). Findings are listed as kind:confidence.

Probe Before After
p7 (symbolic-bound loops) exit 1; 3 always-condition:proved, 3 dead:proved, 1 clone exit 1; 1 clone
p1_loop exit 1; always-condition:proved exit 0; none
p2_shadow (mine::free then std::free), heap detectors double-free:proved, oob-access:proved, use-after-free:heuristic, always-condition:proved always-condition:proved only (see follow-ups)
p3_broken (syntax error) exit 0, no findings, no trace exit 2, coverage 0/1, gap compile-errors
p5_fatal (missing include) exit 0, no findings exit 2, gap compile-errors
compile DB entry for a missing file not reported gap frontend-failed, exit 2 (fan-out and serial)
tests/fixtures/clones/exact.cpp int-overflow witness a=2147483648, b=0 a=-2147483648, b=0

yams dogfood (src/cli/time_parser.cpp, src/topology/protected_relation_cover.cpp via build/debug/compile_commands.json):

  • Default detectors: 2 clone:syntactic before and after.
  • --scope all: identical before and after (2 clone, 2 high-complexity, 1 int-truncation, 1 sign-conversion, 2 unsigned-wrap, all Heuristic or Syntactic). After also reports coverage 2/2 with no gaps.
  • Exit status is 1 on both sides.

After the review fixes (--scope security,redundancy --detectors always-condition,unreachable):

Probe Result
p7 (symbolic-bound loops) exit 1; 1 clone, 2 unsigned-wrap:heuristic; no always-condition or dead
p1_loop exit 1; no always-condition; div-by-zero:heuristic (the real x / d), int-overflow:latent, unsigned-wrap:heuristic
p2_shadow no double-free, no use-after-free; always-condition:proved remains (malloc non-null, see follow-ups)
p3_broken / p5_fatal exit 2, gap compile-errors
continue loop (hit == 0 after it) no always-condition (red test was Proved)
array written in a truncated loop (a[0] == 0 after it) exit 0, none (red test was Proved)
constant-index OOB after an opaque call oob-access:heuristic (was Proved)
global void free(Obj*) called twice no double-free, no use-after-free
quarantined TU, serial / fan-out exit 2, gap quarantined (was exit 0 complete / frontend-failed)

yams dogfood after the review fixes: identical to the table above (default: 2 clone:syntactic; --scope all: 2 clone, 2 high-complexity, 1 int-truncation, 1 sign-conversion, 2 unsigned-wrap), coverage 2/2, exit 1.

Deferred / follow-ups

  • Review items not changed:
    • #4 (coverage matching against the compile command's directory): did not reproduce. A compile DB entry with a relative file and a scan run from another directory reports 1/1 analysed in serial and fan-out, because the front end reports the main file absolutely.
    • #9 (unreachable should treat an opaque loop as imprecise): did not reproduce; a loop calling advance(&i) already havocs i. The probe stays as a guard test.
    • #10, second half (share attachPrecisionEvidence): the null-deref and div-by-zero copies encode different downgrade rules (null-deref's sink-local exception, div-by-zero's latent carve-out). Sharing it would add precision evidence to OOB/UAF findings, a report change that belongs in its own PR.
  • Other portfolio sites: precondition checks in null-deref and div-by-zero, use-after-move, always-condition, unreachable and dead-branch still take the portfolio verdict without a re-check on the reporting solver. They should adopt recheckPortfolioVerdict.
  • New false positive seen while probing (pre-existing): with oob and null-deref both enabled, Obj* o = (Obj*)malloc(sizeof(Obj)); o->v = 1; is reported as oob-access:proved with "region size is 1 bytes". The /usr/local/bin/fold build reports it too.
  • Budgets: the defaults are per_query_ms=30000, budget_seconds=604800 and per_tu_timeout=86400. SolverBudget::total is not enforced by the main detectors, and there is no Z3 memory_max_size. The long defaults are a documented product choice ("findings are valued over wall time"), and enforcing total touches every detector's query loop. This needs a maintainer decision on the defaults and its own PR. TU-level timeouts and worker kills now surface as coverage gaps.
  • Per-query solver timeouts as coverage: unknown results are still counted only in stats and logs. Surfacing them per function needs worker→parent stats plumbing.
  • Evaluation methodology (bench/, untouched here):
    • The self-gold gate is circular: it was built from fold's own output and includes known false positives.
    • Juliet is scored per file (_generate_juliet.py).
    • Published Juliet numbers depend on untracked fp-iter*.json files, and score_fp.py returns [] when gold is missing.
    • bench/score.py always returns 0.
    • scripts/benchmarks/fold_eval/scoring.py matches by kind+file only. Tightening it changes the gate's baselines, so it belongs with the gold rework.
  • Stale install: /usr/local/bin/fold reports 0.1.0+259ae527d4a9.dirty, 23 commits behind and built from a dirty tree. Reinstall from a clean HEAD before dogfooding.
  • malloc results are treated as non-null: if (!p) after malloc is reported always-false (Proved). This is still visible on p2_shadow and needs a nullable-allocation default decision.
  • Integration tests that WARN + return when a binary or backend is missing pass without testing anything. Several detectors (format-string, memory-leak, source double-free, iterator-invalidation) lack dedicated tests.

These changes were written by Claude (Anthropic's AI assistant, via Claude Code) at the maintainer's direction, from an audit the maintainer requested. The audit fixes were each reproduced on a HEAD build, test-first. The review fixes followed the same process, except for the #6 and #10 notes above.

Fixes the confirmed findings from an audit of fold's soundness, reporting and docs, then the findings from a code review of those fixes. There are 18 commits on `master` (`b3da5a4`): the 10 audit fixes, a README/docs commit, and 7 review fixes. Fixes come with a failing test written first unless noted. ## Commits **`76bc00f` test(fanout): build the failure worker before the recovery tests** - Problem: `subprocess_fanout` failed on a clean build because its helper binary was not a test dependency. - Fix: add `depends: [fanout_failure_worker]`. **`6adcc24` fix(detect): never prove post-loop conditions from a bounded unroll** - Problem: dead-branch, always-condition and unreachable followed only the path on which the loop guard held for `loop_unroll` iterations. Early exits (a symbolic bound, `break`, `goto`) were dropped, so a condition after `for (i < n)` was "Proved" dead or constant from the loop having run exactly N times. - Fix: `LoopUnrollExits` (new `loop_havoc.{hpp,cpp}`, which also absorbs the three duplicated havoc helpers) marks the post-loop state as exact only when the unroll left through the loop's only exit. Otherwise the assigned variables are havocked and the entry path is restored. - Test: `test_loop_exit_soundness.cpp` covers symbolic-bound loops, a `break` loop, an exact constant loop as a control (still Proved), and the helpers. **`67483ee` fix(model): apply libc models only to C library callees** - Problem: malloc/free/realloc and taint source/sink models matched the callee's bare identifier. `mine::free(p); free(p);` was a Proved double-free, and `Shell::system(...)` was a command-injection sink. - Fix: `isCLibraryFunction`/`isCLibraryCallee` require the resolved declaration to be global, in `std`, or `extern "C"`, and never a member. Heuristic web-framework taint entries keep name matching (`c_api = false`). - Test: `test_library_callee_identity.cpp` (a namespaced free, a member free, a member `system`) with libc controls. **`0556cc6` fix(report): keep Proved for exact bounded models only** - Problem: `docs/research/soundness.md` defines `proved` as an exact bounded model, but double-free (heap flag), static-ptr-asymmetry (pattern), unused-function and dead-statement (AST facts), the stdlib copy check, and heap OOB/UAF (SAT without a precision check) all assigned Proved. - Fix: - double-free, static-ptr-asymmetry and the stdlib copy check report Heuristic; unused-function and dead-statement report Syntactic. - Heap OOB and UAF keep Proved only when the path carries no imprecision tag. - `sizeof`/`alignof` on complete non-VLA types is now an exact constant (`typeTraitConstant`), so `malloc(sizeof(T))` paths can stay exact. - Test: `test_proved_requires_exact_model.cpp`. **`c9811b7` fix(scan): report unanalyzed translation units and exit 2** - Problem: a TU that failed to parse left no trace, and a scan of only broken files exited 0 with no findings. Logs also went to stdout, which corrupted SARIF/JSON written to stdout. - Fix: - Each TU yields an outcome: clean, `compile-errors`, `frontend-failed`, `analysis-failed`, `timeout`, `crash` or `worker-exit`. Workers carry it on `file_finished`. - SARIF gets `invocations[0].executionSuccessful` plus one `toolExecutionNotifications` entry per gap. Findings JSON gets an additive `coverage` object. - Any gap, or no TU analyzed, exits `2` after writing the report. CLI diagnostics go to stderr. - `docs/USAGE.md` and `AGENTS.md` document the fields and the exit code. - Test: `test_scan_coverage.cpp`, plus `test_cli_paths` `[coverage]` (clean TU exits 0; broken TU exits 2 and SARIF on stdout still parses). **`46aaac4` fix(summary): key function summaries by USR, not qualified name** - Problem: overloads shared one summary key. A cross-TU call to `take(int*, long)` (which null-checks) inherited `take(int*, int)`'s nonnull precondition and was reported as a null-deref. Lookups also spelled static functions differently from stores, so their summaries were never found. - Fix: `summaryStoreKey()` (USR via `semanticFunctionId`) is used for every clang-side store, lookup and dependency edge. Now that static callees' summaries are found, null-deref's summary path applies the same DEBUGASSERT and static-parameter-contract filters as its callee-deref path. - Test: `test_summary_key_identity.cpp`: the overload call was red before the fix; a control call to the dereferencing overload still reports. **`5c352b2` fix(solver): fail closed when the portfolio re-check disagrees** - Problem: with `--portfolio`, a SAT verdict stood even when the reporting solver's re-check timed out or disagreed, so findings were emitted with no model. - Fix: `reconcilePortfolioRecheck()` returns the verdict only when both solvers agree, and unknown otherwise. It is applied at all six sites (null-deref, div-by-zero, int-overflow). - Test: `test_model_validation`. **`8a32f08` fix(int-overflow): render witnesses in the source type's width and sign** - Problem: an `int a` witness printed the raw 64-bit symbol, e.g. `a=2147483648`. - Fix: each binding is printed truncated to its source width with its signedness (`sourceIntTypesOf`, `formatSourceIntValue`). - Test: `test_int_overflow` `[witness]`. **`f0fe006` fix(docs,tests): correct the pre-push hook claim, a dead link, MCP cleanup** - Problem: the README advertised an OpenGrep pre-push hook that is not tracked, and `prior-art.md` linked to `prior-art-full.md`, which never existed. The MCP test `TempDir` destructor could throw and leave directories behind. - Fix: the README names only the tracked pre-commit hook, and the dead link is removed. `TempDir` uses the non-throwing `remove_all`. It stays under the working directory, because the MCP server rejects paths outside its workspace (moving it to the system temp directory broke three MCP tests). **`c785a6a` docs(positioning): drop claims the code and evidence do not support** - Tiers: five tiers, including Latent. Proved now requires an exact model. - Removed claims: "100k LOC in minutes", "no other open-source analyzer / no commercial vendor", the CBMC-via-Z3/MathSAT description and its scaling claim, and "most analyzers stop at SAT". - Added: the Clang Static Analyzer's Z3 refutation, and AFLGo/SAVIOR as related work. The README is left for a separate docs pass. **`2945e59` docs: trim the README and move the performance roadmap to docs/** - Problem: the README listed 11 of fold's 17 detectors, never explained the confidence tiers, did not mention exit code 2 or coverage reporting, and spent half its length on a performance roadmap. - Fix: the README says what fold is, defines the five tiers, what "bounded" means after the loop-exit fix, a quick start, output formats and exit codes, and links; the detector list defers to `docs/USAGE.md`. The performance section moves to `docs/architecture/performance.md`, which now says the gold set is fold's own reviewed output matched by kind and file, so it catches drift rather than measuring precision/recall. The lint hook note moves to `BUILD.md`. ## Review fixes A code review of the commits above found 10 issues. Eight were fixed across the 7 commits below, most with a red test first. #6 was confirmed by reading the code, because a portfolio disagreement cannot be forced end to end; its test covers the new helper. #10 is a refactor. Two did not reproduce, and one half of #10 is deferred (see Deferred). **`9e37fd0` fix(detect): model continue exits and memory stores in truncated loops** (review #1, #7) - Problem: `LoopUnrollExits` treated `continue` as a non-exit, so a loop with only a `continue` escape was still "exact" and post-loop conditions were Proved. When the unroll was truncated, only assigned scalars were havocked; array elements, members and stores through pointers kept their pre-loop values, so `if (a[0] == 0)` after such a loop was Proved. - Fix: `continue` inside the analysed loop (not inside a nested loop) counts as an escape. `LoopUnrollExits::havoc` also havocs the base arrays/aggregates written by subscript or member stores; a store through a pointer, arrow or unknown base havocs all heap arrays and marks the path over-approximated. The dead-branch, always-condition and unreachable sites call `exits.havoc`. - Test: `test_loop_exit_soundness` `[continue]` and `[memory]` (array store and calloc'd buffer), plus an opaque-call loop guard for review #9. **`964769c` fix(oob): require an exact encoding before a constant-index OOB is Proved** (review #3) - Problem: a constant-index stack-array OOB was Proved even on an over-approximated path; the heap and UAF paths already required an exact encoding. - Fix: `emit` takes `exactEncoding(env)` and assigns Proved only with it. - Test: `test_proved_requires_exact_model` `[oob]`: an exact control stays Proved; an opaque call on the path is now Heuristic (was Proved). **`29a292c` fix(oob,uaf): re-check portfolio verdicts on the reporting solver** (review #2) - Problem: with `--portfolio`, OOB and UAF took the portfolio's verdict as final. The reporting solver was never checked, so OOB read its counterexample from an unchecked solver (`i=0` under `i > 5`), and a SAT the reporting solver would not reproduce was still emitted. - Fix: `symbolic::recheckPortfolioVerdict` re-checks a decided verdict on the reporting solver and reconciles the two (disagreement or timeout -> unknown). It is applied at the four OOB and two UAF sites. - Test: `test_proved_requires_exact_model` `[portfolio]`: the counterexample must satisfy the path (was `i=0`, now `i=6`). **`0008244` fix(encoder): a global C++ lookalike is not a C library function** (review #8) - Problem: `isCLibraryFunction` accepted any global non-member function, so a project's `void free(Obj*)` overload was libc free: calling it twice was a double free, and reading after it a use-after-free. - Fix: a global function counts only if it is `extern "C"`, in `std`, or declared in a system header. `extern "C"` also covers C translation units. - Test: `test_library_callee_identity` `[global]` (double-free and UAF 1 -> 0); `test_encoder_callee_names` flips the global C++ helper. **`b8c997c` fix(scan): report quarantined TUs as coverage gaps** (review #5) - Problem: a TU skipped because a prior run quarantined it was invisible. Serial mode dropped it from the attempted set (a scan whose only TU was quarantined was complete and exited 0); fan-out called it `frontend-failed`. - Fix: the shard runner counts every requested TU and records skipped ones with gap reason `quarantined`; a worker emits a `quarantined` `file_finished` record so the parent classifies it the same way. `docs/USAGE.md` lists the reason. - Test: `test_subprocess_fanout` `[quarantine]` covers serial and fan-out. **`ed48d44` fix(solver): never memoize a core from a portfolio disagreement** (review #6) - Problem: null-deref, div-by-zero and int-overflow reconciled the portfolio re-check, but then memoized the re-check's unsat core (and read its model) from the raw re-check result. A portfolio SAT that the re-check called UNSAT became unknown, yet its core was reused as the assumptions for later queries on the same path. The disagreement was silent. - Fix: all six sites use `recheckPortfolioVerdict` and act only on the reconciled verdict. `SolverStats::portfolio_disagreements` counts disagreements and is merged, printed and exported in the detector profile; the sites trace `UNKNOWN (portfolio re-check disagreed)`. - Test: `test_model_validation` `[portfolio]` (agreement, unknown, disagreement, merge). **`fcf474e` refactor(cli): one TU path normalizer for coverage, fan-out and quarantine** (review #10, part) - Problem: `normalizeClangTuPath`, `normalizeQuarantinePath` and fan-out's `normalizePath` were three copies of one routine that must agree. - Fix: `fold/cli/tu_path.hpp` `normalizeTuPath`, used everywhere. No behaviour change. ## Tests - `meson test -C build/release`: **140/140 pass** after the review fixes (macOS arm64, LLVM 21, Apple clang for Conan deps). - `scripts/lint.sh --ci`: exit 0 (format clean; cppcheck reports only pre-existing advisory warnings). - `rust_frontend`'s 500 ms fake-frontend timeout test flaked once at load average 22 during the audit fixes and passed on four reruns. It is timing-sensitive and unrelated to this PR. ## Before / after Before is a build of `b3da5a4`; after is the audit fixes (`c785a6a`). Findings are listed as `kind:confidence`. | Probe | Before | After | |---|---|---| | p7 (symbolic-bound loops) | exit 1; 3 always-condition:proved, 3 dead:proved, 1 clone | exit 1; 1 clone | | p1_loop | exit 1; always-condition:proved | exit 0; none | | p2_shadow (`mine::free` then `std::free`), heap detectors | double-free:proved, oob-access:proved, use-after-free:heuristic, always-condition:proved | always-condition:proved only (see follow-ups) | | p3_broken (syntax error) | **exit 0**, no findings, no trace | **exit 2**, `coverage` 0/1, gap `compile-errors` | | p5_fatal (missing include) | **exit 0**, no findings | **exit 2**, gap `compile-errors` | | compile DB entry for a missing file | not reported | gap `frontend-failed`, exit 2 (fan-out and serial) | | `tests/fixtures/clones/exact.cpp` int-overflow witness | `a=2147483648, b=0` | `a=-2147483648, b=0` | **yams dogfood** (`src/cli/time_parser.cpp`, `src/topology/protected_relation_cover.cpp` via `build/debug/compile_commands.json`): - Default detectors: 2 clone:syntactic before and after. - `--scope all`: identical before and after (2 clone, 2 high-complexity, 1 int-truncation, 1 sign-conversion, 2 unsigned-wrap, all Heuristic or Syntactic). After also reports coverage 2/2 with no gaps. - Exit status is 1 on both sides. **After the review fixes** (`--scope security,redundancy --detectors always-condition,unreachable`): | Probe | Result | |---|---| | p7 (symbolic-bound loops) | exit 1; 1 clone, 2 unsigned-wrap:heuristic; no always-condition or dead | | p1_loop | exit 1; no always-condition; div-by-zero:heuristic (the real `x / d`), int-overflow:latent, unsigned-wrap:heuristic | | p2_shadow | no double-free, no use-after-free; always-condition:proved remains (malloc non-null, see follow-ups) | | p3_broken / p5_fatal | exit 2, gap `compile-errors` | | `continue` loop (`hit == 0` after it) | no always-condition (red test was Proved) | | array written in a truncated loop (`a[0] == 0` after it) | exit 0, none (red test was Proved) | | constant-index OOB after an opaque call | oob-access:heuristic (was Proved) | | global `void free(Obj*)` called twice | no double-free, no use-after-free | | quarantined TU, serial / fan-out | exit 2, gap `quarantined` (was exit 0 complete / `frontend-failed`) | yams dogfood after the review fixes: identical to the table above (default: 2 clone:syntactic; `--scope all`: 2 clone, 2 high-complexity, 1 int-truncation, 1 sign-conversion, 2 unsigned-wrap), coverage 2/2, exit 1. ## Deferred / follow-ups - **Review items not changed:** - #4 (coverage matching against the compile command's directory): did not reproduce. A compile DB entry with a relative `file` and a scan run from another directory reports 1/1 analysed in serial and fan-out, because the front end reports the main file absolutely. - #9 (unreachable should treat an opaque loop as imprecise): did not reproduce; a loop calling `advance(&i)` already havocs `i`. The probe stays as a guard test. - #10, second half (share `attachPrecisionEvidence`): the null-deref and div-by-zero copies encode different downgrade rules (null-deref's sink-local exception, div-by-zero's latent carve-out). Sharing it would add precision evidence to OOB/UAF findings, a report change that belongs in its own PR. - **Other portfolio sites:** precondition checks in null-deref and div-by-zero, use-after-move, always-condition, unreachable and dead-branch still take the portfolio verdict without a re-check on the reporting solver. They should adopt `recheckPortfolioVerdict`. - **New false positive seen while probing (pre-existing):** with `oob` and `null-deref` both enabled, `Obj* o = (Obj*)malloc(sizeof(Obj)); o->v = 1;` is reported as oob-access:proved with "region size is 1 bytes". The `/usr/local/bin/fold` build reports it too. - **Budgets:** the defaults are `per_query_ms=30000`, `budget_seconds=604800` and `per_tu_timeout=86400`. `SolverBudget::total` is not enforced by the main detectors, and there is no Z3 `memory_max_size`. The long defaults are a documented product choice ("findings are valued over wall time"), and enforcing `total` touches every detector's query loop. This needs a maintainer decision on the defaults and its own PR. TU-level timeouts and worker kills now surface as coverage gaps. - **Per-query solver timeouts as coverage:** `unknown` results are still counted only in stats and logs. Surfacing them per function needs worker→parent stats plumbing. - **Evaluation methodology (`bench/`, untouched here):** - The self-gold gate is circular: it was built from fold's own output and includes known false positives. - Juliet is scored per file (`_generate_juliet.py`). - Published Juliet numbers depend on untracked `fp-iter*.json` files, and `score_fp.py` returns `[]` when gold is missing. - `bench/score.py` always returns 0. - `scripts/benchmarks/fold_eval/scoring.py` matches by kind+file only. Tightening it changes the gate's baselines, so it belongs with the gold rework. - **Stale install:** `/usr/local/bin/fold` reports `0.1.0+259ae527d4a9.dirty`, 23 commits behind and built from a dirty tree. Reinstall from a clean HEAD before dogfooding. - **malloc results are treated as non-null:** `if (!p)` after `malloc` is reported always-false (Proved). This is still visible on p2_shadow and needs a nullable-allocation default decision. - Integration tests that `WARN` + `return` when a binary or backend is missing pass without testing anything. Several detectors (format-string, memory-leak, source double-free, iterator-invalidation) lack dedicated tests. --- These changes were written by Claude (Anthropic's AI assistant, via Claude Code) at the maintainer's direction, from an audit the maintainer requested. The audit fixes were each reproduced on a HEAD build, test-first. The review fixes followed the same process, except for the #6 and #10 notes above.
fold:subprocess_fanout reads FOLD_TEST_FANOUT_FAILURE_WORKER, but the
worker target is build_by_default: false and the test declared
`depends: []`, so a plain `meson test -C build/release` ran the two
recovery cases against a binary that was never built and failed both
(`REQUIRE(std::filesystem::exists(helper))`). The test now depends on
the worker, and the suite is green from a fresh build.
The dead-branch, always-condition and unreachable walkers unroll loops
loop_unroll times, following only the path on which the guard holds.
When the guard could also be false (any symbolic bound), that early
exit was dropped, and the loop's assigned variables were havocked only
for loops containing calls. The state after the loop was therefore
"the loop ran exactly loop_unroll times", and conditions on it were
reported as Proved:

  int s = 0; for (unsigned i = 0; i < n; ++i) s += 1;
  if (s == 0) ...   // "dead" proved/exact, "always false" proved

The same held for `s > 100`, while loops, and a `break` out of a
constant loop. On the audit probes this was 7 Proved findings, all
false.

LoopUnrollExits now records whether the unroll saw every way out of
the loop: no iteration could exit early (the guard's false branch was
infeasible), the unroll stopped because the guard became infeasible
rather than because loop_unroll ran out, and the body has no break or
goto that escapes. When any of that fails, the walker havocs the loop's
assigned variables and restores the entry path, as it already did for
opaque loops. A loop the unroll exits exactly (constant bound within
budget) keeps its precise state, so a genuinely dead branch after it
is still Proved.

Loop-bound provenance (--suppress-loop-bound-always-conditions) stays
off by default: it existed to downgrade proofs that leaned on the
unroll bound, and an inexact loop exit now havocs instead of producing
such a proof in the first place.

The duplicated helpers (containsCallExpr, collectAssignedVars,
appendAssignedVars, havocAssignedVars) move from dead_branch.cpp and
always_condition.cpp into detect/loop_havoc so all three walkers share
them; dead_branch.cpp drops under the 800-line soft cap.

Tests: test_loop_exit_soundness covers the symbolic-bound cases for all
three detectors (4 Proved each for dead and always-condition before),
the break escape, the exact-loop negative control, and the extracted
helpers (containsLoopEscape, LoopUnrollExits). Full suite 136/136.
malloc/free heap modeling (encoder), use-after-free's free/realloc, and
the taint source/sink tables selected a model by the callee's bare
identifier. A project function that merely shares a libc name was
modeled as libc:

  namespace mine { void free(void*); }
  mine::free(p);  ... std::free(p);   // double-free "proved", plus
                                      // oob "double free" and a UAF

and `Pool::free(p)` behaved the same way, as did a member function
named `system` as a command-execution sink.

isCLibraryFunction / isCLibraryCallee (encoder_callee_names) accept a
direct callee that is not a class member and is declared at global
scope, in namespace std, or extern "C". The encoder's malloc-family and
free models, use-after-free's free/realloc, and every libc/POSIX taint
entry now require it. Entries that are deliberately method-name
heuristics (get, Query, execute, query, fromJson, readString*, the
request-parameter getters) are marked c_api = false and keep matching
by name as before.

Tests: test_library_callee_identity (namespaced and member `free` are
not double-free/UAF; member `system` is not a sink; libc free twice and
libc system(getenv()) still are), and isCLibraryCallee cases in
test_encoder_callee_names (::free, std::free, mine::free, Pool::free,
a global helper, an indirect call). Full suite 137/137.
Problem: docs/research/soundness.md defines `proved` as an exact bounded
model establishing the property, but several detectors assigned Proved
without one: double-free from a heap-model flag, static-ptr-asymmetry from
a cross-function pattern, unused-function and dead-statement from AST
facts, the stdlib copy-overflow check, and the heap use-after-free / OOB
checks from SAT results whose encoding precision was never consulted. A
SAT result over an over-approximated path (an opaque call, an unsupported
node) can be spurious.

Fix: double-free and static-ptr-asymmetry report Heuristic; unused-function
and dead-statement report Syntactic; the stdlib copy check reports
Heuristic. Heap OOB and use-after-free keep Proved only when the path's
SymbolicEnv carries no imprecision tag. So that `malloc(sizeof(T))` paths
stay exact, sizeof/alignof on complete non-VLA types are now encoded as
exact constants (symbolic::typeTraitConstant) instead of an unsupported
node.

Test: tests/unit/test_proved_requires_exact_model.cpp covers each
downgraded detector, a heap UAF on an exact path (stays Proved, control)
and under an opaque call (Heuristic), and typeTraitConstant including the
VLA negative case. Full suite: 138/138.
Problem: a TU the front end could not parse left no trace. ClangTool::run's
status was discarded, TUs with compile errors were analyzed on a partial
AST and counted as done, and the isolated-worker path dropped TUs that
never produced an AST. A scan of only broken files printed no findings and
exited 0, the same as a clean scan. Separately, the scan logger wrote to
stdout, so any warning corrupted a SARIF/JSON report written to stdout.

Fix: every TU now yields an outcome (clean, compile-errors,
analysis-failed); workers carry it on their file_finished record, and the
fan-out parent adds timeout/crash/worker-exit for the TU a worker died on.
tuCoverage() turns the requested file list plus outcomes into a
ScanCoverage (requested TUs with no outcome are frontend-failed). SARIF
gets a run `invocations` entry (executionSuccessful, one
toolExecutionNotification per gap); findings JSON gets an additive
`coverage` object. A scan with any gap, or with nothing analyzed, exits 2
after writing its report. CLI diagnostics now go to stderr, as
docs/internal/cli-philosophy.md principle 12 requires. USAGE.md and
AGENTS.md document the coverage fields and the exit code.

Test: tests/unit/test_scan_coverage.cpp (classification, path spelling,
complete/untracked controls, merge, both writers, and unchanged output
without coverage); test_cli_paths "[coverage]" covers a clean TU (exit 0,
complete) and a TU with a missing include (exit 2, gap in JSON, SARIF on
stdout parseable with executionSuccessful=false). Before: both probe files
exited 0 with no coverage information. Full suite: 139/139.
Problem: SummaryStore keyed summaries by getQualifiedNameAsString (plus
"@file" for internal linkage), which overloads share. When a TU calls a
function whose body lives in another TU, the lookup returns whichever
overload was summarized last. Example: `take(int*, int)` dereferences p,
`take(int*, long)` checks it, and a call `take(q, 1L)` with a null q was
reported as a null-deref because it inherited the other overload's nonnull
precondition. Lookups also used a different spelling (no "@file") than
stores for static functions, so their summaries were never found at call
sites.

Fix: summaryStoreKey()/summaryStoreKeyForCall() return the declaration's
USR (semanticFunctionId: USR, TU-qualified for internal linkage). Every
clang-side store, lookup, dependency edge and staleness check uses it:
summary_encoder, null-deref, div-by-zero, OOB and the summary priming
pipeline, which still selects targets by the call graph's qualified names.
Rust summaries keep their own keys. Now that static callees' summaries
are found, null-deref's summary-precondition path applies the same
DEBUGASSERT and static-parameter-contract filters as its callee-deref
path.

Test: tests/unit/test_summary_key_identity.cpp covers per-overload
summaries, key identity across redeclarations and distinct keys across
namespaces, and the cross-TU overload call. That call reported a false
null-deref before the fix; a control call to the dereferencing overload
still reports one. Tests that hand-built the old keys
(test_symbolic_summary, test_cross_tu_security) now derive them from the
declaration. Full suite: 140/140 (rust_frontend's 500 ms timeout test
flaked once at load average 22 and passed on four reruns).
Problem: with --portfolio, null-deref, div-by-zero and int-overflow take
the verdict from a separate portfolio solver, then re-check on their own
solver only to obtain a model or unsat core. The re-check's result was
never compared: if it timed out (unknown) or disagreed, the portfolio's
SAT still produced a finding, with no model from the reporting solver.

Fix: symbolic::reconcilePortfolioRecheck() keeps the verdict only when
the re-check reproduces it and otherwise returns unknown. It is applied
at all six re-check sites.

Test: test_model_validation "portfolio verdicts stand only when the
re-check agrees" covers agreement, re-check unknown, and disagreement in
both directions. null_deref, int_overflow, div_by_zero and
source_structure pass.
Problem: symbols are 64-bit bitvectors whose low bits carry the source
value, but int-overflow witnesses printed the whole bitvector. For a
32-bit `int a` the report showed a=2147483648 or a=-9223372034707292160,
which are not values of `a` and would make bad fuzz seeds
(tests/fixtures/clones/exact.cpp).

Fix: detect::sourceIntTypesOf() maps the function's integer parameters
and locals to their width and signedness (a name declared with two types
is left out). collectModelBindings() takes that map and prints each bound
symbol truncated to its source width with its signedness
(formatSourceIntValue). int-overflow passes the map for the current
function; other detectors are unchanged.

Test: test_int_overflow "[witness]": every binding for an `int` overflow
lies in int range, and formatSourceIntValue covers sign/width cases.
exact.cpp before: a=2147483648, a=3221225471. After: a=-2147483648,
a=-1073741825, the int values that actually overflow.
Problem: the README said setup enables "tracked ... OpenGrep pre-push
hooks", but only .githooks/pre-commit is tracked, so fresh clones get no
pre-push hook. docs/research/prior-art.md pointed to prior-art-full.md,
which has never been in the repository. test_mcp_stdio's TempDir
destructor called the throwing remove_all overload; a failure during
unwinding terminates the test binary and leaves .fold-mcp-test-*
directories behind.

Fix: the README names only the tracked pre-commit hook. The pre-push
hook is not committed: it runs `opengrep --config auto`, which needs the
network. The dead reference is removed. TempDir uses the error_code
remove_all overload. It stays under the working directory, because the
MCP server rejects paths outside its workspace root (moving it to
temp_directory_path broke three MCP tests).

Test: mcp_stdio passes.
Problem: docs/positioning.md claimed four confidence tiers (there are
five, including Latent) and defined Proved as any Z3 SAT, which
contradicts soundness.md's exact-model requirement. It claimed "fold
scans a 100k-LOC codebase in minutes" with no committed benchmark (the
audit measured 2 files in 5.9 s at 407 MB RSS). It described CBMC as
asking Z3/MathSAT (its default back end is SAT) and as scaling poorly
past a few thousand lines, without support. It claimed a scan-to-fuzz
loop "no other open-source analyzer covers" and "no commercial vendor
ships" (directed and hybrid fuzzers such as AFLGo and SAVIOR cover
related ground, and prior-art.md cites that line of work). It said most
analyzers "stop at SAT", ignoring the Clang Static Analyzer's Z3
refutation.

Fix: five tiers with Latent defined; Proved requires an exact bounded
model; the CBMC comparison describes the method and fold's real scope
(per-sink, mostly intraprocedural, unroll 5) instead of speed; the
uniqueness claims now describe the loop without exclusivity; CSA's Z3
refutation is acknowledged. The README is left for a separate docs pass.
The README listed 11 of fold's 17 detectors, never explained the
confidence tiers every finding carries, did not mention the new exit
code 2 / coverage reporting, and spent half its length on a
performance roadmap.

It now says what fold is, defines the five tiers (from
include/fold/report/finding.hpp, including concrete-witness and
latent), what "bounded" means after the loop-exit fix, a quick start,
output formats and exit codes (0 none, 1 any unsuppressed finding, 2
unwritable report or any unanalysed translation unit), and links. The
detector list defers to the complete table in docs/USAGE.md.

The performance section moves to docs/architecture/performance.md, with
one correction: the gold set was derived from fold's own reviewed output
and is matched by detector kind and file, so it catches drift rather
than measuring precision/recall independently. The lint pre-commit hook
note moves to BUILD.md.
Two gaps in the loop-exit soundness rule from 6adcc24, both found in
review and reproduced as Proved always-conditions:

- `continue` was not an escape. The walkers drop an arm that ends in
  `continue` (it always leaves the iteration), so iterations that took it
  were never modeled, yet the unroll still counted as exact and the
  loop's variables were not havocked:
    for (i = 0; i < 2; i++) { if (y) continue; x++; }  if (x == 2)
  was "always true" although y != 0 gives x == 0. containsLoopEscape now
  treats a continue that targets the loop as an escape (a switch
  captures break but not continue; a nested loop captures both).

- Havocking a truncated loop reset scalar variables only. Array element
  and member stores from the partial unroll survived:
    for (i = 0; i < n && i < 4; i++) buf[i] = 1;  if (buf[0] == 1)
  was "always true" although n == 0 leaves buf[0] == 0. The unroll now
  records the base variables of array/member stores and replaces their
  arrays/values with fresh ones of the same sort; a store through a
  pointer havocs every tracked heap array (SymbolicEnv::havocHeapArrays)
  and marks the env over-approximated. The three walkers share this via
  LoopUnrollExits::havoc.

Tests (test_loop_exit_soundness): continue, local-array and heap-pointer
stores are no longer Proved by dead-branch, always-condition or
unreachable; the exact constant-bound loop stays Proved (negative
control). A pointer-mutating call inside the loop (review #9) did not
reproduce for unreachable; its case is kept as a regression guard.
Problem: OOBObserver::emit marked every constant-index array OOB as Proved,
even when the path reaching the access was over-approximated (an opaque call
or a truncated loop). The heap and UAF paths already gated on exactEncoding.

Fix: pass exactEncoding(env) into emit and require it alongside a numeral
index before assigning Proved; otherwise the finding is Heuristic.

Test: test_proved_requires_exact_model "[oob]" adds an exact control (stays
Proved) and an opaque-call path (now Heuristic; was Proved).
Problem: with --portfolio the OOB and UAF detectors took the portfolio's
SAT/UNSAT answer as final. The portfolio solves on its own contexts, so the
reporting solver was never checked: OOB read its counterexample from an
unchecked solver (i=0 under `i > 5`), and a SAT the reporting solver would
not reproduce was still emitted. Other detectors already fail closed through
reconcilePortfolioRecheck.

Fix: add symbolic::recheckPortfolioVerdict, which re-checks a decided verdict
on the reporting solver and reconciles the two (disagreement or timeout ->
unknown), and apply it at the four OOB and two UAF portfolio sites. The
now-redundant UNSAT-only artifact re-checks in OOB checkHazard are removed.

Test: test_proved_requires_exact_model "[portfolio]" requires the portfolio
counterexample to satisfy the path (was i=0, now i=6).
Problem: isCLibraryFunction accepted any global-scope non-member function,
so a project's `void free(Obj*)` overload was modeled as libc free: calling
it twice was a double free, and reading after it a use-after-free.

Fix: a global function counts only when it is extern "C", in namespace std,
or declared in a system header. extern "C" also covers C translation units,
where external functions have C language linkage.

Test: test_library_callee_identity "[global]" (DoubleFree and UseAfterFree
now 0; were 1 each); test_encoder_callee_names flips the global C++ helper
to not-a-library-callee. Full suite: 140/140.
Problem: a TU skipped because a prior run quarantined it was not analysed,
but coverage did not say so. The serial path dropped it from the attempted
set, so a scan whose only TU was quarantined was complete and exited 0; the
fan-out path saw no file_finished record and called it frontend-failed.

Fix: the shard runner counts every requested TU and records skipped ones
with gap reason `quarantined`; a worker emits a file_finished record with
that reason so the parent classifies it the same way. USAGE lists the reason.

Test: test_subprocess_fanout "[quarantine]" covers serial (was 2 attempted,
no gap) and fan-out (was frontend-failed). CLI probe: serial and fan-out
both exit 2 with a `quarantined` gap.
Problem: null-deref, div-by-zero and int-overflow re-check a portfolio
verdict on the reporting solver and reconcile the two, but then memoized the
re-check's unsat core (and read its model) from the raw re-check result. A
portfolio SAT that the re-check called UNSAT became unknown, yet its core was
still reused as the assumptions for later queries on the same path. The
disagreement itself was silent: no stat, and the trace showed the portfolio's
verdict.

Fix: all six sites use symbolic::recheckPortfolioVerdict and act only on the
reconciled verdict, so an unknown leaves no core or model behind. The helper
counts SolverStats::portfolio_disagreements (merged, printed, and exported in
the detector profile), and the sites trace
"UNKNOWN (portfolio re-check disagreed)".

Test: test_model_validation "[portfolio]" checks the helper's agreement,
unknown and disagreement cases and the merged counter. Full suite: 140/140.
refactor(cli): one TU path normalizer for coverage, fan-out and quarantine
Some checks failed
build / build-linux (pull_request) Failing after 7m32s
build / build-macos (pull_request) Has been cancelled
build / release (pull_request) Has been cancelled
fcf474e4f0
Problem: normalizeClangTuPath, normalizeQuarantinePath and fan-out's
normalizePath were three copies of the same absolute + weakly_canonical
routine. Coverage, worker records and the quarantine file must agree on a
TU's spelling, and three copies can drift apart.

Fix: move the routine to fold/cli/tu_path.hpp as normalizeTuPath and use it
at every site. No behaviour change.

Test: existing test_scan_coverage (path spellings), test_tu_quarantine and
test_subprocess_fanout, updated to the new name. Full suite: 140/140.
trevon merged commit 8bb39471a7 into master 2026-09-25 06:48:08 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
trevon/fold!1
No description provided.