Add documentation to `UnmarshalJSON` implementations (#179)

diff --git a/config.go b/config.go
index e8ea638..6f08d7d 100644
--- a/config.go
+++ b/config.go
@@ -32,6 +32,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for Config.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (c *Config) UnmarshalJSON(b []byte) error {
 	type rawConfig Config
 	var config rawConfig
diff --git a/expression.go b/expression.go
index 5ecb15c..a9da729 100644
--- a/expression.go
+++ b/expression.go
@@ -47,6 +47,9 @@
 }
 
 // UnmarshalJSON implements json.Unmarshaler for Expression.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (e *Expression) UnmarshalJSON(b []byte) error {
 	result := new(ExpressionData)
 
diff --git a/metadata.go b/metadata.go
index 8ac111a..9509cf6 100644
--- a/metadata.go
+++ b/metadata.go
@@ -57,6 +57,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for MetadataFunctions.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (f *MetadataFunctions) UnmarshalJSON(b []byte) error {
 	type rawFunctions MetadataFunctions
 	var functions rawFunctions
diff --git a/parse_test.go b/parse_test.go
index 725e281..1bc22de 100644
--- a/parse_test.go
+++ b/parse_test.go
@@ -18,6 +18,7 @@
 const testGoldenPlanFileName = "plan.json"
 const testGoldenStateFileName = "state.json"
 const testGoldenSchemasFileName = "schemas.json"
+const testInvalidDir = "invalid"
 
 func testParse(t *testing.T, filename string, typ reflect.Type) {
 	entries, err := os.ReadDir(testFixtureDir)
@@ -31,6 +32,10 @@
 		}
 
 		t.Run(e.Name(), func(t *testing.T) {
+			if e.Name() == testInvalidDir {
+				t.Skip("Skipping known invalid test fixture")
+			}
+
 			expected, err := os.ReadFile(filepath.Join(testFixtureDir, e.Name(), filename))
 			if err != nil {
 				if os.IsNotExist(err) {
diff --git a/plan.go b/plan.go
index 579e8d7..54c37ba 100644
--- a/plan.go
+++ b/plan.go
@@ -142,6 +142,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for Plan.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (p *Plan) UnmarshalJSON(b []byte) error {
 	type rawPlan Plan
 	var plan rawPlan
diff --git a/plan_test.go b/plan_test.go
index 9da2ef6..46c9c35 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -5,6 +5,7 @@
 
 import (
 	"encoding/json"
+	"io"
 	"os"
 	"reflect"
 	"testing"
@@ -187,7 +188,7 @@
 		t.Fatal(err)
 	}
 
-	testCases := map[string]struct {
+	numericsTestCases := map[string]struct {
 		useJSONNumber bool
 		expected      any
 	}{
@@ -200,9 +201,7 @@
 		},
 	}
 
-	for name, testCase := range testCases {
-		name, testCase := name, testCase
-
+	for name, testCase := range numericsTestCases {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 
@@ -210,7 +209,7 @@
 
 			plan.UseJSONNumber(testCase.useJSONNumber)
 
-			err = plan.UnmarshalJSON(b)
+			err = json.Unmarshal(b, &plan)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -232,4 +231,43 @@
 			}
 		})
 	}
+
+	jsonValidationTestCases := map[string]struct {
+		filePath    string
+		expectError bool
+	}{
+		"invalid plan JSON": {
+			filePath:    "testdata/invalid/plan.json",
+			expectError: true,
+		},
+		"valid plan JSON": {
+			filePath: "testdata/basic/plan.json",
+		},
+	}
+
+	for tn, tc := range jsonValidationTestCases {
+		t.Run(tn, func(t *testing.T) {
+			f, err := os.Open(tc.filePath)
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer f.Close()
+
+			b, err := io.ReadAll(f)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			var plan Plan
+			err = json.Unmarshal(b, &plan)
+
+			if tc.expectError && err == nil {
+				t.Fatalf("expected error; got none")
+			}
+
+			if !tc.expectError && err != nil {
+				t.Errorf("expected no error, got %q", err.Error())
+			}
+		})
+	}
 }
diff --git a/schemas.go b/schemas.go
index b65c13b..581f0c4 100644
--- a/schemas.go
+++ b/schemas.go
@@ -60,6 +60,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for ProviderSchemas.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (p *ProviderSchemas) UnmarshalJSON(b []byte) error {
 	type rawSchemas ProviderSchemas
 	var schemas rawSchemas
diff --git a/state.go b/state.go
index ff17aef..8347ad5 100644
--- a/state.go
+++ b/state.go
@@ -78,6 +78,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for State.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (s *State) UnmarshalJSON(b []byte) error {
 	type rawState State
 	var state rawState
diff --git a/state_test.go b/state_test.go
index a817a21..cc9a056 100644
--- a/state_test.go
+++ b/state_test.go
@@ -42,22 +42,44 @@
 	}
 }
 
-func TestStateUnmarshal_valid(t *testing.T) {
-	f, err := os.Open("testdata/no_changes/state.json")
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer f.Close()
-
-	b, err := io.ReadAll(f)
-	if err != nil {
-		t.Fatal(err)
+func TestStateUnmarshal(t *testing.T) {
+	testCases := map[string]struct {
+		filePath    string
+		expectError bool
+	}{
+		"valid state JSON": {
+			filePath: "testdata/no_changes/state.json",
+		},
+		"invalid state JSON": {
+			filePath:    "testdata/invalid/state.json",
+			expectError: true,
+		},
 	}
 
-	var state State
-	err = json.Unmarshal(b, &state)
-	if err != nil {
-		t.Fatal(err)
+	for tn, tc := range testCases {
+		t.Run(tn, func(t *testing.T) {
+			f, err := os.Open(tc.filePath)
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer f.Close()
+
+			b, err := io.ReadAll(f)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			var state State
+			err = json.Unmarshal(b, &state)
+
+			if tc.expectError && err == nil {
+				t.Fatalf("expected error; got none")
+			}
+
+			if !tc.expectError && err != nil {
+				t.Errorf("expected no error, got %q", err.Error())
+			}
+		})
 	}
 }
 
diff --git a/testdata/invalid/plan.json b/testdata/invalid/plan.json
new file mode 100644
index 0000000..b0b1697
--- /dev/null
+++ b/testdata/invalid/plan.json
@@ -0,0 +1,700 @@
+{
+  "format_version": "1.1",
+  "terraform_version": "1.2.0-rc1",
+  "variables": {
+    "foo": {
+      "value": "bar"
+    },
+    "map": {
+      "value": {
+        "foo": "bar",
+        "number": 42
+      }
+    },
+    "number": {
+      "value": 42
+    }
+  },
+  "planned_values": {
+    "outputs": {
+      "foo": {
+        "sensitive": true,
+        "value": "bar",
+        "type": "string"
+      },
+      "interpolated": {
+        "sensitive": false
+      },
+      "interpolated_deep": {
+        "sensitive": false
+      },
+      "list": {
+        "sensitive": false,
+        "value": [
+          "foo",
+          "bar"
+        ],
+        "type": [
+          "tuple",
+          [
+            "string",
+            "string"
+          ]
+        ]
+      },
+      "map": {
+        "sensitive": false,
+        "value": {
+          "foo": "bar",
+          "number": 42
+        },
+        "type": [
+          "object",
+          {
+            "foo": "string",
+            "number": "number"
+          }
+        ]
+      },
+      "referenced": {
+        "sensitive": false
+      },
+      "referenced_deep": {
+        "sensitive": false
+      },
+      "string": {
+        "sensitive": false,
+        "value": "foo",
+        "type": "string"
+      }
+    },
+    "root_module": {
+      "resources": [
+        {
+          "address": "null_resource.bar",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "bar",
+          "provider_name": "registry.terraform.io/hashicorp/null",
+          "schema_version": 0,
+          "sensitive_values": {
+            "triggers": {}
+          }
+        },
+        {
+          "address": "null_resource.baz[0]",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "baz",
+          "index": 0,
+          "provider_name": "registry.terraform.io/hashicorp/null",
+          "schema_version": 0,
+          "sensitive_values": {
+            "triggers": {}
+          }
+        },
+        {
+          "address": "null_resource.baz[1]",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "baz",
+          "index": 1,
+          "provider_name": "registry.terraform.io/hashicorp/null",
+          "schema_version": 0,
+          "sensitive_values": {
+            "triggers": {}
+          }
+        },
+        {
+          "address": "null_resource.baz[2]",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "baz",
+          "index": 2,
+          "provider_name": "registry.terraform.io/hashicorp/null",
+          "schema_version": 0,
+          "sensitive_values": {
+            "triggers": {}
+          }
+        },
+        {
+          "address": "null_resource.foo",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "foo",
+          "provider_name": "registry.terraform.io/hashicorp/null",
+          "schema_version": 0,
+          "values": {
+            "triggers": {
+              "foo": "bar"
+            }
+          },
+          "sensitive_values": {
+            "triggers": {}
+          }
+        }
+      ],
+      "child_modules": [
+        {
+          "resources": [
+            {
+              "address": "module.foo.null_resource.aliased",
+              "mode": "managed",
+              "type": "null_resource",
+              "name": "aliased",
+              "provider_name": "registry.terraform.io/hashicorp/null",
+              "schema_version": 0,
+              "values": {
+                "triggers": null
+              },
+              "sensitive_values": {}
+            },
+            {
+              "address": "module.foo.null_resource.foo",
+              "mode": "managed",
+              "type": "null_resource",
+              "name": "foo",
+              "provider_name": "registry.terraform.io/hashicorp/null",
+              "schema_version": 0,
+              "values": {
+                "triggers": {
+                  "foo": "bar"
+                }
+              },
+              "sensitive_values": {
+                "triggers": {}
+              }
+            }
+          ],
+          "address": "module.foo"
+        }
+      ]
+    }
+  },
+  "resource_changes": [
+    {
+      "address": "module.foo.null_resource.aliased",
+      "module_address": "module.foo",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "aliased",
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {
+          "triggers": null
+        },
+        "after_unknown": {
+          "id": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {}
+      }
+    },
+    {
+      "address": "module.foo.null_resource.foo",
+      "module_address": "module.foo",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "foo",
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {
+          "triggers": {
+            "foo": "bar"
+          }
+        },
+        "after_unknown": {
+          "id": true,
+          "triggers": {}
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    },
+    {
+      "address": "null_resource.bar",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "bar",
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {},
+        "after_unknown": {
+          "id": true,
+          "triggers": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    },
+    {
+      "address": "null_resource.baz[0]",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "baz",
+      "index": 0,
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {},
+        "after_unknown": {
+          "id": true,
+          "triggers": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    },
+    {
+      "address": "null_resource.baz[1]",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "baz",
+      "index": 1,
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {},
+        "after_unknown": {
+          "id": true,
+          "triggers": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    },
+    {
+      "address": "null_resource.baz[2]",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "baz",
+      "index": 2,
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {},
+        "after_unknown": {
+          "id": true,
+          "triggers": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    },
+    {
+      "address": "null_resource.foo",
+      "mode": "managed",
+      "type": "null_resource",
+      "name": "foo",
+      "provider_name": "registry.terraform.io/hashicorp/null",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {
+          "triggers": {
+            "foo": "bar"
+          }
+        },
+        "after_unknown": {
+          "id": true,
+          "triggers": {}
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "triggers": {}
+        }
+      }
+    }
+  ],
+  "output_changes": {
+    "foo": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after": "bar",
+      "after_unknown": false,
+      "before_sensitive": true,
+      "after_sensitive": true
+    },
+    "interpolated": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after_unknown": true,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "interpolated_deep": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after_unknown": true,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "list": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after": [
+        "foo",
+        "bar"
+      ],
+      "after_unknown": false,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "map": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after": {
+        "foo": "bar",
+        "number": 42
+      },
+      "after_unknown": false,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "referenced": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after_unknown": true,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "referenced_deep": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after_unknown": true,
+      "before_sensitive": false,
+      "after_sensitive": false
+    },
+    "string": {
+      "actions": [
+        "create"
+      ],
+      "before": null,
+      "after": "foo",
+      "after_unknown": false,
+      "before_sensitive": false,
+      "after_sensitive": false
+    }
+  },
+  "prior_state": {
+    "format_version": "1.0",
+    "terraform_version": "1.2.0",
+    "values": {
+      "outputs": {
+        "foo": {
+          "sensitive": true,
+          "value": "bar",
+          "type": "string"
+        },
+        "list": {
+          "sensitive": false,
+          "value": [
+            "foo",
+            "bar"
+          ],
+          "type": [
+            "tuple",
+            [
+              "string",
+              "string"
+            ]
+          ]
+        },
+        "map": {
+          "sensitive": false,
+          "value": {
+            "foo": "bar",
+            "number": 42
+          },
+          "type": [
+            "object",
+            {
+              "foo": "string",
+              "number": "number"
+            }
+          ]
+        },
+        "string": {
+          "sensitive": false,
+          "value": "foo",
+          "type": "string"
+        }
+      },
+      "root_module": {}
+    }
+  },
+  "configuration": {
+    "provider_config": {
+      "aws": {
+        "name": "aws",
+        "full_name": "registry.terraform.io/hashicorp/aws",
+        "expressions": {
+          "region": {
+            "constant_value": "us-west-2"
+          }
+        }
+      },
+      "aws.east": {
+        "name": "aws",
+        "full_name": "registry.terraform.io/hashicorp/aws",
+        "alias": "east",
+        "expressions": {
+          "region": {
+            "constant_value": "us-east-1"
+          }
+        }
+      },
+      "null": {
+        "name": "null",
+        "full_name": "registry.terraform.io/hashicorp/null"
+      }
+    },
+    "root_module": {
+      "outputs": {
+        "foo": {
+          "sensitive": true,
+          "expression": {
+            "constant_value": "bar"
+          }
+        },
+        "interpolated": {
+          "expression": {
+            "references": [
+              "null_resource.foo.id",
+              "null_resource.foo"
+            ]
+          }
+        },
+        "interpolated_deep": {
+          "expression": {
+            "references": [
+              "null_resource.foo.id",
+              "null_resource.foo"
+            ]
+          }
+        },
+        "list": {
+          "expression": {
+            "constant_value": [
+              "foo",
+              "bar"
+            ]
+          }
+        },
+        "map": {
+          "expression": {
+            "constant_value": {
+              "foo": "bar",
+              "number": 42
+            }
+          }
+        },
+        "referenced": {
+          "expression": {
+            "references": [
+              "null_resource.foo.id",
+              "null_resource.foo"
+            ]
+          }
+        },
+        "referenced_deep": {
+          "expression": {
+            "references": [
+              "null_resource.foo.id",
+              "null_resource.foo"
+            ]
+          }
+        },
+        "string": {
+          "expression": {
+            "constant_value": "foo"
+          }
+        }
+      },
+      "resources": [
+        {
+          "address": "null_resource.bar",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "bar",
+          "provider_config_key": "null",
+          "expressions": {
+            "triggers": {
+              "references": [
+                "null_resource.foo.id",
+                "null_resource.foo"
+              ]
+            }
+          },
+          "schema_version": 0
+        },
+        {
+          "address": "null_resource.baz",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "baz",
+          "provider_config_key": "null",
+          "expressions": {
+            "triggers": {
+              "references": [
+                "null_resource.foo.id",
+                "null_resource.foo"
+              ]
+            }
+          },
+          "schema_version": 0,
+          "count_expression": {
+            "constant_value": 3
+          }
+        },
+        {
+          "address": "null_resource.foo",
+          "mode": "managed",
+          "type": "null_resource",
+          "name": "foo",
+          "provider_config_key": "null",
+          "provisioners": [
+            {
+              "type": "local-exec",
+              "expressions": {
+                "command": {
+                  "constant_value": "echo hello"
+                }
+              }
+            }
+          ],
+          "expressions": {
+            "triggers": {
+              "constant_value": {
+                "foo": "bar"
+              }
+            }
+          },
+          "schema_version": 0
+        }
+      ],
+      "module_calls": {
+        "foo": {
+          "source": "./foo",
+          "expressions": {
+            "bar": {
+              "constant_value": "baz"
+            },
+            "one": {
+              "constant_value": "two"
+            }
+          },
+          "module": {
+            "outputs": {
+              "foo": {
+                "expression": {
+                  "constant_value": "bar"
+                }
+              }
+            },
+            "resources": [
+              {
+                "address": "null_resource.aliased",
+                "mode": "managed",
+                "type": "null_resource",
+                "name": "aliased",
+                "provider_config_key": "null",
+                "schema_version": 0
+              },
+              {
+                "address": "null_resource.foo",
+                "mode": "managed",
+                "type": "null_resource",
+                "name": "foo",
+                "provider_config_key": "null",
+                "expressions": {
+                  "triggers": {
+                    "constant_value": {
+                      "foo": "bar"
+                    }
+                  }
+                },
+                "schema_version": 0
+              }
+            ],
+            "variables": {
+              "bar": {},
+              "one": {}
+            }
+          }
+        }
+      },
+      "variables": {
+        "foo": {
+          "default": {}}}}}}},
+          "description": "foobar",
+          "sensitive": true,
+        },
+        "map": {
+          "default": {
+            "foo": "bar",
+            "number": 42
+          }
+        },
+        "number": {
+          "default": 42
+        }
+      }
+    }
+  },
+  "relevant_attributes": [
+    {
+      "resource": "null_resource.foo",
+      "attribute": [
+        "id"
+      ]
+    }
+  ]
+}
diff --git a/testdata/invalid/state.json b/testdata/invalid/state.json
new file mode 100644
index 0000000..e4ddd48
--- /dev/null
+++ b/testdata/invalid/state.json
@@ -0,0 +1,17 @@
+{
+  "format_version" : "1.2",
+  "terraform_version": "1.5.0",
+  "variables": {
+    "my-secret": {
+      "value": "PASSWORD123"
+    }
+  },
+  "configuration": {
+    "root_module": {
+      "variables": {
+        "my-secret": {}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}},
+        "sensitive": true
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/validate.go b/validate.go
index 53652ef..8b101dd 100644
--- a/validate.go
+++ b/validate.go
@@ -137,6 +137,10 @@
 	return nil
 }
 
+// UnmarshalJSON implements json.Unmarshaler for ValidateOutput.
+//
+// As per established convention this method should only ever
+// be invoked *indirectly* via [encoding/json] library.
 func (vo *ValidateOutput) UnmarshalJSON(b []byte) error {
 	type rawOutput ValidateOutput
 	var schemas rawOutput