Skip to content

Add JSONEqual semantic comparer - #23

Open
jbsmith7741 wants to merge 3 commits into
mainfrom
json-equal
Open

Add JSONEqual semantic comparer#23
jbsmith7741 wants to merge 3 commits into
mainfrom
json-equal

Conversation

@jbsmith7741

Copy link
Copy Markdown
Member

Quality check

  • Documentation included
  • Test coverage

Summary

Adds JSONEqual, a comparer that normalizes both sides to JSON DOM shape before cmp.Diff, so tests ignore key ordering and whitespace when comparing struct output to embedded JSON fixtures or JSON strings.

Also replaces interface{} with any across public APIs, tests, and documentation for Go 1.18+ idioms. Documentation covers usage with //go:embed and comparer selection; TestJSONEqual covers key order, whitespace, struct-vs-JSON, invalid JSON, and number normalization.

  * Add JSONEqual to compare JSON strings and structs ignoring key order and whitespace.
  * Replace interface{} with any across APIs, tests, and documentation.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add JSONEqual semantic comparer for order/whitespace-insensitive JSON diffs

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Add JSONEqual comparer to semantically compare JSON across strings, bytes, and structs.
• Normalize both sides to JSON DOM before cmp.Diff to ignore key order and whitespace.
• Migrate public APIs, docs, and tests from interface{} to any (Go 1.18+).
Diagram

graph TD
  A["Callers / tests"] --> B["trial.JSONEqual"] --> C["normalizeToJSON"] --> D["encoding/json"]
  B --> E["cmp.Diff"]
  F["Docs"] --> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Decode with `json.Decoder.UseNumber()` to preserve numeric types
  • ➕ Avoids forced float64 conversion during normalization
  • ➕ Better fidelity when tests care about integer-vs-float semantics
  • ➖ Adds complexity (must normalize both sides to json.Number consistently)
  • ➖ May surprise users expecting standard encoding/json behavior
2. Canonicalize JSON by Unmarshal → Marshal and compare canonical strings
  • ➕ Produces stable, comparable JSON bytes/strings (often sorted keys)
  • ➕ Simple mental model for debugging fixture mismatches
  • ➖ Less helpful diffs than cmp.Diff on DOM objects
  • ➖ Still inherits float64 normalization unless UseNumber is also applied

Recommendation: Keep the current DOM-based normalization plus cmp.Diff: it directly targets test pain (whitespace/key order), yields high-quality diffs, and has clear documented limitations. If numeric fidelity becomes a recurring issue, consider a follow-up that optionally normalizes using UseNumber() (e.g., JSONEqualOpt(UseNumber)) rather than changing default behavior.

Files changed (10) +323 / -122

Enhancement (1) +89 / -35
functions.goAdd JSONEqual comparer with JSON DOM normalization; migrate APIs to 'any' +89/-35

Add JSONEqual comparer with JSON DOM normalization; migrate APIs to 'any'

• Introduces 'JSONEqual' and 'normalizeToJSON' to compare values semantically by normalizing both sides to JSON DOM (maps/slices and 'float64' numbers) before running 'cmp.Diff'. Replaces 'interface{}' with 'any' across comparer functions, option builders, and internal diff structures.

functions.go

Refactor (3) +14 / -14
helper.goUpdate Args helper to accept '...any' +1/-1

Update Args helper to accept '...any'

• Changes 'Args(args ...interface{})' to 'Args(args ...any)' to match Go 1.18+ conventions and the updated 'Input' wrapper usage.

helper.go

input.goMigrate Input constructors/accessors to 'any' +4/-4

Migrate Input constructors/accessors to 'any'

• Updates 'newInput', 'Input.Interface', and 'Input.Map' to use 'any' instead of 'interface{}'. Also tweaks an inline TODO comment for clarity.

input.go

trial.goSwitch core trial function types and result formatting to 'any' +9/-9

Switch core trial function types and result formatting to 'any'

• Updates exported 'TestFunc', 'CompareFunc', and 'Comparer' interfaces to use 'any'. Adjusts internal 'result' fields and helper methods to accept variadic '...any' for formatting.

trial.go

Tests (2) +140 / -52
functions_test.goAdd TestJSONEqual coverage and migrate tests to 'any' +101/-13

Add TestJSONEqual coverage and migrate tests to 'any'

• Adds 'TestJSONEqual' validating key-order and whitespace insensitivity, struct-vs-JSON comparisons, invalid JSON error labeling, and nil handling. Updates existing tests and test fixtures to use 'any' and 'map[string]any'/'[]any' shapes.

functions_test.go

trial_test.goRefactor trial tests to use 'any' across function signatures and fixtures +39/-39

Refactor trial tests to use 'any' across function signatures and fixtures

• Updates test helper function signatures and local test case types from 'interface{}' to 'any'. Adjusts map/slice fixtures and the test harness variables accordingly.

trial_test.go

Documentation (4) +80 / -21
README.mdUpdate public docs to use 'any' in IgnoreTypes and Contains shapes +4/-4

Update public docs to use 'any' in IgnoreTypes and Contains shapes

• Replaces 'interface{}' examples with 'any' in README option and Contains documentation. Aligns documentation with Go 1.18+ idioms used by the library.

README.md

api.mdExpose 'any'-based CompareFunc signature and list JSONEqual +3/-2

Expose 'any'-based CompareFunc signature and list JSONEqual

• Updates 'CompareFunc' signature to use 'any' and adjusts 'Input.Interface()' documentation accordingly. Adds 'JSONEqual' to the comparers table for discoverability.

docs/api.md

comparers.mdDocument JSONEqual comparer and migrate comparer signatures to 'any' +70/-12

Document JSONEqual comparer and migrate comparer signatures to 'any'

• Adds a dedicated 'JSONEqual' section describing normalization behavior, accepted input shapes, fixture patterns, and limitations. Updates existing comparer signatures and examples from 'interface{}' to 'any' and extends the scenario table to recommend 'JSONEqual' for JSON/struct semantic equality.

docs/comparers.md

helpers.mdSwitch helper API docs from 'interface{}' to 'any' +3/-3

Switch helper API docs from 'interface{}' to 'any'

• Updates 'Args', 'Input.Map', and 'Input.Interface' documentation to use 'any'. Keeps the helper reference consistent with updated exported APIs.

docs/helpers.md

@qodo-code-review

qodo-code-review Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. JSON numbers lose precision ✓ Resolved 🐞 Bug ≡ Correctness
Description
normalizeToJSON unmarshals into any, which decodes all JSON numbers as float64; this can round
large integers / high-precision decimals and make JSONEqual report equality for different numeric
values. This can silently produce false-positive tests for big IDs/counters or precise numeric
fields.
Code

functions.go[R172-205]

+func normalizeToJSON(v any) (any, error) {
+	if v == nil {
+		return nil, nil
+	}
+	switch x := v.(type) {
+	case string:
+		var out any
+		if err := json.Unmarshal([]byte(x), &out); err != nil {
+			return nil, err
+		}
+		return out, nil
+	case []byte:
+		var out any
+		if err := json.Unmarshal(x, &out); err != nil {
+			return nil, err
+		}
+		return out, nil
+	case json.RawMessage:
+		var out any
+		if err := json.Unmarshal(x, &out); err != nil {
+			return nil, err
+		}
+		return out, nil
+	default:
+		data, err := json.Marshal(v)
+		if err != nil {
+			return nil, err
+		}
+		var out any
+		if err := json.Unmarshal(data, &out); err != nil {
+			return nil, err
+		}
+		return out, nil
+	}
Evidence
JSONEqual relies on normalizeToJSON, which uses json.Unmarshal into any (standard library
behavior: numbers become float64). The documentation also calls out this float64 normalization,
confirming the current behavior and its implications for numeric comparisons.

functions.go[155-206]
docs/comparers.md[205-258]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`JSONEqual` normalizes JSON via `json.Unmarshal(..., &out)` into `any`, which turns all JSON numbers into `float64`. This can round large integers/precise decimals and cause `JSONEqual` to return equal for values that differ in JSON.

### Issue Context
- `normalizeToJSON` uses `json.Unmarshal` into `any` for string/[]byte/RawMessage inputs, and also for `json.Marshal` → `json.Unmarshal` normalization.
- The docs currently note this as a limitation, but the comparer is meant to strengthen tests; silent false positives are risky.

### Fix Focus Areas
- Use `json.Decoder` with `UseNumber()` for all unmarshal steps, then normalize numbers in a way that preserves value semantics (e.g., convert `json.Number` to `*big.Rat` / canonical numeric form) before `cmp.Diff`.
- Ensure both the direct-unmarshal path and marshal→unmarshal path share the same decoder configuration.
- Update docs/tests if the normalized number type changes (e.g., from `float64` to `json.Number` or a canonical numeric representation).

- functions.go[155-206]
- docs/comparers.md[205-258]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Misleading JSON error label ✓ Resolved 🐞 Bug ◔ Observability
Description
JSONEqual reports any normalization failure as “invalid JSON,” even when the failure came from
json.Marshal on an unsupported Go value (not from parsing JSON). This makes failures harder to
diagnose because it obscures whether the problem was marshaling or unmarshaling.
Code

functions.go[R159-167]

+func JSONEqual(actual, expected any) (bool, string) {
+	actualNorm, err := normalizeToJSON(actual)
+	if err != nil {
+		return false, fmt.Sprintf("actual: invalid JSON: %v", err)
+	}
+	expectedNorm, err := normalizeToJSON(expected)
+	if err != nil {
+		return false, fmt.Sprintf("expected: invalid JSON: %v", err)
+	}
Evidence
JSONEqual prefixes any normalizeToJSON error as invalid JSON, but normalizeToJSON includes a
json.Marshal(v) path that can fail for non-JSON reasons (unsupported Go values).

functions.go[155-206]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`JSONEqual` wraps all errors from `normalizeToJSON` as `"<side>: invalid JSON: ..."`. But `normalizeToJSON` can also fail during `json.Marshal(v)` (e.g., unsupported types), which is not an “invalid JSON” error.

### Issue Context
Improving the error prefix to reflect the failing stage makes test failures easier to interpret.

### Fix Focus Areas
- Change `normalizeToJSON` to return structured context (e.g., stage: marshal/unmarshal, input kind) or wrap errors with `%w` and a clearer message.
- Update `JSONEqual` to emit messages like:
 - `actual: cannot unmarshal JSON: ...`
 - `actual: cannot marshal value to JSON: ...`

- functions.go[155-206]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread functions.go Outdated
Comment thread functions.go
  * Add JSONOpt with subset, ignore paths, and use number options; keep JSONEqual unchanged by default.
  * Document JSONContains and options in comparers, api, and examples.
  * Report marshal and unmarshal failures with distinct prefixes instead of labeling all errors as invalid JSON.
  * Update JSONEqual tests and comparers documentation to match the new messages.
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.

1 participant