Split root CLAUDE.md into 19 per-directory files scoped to their source area. Roll v0 history into docs/ARCHIVE.md; retire CONTEXT.md, CONTEXT-ARCHIVE.md, PLAN.md, COMPLETED.md. Move plan docs under docs/. Rescue 9 live deferrals into docs/TODO.md.
24 KiB
Q-W0 Track 2 — architecture-smell audit (functional lens)
Static analysis of the whole src/ tree (extension + src/vst/), 2026-07-28, branch
pq-w0-audit. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in
docs/product/code-organization.md — this track reports the functional smells those did not
target: duplicated algorithms (not merely duplicated responsibilities), reinvented wheels,
poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four
god-modules, the 4× JSON Parser, fat headers, promptText/mintBankId duplication, namespace
flatness, naming families) are not restated; where a finding below touches the same file it
is because the functional mechanism is new.
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
audit's snapshot. Wave assignments reference the Q-W1..Q-W6 waves (landed history in
docs/ARCHIVE.md; sequencing in docs/product/code-organization.md §5).
Findings
T2-01 — 3× copy-pasted length-prefixed wire Cursor, with security-hardening drift
Location: src/provenance.cpp:56–137, src/assignment_request.cpp:25–121,
src/sample_usage.cpp:25–89 (plus a fourth sibling: src/vst/bank_sync.cpp:11–28
parseBankGeneration re-rolls the same guarded decimal accumulate).
Mechanism. The <len>':'<bytes> ext-state wire idiom ("one grammar across every ext-state
seam", per sample_usage's own comment) is implemented as three near-identical putField +
Cursor copies — and they have drifted on the hardening. The two newer copies
(assignment_request, sample_usage) carry a 20-digit length cap and an overflow guard
(len > (SIZE_MAX - digit) / 10 → fail) plus the subtraction-first bounds check
(len > s_.size() - start). The oldest copy (provenance.cpp:65–82) has neither: a crafted
long digit run wraps len silently, and the additive bounds check start + len > s_.size()
can itself wrap, letting a wrapped length pass. Downstream, parseFingerprint
(provenance.cpp:197–199) calls r.trackGuids.reserve(guidCount) on an unbounded count
parsed by the equally unguarded fieldSizeT — a corrupt/crafted Sample.provenance string in
the bank JSON can drive reserve(huge) into std::length_error/bad_alloc thrown through the
shell. (sample_usage fixed exactly this with its count > wire.size()/4 + 1 sanity bound,
sample_usage.cpp:121; the fix was never backported.) std::string::assign clamping keeps the
wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld
in two copies and eroded in the third — the textbook cost of a duplicated algorithm.
Severity: High (the drift already produced a concrete robustness gap on a persisted,
user-editable input; the class of bug will recur with every new wire seam).
Disposition: fix-now, split: (a) backport the hardened field() + a count sanity bound
to provenance.cpp in Q-W0 — small, pure, existing provenance_tests covers round-trip and
malformed-input paths; (b) the structural collapse (one shared wire codec module beside
core/json, all three seams + parseBankGeneration consuming it) belongs to Q-W1, which is
already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the
dedup is a relocation-adjacent move that should ride the wave already creating core/.
T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count
Location: src/tail_control.cpp:88–130 (valueAfterKey + deserializeTailSetting).
Mechanism. The catalogued DRY violation is "JSON Parser duplicated 4×" (bank_model,
bank_book, view_mode_model, owned_manifest). tail_control carries a fifth, structurally
different JSON decode: a substring-scan reader (json.find("\"key\"") → skip ws → parse token).
It is correct for the flat single-object payload it reads (the file argues this honestly), but
it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key
found inside a string value would match — impossible today only because the writer is its own
sole producer). If Q-W1 extracts core/json from the four Parsers and misses this site, the
"one JSON path" goal is silently not achieved.
Severity: Med (no live bug; a completeness gap in the already-planned fix).
Disposition: fix-now, folded into Q-W1 — add tail_control to the Q-W1 consumer list
explicitly. Rationale: zero extra cost when core/json lands; a stray fifth decoder afterward
would be a defect of the wave.
T2-03 — readFileBytes hand-rolled five times, both sides of the artifact split
Location: src/capture.cpp:232, src/capture_realtime.cpp:329 (as readAllBytes),
src/ingest.cpp:86 (comment admits: "of capture.cpp's readFileBytes"),
src/vst/reasampler_processor.cpp:72, and inline in src/vst/reasampler_editor.cpp:749–756.
Mechanism. The identical ifstream-binary-ate/tellg/read whole-file loader exists five times (two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence will be behavioral (e.g. one copy gaining a size ceiling the others lack).
Severity: Med.
Disposition: fix-now, folded into Q-W1 — a trivial pure readFileBytes helper in the
core/ utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a
ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's
job — doing it days earlier in the flat tree would just move the file twice.
T2-04 — the growing GetProjExtState read loop, three copies, pure half only half-used
Location: src/persist.cpp:140–157 (getProjExtStateString),
src/vst/reaper_bridge.cpp:93–107 (self-described "mirrors persist.cpp's growing strategy"),
src/usage_scan.cpp:168–181 (self-described "the persist.cpp idiom").
Mechanism. The grow-buffer-until-it-fits retry loop over GetProjExtState is implemented
three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int
return against the filled buffer — is already extracted pure as
bridge_marshal::decodeGetProjExtState, but only reaper_bridge consumes it; persist and
usage_scan interpret rv inline with their own conventions (persist: rv <= 0 → absent;
bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely
the kind of edge that drifts when defined thrice. usage_scan's copy is prune-safety-adjacent
(an unreadable usage record must abort the prune) — its read loop deserves the tested pure
decode, not an inline reimplementation.
Severity: Med.
Disposition: fix-now, assigned to the downstream wave that opens persist
(Q-W4 per the current wave map; whichever wave splits persist.cpp is the moment). Generalize
the retry policy (next-capacity/done decision) into bridge_marshal (or its core/ successor)
and make all three loops consume it. Rationale: touching persist's session machinery outside
its own wave risks the highest-traffic shell for a dedup that has no live bug today.
T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family
Location (structs): action_bar.h:61, bank_grid.h:23, card_drag.h:109,
component_geometry.h:28,53,106, drag_out.h:40, footer_bar.h:41, mode_switch.h:21,35,
overflow_menu.h:23,39, prune_button.h:32,46, tab_strip.h:24,51, tooltip.h:20,
vst/editor_geometry.h:19, vst/velocity_curve.h:124.
Location (predicates): inline half-open px >= r.x && px < r.x + r.width && … re-typed in
action_bar.cpp, bank_grid.cpp, card_drag.cpp (×2), component_geometry.cpp (×2),
drag_out.cpp, footer_bar.cpp, mode_switch.cpp, overflow_menu.cpp, prune_button.cpp,
tab_strip.cpp (×2), bank_panel.cpp:1049, plus vst/editor_geometry.cpp:20.
Mechanism. §2b.2 catalogued the naming/collision half of this (the footer_bar.h "NAME
NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical
axis-aligned {x, y, w, h} record types, each with its own hand-typed containment predicate.
Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical
concrete implementations that one shared type collapses at compile time — one ui::Rect + one
contains(Rect, x, y) free function (both already exist in embryo as vst/editor_geometry's
Rect/contains), with per-module aliases or thin wrappers only where a struct carries extra
fields (e.g. TabRect::index). Zero runtime cost; deletes ~15 chances for the next half-open/
closed-interval inconsistency to slip in.
Severity: Med. Disposition: fix-now, folded into Q-W2 (the ui/ relocation wave) — collapsing the type zoo is nearly free precisely when every one of these files is being moved and re-namespaced; doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the stated principle for renames (§2b intro); it holds identically for type unification.
T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap)
Location: src/vst/reasampler_editor.cpp — SampleFaceLayout (~line 922) and its builder,
ClusterLayout (~line 964), zonesStripArea/noteEntryFieldsArea/noteEntryFieldRect/
zonesControlPanel/zonesDeckArea/zonesCurveButton (lines 992–1042), the channel-toggle
segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry
computations across the 3,065-LOC TU.
Mechanism. The codebase's own grammar homes exactly this class of math in pure modules
(editor_geometry, knob_deck, curve_popup, capture_browser, …), yet the editor shell has
accreted a second, untested layout layer: whole named layout structs and pure Rect → Rect
functions that take only ints and rects, compiled into the one TU that cannot be unit-tested
without a host window. This is the mirrored form of the pure/shell leak (algorithm math living
untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue
covered the extension tree only — reasampler_editor.cpp (3,065 LOC) and
reasampler_processor.cpp (1,164 LOC) repeat the bank_panel pattern on the VST side and appear
in no existing finding.
Severity: Med (no correctness bug found in the stranded math; the cost is untestability and
the growth trajectory — the editor gained ~500 LOC/phase through r11).
Disposition: document-and-defer, with a named reshape: Q-W0 should surface a downstream
point (the wave that opens src/vst/, or a new one) hoisting the Sample-face/Zone-panel layout
into the existing pure homes (editor_geometry is the natural owner). Rationale: a hoist is a
behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg;
but it must be a recorded point or the layer keeps growing.
T2-07 — the extension links the entire voice engine to serialize one preset blob
Location: CMakeLists.txt:383–385 (instrument_drop → PUBLIC sample_map);
src/instrument_drop.cpp includes vst/sample_map.h, which pulls sampler_core.h →
pitch_shift.h + velocity_curve.h.
Mechanism. instrument_drop (extension side) deliberately reuses
sample_map::serializeComponentState so the .vstpreset payload and the instrument's own
reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer
lives inside the module that also owns zone resolution, WAV decode plumbing, and (via header
fan-in) the whole voice engine — so reaper_reasampler compiles and links sampler_core,
pitch_shift, and velocity_curve object code it never executes. The abstraction is right; its
granularity is wrong: the ComponentState codec is not separable from the engine stack today.
Severity: Low (dead weight in the binary and a misleading dependency edge; no runtime cost —
heuristic (c) is about call chains, which this does not add).
Disposition: document-and-defer to Q-W1/Q-W2 module-homing: when serialization gets its
core/ home, split a component_state codec module (types + serialize/deserialize only) out of
sample_map; both artifacts link the codec, only the VST links the engine. Rationale: purely
structural, zero behavior change, and exactly the kind of module-boundary decision the reorg
waves exist to make once, deliberately.
T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers
Location: src/wav_trim.cpp (canonical parse: parseWavLayout/extractFloatFrames),
src/capture_paths.cpp:31–115 (a second, independent RIFF chunk walker for content hashing),
src/ingest.cpp:108–160 (hand-built 32f WAV writer), src/capture_realtime.cpp:423 (in-place
RIFF/data size patch).
Mechanism. The tree is disciplined about decoding ("no third WAV reader" — sample_map,
editor, processor all route through wav_trim), but RIFF container knowledge is still minted
per site: capture_paths walks chunks with its own tag/size/pad-byte logic to hash fmt +data
while skipping metadata; wav_trim walks the same container shape for layout; ingest writes
headers by hand; capture_realtime patches sizes by offset. Four places know the RIFF framing
rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling)
would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit
on this.
Severity: Low (all four are currently correct against each other by inspection; the smell is
the maintenance surface, not a live divergence).
Disposition: document-and-defer — consolidate into a core/wav home (walker + layout +
writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns
the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates
wav_trim is the natural moment.
T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences
Location: src/capture.cpp:490–537 vs src/capture_realtime.cpp:505–540.
Mechanism. The finished-capture metadata stamp — trackGuids, channelCount, sampleRate,
Master_GetTempo, the TimeMap_GetTimeSigAtTime block, the WAV-aware hashWavContent content
hash (comment block duplicated verbatim, ~10 lines), createdTimestamp — is written twice, once
per backend. The copies have already diverged in quiet ways: offline passes proj = nullptr
(active project) to TimeMap_GetTimeSigAtTime while realtime pins st.proj_; the sampleRate
fallback logic differs in shape; realtime overrides lengthSeconds post-hoc. Some divergence is
semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field
(e.g. a new provenance stamp) must currently be added in two places, and the time-sig
active-project vs pinned-project asymmetry is exactly the kind of drift that produces a
wrong-project stamp during a background-project capture.
Severity: Med.
Disposition: fix-now, folded into Q-W3 (the wave already hoisting capture orchestration
out of main.cpp / right-sizing capture.h). Extract a stampCaptureSample(Sample&, const CaptureRequest&, ReaProject*) shared helper; the divergent bits (length override) stay in the
realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it
there keeps one review of the precision-invariant-adjacent code.
T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted
Location: src/bank_panel.cpp:422–483 (extension: computeThumbnail + pure
ThumbnailKey{id, width, generation} via bank_grid::thumbnailKeyString) vs
src/vst/reasampler_editor.cpp:715–789 (VST: monoPcmFor keyed by bare sampleId,
thumbnailFor keyed by ad-hoc sampleId + "|" + binCount, invalidated by wholesale
clear() at lines 159–160/894).
Mechanism. The dock panel and the VST browser render the same thumbnails through the shared
peaks::computeEnvelope, but the caching layer around it was re-designed independently on each
side: the extension bakes the bank generation into a pure, tested key type; the editor
hand-concats a string key with no generation and relies on call-site clear()s (bank-refresh,
resize). Both are correct today — but correctness on the editor side is distributed across
remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount >
frameCount…") is duplicated verbatim in both TUs (bank_panel.cpp:462,
reasampler_editor.cpp:780), marking the copied design. A future refresh path that forgets the
clear shows stale waveforms with no test to catch it.
Severity: Low.
Disposition: document-and-defer — when T2-06's layout hoist opens the editor, adopt the
pure ThumbnailKey (or a shared thumb_cache helper) on the VST side. Rationale: no live bug;
unifying cache policy is a natural rider on the editor wave, pointless as standalone churn.
T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read
Location: src/vst/sample_map.cpp:775–945 (deserializeComponentState).
Mechanism. Audited the full lift chain for functional soundness: the bounded ByteReader
latches on truncation, every version's tail fields carry per-field corrupt fallbacks
(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed
prefix), and the strict-prefix envelope discipline is honest. No correctness finding. The
smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id
→ zones read sequence that the v6+ shared path also implements (three near-copies of the same
cursor walk, lines 806–841 vs 851+), and each new envelope version adds another
version >= kꞏꞏꞏV*Version stanza to a function already ~170 lines long.
Severity: Low. Disposition: document-and-defer, explicitly. The legacy branches are frozen back-compat contract code with saved-project blobs as their only callers; rewriting them into a table-driven lift risks the one thing they must never break, for zero user-visible gain. Record the pattern so the next envelope bump (v12) prefers extending the shared path over minting another branch. (The unbounded-suffix version-constant naming is §2b territory; not restated.)
Surfaces checked and found clean
Recorded per the wave's no-silent-omission rule; each was read/grepped this audit.
- Pure-module include hygiene, both trees. Every module CLAUDE.md claims pure was scanned
for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean,
src/andsrc/vst/both. The one grep hit inpitch_shift.his a comment (the S16 WDL-exclusion note), not an include. The one cross-tree include (instrument_drop→vst/sample_map.h) is pure-to-pure — see T2-07 for the granularity concern; it is not a boundary violation. bank_sync— the generation/consume decision rules are pure, explicit, and exhaustively commented (rules 1–4);parseBankGenerationis overflow-guarded (its duplication is rolled into T2-01's family, not a separate defect).bridge_marshal— one honest job, done pure, with the S1 string-scan JSON reader documented as retired (verified: no second JSON parser on the VST side;sample_maproutes throughBankBook::deserialize).- The realtime record lifecycle — not an implicit state machine:
RecordPhaseis an explicit enum with pure per-tick transitions inrealtime_record.h;main.cppholds only the handle + project pointer. (Its residence in main.cpp is catalogued §2.1; nothing functional to add.) - Project-identity transitions —
classifyProjectTransitionis pure (capture_paths), the shell passessameProjectObjectas a bool to keep it so; the GUID-primary layering is decision-tabled in one place. usage_scan— every decision delegated to puresample_usage; container recursion is depth-bounded with a protect-on-truncation fail-safe; thestd::functionparm-getter indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain).- Exception boundaries — the three
catch (...)sites (bank_book.cpp:794stol guard,instrument_drop_win.cpp:74REAPER-callback boundary,render_settings.cpp:180stod guard) are all documented, narrow, and non-swallowing in intent (each converts to an explicit failure value). No silent error swallowing found. FxBypassGuard(main.cpp) vsview.cpppark/restore — both are snapshot-mutate-restore over track flags and look like a dedup candidate; they are deliberately not one. Different flag sets, different invariants (precision-neutralization vs Design-View parking), different failure postures. Duplication of shape, not of concept — correctly left separate.- Path resolution —
capture_paths::resolveBankFileis the single resolver on both sides of the split (panel, insert, drag_out, editor, processor). No parallel path logic. - WAV decode on the play path —
wav_trimis genuinely the only decoder (T2-08 concerns the container knowledge spread, not a second decoder). - Draw layer — the VST editor/embed compile the same
draw_kit/theme/component_geometrythe extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the VST side. - Interface cost audit (heuristic (c)) —
ICaptureBackendis the tree's only virtual interface; two real implementations, dispatched once per capture (cold). No hot-path virtual or std::function chain found insampler_core/pitch_shift/sample_map(all static calls). No interface-with-one-implementation found anywhere. - Boolean-parameter proliferation — swept
src/headers for multi-bool signatures; the only hit isbank_grid::applyClick(…, bool ctrl, bool shift, …), which mirrors physical modifier keys and reads fine at call sites. Not a finding.
Cross-checks against the §2/§2b audits (gaps noted, not restated)
- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree
is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued
(
reasampler_editor.cpp3,065 LOC,reasampler_processor.cpp1,164 LOC) — carried here as T2-06's scope note so the reorg waves size thesrc/vst/work realistically. - §2.1's "4× JSON Parser" undercounts by one — T2-02 (
tail_control). - §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05.
Summary table
| ID | Finding | Severity | Disposition | Where |
|---|---|---|---|---|
| T2-01 | Wire Cursor ×3 with hardening drift; provenance unguarded |
High | fix-now (split) | Q-W0 backport + Q-W1 dedup |
| T2-02 | Fifth JSON decoder in tail_control |
Med | fix-now | Q-W1 |
| T2-03 | readFileBytes ×5 across both artifacts |
Med | fix-now | Q-W1 |
| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) |
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 |
| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave |
| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing |
| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing |
| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 |
| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave |
| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) |