Skip to content

fix: Gff.AddFeature deep copies Attributes and Location.SubLocations - #451

Open
techreign wants to merge 1 commit into
bebop:mainfrom
techreign:fix/issue-342
Open

fix: Gff.AddFeature deep copies Attributes and Location.SubLocations#451
techreign wants to merge 1 commit into
bebop:mainfrom
techreign:fix/issue-342

Conversation

@techreign

@techreign techreign commented Sep 10, 2026

Copy link
Copy Markdown

Changes in this PR

  • Gff.AddFeature() now deep copies the Feature.Attributes map and recursively deep copies Feature.Location.SubLocations before appending the feature to Gff.Features, instead of only dereferencing the *Feature pointer (which is a shallow copy of the struct and still shares the map/slice with the caller).
  • Added a small unexported Location.deepCopy() helper used to recursively copy nested SubLocations.
  • Updated AddFeature's doc comment to explain what it actually does and why the deep copy is necessary.
  • Added TestAddFeature_doesNotShareState in io/gff/gff_test.go, which proves that mutating the caller's Attributes/SubLocations after AddFeature does not affect the stored feature, and that mutating the stored feature does not affect the caller's copy.
  • Added a CHANGELOG.md entry under [Unreleased] / Fixed.

Feature.ParentSequence is still set on the caller's original struct (as before) so feature.GetSequence() keeps working directly on the struct passed into AddFeature - this preserves the existing behavior relied on by ExampleGff_AddFeature / ExampleFeature_GetSequence. The issue also floated dropping ParentSequence entirely and removing AddFeature, but that's a breaking API change; this PR sticks to the minimal, non-breaking fix for the actual mutation bug reported.

Why are you making these changes?

Gff.AddFeature() was documented/intended to store a copy of the given Feature, but featureCopy := *feature only shallow-copies the struct. Reference-typed fields (the Attributes map, and the Location.SubLocations slice) kept pointing at the same underlying map/slice as the caller's Feature, so mutating either the caller's original Feature or the stored one after the call would silently leak into the other.

Fixes: #342

Are any changes breaking? (IMPORTANT)

No. AddFeature's signature and exported behavior are unchanged; only the aliasing bug is fixed. No new exported identifiers besides behavior already documented (the new deepCopy method is unexported).

Pre-merge checklist

  • New packages/exported functions have docstrings. (No new exported functions; AddFeature's docstring was updated.)
  • New/changed functionality is thoroughly tested. (TestAddFeature_doesNotShareState)
  • New/changed functionality has a function giving an example of its usage in the associated test file. (Existing ExampleGff_AddFeature / ExampleFeature_GetSequence already cover AddFeature usage and still pass.)
  • Changes are documented in CHANGELOG.md in the [Unreleased] section.
  • All code is properly formatted and linted.
  • The PR template is filled out.

Verification

$ go build ./...
(exit 0)

$ go vet ./...
(exit 0)

$ gofmt -l io/gff/gff.go io/gff/gff_test.go
(no output — clean)

$ go test ./io/gff/... -v -run 'TestParseReader_error|TestParseAtoi_error|TestRead_error|TestAddFeature_doesNotShareState|ExampleGff_AddFeature|ExampleFeature_GetSequence'
=== RUN   TestParseReader_error
--- PASS: TestParseReader_error (0.00s)
=== RUN   TestParseAtoi_error
--- PASS: TestParseAtoi_error (0.00s)
=== RUN   TestRead_error
--- PASS: TestRead_error (0.00s)
=== RUN   TestAddFeature_doesNotShareState
--- PASS: TestAddFeature_doesNotShareState (0.00s)
=== RUN   ExampleGff_AddFeature
--- PASS: ExampleGff_AddFeature (0.00s)
=== RUN   ExampleFeature_GetSequence
--- PASS: ExampleFeature_GetSequence (0.00s)
PASS
ok  	github.com/bebop/poly/io/gff	0.188s

Note: TestGffIO, Example_basic, ExampleRead, ExampleParse, and ExampleBuild (all of which parse data/ecoli-mg1655-short.gff from disk) fail on my Windows dev environment because core.autocrlf=true checks the .gff fixture out with CRLF line endings, which the hand-rolled line/field splitting in Parse doesn't expect. This reproduces identically on a clean checkout of main with no changes applied (verified via git stash), so it is a pre-existing, environment-specific issue unrelated to this fix, and out of scope here.

golangci-lint was not available in this environment, so it was not run.

AddFeature intended to store a copy of the given Feature, but only
dereferenced the pointer (featureCopy := *feature), which is a shallow
copy. Feature.Attributes (a map) and Feature.Location.SubLocations (a
slice, recursively) were still shared between the caller's Feature and
the one appended to Gff.Features, so mutating one after the call leaked
into the other.

AddFeature now deep copies the Attributes map and recursively deep
copies Location's SubLocations, and its doc comment explains why. Added
TestAddFeature_doesNotShareState to prove mutations no longer leak
either direction, and a CHANGELOG entry.

Fixes bebop#342

Co-Authored-By: Claude Fable 5.1 <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.

Gff.AddFeature() code is misleading and mutates Feature state

1 participant