Repository: cashapp/redwood
Pull request #801 (2023-02-07 20:32:04)

Use `FqType` in `Schema`
No more `KClass` or `KType`. This also fixes a bunch of places where we didn't support nested types.

Refs #19


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 034c881fc..e6960b6a0 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -31,7 +31,9 @@ jobs:
 
       - run: ./gradlew build dokkaHtmlMultiModule --parallel -PredwoodNoSamples
 
+      # If we're on the integration branch, save the site to deploy from the publish job.
       - uses: actions/upload-artifact@v3
+        if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/redwood' }}
         with:
           name: site
           path: build/dokka/htmlMultiModule/
diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ComposePlugin.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ComposePlugin.kt
index 066ee8ee5..90b5cc3d6 100644
--- a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ComposePlugin.kt
+++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ComposePlugin.kt
@@ -15,9 +15,6 @@
  */
 package app.cash.redwood.buildsupport
 
-import java.util.Locale
-import org.gradle.api.Project
-import org.gradle.api.provider.ListProperty
 import org.gradle.api.provider.Provider
 import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
 import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin
@@ -32,22 +29,7 @@ import org.jetbrains.kotlin.gradle.plugin.SubpluginOption
 
 @Suppress("unused") // Invoked reflectively by Gradle.
 class ComposePlugin : KotlinCompilerPluginSupportPlugin {
-  private lateinit var extension: ComposeExtension
-
-  override fun apply(target: Project) {
-    extension = target.extensions.create("redwoodBuildCompose", ComposeExtension::class.java)
-  }
-
-  override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean {
-    val name = kotlinCompilation.target.name +
-      kotlinCompilation.name.replaceFirstChar { it.titlecase(Locale.ROOT) }
-
-    val targetCompilations = extension.targetCompilations.get()
-    return when {
-      targetCompilations.isEmpty() -> true
-      else -> name in targetCompilations
-    }
-  }
+  override fun isApplicable(kotlinCompilation: KotlinCompilation<*>) = true
 
   override fun getCompilerPluginId() = "app.cash.redwood.tools.compose"
 
@@ -73,7 +55,3 @@ class ComposePlugin : KotlinCompilerPluginSupportPlugin {
     return kotlinCompilation.target.project.provider { emptyList() }
   }
 }
-
-interface ComposeExtension {
-  val targetCompilations: ListProperty<String>
-}
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
index 756dfda64..5a3228e50 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.tooling.codegen
 
+import app.cash.redwood.tooling.schema.FqType
 import app.cash.redwood.tooling.schema.LayoutModifier
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolTrait
 import app.cash.redwood.tooling.schema.Schema
@@ -34,9 +35,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.STAR
 import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.kotlinpoet.asTypeName
 import com.squareup.kotlinpoet.joinToCode
-import kotlin.reflect.KClass
 
 /*
 @Composable
@@ -108,7 +107,7 @@ internal fun generateComposable(
                     .build()
                 }
                 is Children -> {
-                  val scope = trait.scope?.let { ClassName(schema.composePackage(), it.simpleName!!) }
+                  val scope = trait.scope?.let { ClassName(schema.composePackage(), it.flatName) }
                   ParameterSpec.builder(trait.name, composableLambda(scope))
                     .apply {
                       trait.defaultExpression?.let { defaultValue(it) }
@@ -137,7 +136,7 @@ internal fun generateComposable(
                   add("into(%T::%N) {\n", widgetType, trait.name)
                   indent()
                   trait.scope?.let { scope ->
-                    add("%T.", ClassName(schema.composePackage(), scope.simpleName!! + "Impl"))
+                    add("%T.", ClassName(schema.composePackage(), scope.flatName + "Impl"))
                   }
                   add("%N()\n", trait.name)
                   unindent()
@@ -149,7 +148,7 @@ internal fun generateComposable(
           }
 
           val arguments = listOf(
-            CodeBlock.of("factory = { it.%N.%N() }", schema.name, flatName),
+            CodeBlock.of("factory = { it.%N.%N() }", schema.type.flatName, flatName),
             CodeBlock.builder()
               .add("update = {\n")
               .indent()
@@ -189,8 +188,8 @@ interface RowScope {
 
 internal object RowScopeImpl : RowScope
 */
-internal fun generateScope(schema: Schema, scope: KClass<*>): FileSpec {
-  val scopeName = scope.simpleName!!
+internal fun generateScope(schema: Schema, scope: FqType): FileSpec {
+  val scopeName = scope.flatName
   val scopeType = ClassName(schema.composePackage(), scopeName)
   return FileSpec.builder(scopeType.packageName, scopeType.simpleName)
     .apply {
@@ -239,7 +238,7 @@ private fun generateLayoutModifierFunction(
   schema: Schema,
   layoutModifier: LayoutModifier,
 ): FunSpec {
-  val simpleName = layoutModifier.type.simpleName!!
+  val simpleName = layoutModifier.type.flatName
   return FunSpec.builder(simpleName.replaceFirstChar(Char::lowercaseChar))
     .addAnnotation(ComposeRuntime.Stable)
     .receiver(Redwood.LayoutModifier)
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeProtocolGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeProtocolGeneration.kt
index 60a96e6e5..7ad886a97 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeProtocolGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeProtocolGeneration.kt
@@ -44,9 +44,7 @@ import com.squareup.kotlinpoet.STRING
 import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
 import com.squareup.kotlinpoet.UNIT
-import com.squareup.kotlinpoet.asTypeName
 import com.squareup.kotlinpoet.joinToCode
-import kotlin.reflect.jvm.jvmErasure
 
 /*
 class ExampleProtocolBridge(
@@ -150,9 +148,9 @@ internal fun generateProtocolBridge(
                 .addStatement("val bridge = %T()", ComposeProtocol.ProtocolState)
                 .addStatement("val root = bridge.widgetChildren(%T.Root, %T.Root)", Protocol.Id, Protocol.ChildrenTag)
                 .apply {
-                  val arguments = buildList<CodeBlock> {
+                  val arguments = buildList {
                     for (dependency in schema.allSchemas) {
-                      add(CodeBlock.of("%N = %T(bridge, json, mismatchHandler)", dependency.name, dependency.protocolWidgetFactoryType(schema)))
+                      add(CodeBlock.of("%N = %T(bridge, json, mismatchHandler)", dependency.type.flatName, dependency.protocolWidgetFactoryType(schema)))
                     }
                   }
                   addStatement("val factories = %T(\n%L)", schema.getWidgetFactoriesType(), arguments.joinToCode(separator = ",\n"))
@@ -559,13 +557,7 @@ internal fun generateProtocolLayoutModifierSerializers(
               val serializerId = serializerIds.computeIfAbsent(propertyType) {
                 nextSerializerId++
               }
-              val isSerializable = property.type
-                .jvmErasure
-                .annotations
-                .any {
-                  it.annotationClass.qualifiedName == "kotlinx.serialization.Serializable"
-                }
-              if (isSerializable) {
+              if (property.isSerializable) {
                 serializables += propertyType
               }
               serializerBody.addStatement(
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffConsumingGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffConsumingGeneration.kt
index 7e50506c4..482de9470 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffConsumingGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/diffConsumingGeneration.kt
@@ -33,7 +33,6 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.kotlinpoet.asTypeName
 
 /*
 public class SunspotDiffConsumingNodeFactory<W : Any>(
@@ -115,7 +114,7 @@ internal fun generateDiffConsumingNodeFactory(
                     "%L -> %T(parentId, parentChildren, provider.%N.%N(), json, mismatchHandler)",
                     widget.tag,
                     dependency.diffConsumingNodeType(widget, schema),
-                    dependency.name,
+                    dependency.type.flatName,
                     widget.type.flatName,
                   )
                 }
@@ -350,7 +349,7 @@ internal fun generateDiffConsumingLayoutModifiers(
       }
 
       for (layoutModifier in schema.layoutModifiers) {
-        val typeName = ClassName(schema.widgetPackage(host), layoutModifier.type.simpleName!! + "Impl")
+        val typeName = ClassName(schema.widgetPackage(host), layoutModifier.type.flatName + "Impl")
         val typeBuilder = if (layoutModifier.properties.isEmpty()) {
           TypeSpec.objectBuilder(typeName)
         } else {
@@ -432,7 +431,7 @@ private fun generateJsonElementToLayoutModifier(schema: ProtocolSchema): FunSpec
         )
       } else {
         for ((localSchema, layoutModifier) in layoutModifiers) {
-          val typeName = ClassName(localSchema.widgetPackage(schema), layoutModifier.type.simpleName!! + "Impl")
+          val typeName = ClassName(localSchema.widgetPackage(schema), layoutModifier.type.flatName + "Impl")
           if (layoutModifier.properties.isEmpty()) {
             addStatement("%L -> return %T", layoutModifier.tag, typeName)
           } else {
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/fqNames.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/fqNames.kt
new file mode 100644
index 000000000..7920752ac
--- /dev/null
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/fqNames.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 app.cash.redwood.tooling.codegen
+
+import app.cash.redwood.tooling.schema.FqType
+import app.cash.redwood.tooling.schema.FqType.Variance.In
+import app.cash.redwood.tooling.schema.FqType.Variance.Invariant
+import app.cash.redwood.tooling.schema.FqType.Variance.Out
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.STAR
+import com.squareup.kotlinpoet.TypeName
+import com.squareup.kotlinpoet.WildcardTypeName
+
+internal fun FqType.asTypeName(): TypeName {
+  if (this == FqType.Star) {
+    return STAR
+  }
+  val className = ClassName(names[0], names.drop(1))
+  val typeName = if (parameterTypes.isEmpty()) {
+    className
+  } else {
+    className.parameterizedBy(parameterTypes.map(FqType::asTypeName))
+  }
+  return when (variance) {
+    Invariant -> typeName
+    In -> WildcardTypeName.consumerOf(typeName)
+    Out -> WildcardTypeName.producerOf(typeName)
+  }.copy(nullable = nullable)
+}
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
index 1a38754c6..cb6930e05 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.tooling.codegen
 
+import app.cash.redwood.tooling.schema.FqType
 import app.cash.redwood.tooling.schema.LayoutModifier
 import app.cash.redwood.tooling.schema.ProtocolLayoutModifier
 import app.cash.redwood.tooling.schema.ProtocolSchema
@@ -33,17 +34,14 @@ import com.squareup.kotlinpoet.MemberName
 import com.squareup.kotlinpoet.STRING
 import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.UNIT
-import com.squareup.kotlinpoet.asClassName
-import com.squareup.kotlinpoet.asTypeName
 import com.squareup.kotlinpoet.joinToCode
-import kotlin.reflect.KClass
 
 /**
  * Returns a single string that is likely to be unique within a schema, like `MapEntry` or
  * `NavigationBarButton`.
  */
-internal val KClass<*>.flatName: String
-  get() = asClassName().simpleNames.joinToString(separator = "")
+internal val FqType.flatName: String
+  get() = names.drop(1).joinToString("")
 
 internal val Event.lambdaType: TypeName
   get() {
@@ -58,18 +56,20 @@ private val noArgumentEventLambda = LambdaTypeName.get(returnType = UNIT).copy(n
 
 internal fun Schema.composePackage(host: Schema? = null): String {
   return if (host == null) {
+    val `package` = type.names[0]
     "$`package`.compose"
   } else {
-    "${host.`package`}.compose.${name.lowercase()}"
+    val hostPackage = host.type.names[0]
+    "$hostPackage.compose.${type.flatName.lowercase()}"
   }
 }
 
 internal fun Schema.protocolBridgeType(): ClassName {
-  return ClassName(composePackage(), "${name}ProtocolBridge")
+  return ClassName(composePackage(), "${type.flatName}ProtocolBridge")
 }
 
 internal fun Schema.protocolWidgetFactoryType(host: Schema): ClassName {
-  return ClassName(composePackage(host), "Protocol${name}WidgetFactory")
+  return ClassName(composePackage(host), "Protocol${type.flatName}WidgetFactory")
 }
 
 internal fun Schema.protocolWidgetType(widget: Widget, host: Schema): ClassName {
@@ -77,7 +77,7 @@ internal fun Schema.protocolWidgetType(widget: Widget, host: Schema): ClassName
 }
 
 internal fun Schema.diffConsumingNodeFactoryType(): ClassName {
-  return ClassName(widgetPackage(), "${name}DiffConsumingNodeFactory")
+  return ClassName(widgetPackage(), "${type.flatName}DiffConsumingNodeFactory")
 }
 
 internal fun Schema.diffConsumingNodeType(widget: Widget, host: Schema): ClassName {
@@ -89,11 +89,11 @@ internal fun Schema.widgetType(widget: Widget): ClassName {
 }
 
 internal fun Schema.getWidgetFactoryType(): ClassName {
-  return ClassName(widgetPackage(this), "${name}WidgetFactory")
+  return ClassName(widgetPackage(this), "${type.flatName}WidgetFactory")
 }
 
 internal fun Schema.getMutableWidgetFactoryType(): ClassName {
-  return ClassName(widgetPackage(this), "Mutable${name}WidgetFactory")
+  return ClassName(widgetPackage(this), "Mutable${type.flatName}WidgetFactory")
 }
 
 internal fun Schema.mutableWidgetType(widget: Widget): ClassName {
@@ -105,23 +105,25 @@ internal fun Schema.widgetValueType(widget: Widget): ClassName {
 }
 
 internal fun Schema.getWidgetFactoryProviderType(): ClassName {
-  return ClassName(widgetPackage(this), "${name}WidgetFactoryProvider")
+  return ClassName(widgetPackage(this), "${type.flatName}WidgetFactoryProvider")
 }
 
 internal fun Schema.getWidgetFactoriesType(): ClassName {
-  return ClassName(widgetPackage(this), "${name}WidgetFactories")
+  return ClassName(widgetPackage(this), "${type.flatName}WidgetFactories")
 }
 
 internal fun Schema.widgetPackage(host: Schema = this): String {
   return if (this === host) {
+    val `package` = type.names[0]
     "$`package`.widget"
   } else {
-    "${host.`package`}.widget.${name.lowercase()}"
+    val hostPackage = host.type.names[0]
+    "$hostPackage.widget.${type.flatName.lowercase()}"
   }
 }
 
 internal fun Schema.layoutModifierType(layoutModifier: LayoutModifier): ClassName {
-  return ClassName(`package`, layoutModifier.type.flatName)
+  return ClassName(type.names[0], layoutModifier.type.flatName)
 }
 
 internal fun Schema.layoutModifierSerializer(layoutModifier: LayoutModifier, host: Schema): ClassName {
@@ -129,11 +131,11 @@ internal fun Schema.layoutModifierSerializer(layoutModifier: LayoutModifier, hos
 }
 
 internal fun Schema.layoutModifierImpl(layoutModifier: LayoutModifier): ClassName {
-  return ClassName(composePackage(), layoutModifier.type.simpleName!! + "Impl")
+  return ClassName(composePackage(), layoutModifier.type.flatName + "Impl")
 }
 
 internal fun Schema.getTesterFunction(): MemberName {
-  return MemberName(widgetPackage(this), "${name}Tester")
+  return MemberName(widgetPackage(this), "${type.flatName}Tester")
 }
 
 internal val Schema.toLayoutModifier: MemberName get() =
@@ -170,7 +172,7 @@ internal fun layoutModifierHashCode(layoutModifier: LayoutModifier): FunSpec {
     .returns(INT)
     .apply {
       if (layoutModifier.properties.isEmpty()) {
-        addStatement("return %L", layoutModifier.type.simpleName!!.hashCode())
+        addStatement("return %L", layoutModifier.type.flatName.hashCode())
       } else {
         addStatement("var hash = 17")
         for (property in layoutModifier.properties) {
@@ -183,7 +185,7 @@ internal fun layoutModifierHashCode(layoutModifier: LayoutModifier): FunSpec {
 }
 
 internal fun layoutModifierToString(layoutModifier: LayoutModifier): FunSpec {
-  val simpleName = layoutModifier.type.simpleName!!
+  val simpleName = layoutModifier.type.flatName
   return FunSpec.builder("toString")
     .addModifiers(KModifier.OVERRIDE)
     .returns(STRING)
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetGeneration.kt
index 5e9eb1274..4965ccb00 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetGeneration.kt
@@ -30,7 +30,6 @@ import com.squareup.kotlinpoet.KModifier.PUBLIC
 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.kotlinpoet.asTypeName
 
 /*
 class SunspotWidgetFactories<W : Any>(
@@ -55,11 +54,11 @@ internal fun generateWidgetFactories(schema: Schema): FileSpec {
           for (dependency in schema.allSchemas) {
             val dependencyType = dependency.getWidgetFactoryType().parameterizedBy(typeVariableW)
             addProperty(
-              PropertySpec.builder(dependency.name, dependencyType, OVERRIDE)
-                .initializer(dependency.name)
+              PropertySpec.builder(dependency.type.flatName, dependencyType, OVERRIDE)
+                .initializer(dependency.type.flatName)
                 .build(),
             )
-            constructorBuilder.addParameter(dependency.name, dependencyType)
+            constructorBuilder.addParameter(dependency.type.flatName, dependencyType)
           }
 
           primaryConstructor(constructorBuilder.build())
@@ -70,7 +69,7 @@ internal fun generateWidgetFactories(schema: Schema): FileSpec {
       TypeSpec.interfaceBuilder(schema.getWidgetFactoryProviderType())
         .addTypeVariable(typeVariableW)
         .addSuperinterface(RedwoodWidget.WidgetProvider.parameterizedBy(typeVariableW))
-        .addProperty(schema.name, schema.getWidgetFactoryType().parameterizedBy(typeVariableW))
+        .addProperty(schema.type.flatName, schema.getWidgetFactoryType().parameterizedBy(typeVariableW))
         .apply {
           for (dependency in schema.dependencies) {
             addSuperinterface(dependency.getWidgetFactoryProviderType().parameterizedBy(typeVariableW))
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/FqType.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/FqType.kt
new file mode 100644
index 000000000..4ef52f96b
--- /dev/null
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/FqType.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 app.cash.redwood.tooling.schema
+
+import app.cash.redwood.tooling.schema.FqType.Variance.In
+import app.cash.redwood.tooling.schema.FqType.Variance.Invariant
+import app.cash.redwood.tooling.schema.FqType.Variance.Out
+import kotlin.reflect.KClass
+import kotlin.reflect.KType
+import kotlin.reflect.KTypeProjection
+import kotlin.reflect.KVariance.IN
+import kotlin.reflect.KVariance.INVARIANT
+import kotlin.reflect.KVariance.OUT
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class FqType(
+  /**
+   * The package name followed by one or more simple names. The package may be empty.
+   * A star projection is represented by an empty package and a single "*" simple name.
+   */
+  val names: List<String>,
+  val variance: Variance = Invariant,
+  val parameterTypes: List<FqType> = emptyList(),
+  val nullable: Boolean = false,
+) {
+  init {
+    require(names.size >= 2) {
+      "At least two names are required: package and a simple name: $names"
+    }
+    if (names[1] == "*") {
+      require(names[0] == "") {
+        "Star projection must use empty package name: ${names[0]}"
+      }
+      require(names.size == 2) {
+        "Star projection cannot have nested types: ${names.drop(1)}"
+      }
+      require(variance == Invariant) {
+        "Star projection must be Invariant: $variance"
+      }
+      require(parameterTypes.isEmpty()) {
+        "Star projection must not have parameter types: $parameterTypes"
+      }
+      require(!nullable) {
+        "Star projection must not be nullable"
+      }
+    }
+  }
+
+  public enum class Variance {
+    Invariant,
+
+    @SerialName("in")
+    In,
+
+    @SerialName("out")
+    Out,
+  }
+
+  override fun toString(): String = buildString {
+    when (variance) {
+      Invariant -> Unit // Nothing to do.
+      In -> append("in ")
+      Out -> append("out ")
+    }
+    if (names[0] != "") {
+      append(names[0])
+      append('.')
+    }
+    append(names[1])
+    for (i in 2 until names.size) {
+      append('.')
+      append(names[i])
+    }
+    if (parameterTypes.isNotEmpty()) {
+      append('<')
+      parameterTypes.joinTo(this, separator = ", ")
+      append('>')
+    }
+    if (nullable) {
+      append('?')
+    }
+  }
+
+  public companion object {
+    public val Star: FqType = FqType(listOf("", "*"))
+  }
+}
+
+internal fun KClass<*>.toFqType(): FqType {
+  // We only parse public API declarations on which it should be impossible to use
+  // function-local classes or anonymous classes.
+  var qualifiedName = qualifiedName ?: throw AssertionError(this)
+
+  // First, check for Kotlin types whose enclosing class name is a type that is mapped to a JVM
+  // class. Thus, the class backing the nested Kotlin type does not have an enclosing class
+  // (i.e., a parent) and the normal algorithm will fail.
+  val names = when (qualifiedName) {
+    "kotlin.Boolean.Companion" -> listOf("kotlin", "Boolean", "Companion")
+    "kotlin.Byte.Companion" -> listOf("kotlin", "Byte", "Companion")
+    "kotlin.Char.Companion" -> listOf("kotlin", "Char", "Companion")
+    "kotlin.Double.Companion" -> listOf("kotlin", "Double", "Companion")
+    "kotlin.Enum.Companion" -> listOf("kotlin", "Enum", "Companion")
+    "kotlin.Float.Companion" -> listOf("kotlin", "Float", "Companion")
+    "kotlin.Int.Companion" -> listOf("kotlin", "Int", "Companion")
+    "kotlin.Long.Companion" -> listOf("kotlin", "Long", "Companion")
+    "kotlin.Short.Companion" -> listOf("kotlin", "Short", "Companion")
+    "kotlin.String.Companion" -> listOf("kotlin", "String", "Companion")
+    else -> {
+      val names = ArrayDeque<String>()
+      var target: Class<*>? = java
+      while (target != null) {
+        target = target.enclosingClass
+
+        val dot = qualifiedName.lastIndexOf('.')
+        if (dot == -1) {
+          if (target != null) throw AssertionError(this) // More enclosing classes than dots.
+          names.addFirst(qualifiedName)
+          qualifiedName = ""
+        } else {
+          names.addFirst(qualifiedName.substring(dot + 1))
+          qualifiedName = qualifiedName.substring(0, dot)
+        }
+      }
+
+      names.addFirst(qualifiedName)
+      names.toList()
+    }
+  }
+
+  return FqType(names)
+}
+
+internal fun KType.toFqType(): FqType {
+  val kClass = classifier as? KClass<*>
+    ?: throw IllegalArgumentException("$this classifier must be a class")
+
+  return kClass.toFqType().copy(
+    parameterTypes = arguments.map { it.toFqType() },
+    nullable = isMarkedNullable,
+  )
+}
+
+private fun KTypeProjection.toFqType(): FqType {
+  val json = type?.toFqType() ?: FqType.Star
+  return when (variance) {
+    null, INVARIANT -> json
+    IN -> json.copy(variance = In)
+    OUT -> json.copy(variance = Out)
+  }
+}
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt
index 41e69747d..9f3f133a5 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schema.kt
@@ -19,13 +19,10 @@ import app.cash.redwood.tooling.schema.Widget.Children
 import app.cash.redwood.tooling.schema.Widget.Event
 import app.cash.redwood.tooling.schema.Widget.Property
 import app.cash.redwood.tooling.schema.Widget.Trait
-import kotlin.reflect.KClass
-import kotlin.reflect.KType
 
 public interface Schema {
-  public val name: String
-  public val `package`: String
-  public val scopes: List<KClass<*>>
+  public val type: FqType
+  public val scopes: List<FqType>
   public val widgets: List<Widget>
   public val layoutModifiers: List<LayoutModifier>
   public val dependencies: List<Schema>
@@ -39,7 +36,7 @@ public interface Schema {
 
 public interface Widget {
   /** Either a 'data class' or 'object'. */
-  public val type: KClass<*>
+  public val type: FqType
 
   /** Non-empty list for a 'data class' [type] or empty list for 'object' [type]. */
   public val traits: List<Trait>
@@ -50,30 +47,31 @@ public interface Widget {
   }
 
   public interface Property : Trait {
-    public val type: KType
+    public val type: FqType
   }
 
   public interface Event : Trait {
-    public val parameterType: KType?
+    public val parameterType: FqType?
   }
 
   public interface Children : Trait {
-    public val scope: KClass<*>?
+    public val scope: FqType?
   }
 }
 
 public interface LayoutModifier {
-  public val scopes: List<KClass<*>>
+  public val scopes: List<FqType>
 
   /** Either a 'data class' or 'object'. */
-  public val type: KClass<*>
+  public val type: FqType
 
   /** Non-empty list for a 'data class' [type] or empty list for 'object' [type]. */
   public val properties: List<Property>
 
   public data class Property(
     val name: String,
-    val type: KType,
+    val type: FqType,
+    val isSerializable: Boolean,
     val defaultExpression: String?,
   )
 }
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt
index cd8c23bc8..e642b35b2 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaClasses.kt
@@ -20,13 +20,10 @@ import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolChildren
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolEvent
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolProperty
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolTrait
-import kotlin.reflect.KClass
-import kotlin.reflect.KType
 
 internal data class ParsedProtocolSchema(
-  override val name: String,
-  override val `package`: String,
-  override val scopes: List<KClass<*>>,
+  override val type: FqType,
+  override val scopes: List<FqType>,
   override val widgets: List<ProtocolWidget>,
   override val layoutModifiers: List<ProtocolLayoutModifier>,
   override val dependencies: List<ProtocolSchema>,
@@ -34,14 +31,14 @@ internal data class ParsedProtocolSchema(
 
 internal data class ParsedProtocolWidget(
   override val tag: Int,
-  override val type: KClass<*>,
+  override val type: FqType,
   override val traits: List<ProtocolTrait>,
 ) : ProtocolWidget
 
 internal data class ParsedProtocolProperty(
   override val tag: Int,
   override val name: String,
-  override val type: KType,
+  override val type: FqType,
   override val defaultExpression: String?,
 ) : ProtocolProperty
 
@@ -49,19 +46,19 @@ internal data class ParsedProtocolEvent(
   override val tag: Int,
   override val name: String,
   override val defaultExpression: String?,
-  override val parameterType: KType?,
+  override val parameterType: FqType?,
 ) : ProtocolEvent
 
 internal data class ParsedProtocolChildren(
   override val tag: Int,
   override val name: String,
   override val defaultExpression: String?,
-  override val scope: KClass<*>?,
+  override val scope: FqType?,
 ) : ProtocolChildren
 
 internal data class ParsedProtocolLayoutModifier(
   override val tag: Int,
-  override val scopes: List<KClass<*>>,
-  override val type: KClass<*>,
+  override val scopes: List<FqType>,
+  override val type: FqType,
   override val properties: List<Property>,
 ) : ProtocolLayoutModifier
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt
index c4d125d5c..bdc11e907 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaJson.kt
@@ -17,18 +17,9 @@
 
 package app.cash.redwood.tooling.schema
 
-import app.cash.redwood.tooling.schema.FqType.Variance.In
-import app.cash.redwood.tooling.schema.FqType.Variance.Invariant
-import app.cash.redwood.tooling.schema.FqType.Variance.Out
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolChildren
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolEvent
 import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolProperty
-import kotlin.reflect.KClass
-import kotlin.reflect.KType
-import kotlin.reflect.KTypeProjection
-import kotlin.reflect.KVariance.IN
-import kotlin.reflect.KVariance.INVARIANT
-import kotlin.reflect.KVariance.OUT
 import kotlinx.serialization.EncodeDefault
 import kotlinx.serialization.EncodeDefault.Mode.ALWAYS
 import kotlinx.serialization.ExperimentalSerializationApi
@@ -47,18 +38,18 @@ public data class EmbeddedSchema(
  */
 public fun ProtocolSchema.toEmbeddedSchema(): EmbeddedSchema {
   val schemaJson = SchemaJsonV1(
-    type = FqType(listOf(`package`, name)),
-    scopes = scopes.map { it.toFqType() },
+    type = type,
+    scopes = scopes,
     widgets = widgets.map { widget ->
       WidgetJson(
-        type = widget.type.toFqType(),
+        type = widget.type,
         traits = widget.traits.map { trait ->
           when (trait) {
             is ProtocolProperty -> {
               PropertyJson(
                 tag = trait.tag,
                 name = trait.name,
-                type = trait.type.toFqType(),
+                type = trait.type,
                 defaultExpression = trait.defaultExpression,
               )
             }
@@ -66,7 +57,7 @@ public fun ProtocolSchema.toEmbeddedSchema(): EmbeddedSchema {
               EventJson(
                 tag = trait.tag,
                 name = trait.name,
-                parameterType = trait.parameterType?.toFqType(),
+                parameterType = trait.parameterType,
                 defaultExpression = trait.defaultExpression,
               )
             }
@@ -74,7 +65,7 @@ public fun ProtocolSchema.toEmbeddedSchema(): EmbeddedSchema {
               ChildrenJson(
                 tag = trait.tag,
                 name = trait.name,
-                scope = trait.scope?.toFqType(),
+                scope = trait.scope,
               )
             }
           }
@@ -84,25 +75,23 @@ public fun ProtocolSchema.toEmbeddedSchema(): EmbeddedSchema {
     layoutModifiers = layoutModifiers.map { layoutModifier ->
       LayoutModifierJson(
         tag = layoutModifier.tag,
-        type = layoutModifier.type.toFqType(),
-        scopes = layoutModifier.scopes.map { it.toFqType() },
+        type = layoutModifier.type,
+        scopes = layoutModifier.scopes,
         properties = layoutModifier.properties.map {
           LayoutModifierPropertyJson(
             name = it.name,
-            type = it.type.toFqType(),
+            type = it.type,
             defaultExpression = it.defaultExpression,
           )
         },
       )
     },
-    dependencies = dependencies.map {
-      FqType(listOf(it.`package`, it.name))
-    },
+    dependencies = dependencies.map { it.type },
   )
   val json = json.encodeToString(SchemaJsonV1.serializer(), schemaJson)
 
   return EmbeddedSchema(
-    path = "${`package`.replace('.', '/')}/$name.json",
+    path = type.names[0].replace('.', '/') + "/" + type.names.drop(1).joinToString(".") + ".json",
     json = json,
   )
 }
@@ -113,96 +102,6 @@ private val json = Json {
   classDiscriminator = "kind"
 }
 
-@Serializable
-private data class FqType(
-  /**
-   * The package name followed by one or more simple names. The package may be empty.
-   * A wildcard is represented by an empty package and a single "*" simple name.
-   */
-  val names: List<String>,
-  val variance: Variance = Invariant,
-  val parameterTypes: List<FqType> = emptyList(),
-  val nullable: Boolean = false,
-) {
-  enum class Variance {
-    Invariant,
-
-    @SerialName("in")
-    In,
-
-    @SerialName("out")
-    Out,
-  }
-
-  override fun toString(): String = buildString {
-    when (variance) {
-      Invariant -> Unit // Nothing to do.
-      In -> append("in ")
-      Out -> append("out ")
-    }
-    if (names[0] != "") {
-      append(names[0])
-      append('.')
-    }
-    append(names[1])
-    for (i in 2 until names.size) {
-      append('.')
-      append(names[i])
-    }
-    if (parameterTypes.isNotEmpty()) {
-      append('<')
-      parameterTypes.joinTo(this, separator = ", ")
-      append('>')
-    }
-    if (nullable) {
-      append('?')
-    }
-  }
-}
-
-private fun KClass<*>.toFqType(): FqType {
-  var qualifiedName = qualifiedName ?: throw AssertionError(this)
-  val simpleNames = ArrayDeque<String>()
-
-  var target: Class<*>? = java
-  while (target != null) {
-    target = target.enclosingClass
-
-    val dot = qualifiedName.lastIndexOf('.')
-    if (dot == -1) {
-      if (target != null) throw AssertionError(this) // More enclosing classes than dots.
-      simpleNames.addFirst(qualifiedName)
-      qualifiedName = ""
-    } else {
-      simpleNames.addFirst(qualifiedName.substring(dot + 1))
-      qualifiedName = qualifiedName.substring(0, dot)
-    }
-  }
-
-  simpleNames.addFirst(qualifiedName)
-
-  return FqType(simpleNames)
-}
-
-private fun KType.toFqType(): FqType {
-  val kClass = classifier as? KClass<*>
-    ?: throw IllegalArgumentException("$this classifier must be a class")
-
-  return kClass.toFqType().copy(
-    parameterTypes = arguments.map { it.toFqType() },
-    nullable = isMarkedNullable,
-  )
-}
-
-private fun KTypeProjection.toFqType(): FqType {
-  val json = type?.toFqType() ?: FqType(names = listOf("*"))
-  return when (variance) {
-    null, INVARIANT -> json
-    IN -> json.copy(variance = In)
-    OUT -> json.copy(variance = Out)
-  }
-}
-
 @Serializable
 private data class SchemaJsonV1(
   /** The format version of this JSON. */
diff --git a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
index 55cfcb18c..b5c309d28 100644
--- a/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
+++ b/redwood-tooling-schema/src/main/kotlin/app/cash/redwood/tooling/schema/schemaParser.kt
@@ -90,7 +90,7 @@ public fun parseProtocolSchema(schemaType: KClass<*>, tag: Int = 0): ProtocolSch
         appendLine("Schema @Widget tags must be unique")
         for ((widgetTag, group) in badWidgets) {
           append("\n- @Widget($widgetTag): ")
-          group.joinTo(this) { it.type.qualifiedName!! }
+          group.joinTo(this) { it.type.toString() }
         }
       },
     )
@@ -103,7 +103,7 @@ public fun parseProtocolSchema(schemaType: KClass<*>, tag: Int = 0): ProtocolSch
         appendLine("Schema @LayoutModifier tags must be unique")
         for ((modifierTag, group) in badLayoutModifiers) {
           append("\n- @LayoutModifier($modifierTag): ")
-          group.joinTo(this) { it.type.qualifiedName!! }
+          group.joinTo(this) { it.type.toString() }
         }
       },
     )
@@ -163,8 +163,7 @@ public fun parseProtocolSchema(schemaType: KClass<*>, tag: Int = 0): ProtocolSch
     }
 
   val schema = ParsedProtocolSchema(
-    schemaType.simpleName!!,
-    schemaType.java.packageName,
+    schemaType.toFqType(),
     scopes.toList(),
     widgets,
     layoutModifiers,
@@ -181,8 +180,8 @@ public fun parseProtocolSchema(schemaType: KClass<*>, tag: Int = 0): ProtocolSch
       buildString {
         appendLine("Schema dependency tree contains duplicated widgets")
         for ((widget, schemas) in duplicatedWidgets) {
-          append("\n- ${widget.qualifiedName}: ")
-          schemas.joinTo(this) { "${it.`package`}.${it.name}" }
+          append("\n- $widget: ")
+          schemas.joinTo(this) { it.type.toString() }
         }
       },
     )
@@ -213,15 +212,15 @@ private fun parseWidget(
           require(arguments.size <= 1) {
             "@Property ${memberType.qualifiedName}#${it.name} lambda type can only have zero or one arguments. Found: $arguments"
           }
-          ParsedProtocolEvent(property.tag, it.name!!, defaultExpression, arguments.singleOrNull()?.type)
+          ParsedProtocolEvent(property.tag, it.name!!, defaultExpression, arguments.singleOrNull()?.type?.toFqType())
         } else {
-          ParsedProtocolProperty(property.tag, it.name!!, it.type, defaultExpression)
+          ParsedProtocolProperty(property.tag, it.name!!, it.type.toFqType(), defaultExpression)
         }
       } else if (children != null) {
         require(it.type.isSubtypeOf(childrenType)) {
           "@Children ${memberType.qualifiedName}#${it.name} must be of type '() -> Unit'"
         }
-        var scope: KClass<*>? = null
+        var scope: FqType? = null
         var arguments = it.type.arguments.dropLast(1) // Drop return type.
         if (it.type.annotations.any(ExtensionFunctionType::class::isInstance)) {
           val receiverType = it.type.arguments.first().type
@@ -229,7 +228,7 @@ private fun parseWidget(
           require(receiverClassifier is KClass<*> && receiverType.arguments.isEmpty()) {
             "@Children ${memberType.qualifiedName}#${it.name} lambda receiver can only be a class. Found: $receiverType"
           }
-          scope = receiverClassifier
+          scope = receiverClassifier.toFqType()
           arguments = arguments.drop(1)
         }
         require(arguments.isEmpty()) {
@@ -278,7 +277,7 @@ private fun parseWidget(
     )
   }
 
-  return ParsedProtocolWidget(tag, memberType, traits)
+  return ParsedProtocolWidget(tag, memberType.toFqType(), traits)
 }
 
 private fun parseLayoutModifier(
@@ -297,7 +296,13 @@ private fun parseLayoutModifier(
   val properties = if (memberType.isData) {
     memberType.primaryConstructor!!.parameters.map {
       val defaultExpression = it.findAnnotation<DefaultAnnotation>()?.expression
-      LayoutModifier.Property(it.name!!, it.type, defaultExpression)
+      val isSerializable = (it.type.classifier as? KClass<*>)
+        ?.annotations
+        ?.any { annotation ->
+          annotation.annotationClass.qualifiedName == "kotlinx.serialization.Serializable"
+        }
+        ?: false
+      LayoutModifier.Property(it.name!!, it.type.toFqType(), isSerializable, defaultExpression)
     }
   } else if (memberType.objectInstance != null) {
     emptyList()
@@ -309,8 +314,8 @@ private fun parseLayoutModifier(
 
   return ParsedProtocolLayoutModifier(
     tag,
-    annotation.scopes.toList(),
-    memberType,
+    annotation.scopes.map { it.toFqType() },
+    memberType.toFqType(),
     properties,
   )
 }


Test patch:
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffAwaitingFrameClock.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffAwaitingFrameClock.kt
new file mode 100644
index 000000000..1436a26ef
--- /dev/null
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/DiffAwaitingFrameClock.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 app.cash.redwood.protocol.compose
+
+import androidx.compose.runtime.BroadcastFrameClock
+import androidx.compose.runtime.MonotonicFrameClock
+import app.cash.redwood.protocol.Diff
+import app.cash.redwood.protocol.DiffSink
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+
+class DiffAwaitingFrameClock : MonotonicFrameClock, DiffSink {
+  private val clock = BroadcastFrameClock()
+  private val diffs = Channel<Diff>(Channel.UNLIMITED)
+
+  override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
+    return clock.withFrameNanos(onFrame)
+  }
+
+  override fun sendDiff(diff: Diff) {
+    check(diffs.trySend(diff).isSuccess)
+  }
+
+  suspend fun awaitDiff(timeout: Duration = 2.seconds): Diff = coroutineScope {
+    val tick = launch {
+      while (true) {
+        clock.sendFrame(0L)
+        delay(16)
+      }
+    }
+    val diff = withTimeout(timeout) {
+      diffs.receive()
+    }
+    tick.cancelAndJoin()
+    diff
+  }
+}
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
index 396e57af6..70741a417 100644
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
+++ b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/ProtocolTest.kt
@@ -40,15 +40,13 @@ import kotlin.test.fail
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.plus
 import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.yield
 import kotlinx.serialization.json.JsonPrimitive
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class ProtocolTest {
   @Test fun widgetVersionPropagated() = runTest {
-    val clock = BroadcastFrameClock()
     val composition = ProtocolRedwoodComposition(
-      scope = this + clock,
+      scope = this + BroadcastFrameClock(),
       bridge = ExampleSchemaProtocolBridge.create(),
       diffSink = ::error,
       widgetVersion = 22U,
@@ -64,12 +62,11 @@ class ProtocolTest {
   }
 
   @Test fun childrenInheritIdFromSyntheticParent() = runTest {
-    val clock = BroadcastFrameClock()
-    val diffs = ArrayDeque<Diff>()
+    val diffClock = DiffAwaitingFrameClock()
     val composition = ProtocolRedwoodComposition(
-      scope = this + clock,
+      scope = this + diffClock,
       bridge = ExampleSchemaProtocolBridge.create(),
-      diffSink = { diff -> diffs += diff },
+      diffSink = diffClock,
       widgetVersion = 1U,
     )
 
@@ -82,7 +79,6 @@ class ProtocolTest {
       }
     }
 
-    clock.awaitFrame()
     assertEquals(
       Diff(
         childrenDiffs = listOf(
@@ -102,21 +98,20 @@ class ProtocolTest {
           PropertyDiff(Id(4), PropertyTag(1) /* text */, JsonPrimitive("hello")),
         ),
       ),
-      diffs.removeFirst(),
+      diffClock.awaitDiff(),
     )
 
     composition.cancel()
   }
 
   @Test fun protocolSkipsLambdaChangeOfSamePresence() = runTest {
-    val clock = BroadcastFrameClock()
+    val diffClock = DiffAwaitingFrameClock()
     var state by mutableStateOf(0)
     val bridge = ExampleSchemaProtocolBridge.create()
-    val diffs = ArrayDeque<Diff>()
     val composition = ProtocolRedwoodComposition(
-      scope = this + clock,
+      scope = this + diffClock,
       bridge = bridge,
-      diffSink = { diff -> diffs += diff },
+      diffSink = diffClock,
       widgetVersion = 1U,
     )
 
@@ -133,7 +128,6 @@ class ProtocolTest {
       )
     }
 
-    clock.awaitFrame()
     assertEquals(
       Diff(
         childrenDiffs = listOf(
@@ -147,28 +141,24 @@ class ProtocolTest {
           PropertyDiff(Id(1), PropertyTag(2) /* onClick */, JsonPrimitive(true)),
         ),
       ),
-      diffs.removeFirst(),
+      diffClock.awaitDiff(),
     )
 
     // Invoke the onClick lambda to move the state from 0 to 1.
     bridge.sendEvent(Event(Id(1), EventTag(2)))
-    yield() // Allow state change to be handled.
 
-    clock.awaitFrame()
     assertEquals(
       Diff(
         propertyDiffs = listOf(
           PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 1")),
         ),
       ),
-      diffs.removeFirst(),
+      diffClock.awaitDiff(),
     )
 
     // Invoke the onClick lambda to move the state from 1 to 2.
     bridge.sendEvent(Event(Id(1), EventTag(2)))
-    yield() // Allow state change to be handled.
 
-    clock.awaitFrame()
     assertEquals(
       Diff(
         propertyDiffs = listOf(
@@ -176,21 +166,19 @@ class ProtocolTest {
           PropertyDiff(Id(1), PropertyTag(2) /* text */, JsonPrimitive(false)),
         ),
       ),
-      diffs.removeFirst(),
+      diffClock.awaitDiff(),
     )
 
     // Manually advance state from 2 to 3 to test null to null case.
     state = 3
-    yield() // Allow state change to be handled.
 
-    clock.awaitFrame()
     assertEquals(
       Diff(
         propertyDiffs = listOf(
           PropertyDiff(Id(1), PropertyTag(1) /* text */, JsonPrimitive("state: 3")),
         ),
       ),
-      diffs.removeFirst(),
+      diffClock.awaitDiff(),
     )
 
     composition.cancel()
diff --git a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/testing.kt b/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/testing.kt
deleted file mode 100644
index ff8e4d28b..000000000
--- a/redwood-protocol-compose/src/commonTest/kotlin/app/cash/redwood/protocol/compose/testing.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright (C) 2021 Square, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License 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 app.cash.redwood.protocol.compose
-
-import androidx.compose.runtime.BroadcastFrameClock
-import androidx.compose.runtime.withFrameMillis
-import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
-
-suspend fun BroadcastFrameClock.awaitFrame() {
-  // TODO Remove the need for two frames to happen!
-  //  I think this is because of the diff-sender is a hot loop that immediately reschedules
-  //  itself on the clock. This schedules it ahead of the coroutine which applies changes and
-  //  so we need to trigger an additional frame to actually emit the change's diffs.
-  repeat(2) {
-    coroutineScope {
-      launch(start = UNDISPATCHED) {
-        withFrameMillis { }
-      }
-      sendFrame(0L)
-    }
-  }
-}
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt
index 9ba43814b..eb58577a9 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt
@@ -36,7 +36,6 @@ import com.squareup.kotlinpoet.ParameterSpec
 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.kotlinpoet.asTypeName
 
 /*
 @OptIn(RedwoodCodegenApi::class)
@@ -63,7 +62,7 @@ internal fun generateTester(schema: Schema): FileSpec {
         .addCode("provider = %T(⇥\n", schema.getWidgetFactoriesType())
         .apply {
           for (dependency in schema.allSchemas) {
-            addCode("%N = %T(),\n", dependency.name, dependency.getMutableWidgetFactoryType())
+            addCode("%N = %T(),\n", dependency.type.flatName, dependency.getMutableWidgetFactoryType())
           }
         }
         .addCode("⇤),\n")
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
index 749bb2915..edea0bc23 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/ComposeGenerationTest.kt
@@ -21,10 +21,13 @@ import app.cash.redwood.schema.Default
 import app.cash.redwood.schema.Property
 import app.cash.redwood.schema.Schema
 import app.cash.redwood.schema.Widget
+import app.cash.redwood.tooling.schema.FqType
 import app.cash.redwood.tooling.schema.parseSchema
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 
+object RowScope
+
 class ComposeGenerationTest {
   @Schema(
     [
@@ -32,7 +35,6 @@ class ComposeGenerationTest {
     ],
   )
   interface ScopedAndUnscopedSchema
-  object RowScope
 
   @Widget(1)
   data class Row(
@@ -53,7 +55,7 @@ class ComposeGenerationTest {
   @Test fun `scope is annotated with layout scope marker`() {
     val schema = parseSchema(ScopedAndUnscopedSchema::class)
 
-    val fileSpec = generateScope(schema, RowScope::class)
+    val fileSpec = generateScope(schema, FqType(listOf("example", "RowScope")))
     assertThat(fileSpec.toString()).contains(
       """
       |@LayoutScopeMarker
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
index cc79aaa00..363aff119 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/LayoutModifierGenerationTest.kt
@@ -46,11 +46,11 @@ class LayoutModifierGenerationTest {
   @Test fun `simple names do not collide`() {
     val schema = parseSchema(SimpleNameCollisionSchema::class)
 
-    val topType = schema.layoutModifiers.single { it.type == ContentDescription::class }
+    val topType = schema.layoutModifiers.single { it.type.flatName == "LayoutModifierGenerationTestContentDescription" }
     val topTypeSpec = generateLayoutModifierInterface(schema, topType)
     assertThat(topTypeSpec.toString()).contains("interface LayoutModifierGenerationTestContentDescription")
 
-    val nestedType = schema.layoutModifiers.single { it.type == NavigationBar.ContentDescription::class }
+    val nestedType = schema.layoutModifiers.single { it.type.flatName == "LayoutModifierGenerationTestNavigationBarContentDescription" }
     val nestedTypeSpec = generateLayoutModifierInterface(schema, nestedType)
     assertThat(nestedTypeSpec.toString()).contains("interface LayoutModifierGenerationTestNavigationBarContentDescription")
   }
@@ -70,13 +70,13 @@ class LayoutModifierGenerationTest {
   @Test fun `layout modifier functions are stable`() {
     val schema = parseSchema(ScopedModifierSchema::class)
 
-    val modifier = schema.layoutModifiers.single { it.type == ScopedLayoutModifier::class }
-    val scope = modifier.scopes.single { it == LayoutModifierScope::class }
+    val modifier = schema.layoutModifiers.single { it.type.names.last() == "ScopedLayoutModifier" }
+    val scope = modifier.scopes.single { it.names.last() == "LayoutModifierScope" }
     val scopeSpec = generateScope(schema, scope)
     assertThat(scopeSpec.toString()).contains(
       """
       |  @Stable
-      |  public fun LayoutModifier.scopedLayoutModifier(): LayoutModifier
+      |  public fun LayoutModifier.layoutModifierGenerationTestScopedLayoutModifier(): LayoutModifier
       """.trimMargin(),
     )
   }
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
index 22a383357..b6336f491 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
@@ -52,10 +52,12 @@ class TestingGenerationTest {
     assertThat(testerFileSpec.toString()).contains(
       """
       |@OptIn(RedwoodCodegenApi::class)
-      |public fun HappyPathSchemaTester(scope: CoroutineScope): RedwoodTester = RedwoodTester(
+      |public fun TestingGenerationTestHappyPathSchemaTester(scope: CoroutineScope): RedwoodTester =
+      |    RedwoodTester(
       |  scope = scope,
-      |  provider = HappyPathSchemaWidgetFactories(
-      |    HappyPathSchema = MutableHappyPathSchemaWidgetFactory(),
+      |  provider = TestingGenerationTestHappyPathSchemaWidgetFactories(
+      |    TestingGenerationTestHappyPathSchema =
+      |        MutableTestingGenerationTestHappyPathSchemaWidgetFactory(),
       |  ),
       |)
       """.trimMargin(),
@@ -65,7 +67,8 @@ class TestingGenerationTest {
     assertThat(mutableWidgetFactorySpec.toString()).contains(
       """
       |@RedwoodCodegenApi
-      |public class MutableHappyPathSchemaWidgetFactory : HappyPathSchemaWidgetFactory<MutableWidget> {
+      |public class MutableTestingGenerationTestHappyPathSchemaWidgetFactory :
+      |    TestingGenerationTestHappyPathSchemaWidgetFactory<MutableWidget> {
       |  public override fun TestingGenerationTestBasicWidget():
       |      TestingGenerationTestBasicWidget<MutableWidget> = MutableTestingGenerationTestBasicWidget()
       |}
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
index 04b0cb377..b7283e87e 100644
--- a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/WidgetGenerationTest.kt
@@ -63,7 +63,7 @@ class WidgetGenerationTest {
 
   @Test fun tagInWidgetKdoc() {
     val schema = parseSchema(SimpleNameCollisionSchema::class)
-    val button = schema.widgets.single { it.type == Button::class }
+    val button = schema.widgets.single { it.type.flatName == "WidgetGenerationTestButton" }
 
     val fileSpec = generateWidget(schema, button)
     assertThat(fileSpec.toString()).apply {
diff --git a/redwood-tooling-schema/src/test/kotlin/DefaultPackage.kt b/redwood-tooling-schema/src/test/kotlin/DefaultPackage.kt
new file mode 100644
index 000000000..f132ba854
--- /dev/null
+++ b/redwood-tooling-schema/src/test/kotlin/DefaultPackage.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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.
+ */
+@file:JvmName("Whatever") // Spotless license header doesn't work without this.
+
+class DefaultPackage {
+  class Nested
+}
diff --git a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/FqTypeTest.kt b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/FqTypeTest.kt
new file mode 100644
index 000000000..59c632e47
--- /dev/null
+++ b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/FqTypeTest.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 app.cash.redwood.tooling.schema
+
+import DefaultPackage
+import app.cash.redwood.tooling.schema.FqType.Variance.In
+import app.cash.redwood.tooling.schema.FqType.Variance.Out
+import java.util.PrimitiveIterator
+import kotlin.reflect.typeOf
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FqTypeTest {
+  @Test fun classes() {
+    assertEquals(FqType(listOf("kotlin", "String")), String::class.toFqType())
+    assertEquals(FqType(listOf("app.cash.redwood.tooling.schema", "FqTypeTest")), FqTypeTest::class.toFqType())
+    assertEquals(FqType(listOf("java.util", "PrimitiveIterator", "OfInt")), PrimitiveIterator.OfInt::class.toFqType())
+    assertEquals(FqType(listOf("", "DefaultPackage")), DefaultPackage::class.toFqType())
+    assertEquals(FqType(listOf("", "DefaultPackage", "Nested")), DefaultPackage.Nested::class.toFqType())
+  }
+
+  @Test fun classSpecialCases() {
+    assertEquals(FqType(listOf("kotlin", "Boolean", "Companion")), Boolean.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Byte", "Companion")), Byte.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Char", "Companion")), Char.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Double", "Companion")), Double.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Enum", "Companion")), Enum.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Float", "Companion")), Float.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Int", "Companion")), Int.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Long", "Companion")), Long.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "Short", "Companion")), Short.Companion::class.toFqType())
+    assertEquals(FqType(listOf("kotlin", "String", "Companion")), String.Companion::class.toFqType())
+  }
+
+  @Test fun types() {
+    assertEquals(
+      FqType(listOf("kotlin", "String"), nullable = true),
+      typeOf<String?>().toFqType(),
+    )
+
+    assertEquals(
+      FqType(listOf("kotlin", "Array"), parameterTypes = listOf(FqType(listOf("kotlin", "String")))),
+      typeOf<Array<String>>().toFqType(),
+    )
+
+    assertEquals(
+      FqType(
+        names = listOf("kotlin", "Array"),
+        parameterTypes = listOf(FqType(listOf("kotlin", "String"), variance = Out)),
+      ),
+      typeOf<Array<out String>>().toFqType(),
+    )
+
+    assertEquals(
+      FqType(
+        names = listOf("kotlin", "Array"),
+        parameterTypes = listOf(FqType(listOf("kotlin", "String"), variance = In)),
+      ),
+      typeOf<Array<in String>>().toFqType(),
+    )
+
+    assertEquals(
+      FqType(listOf("kotlin", "Array"), parameterTypes = listOf(FqType.Star)),
+      typeOf<Array<*>>().toFqType(),
+    )
+  }
+
+  @Test fun classTooFewNamesThrows() {
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf())
+    }.hasMessageThat()
+      .isEqualTo("At least two names are required: package and a simple name: []")
+
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("kotlin"))
+    }.hasMessageThat()
+      .isEqualTo("At least two names are required: package and a simple name: [kotlin]")
+  }
+
+  @Test fun star() {
+    assertEquals(FqType(listOf("", "*")), FqType.Star)
+  }
+
+  @Test fun starOtherPropertiesThrows() {
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("kotlin", "*"))
+    }.hasMessageThat()
+      .isEqualTo("Star projection must use empty package name: kotlin")
+
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("", "*", "Nested"))
+    }.hasMessageThat()
+      .isEqualTo("Star projection cannot have nested types: [*, Nested]")
+
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("", "*"), parameterTypes = listOf(Int::class.toFqType()))
+    }.hasMessageThat()
+      .isEqualTo("Star projection must not have parameter types: [kotlin.Int]")
+
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("", "*"), variance = In)
+    }.hasMessageThat()
+      .isEqualTo("Star projection must be Invariant: In")
+
+    assertThrows<IllegalArgumentException> {
+      FqType(listOf("", "*"), nullable = true)
+    }.hasMessageThat()
+      .isEqualTo("Star projection must not be nullable")
+  }
+}
diff --git a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
index 4b2f86d83..c3e78f373 100644
--- a/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
+++ b/redwood-tooling-schema/src/test/kotlin/app/cash/redwood/tooling/schema/SchemaParserTest.kt
@@ -23,7 +23,6 @@ import app.cash.redwood.schema.Schema.Dependency
 import app.cash.redwood.schema.Widget
 import app.cash.redwood.tooling.schema.Widget.Event
 import com.google.common.truth.Truth.assertThat
-import kotlin.reflect.full.createType
 import org.junit.Test
 
 class SchemaParserTest {
@@ -434,9 +433,9 @@ class SchemaParserTest {
     val noArguments = widget.traits.single { it.name == "noArguments" } as Event
     assertThat(noArguments.parameterType).isNull()
     val argument = widget.traits.single { it.name == "argument" } as Event
-    assertThat(argument.parameterType).isEqualTo(String::class.createType())
+    assertThat(argument.parameterType).isEqualTo(String::class.toFqType())
     val argumentOptionalLambda = widget.traits.single { it.name == "argumentOptionalLambda" } as Event
-    assertThat(argumentOptionalLambda.parameterType).isEqualTo(String::class.createType())
+    assertThat(argumentOptionalLambda.parameterType).isEqualTo(String::class.toFqType())
   }
 
   @Schema(
@@ -743,7 +742,7 @@ class SchemaParserTest {
       """
       |Schema dependency tree contains duplicated widgets
       |
-      |- app.cash.redwood.tooling.schema.SchemaParserTest.WidgetDuplicateAcrossDependencies: app.cash.redwood.tooling.schema.SchemaWidgetDuplicateAcrossDependenciesA, app.cash.redwood.tooling.schema.SchemaWidgetDuplicateAcrossDependenciesB
+      |- app.cash.redwood.tooling.schema.SchemaParserTest.WidgetDuplicateAcrossDependencies: app.cash.redwood.tooling.schema.SchemaParserTest.SchemaWidgetDuplicateAcrossDependenciesA, app.cash.redwood.tooling.schema.SchemaParserTest.SchemaWidgetDuplicateAcrossDependenciesB
       """.trimMargin(),
     )
   }
@@ -765,7 +764,7 @@ class SchemaParserTest {
       """
       |Schema dependency tree contains duplicated widgets
       |
-      |- app.cash.redwood.tooling.schema.SchemaParserTest.WidgetDuplicateAcrossDependencies: app.cash.redwood.tooling.schema.SchemaWidgetDuplicateInDependency, app.cash.redwood.tooling.schema.SchemaWidgetDuplicateAcrossDependenciesA
+      |- app.cash.redwood.tooling.schema.SchemaParserTest.WidgetDuplicateAcrossDependencies: app.cash.redwood.tooling.schema.SchemaParserTest.SchemaWidgetDuplicateInDependency, app.cash.redwood.tooling.schema.SchemaParserTest.SchemaWidgetDuplicateAcrossDependenciesA
       """.trimMargin(),
     )
   }