Skip to content

refactor(editor): extract chord templates & handshapes to src/chords.js (R2, step 7) - #151

Merged
byrongamatos merged 2 commits into
mainfrom
refactor/es-module-split-chords
Jul 9, 2026
Merged

refactor(editor): extract chord templates & handshapes to src/chords.js (R2, step 7)#151
byrongamatos merged 2 commits into
mainfrom
refactor/es-module-split-chords

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Step 7 of the editor's ES-module split (R2). main.js 20,601 → 20,160 — the largest cut so far, and the first where the test rework was the actual work.

What moved

src/chords.js (446 lines) — the chord load/save round-trip. Reads S and lanes(); no DOM. Graph stays acyclic: chords → {state, lanes}.

  • flattenChords / _flattenArrChords — fold chords into notes on load
  • the whole @pure:chord-relink helper set — relinkChordTemplate, _buildPreservedTemplates, buildHandshapeChordIdMap, dropOrphanedHandshapes, remapHandshapeChordIds, the sanitizers, _groupFn
  • reconstructChords — rebuild chords + re-link templates + remap handshape chord_ids on save
  • the handshape wire-coercion helpers, and the dirty-count trio (_ensureHandshapes, _bumpHandshapesDirty, _handshapesAreDirty) lifted out of the Handshape-lane section 18k lines further down

main.js's entire diff is the deletions plus the import block. HS_ORPHAN_EPS, _safeWireBool and _wireFloat stay module-private.

The test rework — the reason step 6 stopped here

Three suites drove this cluster by concatenating its source text into a new Function sandbox.

  • chord_relink — the block was already pure, so it becomes a plain real-import suite. 23 cases, no sandbox at all.

  • handshape_authoringhybrid. Imports the chord-template helpers; still slices _handshapeSpanFrets, which stays in main.js. It never called reconstructChords; it replays the logic.

  • suggest_position_wiringthe one that mattered. Its makeReconstruct() wrapped reconstructChords' source in a sandbox and fed it a fabricated S plus a lanes: () => 6 stub:

    const S = { currentArr: 0, arrangements: [null], history: { reset() {} } };
    const { reconstructChords } = new Function('S', 'lanes', fullSrc)(S, () => 6);

    Naively importing the real function would have closed over the real S from state.js and quietly bypassed that fixture. The suite would have stayed green while testing an object the test never populated. So it now drives the real S and the real lanes() — and pins the premise the stub had asserted by fiat:

    assert.strictEqual(lanes(), 6, 'the fixture is a 6-string guitar arrangement');

    (It really is: 'Lead', no tuning array, notes on strings 0–3 → guitar baseline 6.) It also clears LC.active first, since no draw frame is open and lanes() must genuinely compute.

Verification

  • node --test 85/85, pytest 248/248.
  • main.js diff mechanically checked to be deletions + the import block. No unused import, no shadowing, no cycle.
  • Served from local uvicorn on core@main (R0): src/chords.js 200 as text/javascript.
  • All four headless Chromium harnesses green — draw path, state round-trip, hit test, resize. The load path calls flattenChords, so a real chart rendering its notes is direct evidence for it.
  • reconstructChords runs only at save/build, so it isn't reachable from those harnesses. Its cover is the unit suites — which, as of this PR, drive the real function against the real S instead of a copy of its source text.
  • Codex preflight: NO ISSUES (asked specifically about state leaking between the reworked test cases; makeReconstruct() re-seeds S on every call, and node --test gives each file its own process).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Centralized chord-template and handshape processing into a shared module for consistent behavior across the app.
    • Updated chord rebuild/save-load flow to preserve and relink chord templates more reliably.
  • Bug Fixes
    • Improved handshape handling when chord templates change, including dropping orphaned handshapes.
    • Reduced undo/history corruption risk after chord reconstruction.
  • Tests
    • Migrated chord/handshape and position-wiring tests to ESM and switched to importing shared helpers directly, using real wiring/state for better realism.
  • Documentation
    • Updated the ES-module migration guide (step 7) for chord templates & handshapes (R2).

…js (R2, step 7)

src/main.js 20,601 -> 20,160. chords.js holds the chord load/save round-trip:
flattenChords/_flattenArrChords, the whole @pure:chord-relink helper set
(relinkChordTemplate, _buildPreservedTemplates, buildHandshapeChordIdMap,
dropOrphanedHandshapes, remapHandshapeChordIds, the sanitizers, _groupFn),
reconstructChords, the handshape wire-coercion helpers, and the handshape
dirty-count trio lifted out of the Handshape-lane section. Reads S and lanes();
no DOM. Graph stays acyclic: chords -> {state, lanes}.

main.js's whole diff is the deletions plus the import block. HS_ORPHAN_EPS,
_safeWireBool and _wireFloat stay module-private (nothing outside needs them).

THE TEST REWORK, which is what blocked this in step 6:

- chord_relink: the block was already pure, so it becomes a plain real-import
  suite (23 cases). No sandbox at all.
- handshape_authoring: hybrid. Imports the chord-template helpers; still slices
  `_handshapeSpanFrets`, which stays in main.js.
- suggest_position_wiring: the one that mattered. Its makeReconstruct() built a
  `new Function` sandbox around reconstructChords' SOURCE and fed it a fabricated
  `S` plus a `lanes: () => 6` stub. Importing the real function would have closed
  over the real S from state.js and silently bypassed that fixture — the suite
  would have stayed green while testing the wrong object. It now drives the real
  S and the real lanes(), and pins the premise the old stub asserted by fiat:
  `assert.strictEqual(lanes(), 6)` for its 6-string 'Lead' fixture.

Verified: node --test 85/85, pytest 248/248. main.js diff mechanically checked to
be deletions + the import block. No unused import, no shadowing, no cycle. Served
from local uvicorn on core@main (R0): src/chords.js 200 as text/javascript. All
four headless Chromium harnesses green (draw path, state round-trip, hit test,
resize) — the load path runs flattenChords, so a real chart rendering is direct
evidence for it. reconstructChords runs only at save/build, so its cover is the
unit suites, which now drive the real function against the real S.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 9, 2026 11:20
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 907fd389-3158-4270-bdfd-dc6f6953171f

📥 Commits

Reviewing files that changed from the base of the PR and between dd84e81 and 1b7b17e.

📒 Files selected for processing (2)
  • src/chords.js
  • tests/chords.test.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/chords.js

📝 Walkthrough

Walkthrough

Chord flattening, chord reconstruction, template relinking, and handshape remapping are extracted into src/chords.js. src/main.js now imports the shared helpers, and the related tests use direct ESM imports while suggest_position_wiring exercises the real state and lane calculation.

Changes

Chord/handshape module extraction and ESM test migration

Layer / File(s) Summary
Chord flattening entry point
src/chords.js
flattenChords() and _flattenArrChords(arr) move chord instance notes into arr.notes, tag flattened notes, attach harmony functions, and clear chord state.
Template and handshape normalization
src/chords.js
Adds wire coercion, handshape normalization, fret-key and finger normalization, guide-tone sanitation, preserved-template rebuilding, and chord-function normalization helpers.
Chord reconstruction and handshape remapping
src/chords.js
Adds handshape chord-id mapping, orphan dropping, chord-id remapping, chord reconstruction, and handshape dirty tracking around the save/build round trip.
main.js wiring and ESM tests
src/main.js, tests/chord_relink.test.mjs, tests/handshape_authoring.test.mjs, tests/suggest_position_wiring.test.mjs, tests/chords.test.mjs, CHANGELOG.md
Updates src/main.js to import the shared helpers, converts related tests to ESM imports from src/chords.js, switches suggest-position wiring to real state and lanes(), and records the migration in the changelog.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant reconstructChords
  participant arr
  participant S
  participant handshapes
  reconstructChords->>arr: rebuild notes, chords, chord_templates
  reconstructChords->>S: reset history and clear dropped selection
  reconstructChords->>handshapes: drop orphans and remap chord_id values
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: extracting chord templates and handshapes into src/chords.js for the R2 editor refactor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/es-module-split-chords

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR continues the editor ES-module split (R2 step 7) by extracting chord load/save round-trip logic (chord templates + handshapes) out of the monolithic src/main.js into a new, DOM-free src/chords.js, and refactors the affected test suites to use real imports where safe.

Changes:

  • Added src/chords.js and moved flattenChords/_flattenArrChords, chord-template relink helpers, reconstructChords, and handshape normalization/dirty tracking into it.
  • Updated src/main.js to import chord/handshape utilities from src/chords.js and removed the inlined implementations.
  • Reworked test suites to prefer ESM imports over source-text sandboxing, including driving reconstructChords against real S/lanes() where required.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/suggest_position_wiring.test.mjs Switches to ESM imports; drives real reconstructChords, S, and lanes() to avoid fixture bypass.
tests/handshape_authoring.test.mjs Imports chord-template helpers from src/chords.js while continuing to slice _handshapeSpanFrets from src/main.js.
tests/chord_relink.test.mjs Converts chord relink testing from source-slicing to direct imports from src/chords.js.
src/main.js Replaces large in-file chord/handshape implementations with imports from src/chords.js.
src/chords.js New module containing chord flatten/reconstruct and chord-template/handshape helper logic.
CHANGELOG.md Documents ES-module migration step 7 and the accompanying test refactors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/chords.js Outdated
Comment on lines +8 to +9
* Reads `S` and `lanes()`; no DOM. `reconstructChords` resets `S.history`
* because it replaces every note object, invalidating index-based undo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. You're right, and this one was my comment (new in this PR), not moved code. Solo notes survive by referencesuggest_position_wiring asserts exactly that (a note field leaks on solos). What actually invalidates index-based undo is that chord members are rebuilt and arr.notes/arr.chords are replaced wholesale. Reworded.

Comment thread src/chords.js
Comment thread src/chords.js Outdated
Comment on lines +356 to +360
const frets = new Array(L).fill(-1);
for (const n of group) {
if (n.string >= 0 && n.string < L) frets[n.string] = n.fret;
}
const fretKey = frets.join(',');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real latent bug and I fixed it at the root.

relinkChordTemplate looks preserved templates up via _fretKeyForL, and buildHandshapeChordIdMap re-keys the rebuilt ones the same way. The raw frets.join(',') here was the odd one out: two chords differing only by NaN vs undefined in a slot minted two templates that both relinked to the same preserved entry, and the handshape chord_id remap would land on whichever came first.

_fretKeyForL is byte-identical to join(',') whenever every fret is finite — which is why no real chart ever tripped it. Added tests/chords.test.mjs, and verified the new case fails against the old raw-join keying and passes on the fix. (Pre-existing; moved verbatim in this step.)

Three Copilot findings on #151. Two were pre-existing code moved verbatim in
step 7; one was my own module header.

1. [real bug] reconstructChords keyed its local template-dedupe map with a raw
   `frets.join(',')`, but relinkChordTemplate looks preserved templates up — and
   buildHandshapeChordIdMap re-keys the rebuilt ones — via _fretKeyForL, which
   folds every non-finite slot to -1. Two chords differing only by NaN vs
   undefined in one slot therefore minted TWO templates that both relinked to
   the SAME preserved entry, and a handshape chord_id remap would land on
   whichever came first. Keying via _fretKeyForL is identical to join(',') for
   any chart whose frets are all finite, which is why nothing caught it.

2. [dead code] `const soloNotes = []` in reconstructChords was never read or
   written. Removed.

3. [comment] The module header claimed reconstructChords "replaces every note
   object". It doesn't: solo notes survive by reference (suggest_position_wiring
   asserts exactly that — a note field LEAKS on solos), while chord members are
   rebuilt and arr.notes/arr.chords are replaced wholesale. That array
   replacement is what invalidates index-based undo. Reworded.

New tests/chords.test.mjs drives reconstructChords and flattenChords against the
real S and the real lanes(): the dedupe regression (verified to FAIL on the old
raw-join keying and pass on the fix), template sharing across identical voicings,
_fretKeyForL's equivalence to join(',') on finite frets, and a
flatten -> reconstruct round-trip.

node --test 86/86, pytest 248/248, headless draw + hit-test harnesses green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chords.js`:
- Around line 349-353: Solo notes are still carrying chord-only provenance after
flattening, so clear all inherited chord metadata before pushing them into
newNotes. In the solo-note path in chords.js, update the logic around the
group[0] handling to remove not just _fn but also _fromChord and _chordId from
the note object before it is saved through arr.notes. Keep the fix localized to
the lone-note branch so chord members retain their metadata while flattened solo
notes do not.
- Around line 40-42: The chord-flattening logic in the notes push path is
treating a valid zero time as missing. Update the fallback used in the code that
builds notes in the chord handling flow so the `cn.time` value is preserved when
it is 0, using a nullish-style fallback instead of a truthy check. Keep the
change localized to the note creation logic in the chord processing function.
- Around line 170-178: In relinkChordTemplate, the preserved template is being
copied too literally: frets.slice() can retain an outdated handshape width, and
arp is being coerced with a boolean cast that treats the string "false" as true.
Update relinkChordTemplate to normalize the returned frets to the current chart
width using the existing chord/template normalization helpers, and coerce arp
with the same wire-safe parsing approach used elsewhere in chords.js. Keep the
logic centered on relinkChordTemplate, _fretKeyForL, and _normFingers so
preserved handshape-only templates stay consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 89f27c9b-ef75-44f2-9a99-9194c7639aa6

📥 Commits

Reviewing files that changed from the base of the PR and between dc32b33 and dd84e81.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • src/chords.js
  • src/main.js
  • tests/chord_relink.test.mjs
  • tests/handshape_authoring.test.mjs
  • tests/suggest_position_wiring.test.mjs

Comment thread src/chords.js
Comment thread src/chords.js
Comment on lines +170 to +178
export function relinkChordTemplate(frets, preserved, L) {
const old = preserved[_fretKeyForL(frets, L)];
const name = (old && typeof old.name === 'string') ? old.name : '';
return {
name,
frets: frets.slice(),
fingers: _normFingers(old && old.fingers, L),
displayName: (old && typeof old.displayName === 'string') ? old.displayName : name,
arp: !!(old && old.arp),

@coderabbitai coderabbitai Bot Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize relinked template frets and wire-coerce arp.

For preserved handshape-only templates, frets.slice() can keep a stale 6-wide array on wider charts, and !!old.arp flips "false" to true.

Proposed fix
 export function relinkChordTemplate(frets, preserved, L) {
-    const old = preserved[_fretKeyForL(frets, L)];
+    const normFrets = new Array(L);
+    for (let i = 0; i < L; i++) {
+        normFrets[i] = (Array.isArray(frets) && Number.isFinite(frets[i])) ? frets[i] : -1;
+    }
+    const old = preserved[_fretKeyForL(normFrets, L)];
     const name = (old && typeof old.name === 'string') ? old.name : '';
     return {
         name,
-        frets: frets.slice(),
+        frets: normFrets,
         fingers: _normFingers(old && old.fingers, L),
         displayName: (old && typeof old.displayName === 'string') ? old.displayName : name,
-        arp: !!(old && old.arp),
+        arp: _safeWireBool(old && old.arp, false),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function relinkChordTemplate(frets, preserved, L) {
const old = preserved[_fretKeyForL(frets, L)];
const name = (old && typeof old.name === 'string') ? old.name : '';
return {
name,
frets: frets.slice(),
fingers: _normFingers(old && old.fingers, L),
displayName: (old && typeof old.displayName === 'string') ? old.displayName : name,
arp: !!(old && old.arp),
export function relinkChordTemplate(frets, preserved, L) {
const normFrets = new Array(L);
for (let i = 0; i < L; i++) {
normFrets[i] = (Array.isArray(frets) && Number.isFinite(frets[i])) ? frets[i] : -1;
}
const old = preserved[_fretKeyForL(normFrets, L)];
const name = (old && typeof old.name === 'string') ? old.name : '';
return {
name,
frets: normFrets,
fingers: _normFingers(old && old.fingers, L),
displayName: (old && typeof old.displayName === 'string') ? old.displayName : name,
arp: _safeWireBool(old && old.arp, false),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chords.js` around lines 170 - 178, In relinkChordTemplate, the preserved
template is being copied too literally: frets.slice() can retain an outdated
handshape width, and arp is being coerced with a boolean cast that treats the
string "false" as true. Update relinkChordTemplate to normalize the returned
frets to the current chart width using the existing chord/template normalization
helpers, and coerce arp with the same wire-safe parsing approach used elsewhere
in chords.js. Keep the logic centered on relinkChordTemplate, _fretKeyForL, and
_normFingers so preserved handshape-only templates stay consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves are real — I reproduced them against the real module — but I am deferring them out of this PR rather than folding them in. Reasons below.

relinkChordTemplate([3,2,0,-1,-1,-1], {}, 8).frets.length   → 6   (not 8)
relinkChordTemplate([3,-1,...], {"3,-1,...": {arp:"false"}}, 6).arp → true

The 6-wide case is reachable through buildHandshapeChordIdMap's preserve-append path, which calls relinkChordTemplate(old.frets, …, L) with template frets straight off the wire. And !!old.arp on the string "false" is exactly the trap _safeWireBool was written for — it lives in this same module and simply is not used here.

Why not here: this PR is a verbatim move (its diff of main.js is deletions plus an import block, checked mechanically), and both fixes change the saved wire payload — template frets width, and whether arpeggio flips on. That wants checking against routes.py's validator and a real save round-trip, not a rider on a mechanical extraction. Tracked in the follow-up issue, with this repro.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@byrongamatos, that's a reasonable call — since this PR is a mechanical, verbatim extraction, and both fixes touch the saved wire payload (template frets width and the arp boolean semantics), they deserve their own validation pass against routes.py's wire validator and a real save round-trip rather than riding along here.

Would you like me to open a follow-up GitHub issue capturing this repro (the two relinkChordTemplate cases you shared) so it's tracked? If so, I'll include:

  • The two repro snippets (6-wide truncation on wider charts via buildHandshapeChordIdMap's preserve-append path, and arp: "false" coercing to true via !!old.arp)
  • The suggested fix direction (normalize frets to width L, use _safeWireBool for arp)
  • A note that it needs verification against routes.py's validator and a save round-trip
  • Backlinks to this PR and comment, and your username

Let me know and I'll go ahead and create it.

Comment thread src/chords.js
Comment on lines +349 to +353
// A lone note is not a chord, so it carries no harmony fn. Drop any
// `_fn` it inherited (e.g. a chord note dragged out) so the internal
// field can't ride into the saved wire via arr.notes.
delete group[0]._fn;
newNotes.push(group[0]);

@coderabbitai coderabbitai Bot Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear all chord-only metadata from solo notes.

Line 352 drops _fn, but solo notes can still retain _fromChord and _chordId from flattening, leaking editor-only chord provenance into arr.notes.

Proposed fix
             // field can't ride into the saved wire via arr.notes.
             delete group[0]._fn;
+            delete group[0]._fromChord;
+            delete group[0]._chordId;
             newNotes.push(group[0]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A lone note is not a chord, so it carries no harmony fn. Drop any
// `_fn` it inherited (e.g. a chord note dragged out) so the internal
// field can't ride into the saved wire via arr.notes.
delete group[0]._fn;
newNotes.push(group[0]);
// A lone note is not a chord, so it carries no harmony fn. Drop any
// `_fn` it inherited (e.g. a chord note dragged out) so the internal
// field can't ride into the saved wire via arr.notes.
delete group[0]._fn;
delete group[0]._fromChord;
delete group[0]._chordId;
newNotes.push(group[0]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chords.js` around lines 349 - 353, Solo notes are still carrying
chord-only provenance after flattening, so clear all inherited chord metadata
before pushing them into newNotes. In the solo-note path in chords.js, update
the logic around the group[0] handling to remove not just _fn but also
_fromChord and _chordId from the note object before it is saved through
arr.notes. Keep the fix localized to the lone-note branch so chord members
retain their metadata while flattened solo notes do not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, but lower severity than Major, and deferred with the others.

Reproduced: flatten a 2-note chord, delete one member, reconstructChords() → the surviving solo keeps _fromChord and _chordId (_fn is deleted, those two are not), and _buildSaveBody ships notes untouched — it only strips the arrangement-level _anchorEditCount/_handshapeEditCount. So they do reach the wire.

What keeps it cosmetic: _fromChord and _chordId are write-only. flattenChords sets them and nothing anywhere reads them back — grep across src/ finds no reader, only the tag site and a stale comment claiming reconstructChords groups by time+_fromChord (it groups by time alone). So the leak is extra fields on saved notes, not a mis-grouping.

Fixing it still changes the saved payload, so it rides along with the other two in the follow-up issue — together with that stale comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@byrongamatos Agreed — thanks for reproducing it and confirming the write-only nature of _fromChord/_chordId. That's a fair read: since nothing in src/ reads them back, the impact is cosmetic payload bloat rather than a correctness bug in grouping/reconstruction. Deferring it alongside the other two and the stale comment about grouping by time+_fromChord makes sense.

Want me to open a follow-up issue to track this bundle (the _fromChord/_chordId leak, the other two deferred items, and the stale comment fix)? If so, let me know the other two items you're referring to (or just confirm and I'll base it on this thread), and I'll file it with a link back to this PR and comment.

@byrongamatos

Copy link
Copy Markdown
Collaborator Author

Follow-up for the deferred findings: #152 (verified repros for the 6-wide frets, the arp wire-coercion, and the write-only _fromChord/_chordId leak; plus why the zero-time || flag is a false positive on real data).

Fixed in this PR: all three Copilot findings, including the real one — reconstructChords' template dedupe now keys with _fretKeyForL instead of a raw frets.join(','), matching how relinkChordTemplate and buildHandshapeChordIdMap key. New tests/chords.test.mjs guards it (verified to fail on the old keying).

@byrongamatos
byrongamatos merged commit aa8f09e into main Jul 9, 2026
3 checks passed
byrongamatos added a commit that referenced this pull request Jul 9, 2026
…olo provenance (#153)

Closes #152. Three pre-existing data-integrity bugs in the chord save path,
surfaced by CodeRabbit on #151 and each verified by running the real module.

1. relinkChordTemplate stored `frets.slice()` verbatim. A preserved template can
   arrive narrower than the chart — buildHandshapeChordIdMap's preserve-append
   hands it a template straight off the wire — so a 6-wide row survived on a
   7/8-string chart, and a non-finite slot (undefined / NaN / a hand-edited
   string) rode through untouched. It now stores the width-normalized row.

   The fold that _fretKeyForL already applied for the lookup key is now a shared
   `_normFretsToL`, used by both. Key, preserved-lookup and stored row can no
   longer disagree — the same class of bug as the raw-join dedupe key fixed in
   #151. `fingers` was always padded to L this way; `frets` now matches.

2. `arp: !!(old && old.arp)` turned the STRING "false" into true. _safeWireBool
   exists in that same module for exactly this — its own comment names the case —
   and simply was not used. A hand-edited or legacy sloppak with `arp: "false"`
   no longer switches arpeggio on across a load->save round-trip.

3. A chord reduced to one note left `_fromChord` / `_chordId` on the survivor;
   they are now cleared alongside `_fn`. None of the three can reach disk — the
   backend's `_note()` whitelists the keys it writes — but `_fn` is READ BACK by
   `_groupFn`, so a stale one would be adopted by majority vote if that note were
   later dragged into a chord. The comment there claimed the delete was about the
   wire. It isn't; corrected. Also fixed the stale claim above flattenChords that
   reconstructChords groups by "time+_fromChord" (it groups by rounded time).

NOT a bug, checked and closed out in the issue: `time: cn.time || ch.time` in
_flattenArrChords does not drop first-beat notes. Chord-member `time` is
absolute, so a chord on beat one has cn.time === ch.time === 0. Verified against
Arcturus - The Sham Mirrors - Kinetic.feedpak: 66 chords, every member time equal
to its chord's, none zero.

Verified: node --test 86/86, pytest 248/248. Eight new cases in
tests/chords.test.mjs, and EACH was re-run against the un-fixed code to confirm
it fails there — a guard that passes both ways guards nothing.

End-to-end: a real save -> reload round-trip through the running server on the
chord-heavy Arcturus pack (8 templates, 66 chords). Intercepted the actual POST
body: every fret row L-wide, every fret finite, every `arp` a real boolean, no
editor-internal field on any note; "Saved successfully"; templates and chords
both survive the reload. That proves the new shape is ACCEPTED by routes.py —
the discriminating evidence for the bugs themselves is the unit suite.
Headless draw / hit-test / resize harnesses green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants