Skip to content

Commit 2b424bf

Browse files
erraggyclaude
andauthored
perf(fixer): skip defensive deep copy when caller owns document (#297)
* perf(fixer): skip defensive deep copy when caller owns document The fixer deep-copies parsed documents before mutating them, protecting callers who reuse the input. Three code paths in the CLI never reuse the parsed document, so the copy is wasted work: - Fix() parses internally and owns the result — now sets MutableInput with defer-based save/restore for panic safety - CLI stdin path owns its locally-parsed result - CLI source-map path owns its locally-parsed result Also modernizes isFixEnabled to use slices.Contains (gopls hint). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(fixer): add coverage for Fix() mutable input save/restore Exercises the Fix(path) code path to cover the new MutableInput save/restore logic, and verifies the field is restored after return. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(fixer): add subtest for MutableInput=true save/restore Addresses CodeRabbit review: the false→false case would also pass if Fix() hardcoded false instead of saving/restoring. The true→true subtest validates the defer actually restores the original value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(fixer): add parse-error subtest for Fix() MutableInput Addresses CodeRabbit review: adds error-path subtest. Note this tests the early-return path (parse failure at line 408, before save/restore), not the defer — the field is untouched, not restored. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(docs): correct dangerous deep copy advice in copilot instructions The previous guideline recommended JSON marshal/unmarshal for deep copying OAS documents — this silently loses interface{} type distinctions (string vs []string in OAS 3.1 schema.Type) and drops json:"-" fields. Updated to recommend the generated DeepCopy() methods and document the WithMutableInput(true) opt-in for owned documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(docs): replace JSON marshal deep copy advice across all docs Audit found locations recommending JSON marshal/unmarshal for deep copying OAS documents. This approach silently loses interface{} type distinctions (string vs []string in OAS 3.1 schema.Type) and drops json:"-" fields. Updated all to recommend generated DeepCopy() methods instead. Files fixed: - AGENTS.md - .claude/docs/oas-concepts.md - .claude/agents/developer.md - CONTRIBUTORS.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(claude): add deep copy, make check, and docs/ generation guidance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6ba2b4 commit 2b424bf

9 files changed

Lines changed: 49 additions & 15 deletions

File tree

.claude/agents/developer.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,7 @@ servers := []*parser.Server{
173173
}
174174

175175
// Deep copy when modifying to avoid mutations
176-
data, _ := json.Marshal(original)
177-
var copy MyType
178-
json.Unmarshal(data, &copy)
176+
copy := original.DeepCopy()
179177
```
180178

181179
## Testing Requirements

.claude/docs/oas-concepts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ if typeStr, ok := schema.Type.(string); ok {
6666
1. **Assuming schema.Type is always a string** - Use type assertions and handle both string and []string cases
6767
2. **Creating value slices instead of pointer slices** - Check parser types and use `&Type{...}` syntax
6868
3. **Forgetting to track conversion issues** - Add issues for every lossy conversion or unsupported feature
69-
4. **Mutating source documents** - Always deep copy before modification (use JSON marshal/unmarshal)
69+
4. **Mutating source documents** - Always deep copy before modification using generated `DeepCopy()` methods (e.g., `doc.DeepCopy()`). Never use JSON marshal/unmarshal — it loses `interface{}` type distinctions and drops `json:"-"` fields
7070
5. **Not handling operation-level consumes/produces** - Check operation-level first, then fall back to document-level
7171
6. **Ignoring version-specific features during conversion** - Explicitly check and warn about features that don't convert
7272
7. **Confusing Version (string) with OASVersion (enum)** - `ParseResult` has TWO version fields:

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ if typeStr, ok := schema.Type.(string); ok {
8989
1. **Assuming schema.Type is always a string** - Use type assertions and handle both string and []string cases
9090
2. **Creating value slices instead of pointer slices** - Check parser types and use `&Type{...}` syntax
9191
3. **Forgetting to track conversion issues** - Add issues for every lossy conversion or unsupported feature
92-
4. **Mutating source documents** - Always deep copy before modification (use JSON marshal/unmarshal)
92+
4. **Mutating source documents** - Deep copy before modification using generated `DeepCopy()` methods on parser types (e.g., `doc.DeepCopy()`). Never use JSON marshal/unmarshal for deep copying — it silently loses `interface{}` type distinctions (e.g., `string` vs `[]string` in OAS 3.1 `schema.Type`) and drops fields tagged `json:"-"`. When the caller owns the document and won't reuse it, use `WithMutableInput(true)` to skip the copy entirely.
9393
5. **Not handling operation-level consumes/produces** - Check operation-level first, then fall back to document-level
9494
6. **Ignoring version-specific features during conversion** - Explicitly check and warn about features that don't convert
9595

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ For documentation-only changes, only items 3, 6, and 7 apply.
146146
1. **Type assertions:** Don't assume `schema.Type` is always a string - check both string and []string
147147
2. **Pointer slices:** Use `&Type{...}` for pointer semantics, not value types
148148
3. **Conversion issues:** Track all lossy conversions or unsupported features
149-
4. **Deep copy:** Never mutate source documents - use JSON marshal/unmarshal
149+
4. **Deep copy:** Never mutate source documents - use generated `DeepCopy()` methods (e.g., `doc.DeepCopy()`), never JSON marshal/unmarshal
150150
5. **Operation-level overrides:** Check operation-level fields before falling back to document-level
151151
6. **Version features:** Explicitly handle version-specific features during conversion
152152

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
- **Use constants**: `httputil.MethodGet`, `severity.SeverityError`
4545
- **Always run `go_diagnostics`** after edits—hints improve perf 5-15%
4646
- **Favor fixing immediately** over deferring issues
47+
- **Deep copy**: Use generated `doc.DeepCopy()` methods, **never** JSON marshal/unmarshal (loses `interface{}` types, drops `json:"-"` fields)
48+
- **`make check` before pushing** — not just `go test`; catches lint, formatting, and trailing whitespace
49+
- **`docs/` is generated**: Files are copied by mkdocs build scripts — always edit source files at repo root
4750

4851
## Orchestrator Mode
4952

CONTRIBUTORS.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,9 +614,7 @@ modified := sourceDoc
614614
modified.Info.Title = "New Title" // Changes sourceDoc too!
615615

616616
// ✅ Correct - deep copy first
617-
data, _ := json.Marshal(sourceDoc)
618-
var modified parser.OAS3Document
619-
json.Unmarshal(data, &modified)
617+
modified := sourceDoc.DeepCopy()
620618
modified.Info.Title = "New Title" // Safe
621619
```
622620

cmd/oastools/commands/fix.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ func HandleFix(args []string) error {
230230
f.GenericNamingConfig = genericConfig
231231
f.OperationIdNamingConfig = operationIdConfig
232232
f.DryRun = flags.DryRun
233+
f.MutableInput = true
233234
if flags.StubResponseDesc != "" {
234235
f.StubConfig.ResponseDescription = flags.StubResponseDesc
235236
}
@@ -264,6 +265,7 @@ func HandleFix(args []string) error {
264265
fixer.WithGenericNamingConfig(genericConfig),
265266
fixer.WithOperationIdNamingConfig(operationIdConfig),
266267
fixer.WithDryRun(flags.DryRun),
268+
fixer.WithMutableInput(true),
267269
}
268270
if parseResult.SourceMap != nil {
269271
fixOpts = append(fixOpts, fixer.WithSourceMap(parseResult.SourceMap))

fixer/fixer.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package fixer
22

33
import (
44
"fmt"
5+
"slices"
56

67
"github.com/erraggy/oastools/parser"
78
)
@@ -407,6 +408,10 @@ func (f *Fixer) Fix(specPath string) (*FixResult, error) {
407408
return nil, fmt.Errorf("fixer: failed to parse specification: %w", err)
408409
}
409410

411+
// Fix() owns the parsed document, so always mutate in place.
412+
origMutable := f.MutableInput
413+
f.MutableInput = true
414+
defer func() { f.MutableInput = origMutable }()
410415
return f.FixParsed(*parseResult)
411416
}
412417

@@ -445,12 +450,7 @@ func (f *Fixer) isFixEnabled(fixType FixType) bool {
445450
if len(f.EnabledFixes) == 0 {
446451
return true // if explicitly set to empty/nil, enable all fixes
447452
}
448-
for _, ft := range f.EnabledFixes {
449-
if ft == fixType {
450-
return true
451-
}
452-
}
453-
return false
453+
return slices.Contains(f.EnabledFixes, fixType)
454454
}
455455

456456
// populateFixLocation fills in Line/Column/File from the SourceMap if available.

fixer/fixer_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,36 @@ paths:
601601
assert.Greater(t, len(pathItem.Get.Parameters), originalParamCount,
602602
"Original document should be mutated when Fixer.MutableInput is true")
603603
}
604+
605+
// TestFix_SetsInternalMutableInput verifies that Fix() internally sets
606+
// MutableInput to skip the defensive copy and restores it afterward.
607+
func TestFix_SetsInternalMutableInput(t *testing.T) {
608+
t.Run("restores false to false", func(t *testing.T) {
609+
f := New()
610+
assert.False(t, f.MutableInput, "default should be false")
611+
612+
result, err := f.Fix("../testdata/bench/small-oas3.yaml")
613+
require.NoError(t, err)
614+
assert.True(t, result.Success)
615+
assert.False(t, f.MutableInput, "MutableInput should be restored after Fix()")
616+
})
617+
618+
t.Run("restores true to true", func(t *testing.T) {
619+
f := New()
620+
f.MutableInput = true
621+
622+
result, err := f.Fix("../testdata/bench/small-oas3.yaml")
623+
require.NoError(t, err)
624+
assert.True(t, result.Success)
625+
assert.True(t, f.MutableInput, "MutableInput should be restored to original true value")
626+
})
627+
628+
t.Run("untouched on parse error", func(t *testing.T) {
629+
f := New()
630+
f.MutableInput = true
631+
632+
_, err := f.Fix("does-not-exist.yaml")
633+
require.Error(t, err)
634+
assert.True(t, f.MutableInput, "MutableInput should be untouched when Fix() fails before save/restore")
635+
})
636+
}

0 commit comments

Comments
 (0)