Repository: smithy-lang/smithy
Pull request #2005 (2023-10-09 17:29:27)

Add smoke test traits
This commit adds a new package containing the smithy model for smoke tests and their implementation. The model uses the `smithy.test` namespace, which it shares with other test trait models like protocol tests.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/docs/source-2.0/index.rst b/docs/source-2.0/index.rst
index d6abe343d..7b5aafff2 100644
--- a/docs/source-2.0/index.rst
+++ b/docs/source-2.0/index.rst
@@ -157,4 +157,5 @@ Read more
 
     Source code <https://github.com/smithy-lang/smithy>
     Awesome Smithy <https://github.com/smithy-lang/awesome-smithy>
+    Smithy Examples <https://github.com/smithy-lang/smithy-examples>
     1.0 Documentation <https://smithy.io/1.0/>
diff --git a/settings.gradle b/settings.gradle
index 4403ed5b1..89a47440a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -30,5 +30,6 @@ include ":smithy-aws-cloudformation-traits"
 include ":smithy-aws-cloudformation"
 include ":smithy-validation-model"
 include ":smithy-rules-engine"
+include ":smithy-smoke-test-traits"
 include ":smithy-syntax"
 include ":smithy-aws-endpoints"
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedOperationError.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedOperationError.java
index 1f431156c..3bbf2e327 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedOperationError.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedOperationError.java
@@ -21,6 +21,8 @@ import java.util.List;
 import java.util.stream.Collectors;
 import software.amazon.smithy.diff.ChangedShape;
 import software.amazon.smithy.diff.Differences;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.ShapeId;
 import software.amazon.smithy.model.validation.Severity;
@@ -33,17 +35,18 @@ public final class AddedOperationError extends AbstractDiffEvaluator {
     @Override
     public List<ValidationEvent> evaluate(Differences differences) {
         return differences.changedShapes(OperationShape.class)
-                .flatMap(change -> createErrorViolations(change).stream())
+                .flatMap(change -> createErrorViolations(change, differences.getNewModel()).stream())
                 .collect(Collectors.toList());
     }
 
-    private List<ValidationEvent> createErrorViolations(ChangedShape<OperationShape> change) {
+    private List<ValidationEvent> createErrorViolations(ChangedShape<OperationShape> change, Model newModel) {
         if (change.getOldShape().getErrors().equals(change.getNewShape().getErrors())) {
             return Collections.emptyList();
         }
 
         List<ValidationEvent> events = new ArrayList<>();
         for (ShapeId error : change.getNewShape().getErrors()) {
+            SourceLocation errorSource = newModel.expectShape(error).getSourceLocation();
             if (!change.getOldShape().getErrors().contains(error)) {
                 events.add(
                         ValidationEvent.builder()
@@ -57,6 +60,7 @@ public final class AddedOperationError extends AbstractDiffEvaluator {
                                                 + "parameter to an operation).",
                                         error, change.getShapeId()))
                                 .shape(change.getNewShape())
+                                .sourceLocation(errorSource)
                                 .build()
                 );
             }
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedRequiredMember.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedRequiredMember.java
index ad48555bf..7d3cb192a 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedRequiredMember.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/AddedRequiredMember.java
@@ -52,7 +52,7 @@ public class AddedRequiredMember extends AbstractDiffEvaluator {
     private ValidationEvent emit(MemberShape memberShape) {
         return ValidationEvent.builder()
                 .id(getEventId())
-                .shapeId(memberShape.getId())
+                .shape(memberShape)
                 .message("Adding a new member with the `required` trait "
                         + "but not the `default` trait is backwards-incompatible.")
                 .severity(Severity.ERROR)
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedEnumTrait.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedEnumTrait.java
index d9d06cbfe..72cba13cd 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedEnumTrait.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedEnumTrait.java
@@ -23,6 +23,7 @@ import java.util.Optional;
 import java.util.stream.Collectors;
 import software.amazon.smithy.diff.ChangedShape;
 import software.amazon.smithy.diff.Differences;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.traits.EnumDefinition;
 import software.amazon.smithy.model.traits.EnumTrait;
@@ -85,6 +86,7 @@ public final class ChangedEnumTrait extends AbstractDiffEvaluator {
                                 .severity(Severity.ERROR)
                                 .message(String.format("Enum value `%s` was removed", definition.getValue()))
                                 .shape(change.getNewShape())
+                                .sourceLocation(oldTrait.getSourceLocation())
                                 .id(getEventId() + REMOVED + enumIndex)
                                 .build()
                 );
@@ -101,6 +103,7 @@ public final class ChangedEnumTrait extends AbstractDiffEvaluator {
                                             newValue.getName().orElse(null),
                                             definition.getValue()))
                                     .shape(change.getNewShape())
+                                    .sourceLocation(change.getNewShape().getSourceLocation())
                                     .id(getEventId() + NAME_CHANGED + enumIndex)
                                     .build()
                     );
@@ -120,12 +123,19 @@ public final class ChangedEnumTrait extends AbstractDiffEvaluator {
                                             + "can cause compatibility issues when ordinal values are used for "
                                             + "iteration, serialization, etc.", definition.getValue()))
                                     .shape(change.getNewShape())
+                                    .sourceLocation(oldTrait.getSourceLocation())
                                     .id(getEventId() + ORDER_CHANGED + newPosition)
                                     .build()
                     );
                     oldEndPosition++;
                 } else {
-                    events.add(note(change.getNewShape(), String.format(
+                    SourceLocation appendedSource = newTrait.getSourceLocation();
+                    // If the new trait is synthetic, its source will always be N/A.
+                    // Fall back to the shape's location.
+                    if (newTrait.isSynthetic()) {
+                        appendedSource = change.getNewShape().getSourceLocation();
+                    }
+                    events.add(note(change.getNewShape(), appendedSource, String.format(
                             "Enum value `%s` was appended", definition.getValue())));
                 }
             }
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedNullability.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedNullability.java
index 8d2963ee5..7808ab9c6 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedNullability.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ChangedNullability.java
@@ -25,6 +25,7 @@ import java.util.stream.Stream;
 import software.amazon.smithy.diff.ChangedShape;
 import software.amazon.smithy.diff.Differences;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.knowledge.NullableIndex;
 import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.Shape;
@@ -96,33 +97,36 @@ public class ChangedNullability extends AbstractDiffEvaluator {
         boolean oldHasInput = hasInputTrait(differences.getOldModel(), oldMember);
         boolean newHasInput = hasInputTrait(differences.getNewModel(), newMember);
         ShapeId shape = change.getShapeId();
+        SourceLocation oldMemberSourceLocation = oldMember.getSourceLocation();
         Shape newTarget = differences.getNewModel().expectShape(newMember.getTarget());
         List<ValidationEvent> eventsToAdd = new ArrayList<>();
+        SourceLocation newMemberContainerSource = differences.getNewModel().expectShape(newMember.getContainer())
+                .getSourceLocation();
 
         if (oldHasInput && !newHasInput) {
             // If there was an input trait before, but not now, then the nullability must have
             // changed from nullable to non-nullable.
-            eventsToAdd.add(emit(Severity.ERROR, "RemovedInputTrait", shape, message,
+            eventsToAdd.add(emit(Severity.ERROR, "RemovedInputTrait", shape, newMemberContainerSource, message,
                             "The @input trait was removed from " + newMember.getContainer()));
         } else if (!oldHasInput && newHasInput) {
             // If there was no input trait before, but there is now, then the nullability must have
             // changed from non-nullable to nullable.
-            eventsToAdd.add(emit(Severity.DANGER, "AddedInputTrait", shape, message,
+            eventsToAdd.add(emit(Severity.DANGER, "AddedInputTrait", shape, newMemberContainerSource, message,
                             "The @input trait was added to " + newMember.getContainer()));
         } else if (!newHasInput) {
             // Can't add clientOptional to a preexisting required member.
             if (change.isTraitAdded(ClientOptionalTrait.ID) && change.isTraitInBoth(RequiredTrait.ID)) {
-                eventsToAdd.add(emit(Severity.ERROR, "AddedClientOptionalTrait", shape, message,
-                                "The @clientOptional trait was added to a @required member."));
+                eventsToAdd.add(emit(Severity.ERROR, "AddedClientOptionalTrait", shape, oldMemberSourceLocation,
+                        message, "The @clientOptional trait was added to a @required member."));
             }
             // Can't add required to a member unless the member is marked as @clientOptional or part of @input.
             if (change.isTraitAdded(RequiredTrait.ID) && !newMember.hasTrait(ClientOptionalTrait.ID)) {
-                eventsToAdd.add(emit(Severity.ERROR, "AddedRequiredTrait", shape, message,
+                eventsToAdd.add(emit(Severity.ERROR, "AddedRequiredTrait", shape, oldMemberSourceLocation, message,
                                 "The @required trait was added to a member."));
             }
             // Can't add the default trait to a member unless the member was previously required.
             if (change.isTraitAdded(DefaultTrait.ID) && !change.isTraitRemoved(RequiredTrait.ID)) {
-                eventsToAdd.add(emit(Severity.ERROR, "AddedDefaultTrait", shape, message,
+                eventsToAdd.add(emit(Severity.ERROR, "AddedDefaultTrait", shape, oldMemberSourceLocation, message,
                                 "The @default trait was added to a member that was not previously @required."));
             }
             // Can only remove the required trait if the member was nullable or replaced by the default trait.
@@ -131,20 +135,21 @@ public class ChangedNullability extends AbstractDiffEvaluator {
                     && !oldMember.hasTrait(ClientOptionalTrait.ID)) {
                 if (newTarget.isStructureShape() || newTarget.isUnionShape()) {
                     eventsToAdd.add(emit(Severity.WARNING, "RemovedRequiredTrait.StructureOrUnion", shape,
-                                    message, "The @required trait was removed from a member that targets a "
-                                    + newTarget.getType() + ". This is backward compatible in generators that "
-                                    + "always treat structures and unions as optional (e.g., AWS generators)"));
+                                    oldMemberSourceLocation, message, "The @required trait was removed from a member "
+                                    + "that targets a " + newTarget.getType() + ". This is backward compatible in "
+                                    + "generators that always treat structures and unions as optional "
+                                    + "(e.g., AWS generators)"));
                 } else {
-                    eventsToAdd.add(emit(Severity.ERROR, "RemovedRequiredTrait", shape, message,
-                                    "The @required trait was removed and not replaced with the @default trait and "
-                                    + "@addedDefault trait."));
+                    eventsToAdd.add(emit(Severity.ERROR, "RemovedRequiredTrait", shape, oldMemberSourceLocation,
+                                    message, "The @required trait was removed and not replaced with the @default "
+                                    + "trait and @addedDefault trait."));
                 }
             }
         }
 
         // If not specific event was emitted, emit a generic event.
         if (eventsToAdd.isEmpty()) {
-            eventsToAdd.add(emit(Severity.ERROR, null, shape, null, message));
+            eventsToAdd.add(emit(Severity.ERROR, null, shape, oldMemberSourceLocation, null, message));
         }
 
         events.addAll(eventsToAdd);
@@ -158,6 +163,7 @@ public class ChangedNullability extends AbstractDiffEvaluator {
             Severity severity,
             String eventIdSuffix,
             ShapeId shape,
+            SourceLocation sourceLocation,
             String prefixMessage,
             String message
     ) {
@@ -166,6 +172,7 @@ public class ChangedNullability extends AbstractDiffEvaluator {
         return ValidationEvent.builder()
                 .id(actualId)
                 .shapeId(shape)
+                .sourceLocation(sourceLocation)
                 .message(actualMessage)
                 .severity(severity)
                 .build();
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ModifiedTrait.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ModifiedTrait.java
index 3a792e724..fc51e84a0 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ModifiedTrait.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/ModifiedTrait.java
@@ -273,6 +273,7 @@ public final class ModifiedTrait extends AbstractDiffEvaluator {
                                                          .id(getValidationEventId(this, trait))
                                                          .severity(severity)
                                                          .shape(shape)
+                                                         .sourceLocation(left.getSourceLocation())
                                                          .message(message)
                                                          .build());
             }
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinition.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinition.java
index 87fe377e5..5364f6f8d 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinition.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinition.java
@@ -34,6 +34,7 @@ public final class RemovedTraitDefinition extends AbstractDiffEvaluator {
                         .id(getEventId())
                         .severity(Severity.ERROR)
                         .shape(shape)
+                        .sourceLocation(shape.expectTrait(TraitDefinition.class).getSourceLocation())
                         .message(String.format("Trait definition `%s` was removed", shape.getId()))
                         .build())
                 .collect(Collectors.toList());
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/EnumShape.java b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/EnumShape.java
index 95f8d7666..39eeb3c02 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/EnumShape.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/EnumShape.java
@@ -24,6 +24,7 @@ import java.util.function.Consumer;
 import java.util.logging.Logger;
 import java.util.regex.Pattern;
 import software.amazon.smithy.model.SourceException;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.traits.DeprecatedTrait;
 import software.amazon.smithy.model.traits.DocumentationTrait;
 import software.amazon.smithy.model.traits.EnumDefinition;
@@ -517,5 +518,10 @@ public final class EnumShape extends StringShape {
             members(NamedMemberUtils.flattenMixins(members.get(), getMixins(), getId(), getSourceLocation()));
             return (Builder) super.flattenMixins();
         }
+
+        @Override
+        public Builder source(SourceLocation source) {
+            return (Builder) super.source(source);
+        }
     }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
index 08ae9d87f..ec6283390 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
@@ -647,7 +647,11 @@ public final class SmithyIdlModelSerializer {
             codeWriter.writeOptionalIdList("operations", shape.getIntroducedOperations());
             codeWriter.writeOptionalIdList("collectionOperations", shape.getCollectionOperations());
             codeWriter.writeOptionalIdList("resources", shape.getIntroducedResources());
-
+            if (shape.hasProperties()) {
+              codeWriter.openBlock("properties: {");
+              shape.getProperties().forEach((name, shapeId) -> codeWriter.write("$L: $I", name, shapeId));
+              codeWriter.closeBlock("}");
+            }
             codeWriter.closeBlock("}");
             codeWriter.write("");
             return null;


Test patch:
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedOperationErrorTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedOperationErrorTest.java
index 38fa859a3..032c6f12f 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedOperationErrorTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedOperationErrorTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StructureShape;
@@ -31,13 +32,17 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class AddedOperationErrorTest {
     @Test
     public void detectsAddedErrors() {
+        SourceLocation s1 = new SourceLocation("main.smithy", 1, 2);
+        SourceLocation s2 = new SourceLocation("main.smithy", 3, 4);
         Shape e1 = StructureShape.builder()
                 .id("foo.baz#E1")
                 .addTrait(new ErrorTrait("client"))
+                .source(s1)
                 .build();
         Shape e2 = StructureShape.builder()
                 .id("foo.baz#E2")
                 .addTrait(new ErrorTrait("client"))
+                .source(s2)
                 .build();
         OperationShape operation1 = OperationShape.builder().id("foo.baz#Operation").build();
         Shape operation2 = operation1.toBuilder().addError(e1.getId()).addError(e2.getId()).build();
@@ -47,6 +52,8 @@ public class AddedOperationErrorTest {
 
         assertThat(TestHelper.findEvents(events, "AddedOperationError").size(), equalTo(2));
         assertThat(TestHelper.findEvents(events, "AddedOperationError.E1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "AddedOperationError.E1").stream().findFirst().get().getSourceLocation(), equalTo(s1));
         assertThat(TestHelper.findEvents(events, "AddedOperationError.E2").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "AddedOperationError.E2").stream().findFirst().get().getSourceLocation(), equalTo(s2));
     }
 }
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedRequiredMemberTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedRequiredMemberTest.java
index ff5fc3587..3f76ce5b6 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedRequiredMemberTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedRequiredMemberTest.java
@@ -5,6 +5,7 @@ import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.node.StringNode;
+import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.shapes.StructureShape;
 import software.amazon.smithy.model.traits.DefaultTrait;
@@ -20,8 +21,15 @@ public class AddedRequiredMemberTest {
         StringShape s = StringShape.builder().id("smithy.example#Str").build();
         StructureShape a = StructureShape.builder().id("smithy.example#A")
                 .build();
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
+        MemberShape member = MemberShape.builder()
+                .id(a.getId().withMember("foo"))
+                .target(s.getId())
+                .addTrait(new RequiredTrait())
+                .source(source)
+                .build();
         StructureShape b = StructureShape.builder().id("smithy.example#A")
-                .addMember("foo", s.getId(), b2 -> b2.addTrait(new RequiredTrait()))
+                .addMember(member)
                 .build();
         Model model1 = Model.builder().addShapes(s, a).build();
         Model model2 = Model.builder().addShapes(s, b).build();
@@ -34,6 +42,8 @@ public class AddedRequiredMemberTest {
         assertThat(TestHelper.findEvents(result.getDiffEvents(), "AddedRequiredMember").get(0).getMessage(),
                 equalTo("Adding a new member with the `required` trait " +
                         "but not the `default` trait is backwards-incompatible."));
+        assertThat(TestHelper.findEvents(result.getDiffEvents(), "AddedRequiredMember").get(0).getSourceLocation(),
+                equalTo(source));
     }
 
     @Test
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedTraitDefinitionTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedTraitDefinitionTest.java
index bdff64d47..f4c85d1ba 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedTraitDefinitionTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/AddedTraitDefinitionTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.traits.TraitDefinition;
@@ -30,9 +31,11 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class AddedTraitDefinitionTest {
     @Test
     public void detectsAddedTraitDefinition() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         Shape definition = StringShape.builder()
                 .id("foo.baz#bam")
                 .addTrait(TraitDefinition.builder().build())
+                .source(source)
                 .build();
 
         Model modelA = Model.assembler().assemble().unwrap();
@@ -40,5 +43,6 @@ public class AddedTraitDefinitionTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "AddedTraitDefinition").size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 }
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedEnumTraitTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedEnumTraitTest.java
index ec3bf1853..a1d6fc755 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedEnumTraitTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedEnumTraitTest.java
@@ -16,16 +16,15 @@
 package software.amazon.smithy.diff.evaluators;
 
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.allOf;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.stringContainsInOrder;
 
 import java.util.List;
 
-import org.hamcrest.core.Every;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.EnumShape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.traits.EnumDefinition;
@@ -36,6 +35,7 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class ChangedEnumTraitTest {
     @Test
     public void detectsAppendedEnums() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -47,6 +47,7 @@ public class ChangedEnumTraitTest {
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder().value("foo").build())
                         .addEnum(EnumDefinition.builder().value("baz").build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
@@ -55,10 +56,12 @@ public class ChangedEnumTraitTest {
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").get(0).getSeverity(), equalTo(Severity.NOTE));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsAppendedEnumsEnumTraitNoNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -71,6 +74,7 @@ public class ChangedEnumTraitTest {
                 .id("foo.baz#Baz")
                 .addMember("foo", "foo")
                 .addMember("baz", "baz")
+                .source(source)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
@@ -80,12 +84,17 @@ public class ChangedEnumTraitTest {
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSeverity(),
                 equalTo(Severity.ERROR));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").get(1).getSeverity(),
                 equalTo(Severity.NOTE));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").get(1).getSourceLocation(),
+                equalTo(source));
     }
 
     @Test
     public void detectsAppendedEnumsEnumTraitWithNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -93,30 +102,36 @@ public class ChangedEnumTraitTest {
                                 .name("foo")
                                 .value("foo")
                                 .build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
                 .id("foo.baz#Baz")
                 .addMember("foo", "foo")
                 .addMember("baz", "baz")
+                .source(source)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").stream()
                 .allMatch(e -> e.getSeverity() == Severity.NOTE), equalTo(true));
     }
 
     @Test
     public void detectsRemovedEnums() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder().value("foo").build())
                         .addEnum(EnumDefinition.builder().value("baz").build())
                         .addEnum(EnumDefinition.builder().value("bat").build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         StringShape s2 = StringShape.builder()
@@ -130,12 +145,18 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.2").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.2").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(2));
     }
 
     @Test
     public void detectsRemovedEnumsEnumTraitNoNameToEnumShape() {
+        SourceLocation beforeSource = new SourceLocation("before.smithy", 1, 2);
+        SourceLocation afterSource = new SourceLocation("after.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -145,24 +166,31 @@ public class ChangedEnumTraitTest {
                         .addEnum(EnumDefinition.builder()
                                 .value("baz")
                                 .build())
+                        .sourceLocation(beforeSource)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
                 .id("foo.baz#Baz")
                 .addMember("foo", "foo")
+                .source(afterSource)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(beforeSource));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(afterSource));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
     }
 
     @Test
     public void detectsRemovedEnumsEnumTraitWithNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -174,6 +202,7 @@ public class ChangedEnumTraitTest {
                                 .name("baz")
                                 .value("baz")
                                 .build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
@@ -185,12 +214,15 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
     }
 
     @Test
     public void detectsRenamedEnums() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -204,18 +236,24 @@ public class ChangedEnumTraitTest {
                         .addEnum(EnumDefinition.builder().value("foo").name("NEW1").build())
                         .addEnum(EnumDefinition.builder().value("baz").name("NEW2").build())
                         .build())
+                .source(source)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.1").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(2));
     }
 
     @Test
     public void detectsRenamedEnumsEnumTraitNoNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -227,18 +265,22 @@ public class ChangedEnumTraitTest {
         EnumShape s2 = EnumShape.builder()
                 .id("foo.baz#Baz")
                 .addMember("NEW", "foo")
+                .source(source)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSeverity(),
                 equalTo(Severity.ERROR));
     }
 
     @Test
     public void detectsRenamedEnumsEnumTraitWithNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -251,22 +293,27 @@ public class ChangedEnumTraitTest {
         EnumShape s2 = EnumShape.builder()
                 .id("foo.baz#Baz")
                 .addMember("NEW", "foo")
+                .source(source)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSeverity(),
                 equalTo(Severity.ERROR));
     }
 
     @Test
     public void detectsInsertedEnums() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder().value("foo").build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         StringShape s2 = StringShape.builder()
@@ -282,16 +329,22 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.1").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(2));
     }
 
     @Test
     public void detectsInsertedEnumsBeforeAppendedEnums() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder().value("foo").build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         StringShape s2 = StringShape.builder()
@@ -308,18 +361,24 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").get(0)
+                .getSourceLocation(), equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.1").get(0)
+                .getSourceLocation(), equalTo(source));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(2));
     }
 
     @Test
     public void detectsInsertedEnumsEnumTraitNoNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder()
                                 .value("foo")
                                 .build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
@@ -333,12 +392,15 @@ public class ChangedEnumTraitTest {
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").size(), equalTo(2));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
     }
 
     @Test
     public void detectsInsertedEnumsEnumTraitWithNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -346,6 +408,7 @@ public class ChangedEnumTraitTest {
                                 .name("foo")
                                 .value("foo")
                                 .build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
@@ -358,18 +421,23 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.OrderChanged.0").stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
     }
 
     @Test
     public void detectsAppendedEnumsAfterRemovedEnums() {
+        SourceLocation beforeSource = new SourceLocation("before.smithy", 1, 2);
+        SourceLocation afterSource   = new SourceLocation("after.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
                         .addEnum(EnumDefinition.builder().value("old1").build())
                         .addEnum(EnumDefinition.builder().value("old2").build())
                         .addEnum(EnumDefinition.builder().value("old3").build())
+                        .sourceLocation(beforeSource)
                         .build())
                 .build();
         StringShape s2 = StringShape.builder()
@@ -378,6 +446,7 @@ public class ChangedEnumTraitTest {
                         .addEnum(EnumDefinition.builder().value("old1").build())
                         .addEnum(EnumDefinition.builder().value("old3").build())
                         .addEnum(EnumDefinition.builder().value("new1").build())
+                        .sourceLocation(afterSource)
                         .build())
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
@@ -387,18 +456,24 @@ public class ChangedEnumTraitTest {
         List<ValidationEvent> changeEvents = TestHelper.findEvents(allEvents, "ChangedEnumTrait");
         assertThat(changeEvents.size(), equalTo(2));
         assertThat(TestHelper.findEvents(changeEvents, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(changeEvents, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(beforeSource));
 
         ValidationEvent removedEvent = changeEvents.get(0);
         assertThat(removedEvent.getSeverity(), equalTo(Severity.ERROR));
         assertThat(removedEvent.getMessage(), stringContainsInOrder("Enum value `old2` was removed"));
+        assertThat(removedEvent.getSourceLocation(), equalTo(beforeSource));
 
         ValidationEvent appendedEvent = changeEvents.get(1);
         assertThat(appendedEvent.getSeverity(), equalTo(Severity.NOTE));
         assertThat(appendedEvent.getMessage(), stringContainsInOrder("Enum value `new1` was appended"));
+        assertThat(appendedEvent.getSourceLocation(), equalTo(afterSource));
     }
 
     @Test
     public void detectsAppendedEnumsAfterRemovedEnumsEnumTraitNoNameToEnumShape() {
+        SourceLocation beforeSource = new SourceLocation("before.smithy", 1, 2);
+        SourceLocation afterSource = new SourceLocation("after.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -411,6 +486,7 @@ public class ChangedEnumTraitTest {
                         .addEnum(EnumDefinition.builder()
                                 .value("old3")
                                 .build())
+                        .sourceLocation(beforeSource)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
@@ -418,6 +494,7 @@ public class ChangedEnumTraitTest {
                 .addMember("old1", "old1")
                 .addMember("old3", "old3")
                 .addMember("new1", "new1")
+                .source(afterSource)
                 .build();
         Model modelA = Model.assembler().addShape(s1).assemble().unwrap();
         Model modelB = Model.assembler().addShape(s2).assemble().unwrap();
@@ -425,8 +502,14 @@ public class ChangedEnumTraitTest {
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").size(), equalTo(4));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.0").get(0).getSourceLocation(),
+                equalTo(afterSource));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(beforeSource));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.2").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.NameChanged.2").get(0).getSourceLocation(),
+                equalTo(afterSource));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").subList(0, 3).stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").subList(3, 4).stream()
@@ -435,6 +518,7 @@ public class ChangedEnumTraitTest {
 
     @Test
     public void detectsAppendedEnumsAfterRemovedEnumsEnumTraitWithNameToEnumShape() {
+        SourceLocation source = new SourceLocation("main.smithy", 1, 2);
         StringShape s1 = StringShape.builder()
                 .id("foo.baz#Baz")
                 .addTrait(EnumTrait.builder()
@@ -450,6 +534,7 @@ public class ChangedEnumTraitTest {
                                 .name("old3")
                                 .value("old3")
                                 .build())
+                        .sourceLocation(source)
                         .build())
                 .build();
         EnumShape s2 = EnumShape.builder()
@@ -464,6 +549,8 @@ public class ChangedEnumTraitTest {
 
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").size(), equalTo(2));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait.Removed.1").stream()
                 .allMatch(e -> e.getSeverity() == Severity.ERROR), equalTo(true));
         assertThat(TestHelper.findEvents(events, "ChangedEnumTrait").subList(1, 2).stream()
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedMemberTargetTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedMemberTargetTest.java
index d1d16ae48..a405893f1 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedMemberTargetTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedMemberTargetTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.ListShape;
 import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.Shape;
@@ -38,17 +39,21 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class ChangedMemberTargetTest {
     @Test
     public void detectsIncompatibleTypeChanges() {
+        SourceLocation source = new SourceLocation("a.smithy", 2, 3);
         Shape shape1 = StringShape.builder().id("foo.baz#String").build();
         MemberShape member1 = MemberShape.builder().id("foo.baz#List$member").target(shape1.getId()).build();
         ListShape list1 = ListShape.builder().id("foo.baz#List").member(member1).build();
         Shape shape2 = TimestampShape.builder().id("foo.baz#Timestamp").build();
-        MemberShape member2 = MemberShape.builder().id("foo.baz#List$member").target(shape2.getId()).build();
+        MemberShape member2 = MemberShape.builder().id("foo.baz#List$member").target(shape2.getId()).source(source)
+                .build();
         ListShape list2 = ListShape.builder().id("foo.baz#List").member(member2).build();
         Model modelA = Model.assembler().addShapes(shape1, shape2, member1, list1).assemble().unwrap();
         Model modelB = Model.assembler().addShapes(shape1, shape2, member2, list2).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "ChangedMemberTarget").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "ChangedMemberTarget").get(0).getSourceLocation(),
+                equalTo(source));
         assertThat(TestHelper.findEvents(events, member2.getId()).size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(1));
     }
@@ -152,7 +157,7 @@ public class ChangedMemberTargetTest {
 
     @Test
     public void detectsTraitAddedToMemberTarget() {
-                StringShape shape1 = StringShape.builder().id("foo.baz#String1").build();
+        StringShape shape1 = StringShape.builder().id("foo.baz#String1").build();
         MemberShape member1 = MemberShape.builder().id("foo.baz#List$member").target(shape1.getId()).build();
         ListShape list1 = ListShape.builder().id("foo.baz#List").member(member1).build();
 
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedNullabilityTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedNullabilityTest.java
index 0205a2990..c14298087 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedNullabilityTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ChangedNullabilityTest.java
@@ -11,6 +11,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.ModelSerializer;
@@ -18,6 +19,7 @@ import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.ShapeId;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.shapes.UnionShape;
 import software.amazon.smithy.model.traits.BoxTrait;
 import software.amazon.smithy.model.traits.ClientOptionalTrait;
 import software.amazon.smithy.model.traits.DefaultTrait;
@@ -75,10 +77,12 @@ public class ChangedNullabilityTest {
 
     @Test
     public void detectsInvalidAdditionOfDefaultTrait() {
+        SourceLocation source = new SourceLocation("a.smithy", 5, 6);
         StringShape s = StringShape.builder().id("smithy.example#Str").build();
         StructureShape a = StructureShape.builder()
                 .id("smithy.example#A")
-                .addMember("foo", s.getId())
+                .addMember("foo", s.getId(), builder -> builder.source(source))
+                .source(source)
                 .build();
         StructureShape b = StructureShape.builder()
                 .id("smithy.example#A")
@@ -93,6 +97,7 @@ public class ChangedNullabilityTest {
                            .filter(event -> event.getSeverity() == Severity.ERROR)
                            .filter(event -> event.getId().equals("ChangedNullability.AddedDefaultTrait"))
                            .filter(event -> event.getShapeId().get().equals(a.getAllMembers().get("foo").getId()))
+                           .filter(event -> event.getSourceLocation().equals(source))
                            .filter(event -> event.getMessage().contains("The @default trait was added to a member that "
                                                                         + "was not previously @required"))
                            .count(), equalTo(1L));
@@ -122,17 +127,21 @@ public class ChangedNullabilityTest {
 
     @Test
     public void detectsInvalidRemovalOfRequired() {
+        SourceLocation memberSource = new SourceLocation("a.smithy", 7, 7);
         StringShape s = StringShape.builder().id("smithy.example#Str").build();
+        MemberShape requiredM = MemberShape.builder().id("smithy.example#A$foo").target(s).addTrait(new RequiredTrait())
+                .source(memberSource).build();
+        MemberShape m = MemberShape.builder().id("smithy.example#A$foo").target(s).source(memberSource).build();
         StructureShape a = StructureShape.builder()
                 .id("smithy.example#A")
-                .addMember("foo", s.getId(), b1 -> b1.addTrait(new RequiredTrait()))
+                .addMember(requiredM)
                 .build();
         StructureShape b = StructureShape.builder()
                 .id("smithy.example#A")
-                .addMember("foo", s.getId())
+                .addMember(m)
                 .build();
-        Model model1 = Model.builder().addShapes(s, a).build();
-        Model model2 = Model.builder().addShapes(s, b).build();
+        Model model1 = Model.builder().addShapes(s, a, requiredM).build();
+        Model model2 = Model.builder().addShapes(s, b, m).build();
         ModelDiff.Result result = ModelDiff.builder().oldModel(model1).newModel(model2).compare();
 
         assertThat(result.isDiffBreaking(), is(true));
@@ -140,6 +149,7 @@ public class ChangedNullabilityTest {
                            .filter(event -> event.getSeverity() == Severity.ERROR)
                            .filter(event -> event.getId().equals("ChangedNullability.RemovedRequiredTrait"))
                            .filter(event -> event.getShapeId().get().equals(a.getAllMembers().get("foo").getId()))
+                           .filter(event -> event.getSourceLocation().equals(memberSource))
                            .filter(event -> event.getMessage().contains("The @required trait was removed and not "
                                                                         + "replaced with the @default trait"))
                            .count(), equalTo(1L));
@@ -147,7 +157,9 @@ public class ChangedNullabilityTest {
 
     @Test
     public void detectAdditionOfRequiredTrait() {
-        MemberShape member1 = MemberShape.builder().id("foo.baz#Baz$bam").target("foo.baz#String").build();
+        SourceLocation memberSource = new SourceLocation("a.smithy", 5, 6);
+        MemberShape member1 = MemberShape.builder().id("foo.baz#Baz$bam").target("foo.baz#String").source(memberSource)
+                .build();
         MemberShape member2 = member1.toBuilder().addTrait(new RequiredTrait()).build();
         StructureShape shapeA1 = StructureShape.builder().id("foo.baz#Baz").addMember(member1).build();
         StructureShape shapeA2 = StructureShape.builder().id("foo.baz#Baz").addMember(member2).build();
@@ -159,16 +171,19 @@ public class ChangedNullabilityTest {
         assertThat(events.stream()
                            .filter(event -> event.getSeverity() == Severity.ERROR)
                            .filter(event -> event.getId().equals("ChangedNullability.AddedRequiredTrait"))
+                           .filter(event -> event.getSourceLocation().equals(memberSource))
                            .filter(event -> event.getMessage().contains("The @required trait was added to a member"))
                            .count(), equalTo(1L));
     }
 
     @Test
     public void detectAdditionOfClientOptionalTrait() {
+        SourceLocation memberSource = new SourceLocation("a.smithy", 5, 6);
         MemberShape member1 = MemberShape.builder()
                 .id("foo.baz#Baz$bam")
                 .target("foo.baz#String")
                 .addTrait(new RequiredTrait())
+                .source(memberSource)
                 .build();
         MemberShape member2 = member1.toBuilder().addTrait(new ClientOptionalTrait()).build();
         StructureShape shapeA1 = StructureShape.builder().id("foo.baz#Baz").addMember(member1).build();
@@ -181,6 +196,7 @@ public class ChangedNullabilityTest {
         assertThat(events.stream()
                            .filter(event -> event.getSeverity() == Severity.ERROR)
                            .filter(event -> event.getId().equals("ChangedNullability.AddedClientOptionalTrait"))
+                           .filter(event -> event.getSourceLocation().equals(memberSource))
                            .filter(event -> event.getMessage().contains("The @clientOptional trait was added to a "
                                                                         + "@required member"))
                            .count(), equalTo(1L));
@@ -188,6 +204,7 @@ public class ChangedNullabilityTest {
 
     @Test
     public void detectsAdditionOfInputTrait() {
+        SourceLocation structureSource = new SourceLocation("a.smithy", 5, 6);
         MemberShape member1 = MemberShape.builder()
                 .id("foo.baz#Baz$bam")
                 .target("foo.baz#String")
@@ -196,7 +213,7 @@ public class ChangedNullabilityTest {
         MemberShape member2 = member1.toBuilder().addTrait(new DocumentationTrait("docs")).build();
         StructureShape shapeA1 = StructureShape.builder().id("foo.baz#Baz").addMember(member1).build();
         StructureShape shapeA2 = StructureShape.builder().id("foo.baz#Baz").addMember(member2)
-                                                         .addTrait(new InputTrait()).build();
+                                                         .addTrait(new InputTrait()).source(structureSource).build();
         StringShape target = StringShape.builder().id("foo.baz#String").build();
         Model modelA = Model.assembler().addShapes(shapeA1, member1, target).assemble().unwrap();
         Model modelB = Model.assembler().addShapes(shapeA2, member2, target).assemble().unwrap();
@@ -205,15 +222,18 @@ public class ChangedNullabilityTest {
         assertThat(events.stream()
                            .filter(event -> event.getSeverity() == Severity.DANGER)
                            .filter(event -> event.getId().equals("ChangedNullability.AddedInputTrait"))
+                           .filter(event -> event.getSourceLocation().equals(structureSource))
                            .filter(event -> event.getMessage().contains("The @input trait was added to"))
                            .count(), equalTo(1L));
         assertThat(events.stream()
                             .filter(event -> event.getId().contains("ChangedNullability"))
+                            .filter(event -> event.getSourceLocation().equals(structureSource))
                             .count(), equalTo(1L));
     }
 
     @Test
     public void detectsRemovalOfInputTrait() {
+        SourceLocation structureSource = new SourceLocation("a.smithy", 5, 6);
         MemberShape member1 = MemberShape.builder()
                 .id("foo.baz#Baz$bam")
                 .target("foo.baz#String")
@@ -230,6 +250,7 @@ public class ChangedNullabilityTest {
         StructureShape shapeA2 = StructureShape.builder()
                 .id("foo.baz#Baz")
                 .addMember(member2)
+                .source(structureSource)
                 .build();
         StringShape target = StringShape.builder().id("foo.baz#String").build();
         Model modelA = Model.assembler().addShapes(shapeA1, member1, target).assemble().unwrap();
@@ -239,6 +260,7 @@ public class ChangedNullabilityTest {
         assertThat(events.stream()
                            .filter(event -> event.getSeverity() == Severity.ERROR)
                            .filter(event -> event.getId().equals("ChangedNullability.RemovedInputTrait"))
+                           .filter(event -> event.getSourceLocation().equals(structureSource))
                            .filter(event -> event.getMessage().contains("The @input trait was removed from"))
                            .count(), equalTo(1L));
     }
@@ -324,15 +346,12 @@ public class ChangedNullabilityTest {
 
     @Test
     public void specialHandlingForRequiredStructureMembers() {
-        String originalModel =
-                "$version: \"2.0\"\n"
-                + "namespace smithy.example\n"
-                + "structure Baz {}\n"
-                + "structure Foo {\n"
-                + "    @required\n"
-                + "    baz: Baz\n"
-                + "}\n";
-        Model oldModel = Model.assembler().addUnparsedModel("foo.smithy", originalModel).assemble().unwrap();
+        SourceLocation memberSource = new SourceLocation("a.smithy", 8, 2);
+        StructureShape structBaz = StructureShape.builder().id("smithy.example#Baz").build();
+        MemberShape memberBaz = MemberShape.builder().id("smithy.example#Foo$baz").addTrait(new RequiredTrait())
+                .target(structBaz).source(memberSource).build();
+        StructureShape structFoo = StructureShape.builder().id("smithy.example#Foo").addMember(memberBaz).build();
+        Model oldModel = Model.assembler().addShapes(structFoo, structBaz, memberBaz).assemble().unwrap();
         Model newModel = ModelTransformer.create().replaceShapes(oldModel, ListUtils.of(
                 Shape.shapeToBuilder(oldModel.expectShape(ShapeId.from("smithy.example#Foo$baz")))
                         .removeTrait(RequiredTrait.ID)
@@ -343,22 +362,19 @@ public class ChangedNullabilityTest {
 
         assertThat(events, hasSize(1));
         assertThat(events.get(0).getSeverity(), is(Severity.WARNING));
+        assertThat(events.get(0).getSourceLocation(), equalTo(memberSource));
     }
 
     @Test
     public void specialHandlingForRequiredUnionMembers() {
-        String originalModel =
-                "$version: \"2.0\"\n"
-                + "namespace smithy.example\n"
-                + "union Baz {\n"
-                + "    a: String\n"
-                + "    b: String\n"
-                + "}\n"
-                + "structure Foo {\n"
-                + "    @required\n"
-                + "    baz: Baz\n"
-                + "}\n";
-        Model oldModel = Model.assembler().addUnparsedModel("foo.smithy", originalModel).assemble().unwrap();
+        SourceLocation memberSource = new SourceLocation("b.smithy", 3, 4);
+        MemberShape memberA = MemberShape.builder().id("smithy.example#Baz$a").target("smithy.api#String").build();
+        MemberShape memberB = MemberShape.builder().id("smithy.example#Baz$B").target("smithy.api#String").build();
+        UnionShape union = UnionShape.builder().id("smithy.example#Baz").addMember(memberA).addMember(memberB).build();
+        MemberShape memberBaz = MemberShape.builder().id("smithy.example#Foo$baz").addTrait(new RequiredTrait())
+                .target(union).source(memberSource).build();
+        StructureShape struct = StructureShape.builder().id("smithy.example#Foo").addMember(memberBaz).build();
+        Model oldModel = Model.assembler().addShapes(union, struct, memberA, memberB, memberBaz).assemble().unwrap();
         Model newModel = ModelTransformer.create().replaceShapes(oldModel, ListUtils.of(
                 Shape.shapeToBuilder(oldModel.expectShape(ShapeId.from("smithy.example#Foo$baz")))
                         .removeTrait(RequiredTrait.ID)
@@ -369,6 +385,7 @@ public class ChangedNullabilityTest {
 
         assertThat(events, hasSize(1));
         assertThat(events.get(0).getSeverity(), is(Severity.WARNING));
+        assertThat(events.get(0).getSourceLocation(), equalTo(memberSource));
     }
 
     @Test
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ModifiedTraitTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ModifiedTraitTest.java
index 1034860b1..e27be2901 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ModifiedTraitTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ModifiedTraitTest.java
@@ -153,7 +153,11 @@ public class ModifiedTraitTest {
 
         assertThat(events, hasSize(3));
         assertThat(events.stream().filter(e -> e.getSeverity() == Severity.WARNING).count(), equalTo(1L));
+        assertThat(events.stream().filter(e -> e.getSourceLocation().getFilename().endsWith("a.smithy")).count(),
+                equalTo(1L));
         assertThat(events.stream().filter(e -> e.getSeverity() == Severity.NOTE).count(), equalTo(2L));
+        assertThat(events.stream().filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(),
+                equalTo(2L));
 
         assertThat(messages, containsInAnyOrder(
                 "Changed trait `smithy.example#b` from \"hello\" to \"hello!\"",
@@ -178,6 +182,8 @@ public class ModifiedTraitTest {
         assertThat(events, hasSize(2));
         assertThat(events.stream().filter(e -> e.getSeverity() == Severity.ERROR).count(), equalTo(1L));
         assertThat(events.stream().filter(e -> e.getSeverity() == Severity.WARNING).count(), equalTo(1L));
+        assertThat(events.stream().filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(),
+                equalTo(2L));
 
         assertThat(messages, containsInAnyOrder(
                 "Added trait contents to `smithy.example#aTrait` at path `/bar` with value \"no\"",
@@ -199,6 +205,10 @@ public class ModifiedTraitTest {
         List<String> messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList());
 
         assertThat(events, hasSize(4));
+        assertThat(events.stream().filter(e -> e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("a.smithy")).count(), equalTo(2L));
+        assertThat(events.stream().filter(e -> !e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(), equalTo(2L));
         assertThat(messages, containsInAnyOrder(
                 "Changed trait contents of `smithy.example#aTrait` at path `/foo/1` from \"b\" to \"B\"",
                 "Added trait contents to `smithy.example#aTrait` at path `/foo/3` with value \"4\"",
@@ -222,6 +232,10 @@ public class ModifiedTraitTest {
         List<String> messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList());
 
         assertThat(events, hasSize(4));
+        assertThat(events.stream().filter(e -> e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("a.smithy")).count(), equalTo(2L));
+        assertThat(events.stream().filter(e -> !e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(), equalTo(2L));
         assertThat(messages, containsInAnyOrder(
                 "Changed trait contents of `smithy.example#aTrait` at path `/foo/1` from \"b\" to \"B\"",
                 "Added trait contents to `smithy.example#aTrait` at path `/foo/3` with value \"4\"",
@@ -244,6 +258,10 @@ public class ModifiedTraitTest {
         List<String> messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList());
 
         assertThat(events, hasSize(4));
+        assertThat(events.stream().filter(e -> e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("a.smithy")).count(), equalTo(2L));
+        assertThat(events.stream().filter(e -> !e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(), equalTo(2L));
         assertThat(messages, containsInAnyOrder(
                 "Changed trait contents of `smithy.example#aTrait` at path `/foo/bam` from \"b\" to \"B\"",
                 "Removed trait contents from `smithy.example#aTrait` at path `/foo`. Removed value: {\n    \"baz\": \"1\",\n    \"bam\": \"2\",\n    \"boo\": \"3\"\n}",
@@ -266,6 +284,8 @@ public class ModifiedTraitTest {
         List<String> messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList());
 
         assertThat(events, hasSize(2));
+        assertThat(events.stream().filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(),
+                equalTo(2L));
         assertThat(messages, containsInAnyOrder(
                 "Changed trait contents of `smithy.example#aTrait` at path `/baz/foo` from \"a\" to \"b\"",
                 "Changed trait contents of `smithy.example#aTrait` at path `/baz/baz` from \"a\" to \"b\""
@@ -286,6 +306,10 @@ public class ModifiedTraitTest {
         List<String> messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList());
 
         assertThat(events, hasSize(12));
+        assertThat(events.stream().filter(e -> e.getMessage().contains("Removed"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("a.smithy")).count(), equalTo(3L));
+        assertThat(events.stream().filter(e -> e.getMessage().contains("Changed") || e.getMessage().contains("Added"))
+                .filter(e -> e.getSourceLocation().getFilename().endsWith("b.smithy")).count(), equalTo(9L));
         assertThat(messages, containsInAnyOrder(
                 "Added trait contents to `smithy.example#aTrait` at path `/a` with value \"a\"",
                 "Removed trait contents from `smithy.example#aTrait` at path `/b`. Removed value: \"a\"",
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedEntityBindingTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedEntityBindingTest.java
index 36c22dfa7..7d1ce1cc2 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedEntityBindingTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedEntityBindingTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.ResourceShape;
 import software.amazon.smithy.model.shapes.ServiceShape;
@@ -30,11 +31,13 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class RemovedEntityBindingTest {
     @Test
     public void detectsRemovedOperationFromService() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         OperationShape o = OperationShape.builder().id("foo.baz#Operation").build();
         ServiceShape service1 = ServiceShape.builder()
                 .version("1")
                 .id("foo.baz#Service")
                 .addOperation(o.getId())
+                .source(source)
                 .build();
         ServiceShape service2 = service1.toBuilder().clearOperations().build();
         Model modelA = Model.assembler().addShapes(service1, o).assemble().unwrap();
@@ -42,27 +45,32 @@ public class RemovedEntityBindingTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "RemovedOperationBinding.FromService.Operation").size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsRemovedOperationFromResource() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         OperationShape o = OperationShape.builder().id("foo.baz#Operation").build();
-        ResourceShape r1 = ResourceShape.builder().id("foo.baz#Resource").addOperation(o.getId()).build();
+        ResourceShape r1 = ResourceShape.builder().id("foo.baz#Resource").addOperation(o.getId()).source(source).build();
         ResourceShape r2 = r1.toBuilder().clearOperations().build();
         Model modelA = Model.assembler().addShapes(r1, o).assemble().unwrap();
         Model modelB = Model.assembler().addShapes(r2, o).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "RemovedOperationBinding.FromResource.Operation").size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsRemovedResourceFromService() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         ResourceShape r = ResourceShape.builder().id("foo.baz#Resource").build();
         ServiceShape service1 = ServiceShape.builder()
                 .id("foo.baz#Service")
                 .version("1")
                 .addResource(r.getId())
+                .source(source)
                 .build();
         ServiceShape service2 = service1.toBuilder().clearResources().build();
         Model modelA = Model.assembler().addShapes(service1, r).assemble().unwrap();
@@ -70,17 +78,20 @@ public class RemovedEntityBindingTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "RemovedResourceBinding.FromService.Resource").size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsRemovedResourceFromResource() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         ResourceShape child = ResourceShape.builder().id("foo.baz#C").build();
-        ResourceShape p1 = ResourceShape.builder().id("foo.baz#P").addResource(child.getId()).build();
+        ResourceShape p1 = ResourceShape.builder().id("foo.baz#P").addResource(child.getId()).source(source).build();
         ResourceShape p2 = p1.toBuilder().clearResources().build();
         Model modelA = Model.assembler().addShapes(p1, child).assemble().unwrap();
         Model modelB = Model.assembler().addShapes(p2, child).assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "RemovedResourceBinding.FromResource.C").size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 }
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedOperationErrorTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedOperationErrorTest.java
index 3c6ff908a..70af4d2f8 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedOperationErrorTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedOperationErrorTest.java
@@ -23,6 +23,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StructureShape;
@@ -32,6 +33,7 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class RemovedOperationErrorTest {
     @Test
     public void detectsRemovedErrors() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         Shape e1 = StructureShape.builder()
                 .id("foo.baz#E1")
                 .addTrait(new ErrorTrait("client"))
@@ -44,6 +46,7 @@ public class RemovedOperationErrorTest {
                 .id("foo.baz#Operation")
                 .addError(e1)
                 .addError(e2)
+                .source(source)
                 .build();
         Shape operation2 = operation1.toBuilder().clearErrors().build();
         Model modelA = Model.assembler().addShapes(operation1, e1, e2).assemble().unwrap();
@@ -52,6 +55,7 @@ public class RemovedOperationErrorTest {
 
         // Emits an event for each removal.
         assertThat(TestHelper.findEvents(events, "RemovedOperationError").size(), equalTo(2));
+        assertThat(events.stream().filter(e -> e.getSourceLocation().equals(source)).count(), equalTo(2L));
         assertThat(TestHelper.findEvents(events, "RemovedOperationError.E1").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, "RemovedOperationError.E2").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, "RemovedOperationError").get(0).toString(),
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinitionTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinitionTest.java
index 59b27c7d1..5fdf3d2eb 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinitionTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedTraitDefinitionTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.traits.TraitDefinition;
@@ -30,9 +31,10 @@ import software.amazon.smithy.model.validation.ValidationEvent;
 public class RemovedTraitDefinitionTest {
     @Test
     public void detectsRemovedTraitDefinition() {
+        SourceLocation source = new SourceLocation("bar.smithy");
         Shape definition = StringShape.builder()
                 .id("foo.baz#bam")
-                .addTrait(TraitDefinition.builder().build())
+                .addTrait(TraitDefinition.builder().sourceLocation(source).build())
                 .build();
 
         Model modelA = Model.assembler().addShape(definition).assemble().unwrap();
@@ -40,5 +42,7 @@ public class RemovedTraitDefinitionTest {
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
         assertThat(TestHelper.findEvents(events, "RemovedTraitDefinition").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "RemovedTraitDefinition").get(0).getSourceLocation(),
+                equalTo(source));
     }
 }
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ServiceRenameTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ServiceRenameTest.java
index f94d57208..aa9a584d6 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ServiceRenameTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/ServiceRenameTest.java
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.diff.ModelDiff;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.ServiceShape;
 import software.amazon.smithy.model.validation.Severity;
@@ -48,6 +49,7 @@ public class ServiceRenameTest {
 
     @Test
     public void detectsRenameRemoved() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         ServiceShape service1 = service.toBuilder()
                 .putRename(operation.getId(), "O1")
                 .build();
@@ -57,6 +59,7 @@ public class ServiceRenameTest {
 
         ServiceShape service2 = service.toBuilder()
                 .clearRename()
+                .source(source)
                 .build();
         Model modelB = modelA.toBuilder().addShape(service2).build();
 
@@ -64,10 +67,12 @@ public class ServiceRenameTest {
         assertThat(TestHelper.findEvents(events, "ServiceRename").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, service2.getId()).size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsRenameChange() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         ServiceShape service1 = service.toBuilder()
                 .putRename(operation.getId(), "O1")
                 .build();
@@ -77,6 +82,7 @@ public class ServiceRenameTest {
 
         ServiceShape service2 = service.toBuilder()
                 .putRename(operation.getId(), "O2")
+                .source(source)
                 .build();
         Model modelB = modelA.toBuilder().addShape(service2).build();
 
@@ -84,16 +90,19 @@ public class ServiceRenameTest {
         assertThat(TestHelper.findEvents(events, "ServiceRename").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, service2.getId()).size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
     public void detectsRenameAdded() {
+        SourceLocation source = new SourceLocation("foo.smithy");
         Model modelA = Model.builder()
                 .addShapes(operation, service)
                 .build();
 
         ServiceShape service2 = service.toBuilder()
                 .putRename(operation.getId(), "O2")
+                .source(source)
                 .build();
         Model modelB = modelA.toBuilder().addShape(service2).build();
 
@@ -102,6 +111,7 @@ public class ServiceRenameTest {
         assertThat(TestHelper.findEvents(events, "ServiceRename").size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, service2.getId()).size(), equalTo(1));
         assertThat(TestHelper.findEvents(events, Severity.ERROR).size(), equalTo(1));
+        assertThat(events.get(0).getSourceLocation(), equalTo(source));
     }
 
     @Test
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
index 226d86778..6dcf0e75a 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
@@ -24,20 +24,24 @@ resource MyResource {
         id: String
     }
     put: ResourceOperation
-    create: ResourceOperation
+    create: EmptyOperation
     read: ReadonlyResourceOperation
     update: ResourceOperation
     delete: ResourceOperation
-    list: ReadonlyResourceOperation
+    list: CollectionResourceOperation
     operations: [
-        ResourceOperation
+        CollectionResourceOperation
     ]
     collectionOperations: [
-        ResourceOperation
+        CollectionResourceOperation
     ]
     resources: [
         SubResource
     ]
+    properties: {
+        value: String
+        other: String
+    }
 }
 
 resource SubResource {
@@ -46,6 +50,15 @@ resource SubResource {
     }
 }
 
+@readonly
+operation CollectionResourceOperation {
+    input := {}
+    output := {}
+    errors: [
+        Error
+    ]
+}
+
 operation EmptyOperation {
     input: Unit
     output: Unit
@@ -62,6 +75,7 @@ operation MyOperation {
 @readonly
 operation ReadonlyResourceOperation {
     input := {
+        @required
         id: String
     }
     output: Unit
@@ -70,9 +84,13 @@ operation ReadonlyResourceOperation {
 @idempotent
 operation ResourceOperation {
     input := {
+        @required
         id: String
     }
-    output: Unit
+    output := {
+        value: String
+        other: String
+    }
 }
 
 @error("client")
diff --git a/smithy-smoke-test-traits/build.gradle b/smithy-smoke-test-traits/build.gradle
new file mode 100644
index 000000000..0dd359c84
--- /dev/null
+++ b/smithy-smoke-test-traits/build.gradle
@@ -0,0 +1,25 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+description = "Defines smoke test traits."
+
+ext {
+    displayName = "Smithy :: Smoke Test Traits"
+    moduleName = "software.amazon.smithy.smoketest.traits"
+}
+
+dependencies {
+    api project(":smithy-model")
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/Expectation.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/Expectation.java
new file mode 100644
index 000000000..28fb996ff
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/Expectation.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.Optional;
+import software.amazon.smithy.model.node.ExpectationNotMetException;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.ToNode;
+import software.amazon.smithy.model.validation.ValidationUtils;
+import software.amazon.smithy.utils.ListUtils;
+
+/**
+ * Defines the expected result of the service call for a smoke test case.
+ *
+ * <p>This can either be a successful response, any error response, or a
+ * specific error response.
+ */
+public final class Expectation implements ToNode {
+    private static final String SUCCESS = "success";
+    private static final String FAILURE = "failure";
+
+    private final FailureExpectation failure;
+
+    private Expectation(FailureExpectation failure) {
+        this.failure = failure;
+    }
+
+    /**
+     * @return Creates an expectation that the service call for a smoke
+     * test case is successful.
+     */
+    public static Expectation success() {
+        return new Expectation(null);
+    }
+
+    /**
+     * @param failure The failure to expect.
+     * @return Creates an expectation that the service call for a smoke test
+     * case will result in the given failure.
+     */
+    public static Expectation failure(FailureExpectation failure) {
+        return new Expectation(failure);
+    }
+
+    /**
+     * Creates a {@link Expectation} from a {@link Node}.
+     *
+     * @param node Node to deserialize into a {@link Expectation}.
+     * @return Returns the created {@link Expectation}.
+     */
+    public static Expectation fromNode(Node node) {
+        ObjectNode o = node.expectObjectNode();
+        if (o.containsMember(SUCCESS)) {
+            o.expectNoAdditionalProperties(ListUtils.of(SUCCESS));
+            return Expectation.success();
+        } else if (o.containsMember(FAILURE)) {
+            o.expectNoAdditionalProperties(ListUtils.of(FAILURE));
+            FailureExpectation failure = FailureExpectation.fromNode(o.expectObjectMember(FAILURE));
+            return Expectation.failure(failure);
+        } else {
+            throw new ExpectationNotMetException("Expected an object with exactly one `" + SUCCESS + "` or `" + FAILURE
+                    + "` property, but found properties: " + ValidationUtils.tickedList(o.getStringMap().keySet()), o);
+        }
+    }
+
+    /**
+     * @return Whether the service call is expected to succeed.
+     */
+    public boolean isSuccess() {
+        return failure == null;
+    }
+
+    /**
+     * @return Whether the service call is expected to fail.
+     */
+    public boolean isFailure() {
+        return failure != null;
+    }
+
+    /**
+     * @return The expected failure, if this expectation is a failure expectation.
+     */
+    public Optional<FailureExpectation> getFailure() {
+        return Optional.ofNullable(failure);
+    }
+
+    @Override
+    public Node toNode() {
+        ObjectNode.Builder builder = Node.objectNodeBuilder();
+        if (this.isSuccess()) {
+            builder.withMember(SUCCESS, Node.objectNode());
+        } else {
+            Node failureNode = this.getFailure()
+                    .map(FailureExpectation::toNode)
+                    .orElse(Node.objectNode());
+            builder.withMember(FAILURE, failureNode);
+        }
+        return builder.build();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        } else if (o == null || o.getClass() != getClass()) {
+            return false;
+        } else {
+            return toNode().equals(((Expectation) o).toNode());
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return toNode().hashCode();
+    }
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/FailureExpectation.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/FailureExpectation.java
new file mode 100644
index 000000000..ffcd8962e
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/FailureExpectation.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.Optional;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.StringNode;
+import software.amazon.smithy.model.node.ToNode;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * Defines the expected failure of a service call for a smoke test case.
+ *
+ * <p>This can be any error response, or a specific error response.
+ */
+public final class FailureExpectation implements ToNode {
+    private static final String ERROR_ID = "errorId";
+
+    private final ShapeId errorId;
+
+    private FailureExpectation(ShapeId errorId) {
+        this.errorId = errorId;
+    }
+
+    /**
+     * @return Creates a failure expectation that the service call will result
+     * in any error response.
+     */
+    public static FailureExpectation anyError() {
+        return new FailureExpectation(null);
+    }
+
+    /**
+     * @param errorId Shape ID of the expected error.
+     * @return Creates a failure expectation that the service call will result
+     * in an error matching the given shape ID.
+     */
+    public static FailureExpectation errorWithId(ShapeId errorId) {
+        return new FailureExpectation(errorId);
+    }
+
+    /**
+     * Gets the ID of the expected error shape.
+     *
+     * <p>If present, it indicates the call should throw a matching error.
+     * Otherwise, the call should throw any error.
+     *
+     * @return The ID of the expected error shape.
+     */
+    public Optional<ShapeId> getErrorId() {
+        return Optional.ofNullable(errorId);
+    }
+
+    @Override
+    public Node toNode() {
+        return Node.objectNodeBuilder()
+                .withOptionalMember(ERROR_ID, this.getErrorId().map(ShapeId::toString).map(StringNode::from))
+                .build();
+    }
+
+    public static FailureExpectation fromNode(Node node) {
+        ObjectNode o = node.expectObjectNode();
+        return o.getStringMember(ERROR_ID)
+                .map(ShapeId::fromNode)
+                .map(FailureExpectation::errorWithId)
+                .orElse(FailureExpectation.anyError());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        } else if (o == null || o.getClass() != getClass()) {
+            return false;
+        } else {
+            return toNode().equals(((FailureExpectation) o).toNode());
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return toNode().hashCode();
+    }
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCase.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCase.java
new file mode 100644
index 000000000..d7a06b4b9
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCase.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.StringNode;
+import software.amazon.smithy.model.node.ToNode;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.utils.ListUtils;
+import software.amazon.smithy.utils.SmithyBuilder;
+import software.amazon.smithy.utils.Tagged;
+import software.amazon.smithy.utils.ToSmithyBuilder;
+
+/**
+ * Defines a single smoke test case.
+ */
+public final class SmokeTestCase implements Tagged, ToNode, ToSmithyBuilder<SmokeTestCase> {
+    private static final String ID = "id";
+    private static final String PARAMS = "params";
+    private static final String VENDOR_PARAMS = "vendorParams";
+    private static final String VENDOR_PARAMS_SHAPE = "vendorParamsShape";
+    private static final String EXPECT = "expect";
+    private static final String TAGS = "tags";
+
+    private final String id;
+    private final ObjectNode params;
+    private final ObjectNode vendorParams;
+    private final ShapeId vendorParamsShape;
+    private final Expectation expectation;
+    private final List<String> tags;
+
+    private SmokeTestCase(Builder builder) {
+        this.id = SmithyBuilder.requiredState(ID, builder.id);
+        this.params = builder.params;
+        this.vendorParams = builder.vendorParams;
+        this.vendorParamsShape = builder.vendorParamsShape;
+        this.expectation = SmithyBuilder.requiredState(EXPECT, builder.expectation);
+        this.tags = ListUtils.copyOf(builder.tags);
+    }
+
+    /**
+     * @return Creates a builder used to build a {@link SmokeTestCase}.
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+
+    /**
+     * Creates a {@link SmokeTestCase} from a {@link Node}.
+     *
+     * @param node Node to deserialize into a {@link SmokeTestCase}.
+     * @return Returns the created {@link SmokeTestCase}.
+     */
+    public static SmokeTestCase fromNode(Node node) {
+        Builder builder = builder();
+        ObjectNode o = node.expectObjectNode();
+        builder.id(o.expectStringMember(ID).getValue());
+        o.getObjectMember(PARAMS).ifPresent(builder::params);
+        o.getObjectMember(VENDOR_PARAMS).ifPresent(builder::vendorParams);
+        o.getStringMember(VENDOR_PARAMS_SHAPE).map(StringNode::expectShapeId).ifPresent(builder::vendorParamsShape);
+        builder.expectation(Expectation.fromNode(o.expectObjectMember(EXPECT)));
+        o.getArrayMember(TAGS).ifPresent(tags -> builder.tags(tags.getElementsAs(StringNode::getValue)));
+        return builder.build();
+    }
+
+    /**
+     * Get the ID of this test case.
+     *
+     * <p>This ID is unique across all smoke test cases for all operations
+     * bound to the same service, and can be used to generate test case names.
+     *
+     * @return Returns the smoke test case ID.
+     */
+    public String getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the input parameters of this test case.
+     *
+     * <p>These parameters, if present, are passed to the operation the smoke test
+     * targets. If not present, the operation does not have any input.
+     *
+     * @return Returns the optionally present input parameters.
+     */
+    public Optional<ObjectNode> getParams() {
+        return Optional.ofNullable(this.params);
+    }
+
+    /**
+     * Get the vendor-specific parameters of this test case.
+     *
+     * <p>These are custom parameters that can be used to influence the
+     * request for this test case.
+     *
+     * <p>These parameters match the shape id returned by {@link #getVendorParamsShape()},
+     * if present.
+     *
+     * @see #getVendorParamsShape()
+     * @return Returns the optionally present vendor-specific parameters.
+     */
+    public Optional<ObjectNode> getVendorParams() {
+        return Optional.ofNullable(this.vendorParams);
+    }
+
+    /**
+     * Get the shape ID of vendor-specific parameters used by this test case.
+     *
+     * <p>If present, {@link #getVendorParams()} will match this shape's definition.
+     *
+     * @see #getVendorParams()
+     * @return The optionally present shape ID of vendor-specific parameters.
+     */
+    public Optional<ShapeId> getVendorParamsShape() {
+        return Optional.ofNullable(this.vendorParamsShape);
+    }
+
+    /**
+     * Get the expected response from the service call for this test case.
+     *
+     * <p>This expectation can be either a successful response, any error
+     * response, or a specific error response.
+     *
+     * @return Returns expectation.
+     */
+    public Expectation getExpectation() {
+        return this.expectation;
+    }
+
+    @Override
+    public List<String> getTags() {
+        return this.tags;
+    }
+
+    @Override
+    public Builder toBuilder() {
+        return builder()
+                .id(this.getId())
+                .params(this.getParams().orElse(null))
+                .vendorParams(this.getVendorParams().orElse(null))
+                .vendorParamsShape(this.getVendorParamsShape().orElse(null))
+                .expectation(this.getExpectation())
+                .tags(this.getTags());
+    }
+
+    @Override
+    public Node toNode() {
+        ObjectNode.Builder builder = Node.objectNodeBuilder()
+                .withMember(ID, this.getId())
+                .withOptionalMember(PARAMS, this.getParams())
+                .withOptionalMember(VENDOR_PARAMS, this.getVendorParams())
+                .withOptionalMember(VENDOR_PARAMS_SHAPE,
+                        this.getVendorParamsShape().map(ShapeId::toString).map(Node::from))
+                .withMember(EXPECT, this.getExpectation());
+
+        if (!this.tags.isEmpty()) {
+            builder.withMember(TAGS, ArrayNode.fromStrings(tags));
+        }
+
+        return builder.build();
+
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        } else if (o == null || o.getClass() != getClass()) {
+            return false;
+        } else {
+            return toNode().equals(((SmokeTestCase) o).toNode());
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return toNode().hashCode();
+    }
+
+    public static final class Builder implements SmithyBuilder<SmokeTestCase> {
+        private String id;
+        private ObjectNode params;
+        private ObjectNode vendorParams;
+        private ShapeId vendorParamsShape;
+        private Expectation expectation;
+        private final List<String> tags = new ArrayList<>();
+
+        private Builder() {}
+
+        /**
+         * Sets the smoke test case ID.
+         *
+         * <p>Must be unique across all smoke test cases on other operations
+         * bound to the same service.
+         *
+         * <p>Must match the following regex: {@code ^[A-Za-z_][A-Za-z0-9_]+$}
+         *
+         * @param id Test case ID.
+         * @return Returns the builder.
+         */
+        public Builder id(String id) {
+            this.id = id;
+            return this;
+        }
+
+        /**
+         * Sets the input parameters to the operation targeted by the smoke test case.
+         *
+         * <p>These parameters must be compatible with the operation's input.
+         *
+         * @param params Test case operation input parameters.
+         * @return Returns the builder.
+         */
+        public Builder params(ObjectNode params) {
+            this.params = params;
+            return this;
+        }
+
+        /**
+         * Sets vendor-specific parameters for the test case which can be used to
+         * influence the request.
+         *
+         * <p>If {@link #vendorParamsShape(ShapeId)} is set, these parameters must
+         * match that shape's definition.
+         *
+         * @param vendorParams Vendor-specific parameters for the test case.
+         * @return Returns the builder.
+         */
+        public Builder vendorParams(ObjectNode vendorParams) {
+            this.vendorParams = vendorParams;
+            return this;
+        }
+
+        /**
+         * Sets the shape ID of vendor-specific parameters for the test case.
+         *
+         * <p>If set, {@link #vendorParams(ObjectNode)} must be compatible with
+         * this shape's definition
+         *
+         * @param vendorParamsShape Shape ID of vendor-specific parameters.
+         * @return Returns the builder.
+         */
+        public Builder vendorParamsShape(ShapeId vendorParamsShape) {
+            this.vendorParamsShape = vendorParamsShape;
+            return this;
+        }
+
+        /**
+         * Sets the expected response of the service call for the test case.
+         *
+         * @param expectation Expected response of the service call.
+         * @return Returns the builder.
+         */
+        public Builder expectation(Expectation expectation) {
+            this.expectation = expectation;
+            return this;
+        }
+
+        /**
+         * Sets the tags that can be used to categorize and group test cases.
+         *
+         * @param tags Tags to attach to the test case.
+         * @return Returns the builder.
+         */
+        public Builder tags(List<String> tags) {
+            this.tags.clear();
+            this.tags.addAll(tags);
+            return this;
+        }
+
+        @Override
+        public SmokeTestCase build() {
+            return new SmokeTestCase(this);
+        }
+    }
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java
new file mode 100644
index 000000000..e8e6f87f5
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.knowledge.OperationIndex;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.validation.AbstractValidator;
+import software.amazon.smithy.model.validation.NodeValidationVisitor;
+import software.amazon.smithy.model.validation.ValidationEvent;
+import software.amazon.smithy.model.validation.node.TimestampValidationStrategy;
+
+/**
+ * Validates the following:
+ *
+ * <ul>
+ *     <li>vendorParamsShape is a valid shape.</li>
+ *     <li>vendorParams is compatible with vendorParamsShape.</li>
+ *     <li>input is valid for the operation under test.</li>
+ * </ul>
+ */
+public class SmokeTestCaseValidator extends AbstractValidator {
+
+    @Override
+    public List<ValidationEvent> validate(Model model) {
+        OperationIndex operationIndex = OperationIndex.of(model);
+        List<ValidationEvent> events = new ArrayList<>();
+        for (Shape shape : model.getShapesWithTrait(SmokeTestsTrait.class)) {
+            SmokeTestsTrait trait = shape.expectTrait(SmokeTestsTrait.class);
+            List<SmokeTestCase> testCases = trait.getTestCases();
+
+            for (SmokeTestCase testCase : testCases) {
+                // Validate vendor params shape
+                Optional<ShapeId> vendorParamsShapeIdOptional = testCase.getVendorParamsShape();
+                Optional<ObjectNode> vendorParamsOptional = testCase.getVendorParams();
+                if (vendorParamsShapeIdOptional.isPresent()) {
+                    if (!vendorParamsOptional.isPresent()) {
+                        events.add(warning(shape, trait, String.format(
+                                "Smoke test case with ID `%s` defined a `vendorParamsShape` but no `vendorParams`",
+                                testCase.getId())));
+                    } else {
+                        Optional<Shape> vendorParamsShapeOptional = model.getShape(vendorParamsShapeIdOptional.get());
+                        if (vendorParamsShapeOptional.isPresent()) {
+                            Shape vendorParamsShape = vendorParamsShapeOptional.get();
+                            NodeValidationVisitor vendorParamsValidator = createVisitor(vendorParamsOptional.get(),
+                                    model, shape, testCase.getId(), ".vendorParams");
+                            events.addAll(vendorParamsShape.accept(vendorParamsValidator));
+                        }
+
+                    }
+                } else if (vendorParamsOptional.isPresent()) {
+                    events.add(warning(shape, trait, String.format(
+                            "Smoke test case with ID `%s` defined `vendorParams` but no `vendorParamsShape`",
+                            testCase.getId())));
+                }
+
+                // Validate input params
+                StructureShape input = operationIndex.expectInputShape(shape);
+                if (input != null && testCase.getParams().isPresent()) {
+                    NodeValidationVisitor paramsValidator = createVisitor(testCase.getParams().get(), model, shape,
+                            testCase.getId(), ".params");
+                    events.addAll(input.accept(paramsValidator));
+                } else if (testCase.getParams().isPresent()) {
+                    events.add(error(shape, trait, String.format(
+                            "Smoke test parameters provided for operation with no input: `%s`",
+                            Node.printJson(testCase.getParams().get())
+
+                    )));
+                }
+            }
+        }
+        return events;
+    }
+
+    private NodeValidationVisitor createVisitor(
+            ObjectNode node,
+            Model model,
+            Shape shape,
+            String caseId,
+            String contextSuffix
+    ) {
+        return NodeValidationVisitor.builder()
+                .model(model)
+                .eventShapeId(shape.getId())
+                .value(node)
+                .startingContext(SmokeTestsTrait.ID + "." + caseId + contextSuffix)
+                .eventId(getName())
+                .timestampValidationStrategy(TimestampValidationStrategy.EPOCH_SECONDS)
+                .allowOptionalNull(true)
+                .build();
+    }
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestsTrait.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestsTrait.java
new file mode 100644
index 000000000..338d6db49
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestsTrait.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.List;
+import software.amazon.smithy.model.SourceLocation;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.traits.AbstractTrait;
+import software.amazon.smithy.model.traits.Trait;
+import software.amazon.smithy.utils.ListUtils;
+
+/**
+ * Defines a set of test cases to send to a live service to ensure that
+ * a client can connect to the service and get the right kind of response.
+ */
+public final class SmokeTestsTrait extends AbstractTrait {
+    public static final ShapeId ID = ShapeId.from("smithy.test#smokeTests");
+
+    private final List<SmokeTestCase> testCases;
+
+    public SmokeTestsTrait(List<SmokeTestCase> testCases) {
+        this(SourceLocation.NONE, testCases);
+    }
+
+    public SmokeTestsTrait(SourceLocation sourceLocation, List<SmokeTestCase> testCases) {
+        super(ID, sourceLocation);
+        this.testCases = ListUtils.copyOf(testCases);
+    }
+
+    public static final class Provider extends AbstractTrait.Provider {
+        public Provider() {
+            super(ID);
+        }
+
+        @Override
+        public Trait createTrait(ShapeId target, Node value) {
+            ArrayNode values = value.expectArrayNode();
+            List<SmokeTestCase> testCases = values.getElementsAs(SmokeTestCase::fromNode);
+            SmokeTestsTrait result = new SmokeTestsTrait(value.getSourceLocation(), testCases);
+            result.setNodeCache(value);
+            return result;
+        }
+    }
+
+    /**
+     * @return The smoke test cases to perform on the operation.
+     */
+    public List<SmokeTestCase> getTestCases() {
+        return this.testCases;
+    }
+
+    @Override
+    protected Node createNode() {
+        return this.getTestCases().stream().collect(ArrayNode.collect(this.getSourceLocation()));
+    }
+}
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/UniqueSmokeTestCaseIdValidator.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/UniqueSmokeTestCaseIdValidator.java
new file mode 100644
index 000000000..ed8d16684
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/UniqueSmokeTestCaseIdValidator.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ *  http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.OperationShape;
+import software.amazon.smithy.model.shapes.ServiceShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.validation.AbstractValidator;
+import software.amazon.smithy.model.validation.ValidationEvent;
+import software.amazon.smithy.model.validation.ValidationUtils;
+import software.amazon.smithy.utils.ListUtils;
+
+/**
+ * Validates that smoke test cases have unique IDs within a service closure,
+ * or within the trait if it is not connected to a service.
+ */
+public class UniqueSmokeTestCaseIdValidator extends AbstractValidator {
+
+    @Override
+    public List<ValidationEvent> validate(Model model) {
+        List<ValidationEvent> events = new ArrayList<>();
+        Set<ServiceShape> serviceShapes = model.getServiceShapes();
+        Set<ShapeId> serviceBoundOperationIds = new HashSet<>();
+        // Validate test case ids within each service closure
+        for (ServiceShape service : serviceShapes) {
+            Set<ShapeId> operationIds = service.getAllOperations();
+            serviceBoundOperationIds.addAll(operationIds);
+            List<Shape> shapes = operationIds.stream()
+                    .map(model::getShape)
+                    .filter(Optional::isPresent)
+                    .map(Optional::get)
+                    .collect(Collectors.toList());
+            addValidationEventsForShapes(shapes, events);
+        }
+
+        // Also validate ids are unique within each non-service bound operation
+        List<OperationShape> shapes = model.getOperationShapesWithTrait(SmokeTestsTrait.class).stream()
+                .filter(shape -> !serviceBoundOperationIds.contains(shape.getId()))
+                .collect(Collectors.toList());
+        for (OperationShape shape : shapes) {
+            addValidationEventsForShapes(ListUtils.of(shape), events);
+        }
+        return events;
+    }
+
+    private void addValidationEventsForShapes(List<? extends Shape> shapes, List<ValidationEvent> events) {
+        Map<String, List<Shape>> testCaseIdsToOperations = new HashMap<>();
+        for (Shape shape : shapes) {
+            if (!shape.isOperationShape() || !shape.hasTrait(SmokeTestsTrait.class)) {
+                continue;
+            }
+
+            SmokeTestsTrait trait = shape.expectTrait(SmokeTestsTrait.class);
+            for (SmokeTestCase testCase : trait.getTestCases()) {
+                testCaseIdsToOperations.computeIfAbsent(testCase.getId(), id -> new ArrayList<>()).add(shape);
+            }
+        }
+
+        for (Map.Entry<String, List<Shape>> entry : testCaseIdsToOperations.entrySet()) {
+            if (entry.getValue().size() > 1) {
+                for (Shape shape : entry.getValue()) {
+                    events.add(error(shape, String.format(
+                            "Conflicting `%s` test case IDs found for ID `%s`: %s",
+                            SmokeTestsTrait.ID, entry.getKey(),
+                            ValidationUtils.tickedList(entry.getValue().stream().map(Shape::getId))
+                    )));
+                }
+            }
+        }
+    }
+
+
+}
diff --git a/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService
new file mode 100644
index 000000000..9f92a2a31
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService
@@ -0,0 +1,1 @@
+software.amazon.smithy.smoketests.traits.SmokeTestsTrait$Provider
diff --git a/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
new file mode 100644
index 000000000..30ff7e381
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
@@ -0,0 +1,2 @@
+software.amazon.smithy.smoketests.traits.SmokeTestCaseValidator
+software.amazon.smithy.smoketests.traits.UniqueSmokeTestCaseIdValidator
diff --git a/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/manifest b/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/manifest
new file mode 100644
index 000000000..8970a79dc
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/manifest
@@ -0,0 +1,1 @@
+smithy.test.smithy
diff --git a/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy b/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy
new file mode 100644
index 000000000..365e27370
--- /dev/null
+++ b/smithy-smoke-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy
@@ -0,0 +1,89 @@
+$version: "2.0"
+
+namespace smithy.test
+
+/// Defines a set of test cases to send to a live service to ensure that a
+/// client can successfully connect to a service and get the right kind of
+/// response.
+@trait(selector: "operation")
+list smokeTests {
+    /// A list of smoke tests to perform on the operation
+    member: SmokeTestCase
+}
+
+/// A single smoke test case definition.
+@private
+structure SmokeTestCase {
+    /// The identifier of the test case. This identifier may be used by
+    /// smoke test implementations to generate test case names. The provided
+    /// `id` MUST match Smithy's `IDENTIFIER` ABNF. No two test cases can
+    /// share the same ID, including test cases defined for other operations
+    /// bound to the same service.
+    @required
+    @pattern("^[A-Za-z_][A-Za-z0-9_]+$")
+    id: String
+
+    /// Defines the input parameters used to generate the request. These
+    /// parameters MUST be compatible with the input of the operation.
+    ///
+    /// Parameter values that contain binary data MUST be defined using values
+    /// that can be represented in plain text as the plain text representation
+    /// (for example, use "foo" and not "Zm9vCg==").
+    params: Document
+
+    /// Defines vendor-specific parameters that are used to influence the
+    /// request. For example, some vendors might utilize environment variables,
+    /// configuration files on disk, or other means to influence the
+    /// serialization formats used by clients or servers.
+    ///
+    /// If a `vendorParamsShape` is set, these parameters MUST be compatible with
+    /// that shape's definition.
+    vendorParams: Document
+
+    /// A shape to be used to validate the `vendorParams` member contents.
+    ///
+    /// If set, the parameters in `vendorParams` MUST be compatible with this
+    /// shape's definition.
+    @idRef(failWhenMissing: true)
+    vendorParamsShape: String
+
+    /// Defines the response that is expected from the service call. This can
+    /// be either a successful response, an error message, or a specific error
+    /// response.
+    @required
+    expect: Expectation
+
+    /// Attaches a list of tags that can be used to categorize and group
+    /// test cases. If a test case uses a feature that requires special
+    /// configuration, it should be tagged.
+    tags: TagList
+}
+
+/// The different kinds of expectations that can be made for a test case.
+@private
+union Expectation {
+    /// Indicates that the call is expeted to not throw an error. No other
+    /// assertions are made about the response.
+    success: Unit
+
+    /// Indicates that the call is expected to throw an error.
+    failure: FailureExpectation
+}
+
+@private
+structure FailureExpectation {
+    /// Indicates that the call is expected to throw a specific type of error
+    /// matching the targeted shape. If not specified, the error can be of
+    /// any type.
+    @idRef(failWhenMissing: true, selector: "[trait|error]")
+    errorId: String
+}
+
+@private
+list TagList {
+    member: Tag
+}
+
+@private
+@pattern("^[A-Za-z][A-Za-z0-9_\\-]+$")
+string Tag
diff --git a/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/RunnerTest.java b/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/RunnerTest.java
new file mode 100644
index 000000000..a138bae3e
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/RunnerTest.java
@@ -0,0 +1,21 @@
+package software.amazon.smithy.smoketests.traits;
+
+import java.util.concurrent.Callable;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.smithy.model.validation.testrunner.SmithyTestCase;
+import software.amazon.smithy.model.validation.testrunner.SmithyTestSuite;
+
+
+public class RunnerTest {
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("source")
+    public void testRunner(String filename, Callable<SmithyTestCase.Result> callable) throws Exception {
+        callable.call();
+    }
+
+    public static Stream<?> source() {
+        return SmithyTestSuite.defaultParameterizedTestSource(RunnerTest.class);
+    }
+}
diff --git a/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/SmokeTestsTraitTest.java b/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/SmokeTestsTraitTest.java
new file mode 100644
index 000000000..e90632a5d
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/java/software/amazon/smithy/smoketests/traits/SmokeTestsTraitTest.java
@@ -0,0 +1,122 @@
+package software.amazon.smithy.smoketests.traits;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+public class SmokeTestsTraitTest {
+    @Test
+    public void successTest() {
+        SmokeTestCase testCase = getSmokeTestCase("success-test.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+
+        Expectation expectation = testCase.getExpectation();
+        assertThat(Expectation.fromNode(expectation.toNode()), equalTo(expectation));
+        assertTrue(expectation.isSuccess());
+        assertFalse(expectation.isFailure());
+        assertThat(expectation.getFailure(), equalTo(Optional.empty()));
+    }
+
+    @Test
+    public void anyFailureTest() {
+        SmokeTestCase testCase = getSmokeTestCase("any-failure-test.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+
+        Expectation expectation = testCase.getExpectation();
+        assertThat(Expectation.fromNode(expectation.toNode()), equalTo(expectation));
+        assertTrue(expectation.isFailure());
+        assertFalse(expectation.isSuccess());
+        assertTrue(expectation.getFailure().isPresent());
+
+        FailureExpectation failureExpectation = expectation.getFailure().get();
+        assertThat(FailureExpectation.fromNode(failureExpectation.toNode()), equalTo(failureExpectation));
+        assertThat(failureExpectation.getErrorId(), equalTo(Optional.empty()));
+    }
+
+    @Test
+    public void specificFailureTest() {
+        SmokeTestCase testCase = getSmokeTestCase("specific-failure-test.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+
+        Expectation expectation = testCase.getExpectation();
+        assertThat(Expectation.fromNode(expectation.toNode()), equalTo(expectation));
+        assertTrue(expectation.isFailure());
+        assertFalse(expectation.isSuccess());
+        assertTrue(expectation.getFailure().isPresent());
+
+        FailureExpectation failureExpectation = expectation.getFailure().get();
+        assertThat(FailureExpectation.fromNode(failureExpectation.toNode()), equalTo(failureExpectation));
+        assertTrue(failureExpectation.getErrorId().isPresent());
+        assertThat(failureExpectation.getErrorId().get(), equalTo(ShapeId.from("smithy.example#SayHelloError")));
+    }
+
+    @Test
+    public void withParams() {
+        SmokeTestCase testCase = getSmokeTestCase("test-with-params.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+        assertTrue(testCase.getParams().isPresent());
+        assertThat(testCase.getParams().get(), notNullValue());
+    }
+
+    @Test
+    public void withVendorParams() {
+        SmokeTestCase testCase = getSmokeTestCase("test-with-vendor-params.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+        assertTrue(testCase.getVendorParams().isPresent());
+        assertThat(testCase.getVendorParams().get(), notNullValue());
+        assertThat(testCase.getVendorParamsShape(), equalTo(Optional.empty()));
+    }
+
+    @Test
+    public void withVendorParamsShape() {
+        SmokeTestCase testCase = getSmokeTestCase("test-with-vendor-params-shape.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+        assertTrue(testCase.getVendorParams().isPresent());
+        assertThat(testCase.getVendorParams(), notNullValue());
+        assertTrue(testCase.getVendorParamsShape().isPresent());
+        assertThat(testCase.getVendorParamsShape().get(), equalTo(ShapeId.from("smithy.example#VendorParams")));
+    }
+
+    @Test
+    public void withTags() {
+        SmokeTestCase testCase = getSmokeTestCase("test-with-tags.smithy");
+
+        assertThat(testCase.toBuilder().build(), equalTo(testCase));
+        assertThat(SmokeTestCase.fromNode(testCase.toNode()), equalTo(testCase));
+
+        assertThat(testCase.getTags(), contains("foo", "bar"));
+        assertThat(testCase.toBuilder().build().getTags(), contains("foo", "bar"));
+    }
+
+    private static SmokeTestCase getSmokeTestCase(String filename) {
+        Model model = Model.assembler()
+                .addImport(SmokeTestsTraitTest.class.getResource(filename))
+                .discoverModels()
+                .assemble()
+                .unwrap();
+        return model.expectShape(ShapeId.from("smithy.example#SayHello"))
+                .expectTrait(SmokeTestsTrait.class)
+                .getTestCases()
+                .get(0);
+    }
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/any-failure-test.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/any-failure-test.smithy
new file mode 100644
index 000000000..508933d6f
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/any-failure-test.smithy
@@ -0,0 +1,15 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "any_failure",
+        expect: {
+            failure: {}
+        }
+    }
+])
+operation SayHello {}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.errors b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.errors
new file mode 100644
index 000000000..a24d3d91d
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.errors
@@ -0,0 +1,3 @@
+[ERROR] smithy.example#SayHello: Conflicting `smithy.test#smokeTests` test case IDs found for ID `say_hello`: `smithy.example#SayHello`, `smithy.example#SayHello`, `smithy.example#SayHello2` | UniqueSmokeTestCaseId
+[ERROR] smithy.example#SayHello2: Conflicting `smithy.test#smokeTests` test case IDs found for ID `say_hello`: `smithy.example#SayHello`, `smithy.example#SayHello`, `smithy.example#SayHello2` | UniqueSmokeTestCaseId
+[ERROR] smithy.example#SayHello3: Conflicting `smithy.test#smokeTests` test case IDs found for ID `say_hello`: `smithy.example#SayHello3`, `smithy.example#SayHello3` | UniqueSmokeTestCaseId
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.smithy
new file mode 100644
index 000000000..2b316c032
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/duplicate-ids.smithy
@@ -0,0 +1,113 @@
+$version: "2.0"
+
+metadata suppressions = [
+    {
+        id: "UnreferencedShape"
+        namespace: "smithy.example"
+    }
+]
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+service SayStuff {
+    version: "2023-10-11"
+    operations: [SayHello, SayHello2]
+}
+
+@smokeTests([
+    {
+        id: "say_hello", // Conflicts with self and SayHello2
+        params: {},
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "say_hello", // Conflicts with self and SayHello2
+        params: {},
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello {
+    input := {}
+    output := {}
+}
+
+@smokeTests([
+    {
+        id: "say_hello", // Conflicts with SayHello
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "not_say_hello", // No conflict
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello2 {
+    input := {}
+    output := {}
+}
+
+// Not bound to service, shouldn't conflict with others that are.
+@smokeTests([
+    {
+        id: "say_hello", // Conflicts with self
+        params: {},
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "say_hello", // Conflicts with self
+        params: {},
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello3 {
+    input := {}
+    output := {}
+}
+
+@smokeTests([
+    {
+        id: "say_hello", // No conflict
+        params: {},
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello4 {
+    input := {}
+    output := {}
+}
+
+service OtherSayStuff {
+    version: "2023-10-11"
+    operations: [SayHello5]
+}
+
+// Shouldn't conflict between services
+@smokeTests([
+    {
+        id: "say_hello", // No conflict
+        params: {},
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello5 {
+   input := {}
+   output := {}
+}
\ No newline at end of file
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.errors b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.errors
new file mode 100644
index 000000000..a28de7527
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.errors
@@ -0,0 +1,3 @@
+[WARNING] smithy.example#SayHello: smithy.test#smokeTests.say_hello.params: Invalid structure member `foo` found for `smithy.example#SayHelloInput` | SmokeTestCase
+[ERROR] smithy.example#SayHello2: smithy.test#smokeTests.say_hello_2.params.foo: Expected string value for string shape, `smithy.api#String`; found number value, `1` | SmokeTestCase
+[ERROR] smithy.example#SayHello3: smithy.test#smokeTests.say_hello_3.params: Missing required structure member `foo` for `smithy.example#SayHello3Input` | SmokeTestCase
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.smithy
new file mode 100644
index 000000000..792d64664
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/invalid-params.smithy
@@ -0,0 +1,56 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "say_hello",
+        params: {
+            "foo": "bar"
+        },
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello {
+    input := {}
+    output := {}
+}
+
+@smokeTests([
+    {
+        id: "say_hello_2",
+        params: {
+            foo: 1
+        },
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello2 {
+    input := {
+        foo: String
+    }
+    output := {}
+}
+
+@smokeTests([
+    {
+        id: "say_hello_3",
+        params: {},
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello3 {
+    input := {
+        @required
+        foo: String
+    }
+    output := {}
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.errors b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.errors
new file mode 100644
index 000000000..6fb2a9484
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.errors
@@ -0,0 +1,2 @@
+[DANGER] -: Syntactic shape ID `MissingVendorParamsShape` does not resolve to a valid shape ID: `smithy.example#MissingVendorParamsShape`. Did you mean to quote this string? Are you missing a model file? | SyntacticShapeIdTarget
+[ERROR] smithy.example#SayHello: Error validating trait `smithy.test#smokeTests`.0.vendorParamsShape: Shape ID `smithy.example#MissingVendorParamsShape` was not found in the model | TraitValue
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.smithy
new file mode 100644
index 000000000..943ca643a
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/missing-vendor-params-shape.smithy
@@ -0,0 +1,23 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "say_hello",
+        params: {},
+        vendorParams: {
+            foo: "bar"
+        },
+        vendorParamsShape: MissingVendorParamsShape,
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello {
+    input := {}
+    output := {}
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.errors b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.errors
new file mode 100644
index 000000000..d8f19a910
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.errors
@@ -0,0 +1,5 @@
+[WARNING] smithy.example#SayHello: Smoke test case with ID `missing_vendor_params` defined a `vendorParamsShape` but no `vendorParams` | SmokeTestCase
+[WARNING] smithy.example#SayHello: Smoke test case with ID `missing_vendor_params_shape` defined `vendorParams` but no `vendorParamsShape` | SmokeTestCase
+[WARNING] smithy.example#SayHello: smithy.test#smokeTests.extra_vendor_params_member.vendorParams: Invalid structure member `bar` found for `smithy.example#VendorParams` | SmokeTestCase
+[ERROR] smithy.example#SayHello: smithy.test#smokeTests.missing_vendor_params_member.vendorParams: Missing required structure member `foo` for `smithy.example#VendorParams` | SmokeTestCase
+[ERROR] smithy.example#SayHello: smithy.test#smokeTests.wrong_vendor_params_member_type.vendorParams.foo: Expected string value for string shape, `smithy.api#String`; found number value, `1` | SmokeTestCase
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.smithy
new file mode 100644
index 000000000..bb7a97721
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/errorfiles/vendor-params-validation.smithy
@@ -0,0 +1,62 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "missing_vendor_params",
+        vendorParamsShape: VendorParams,
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "extra_vendor_params_member",
+        vendorParams: {
+            foo: "bar",
+            bar: "baz"
+        },
+        vendorParamsShape: VendorParams,
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "missing_vendor_params_member",
+        vendorParams: {},
+        vendorParamsShape: VendorParams,
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "wrong_vendor_params_member_type",
+        vendorParams: {
+            foo: 1
+        },
+        vendorParamsShape: VendorParams,
+        expect: {
+            success: {}
+        }
+    },
+    {
+        id: "missing_vendor_params_shape",
+        vendorParams: {
+            foo: "bar"
+        },
+        expect: {
+            success: {}
+        }
+    },
+])
+operation SayHello {
+    input := {}
+    output := {}
+}
+
+structure VendorParams {
+    @required
+    foo: String
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/specific-failure-test.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/specific-failure-test.smithy
new file mode 100644
index 000000000..5e6751cf5
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/specific-failure-test.smithy
@@ -0,0 +1,22 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "specific_failure",
+        expect: {
+            failure: {
+                errorId: SayHelloError
+            }
+        }
+    }
+])
+operation SayHello {
+    errors: [SayHelloError]
+}
+
+@error("server")
+structure SayHelloError {}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/success-test.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/success-test.smithy
new file mode 100644
index 000000000..c08a6ce85
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/success-test.smithy
@@ -0,0 +1,15 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "success",
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello {}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-params.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-params.smithy
new file mode 100644
index 000000000..af9d8bae5
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-params.smithy
@@ -0,0 +1,22 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "say_hello",
+        params: {
+            foo: "bar"
+        }
+        expect: {
+            success: {}
+        }
+    }
+])
+operation SayHello {
+    input := {
+        foo: String
+    }
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-tags.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-tags.smithy
new file mode 100644
index 000000000..f605a1e65
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-tags.smithy
@@ -0,0 +1,16 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "say_hello",
+        expect: {
+            success: {}
+        },
+        tags: ["foo", "bar"]
+    }
+])
+operation SayHello {}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params-shape.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params-shape.smithy
new file mode 100644
index 000000000..134074bbb
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params-shape.smithy
@@ -0,0 +1,23 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "with_vendor_params_shape",
+        expect: {
+            success: {}
+        },
+        vendorParams: {
+            foo: "Bar"
+        },
+        vendorParamsShape: VendorParams
+    }
+])
+operation SayHello {}
+
+structure VendorParams {
+    foo: String
+}
diff --git a/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params.smithy b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params.smithy
new file mode 100644
index 000000000..221dd2007
--- /dev/null
+++ b/smithy-smoke-test-traits/src/test/resources/software/amazon/smithy/smoketests/traits/test-with-vendor-params.smithy
@@ -0,0 +1,18 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.test#smokeTests
+
+@smokeTests([
+    {
+        id: "with_vendor_params",
+        expect: {
+            success: {}
+        },
+        vendorParams: {
+            foo: "Bar"
+        }
+    }
+])
+operation SayHello {}