From c9bf62e0101ccf0a99777f82ad31e76de7c971e1 Mon Sep 17 00:00:00 2001 From: Vitthal Mirji Date: Wed, 7 Oct 2026 10:02:50 +0200 Subject: [PATCH 1/4] refactor: depend on ctdc-core instead of a second copy of the contract engine --- build.sbt | 46 +-- .../SchemaConformsMaterializer.scala | 16 - .../contracts/derive/ShapeDerivation.scala | 50 --- .../contracts/internal/ContractMacros.scala | 163 ---------- .../SchemaConformsMaterializer.scala | 14 - .../contracts/derive/ShapeDerivation.scala | 45 --- .../contracts/internal/ContractMacros.scala | 62 ---- .../core/contracts/internal/TypeShapes.scala | 125 -------- .../core/contracts/SchemaConforms.scala | 30 -- .../core/contracts/SchemaPolicy.scala | 51 --- .../core/contracts/derive/Shape.scala | 37 --- .../core/contracts/derive/package.scala | 17 + .../contracts/internal/ComparisonRules.scala | 93 ------ .../core/contracts/internal/SchemaAST.scala | 127 -------- .../core/contracts/internal/ShapeDiff.scala | 227 -------------- .../flowforge/core/contracts/package.scala | 45 ++- .../internal/ShapeDiffPolicySpec.scala | 291 ------------------ project/Dependencies.scala | 11 +- 18 files changed, 67 insertions(+), 1383 deletions(-) delete mode 100644 modules/core/src/main/scala-2/com/flowforge/core/contracts/SchemaConformsMaterializer.scala delete mode 100644 modules/core/src/main/scala-2/com/flowforge/core/contracts/derive/ShapeDerivation.scala delete mode 100644 modules/core/src/main/scala-2/com/flowforge/core/contracts/internal/ContractMacros.scala delete mode 100644 modules/core/src/main/scala-3/com/flowforge/core/contracts/SchemaConformsMaterializer.scala delete mode 100644 modules/core/src/main/scala-3/com/flowforge/core/contracts/derive/ShapeDerivation.scala delete mode 100644 modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/ContractMacros.scala delete mode 100644 modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/TypeShapes.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/SchemaConforms.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/SchemaPolicy.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/derive/Shape.scala create mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/derive/package.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/internal/ComparisonRules.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/internal/SchemaAST.scala delete mode 100644 modules/core/src/main/scala/com/flowforge/core/contracts/internal/ShapeDiff.scala delete mode 100644 modules/core/src/test/scala/com/flowforge/core/contracts/internal/ShapeDiffPolicySpec.scala diff --git a/build.sbt b/build.sbt index addbcbb5..c8b749a9 100644 --- a/build.sbt +++ b/build.sbt @@ -204,52 +204,14 @@ lazy val core = moduleProject("core") coverageExcludedPackages := Seq( "com.flowforge.core.examples.*", ).mkString(";"), - // The macros run in the compiler rather than in a test, so the instrumentation never sees them. The - // comparison they share lives in the same package and is ordinary code with its own tests, which is - // why these are excluded by file instead of by package. - coverageExcludedFiles := Seq( - ".*/ContractMacros.scala", - ".*/SchemaConformsMaterializer.scala", - ".*/ShapeDerivation.scala", - ".*/TypeShapes.scala", - ).mkString(";"), // Core module requires 90% coverage (foundational code) coverageMinimumStmtTotal := 90, coverageMinimumBranchTotal := 85, coverageFailOnMinimum := enforceCoverageThreshold, - // Section 13.3 - Version-specific dependencies for Scala 2/3 cross-build - libraryDependencies ++= { - CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => - Seq( - "com.softwaremill.magnolia1_2" %% "magnolia" % "1.1.10", - "org.scala-lang" % "scala-reflect" % scalaVersion.value, - ) - case Some((3, _)) => - Seq( - // Scala 3 derives and inspects types with the compiler's own quotes reflection, so there is - // no Magnolia or scala-reflect to add here. - ) - case _ => Seq.empty - } - }, - // Section 13.3 - Version-specific source directories for Scala 2/3 cross-build - Compile / unmanagedSourceDirectories ++= { - val base = (Compile / sourceDirectory).value - CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => Seq(base / "scala-2") - case Some((3, _)) => Seq(base / "scala-3") - case _ => Nil - } - }, - Test / unmanagedSourceDirectories ++= { - val base = (Test / sourceDirectory).value - CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, _)) => Seq(base / "scala-2") - case Some((3, _)) => Seq(base / "scala-3") - case _ => Nil - } - }, + // Magnolia, scala-reflect and the scala-2/scala-3 source directories used to be wired here, because the + // contract macro is spelled differently on each Scala version. That macro now ships in ctdc-core, which + // carries its own per-version sources and dependencies, so everything left in this module is ordinary + // version-agnostic Scala. ) .settings(mimaSettings("core"): _*) diff --git a/modules/core/src/main/scala-2/com/flowforge/core/contracts/SchemaConformsMaterializer.scala b/modules/core/src/main/scala-2/com/flowforge/core/contracts/SchemaConformsMaterializer.scala deleted file mode 100644 index bbcb0608..00000000 --- a/modules/core/src/main/scala-2/com/flowforge/core/contracts/SchemaConformsMaterializer.scala +++ /dev/null @@ -1,16 +0,0 @@ -package com.flowforge.core.contracts - -import scala.language.experimental.macros - -/** Scala 2 materializer for [[SchemaConforms]]. Mixed into `object SchemaConforms`. */ -trait SchemaConformsMaterializer { - - /** - * Materialize compile-time evidence that Out conforms to Contract under policy P. - * - * The macro builds a `TypeShape` for each side and compares them under the policy. No runtime overhead - - * pure compile-time validation. - */ - implicit def materialize[Out, Contract, P <: SchemaPolicy]: SchemaConforms[Out, Contract, P] = - macro internal.ContractMacros.conformsImpl[Out, Contract, P] -} diff --git a/modules/core/src/main/scala-2/com/flowforge/core/contracts/derive/ShapeDerivation.scala b/modules/core/src/main/scala-2/com/flowforge/core/contracts/derive/ShapeDerivation.scala deleted file mode 100644 index c1fde1df..00000000 --- a/modules/core/src/main/scala-2/com/flowforge/core/contracts/derive/ShapeDerivation.scala +++ /dev/null @@ -1,50 +0,0 @@ -// scalafix:off DisableSyntax.throw -package com.flowforge.core.contracts.derive - -import com.flowforge.core.contracts.internal.TypeShape -import magnolia1._ - -import scala.language.experimental.macros - -/** Scala 2 derivation of [[Shape]], via Magnolia. Mixed into `object Shape`. */ -trait ShapeDerivation { - type Typeclass[T] = Shape[T] - - def join[T](caseClass: CaseClass[Typeclass, T]): Shape[T] = - new Shape[T] { - val fields: List[Field] = - caseClass.parameters.toList.map { p => - val typeName = p.typeName // Magnolia 1 (Scala 2) exposes typeName - Field( - name = p.label, - tpe = render(typeName), - hasDefault = p.default.isDefined, - isOptional = typeName.full == optionName, - ) - } - } - - private val optionName = "scala.Option" - - /** - * A field type rendered the way Scala 3 reflection renders it. - * - * Magnolia reports a type name and its arguments separately, so `Option[String]` arrives as `scala.Option` - * carrying one argument rather than as one rendered string. Reading only the name therefore dropped the - * element type, and made every field read as required, because the name on its own never contains the - * `Option[` that optionality used to be decided by. - */ - private def render(typeName: TypeName): String = { - val name = TypeShape.simpleName(typeName.full) - if (typeName.typeArguments.isEmpty) name - else s"$name[${typeName.typeArguments.map(render).mkString(", ")}]" - } - - // Keep v1.0 scope to products only; abort on sums for now - def split[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] = - throw new IllegalArgumentException( - s"Shape supports case classes only for now (got sum type ${ctx.typeName.full})", - ) - - implicit def gen[T]: Shape[T] = macro Magnolia.gen[T] -} diff --git a/modules/core/src/main/scala-2/com/flowforge/core/contracts/internal/ContractMacros.scala b/modules/core/src/main/scala-2/com/flowforge/core/contracts/internal/ContractMacros.scala deleted file mode 100644 index 96327e8f..00000000 --- a/modules/core/src/main/scala-2/com/flowforge/core/contracts/internal/ContractMacros.scala +++ /dev/null @@ -1,163 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.SchemaPolicy -import com.flowforge.core.contracts.internal.TypeShape._ - -import scala.reflect.macros.blackbox - -/** - * Scala 2 half of compile-time contract validation. - * - * This only turns the two types into [[TypeShape]]s and hands them to [[ShapeDiff]], which owns the policy - * rules and the error text. Keeping the comparison out of the macro is what lets Scala 3 reuse it and what - * makes the rules testable without compiling anything. - */ -object ContractMacros { - - def conformsImpl[Out: c.WeakTypeTag, Contract: c.WeakTypeTag, P <: SchemaPolicy: c.WeakTypeTag]( - c: blackbox.Context, - ): c.Tree = { - import c.universe._ - - // Type inspection utilities - object TypeInspector { - def isCaseClass(t: Type): Boolean = { - val sym = t.typeSymbol - sym.isClass && sym.asClass.isCaseClass - } - - def appliedArgs(t: Type): List[Type] = t match { - case TypeRef(_, _, args) => args - case _ => Nil - } - - def optionArg(t: Type): Option[Type] = - if (t <:< typeOf[Option[_]]) appliedArgs(t).headOption - else None - - def seqArg(t: Type): Option[Type] = { - val isSeqLike = t <:< typeOf[List[_]] || t <:< typeOf[Seq[_]] || - t <:< typeOf[Vector[_]] || t <:< typeOf[Array[_]] || - t <:< typeOf[Set[_]] - if (isSeqLike) appliedArgs(t).headOption - else None - } - - def mapArgs(t: Type): Option[(Type, Type)] = - if (t <:< typeOf[Map[_, _]]) { - appliedArgs(t) match { - case k :: v :: Nil => Some((k, v)) - case _ => None - } - } else None - - def isAtomicKey(t: Type): Boolean = - t =:= typeOf[String] || t =:= typeOf[Int] || t =:= typeOf[Long] || - t =:= typeOf[Short] || t =:= typeOf[Byte] || t =:= typeOf[Boolean] - - def isTuple(t: Type): Boolean = - t.typeSymbol.fullName.startsWith("scala.Tuple") - } - - /** - * A type this macro cannot look inside, compared by its name. - * - * There used to be a closed list of leaf types here, on the reasoning that a type outside it is one no - * sink can write. That is not something a contract can know: a sink takes the writer as a function, so - * whether a `UUID` or a domain enum can be written is decided by the writer the caller supplies, not by - * this list. Rejecting an unlisted leaf therefore turned away pipelines that were fine. Comparing it by - * name still catches the drift that is this macro's job, because a leaf that changes type changes its - * name. - */ - def opaqueLeaf(t: Type): PrimitiveShape = PrimitiveShape(TypeShape.simpleName(t.toString)) - - def unsupportedTuple(t: Type): Nothing = - c.abort( - c.enclosingPosition, - s"Unsupported tuple in SchemaConforms derivation: ${t.toString}. " + - "A tuple has no field names to compare, so use a case class instead.", - ) - - // TypeShape builder - pure functional approach - object ShapeBuilder { - def buildTypeShape(tpe: Type, inField: Boolean = false): TypeShape = { - import TypeInspector._ - - optionArg(tpe).map { inner => - // Field-level Option is captured on FieldShape.isOptional; avoid double-wrapping there. - // Outside of field context (e.g., List[Option[A]]), preserve optionality as OptionalShape. - if (inField) buildTypeShape(inner, inField = false) - else TypeShape.OptionalShape(buildTypeShape(inner, inField = false)) - }.getOrElse { - seqArg(tpe).map(elem => SequenceShape(buildTypeShape(elem))).getOrElse { - mapArgs(tpe).map { - case (k, v) => - if (!isAtomicKey(k)) { - c.abort( - c.enclosingPosition, - s"Unsupported Map key type: ${k.toString}. Allowed: String, Int, Long, Short, Byte, Boolean", - ) - } - MapShape(PrimitiveShape(TypeShape.simpleName(k.toString)), buildTypeShape(v)) - }.getOrElse { - // Tuples are checked first because every TupleN is itself a case class. Reading one as a - // struct of `_1`, `_2` would make positional junk look like a named schema. - if (isTuple(tpe)) unsupportedTuple(tpe) - else if (isCaseClass(tpe)) buildStructShape(tpe) - else opaqueLeaf(tpe) - } - } - } - } - - private def buildStructShape(tpe: Type): StructShape = { - val sym = tpe.typeSymbol - val ctor = sym.asClass.primaryConstructor - val params = ctor.asMethod.paramLists.flatten - - val fields = params.map { param => - val name = param.name.toString - val paramType = tpe.member(param.name).asMethod.returnType - val hasDefault = param.asTerm.isParamWithDefault - val (underlyingType, isOptional) = - TypeInspector.optionArg(paramType).fold((paramType, false))(t => (t, true)) - // For field-level shape, pass inField = true so Option is carried via isOptional flag - FieldShape(name, buildTypeShape(underlyingType, inField = true), hasDefault, isOptional) - } - - StructShape(fields) - } - } - - // Matched by subtyping rather than by the rendered type name, so that a policy named as the trait - // (SchemaPolicy.Backward) and the same policy named as the case object (SchemaPolicy.Backward.type) - // resolve to the same rules. The policy traits are disjoint, so at most one entry can match. - val known: List[(Type, SchemaPolicy)] = List( - typeOf[SchemaPolicy.Exact] -> SchemaPolicy.Exact, - typeOf[SchemaPolicy.ExactUnordered] -> SchemaPolicy.ExactUnordered, - typeOf[SchemaPolicy.ExactUnorderedCI] -> SchemaPolicy.ExactUnorderedCI, - typeOf[SchemaPolicy.ExactOrdered] -> SchemaPolicy.ExactOrdered, - typeOf[SchemaPolicy.ExactOrderedCI] -> SchemaPolicy.ExactOrderedCI, - typeOf[SchemaPolicy.ExactByPosition] -> SchemaPolicy.ExactByPosition, - typeOf[SchemaPolicy.Backward] -> SchemaPolicy.Backward, - typeOf[SchemaPolicy.Forward] -> SchemaPolicy.Forward, - typeOf[SchemaPolicy.Full] -> SchemaPolicy.Full, - ) - - val rules = known.collectFirst { case (t, policy) if weakTypeOf[P] <:< t => ComparisonRules.of(policy) } - .getOrElse(ComparisonRules.strictest) - - ShapeDiff - .report( - policyName = weakTypeOf[P].toString, - outName = weakTypeOf[Out].toString, - contractName = weakTypeOf[Contract].toString, - rules = rules, - out = ShapeBuilder.buildTypeShape(weakTypeOf[Out]), - contract = ShapeBuilder.buildTypeShape(weakTypeOf[Contract]), - ) - .foreach(message => c.abort(c.enclosingPosition, message)) - - q"new _root_.com.flowforge.core.contracts.SchemaConforms[${weakTypeOf[Out]}, ${weakTypeOf[Contract]}, ${weakTypeOf[P]}] {}" - } -} diff --git a/modules/core/src/main/scala-3/com/flowforge/core/contracts/SchemaConformsMaterializer.scala b/modules/core/src/main/scala-3/com/flowforge/core/contracts/SchemaConformsMaterializer.scala deleted file mode 100644 index 8e27feff..00000000 --- a/modules/core/src/main/scala-3/com/flowforge/core/contracts/SchemaConformsMaterializer.scala +++ /dev/null @@ -1,14 +0,0 @@ -package com.flowforge.core.contracts - -/** Scala 3 materializer for [[SchemaConforms]]. Mixed into `object SchemaConforms`. */ -trait SchemaConformsMaterializer { - - /** - * Materialize compile-time evidence that Out conforms to Contract under policy P. - * - * The macro builds a `TypeShape` for each side and compares them under the policy. No runtime overhead - - * pure compile-time validation. - */ - inline given materialize[Out, Contract, P <: SchemaPolicy]: SchemaConforms[Out, Contract, P] = - ${ internal.ContractMacros.conformsImpl[Out, Contract, P] } -} diff --git a/modules/core/src/main/scala-3/com/flowforge/core/contracts/derive/ShapeDerivation.scala b/modules/core/src/main/scala-3/com/flowforge/core/contracts/derive/ShapeDerivation.scala deleted file mode 100644 index 1fd2d68b..00000000 --- a/modules/core/src/main/scala-3/com/flowforge/core/contracts/derive/ShapeDerivation.scala +++ /dev/null @@ -1,45 +0,0 @@ -package com.flowforge.core.contracts.derive - -import com.flowforge.core.contracts.internal.{ TypeShape, TypeShapes } - -import scala.quoted.* - -/** Scala 3 derivation of [[Shape]], via a quotes macro. Mixed into `object Shape`. */ -trait ShapeDerivation { - inline given gen[T]: Shape[T] = ${ ShapeDerivation.genImpl[T] } -} - -object ShapeDerivation { - - def genImpl[T: Type](using q: Quotes): Expr[Shape[T]] = { - import q.reflect.* - - val tpe = TypeRepr.of[T].dealias - // Keep v1.0 scope to products only; reject everything else, including sums - if (!TypeShapes.isCaseClass(tpe)) { - report.errorAndAbort(s"Shape supports case classes only for now (got ${tpe.show})") - } - - val fieldExprs = Expr.ofList(TypeShapes.params(tpe).map { - case (name, fieldType, hasDefault) => - // Read off the type out here: inside the quote a different Quotes instance is in scope, so a - // `TypeRepr` from this one does not typecheck there. - val rendered = Expr(TypeShape.simpleName(fieldType.show)) - val isOptional = Expr(TypeShapes.isOption(fieldType.dealias)) - '{ - Field( - name = ${ Expr(name) }, - tpe = $rendered, - hasDefault = ${ Expr(hasDefault) }, - isOptional = $isOptional, - ) - } - }) - - '{ - new Shape[T] { - val fields: List[Field] = $fieldExprs - } - } - } -} diff --git a/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/ContractMacros.scala b/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/ContractMacros.scala deleted file mode 100644 index 1b5736ac..00000000 --- a/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/ContractMacros.scala +++ /dev/null @@ -1,62 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.{ SchemaConforms, SchemaPolicy } - -import scala.quoted.* - -/** - * Scala 3 half of compile-time contract validation. - * - * This only turns the two types into [[TypeShape]]s and hands them to [[ShapeDiff]], which owns the policy - * rules and the error text. The Scala 2 macro in `src/main/scala-2` does the same, so the two versions cannot - * drift in what they accept. - */ -object ContractMacros { - - def conformsImpl[Out: Type, Contract: Type, P <: SchemaPolicy: Type]( - using - q: Quotes, - ): Expr[SchemaConforms[Out, Contract, P]] = { - import q.reflect.* - - ShapeDiff - .report( - policyName = TypeRepr.of[P].show, - outName = TypeRepr.of[Out].show, - contractName = TypeRepr.of[Contract].show, - rules = rulesOf[P], - out = TypeShapes.of(TypeRepr.of[Out]), - contract = TypeShapes.of(TypeRepr.of[Contract]), - ) - .foreach(message => report.errorAndAbort(message)) - - '{ new SchemaConforms[Out, Contract, P] {} } - } - - /** - * The comparison rules the policy type `P` stands for. - * - * Matched by subtyping rather than by the rendered type name, so that a policy named as the trait - * (`SchemaPolicy.Backward`) and the same policy named as the case object (`SchemaPolicy.Backward.type`) - * resolve to the same rules. The policy traits are disjoint, so at most one branch can match. - */ - private def rulesOf[P <: SchemaPolicy: Type](using q: Quotes): ComparisonRules = { - import q.reflect.* - val requested = TypeRepr.of[P] - - val known: List[(TypeRepr, SchemaPolicy)] = List( - TypeRepr.of[SchemaPolicy.Exact] -> SchemaPolicy.Exact, - TypeRepr.of[SchemaPolicy.ExactUnordered] -> SchemaPolicy.ExactUnordered, - TypeRepr.of[SchemaPolicy.ExactUnorderedCI] -> SchemaPolicy.ExactUnorderedCI, - TypeRepr.of[SchemaPolicy.ExactOrdered] -> SchemaPolicy.ExactOrdered, - TypeRepr.of[SchemaPolicy.ExactOrderedCI] -> SchemaPolicy.ExactOrderedCI, - TypeRepr.of[SchemaPolicy.ExactByPosition] -> SchemaPolicy.ExactByPosition, - TypeRepr.of[SchemaPolicy.Backward] -> SchemaPolicy.Backward, - TypeRepr.of[SchemaPolicy.Forward] -> SchemaPolicy.Forward, - TypeRepr.of[SchemaPolicy.Full] -> SchemaPolicy.Full, - ) - - known.collectFirst { case (tpe, policy) if requested <:< tpe => ComparisonRules.of(policy) } - .getOrElse(ComparisonRules.strictest) - } -} diff --git a/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/TypeShapes.scala b/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/TypeShapes.scala deleted file mode 100644 index d7afe14c..00000000 --- a/modules/core/src/main/scala-3/com/flowforge/core/contracts/internal/TypeShapes.scala +++ /dev/null @@ -1,125 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.internal.TypeShape.* - -import scala.quoted.* - -/** - * Turning a Scala 3 type into a [[TypeShape]]. - * - * The two macros that need this, contract checking and `Shape` derivation, ask different questions of the - * same type, so the reflection lives here and each of them keeps only its own job. - */ -object TypeShapes { - - /** The normalized shape of `tpe`. */ - def of(using q: Quotes)(tpe: q.reflect.TypeRepr): TypeShape = shapeOf(tpe, inField = false) - - /** - * Primary-constructor parameters of a case class, as (name, type, whether it has a default). - * - * Names and types come from the case fields and defaults from the constructor, because only the constructor - * parameters carry the default flag. - */ - def params(using q: Quotes)(tpe: q.reflect.TypeRepr): List[(String, q.reflect.TypeRepr, Boolean)] = { - import q.reflect.* - val sym = tpe.typeSymbol - val hasDefault = sym.primaryConstructor.paramSymss.flatten - .filterNot(_.isTypeParam) - .map(p => p.name -> p.flags.is(Flags.HasDefault)) - .toMap - sym.caseFields.map(f => (f.name, tpe.memberType(f), hasDefault.getOrElse(f.name, false))) - } - - /** Whether `tpe` is a case class, and so has a struct shape rather than being opaque. */ - def isCaseClass(using q: Quotes)(tpe: q.reflect.TypeRepr): Boolean = { - import q.reflect.* - val sym = tpe.typeSymbol - sym.isClassDef && sym.flags.is(Flags.Case) - } - - /** Whether `tpe` is `Option[_]`. */ - def isOption(using q: Quotes)(tpe: q.reflect.TypeRepr): Boolean = - tpe.asType match { - case '[Option[t]] => true - case _ => false - } - - private def shapeOf(using q: Quotes)(tpe: q.reflect.TypeRepr, inField: Boolean): TypeShape = { - import q.reflect.* - val t = tpe.dealias - t.asType match { - case '[Option[a]] => - // Field-level Option is captured on FieldShape.isOptional; avoid double-wrapping there. - // Outside of field context (e.g., List[Option[A]]), preserve optionality as OptionalShape. - val inner = shapeOf(TypeRepr.of[a], inField = false) - if (inField) inner else OptionalShape(inner) - - case '[Map[k, v]] => - val key = TypeRepr.of[k].dealias - if (!isAtomicKey(key)) { - report.errorAndAbort( - s"Unsupported Map key type: ${key.show}. Allowed: String, Int, Long, Short, Byte, Boolean", - ) - } - MapShape(PrimitiveShape(TypeShape.simpleName(key.show)), shapeOf(TypeRepr.of[v], inField = false)) - - case '[Seq[a]] => SequenceShape(shapeOf(TypeRepr.of[a], inField = false)) - case '[Set[a]] => SequenceShape(shapeOf(TypeRepr.of[a], inField = false)) - case '[Array[a]] => SequenceShape(shapeOf(TypeRepr.of[a], inField = false)) - - case _ => structOrLeaf(t) - } - } - - private def structOrLeaf(using q: Quotes)(tpe: q.reflect.TypeRepr): TypeShape = { - import q.reflect.* - // Tuples are checked first because every TupleN is itself a case class. Reading one as a struct of - // `_1`, `_2` would make positional junk look like a named schema, so a tuple is rejected rather than - // reinterpreted. - if (tpe <:< TypeRepr.of[Tuple]) unsupportedTuple(tpe) - else if (isCaseClass(tpe)) StructShape(fieldsOf(tpe)) - else opaqueLeaf(tpe) - } - - /** - * A type this macro cannot look inside, compared by its name. - * - * There used to be a closed list of leaf types here, on the reasoning that a type outside it is one no sink - * can write. That is not something a contract can know: a sink takes the writer as a function, so whether a - * `UUID` or a domain enum can be written is decided by the writer the caller supplies, not by this list. - * Rejecting an unlisted leaf therefore turned away pipelines that were fine. Comparing it by name still - * catches the drift that is this macro's job, because a leaf that changes type changes its name. - */ - private def opaqueLeaf(using q: Quotes)(tpe: q.reflect.TypeRepr): PrimitiveShape = { - import q.reflect.* - PrimitiveShape(TypeShape.simpleName(tpe.show)) - } - - private def unsupportedTuple(using q: Quotes)(tpe: q.reflect.TypeRepr): Nothing = { - import q.reflect.* - report.errorAndAbort( - s"Unsupported tuple in SchemaConforms derivation: ${tpe.show}. " + - "A tuple has no field names to compare, so use a case class instead.", - ) - } - - private def fieldsOf(using q: Quotes)(tpe: q.reflect.TypeRepr): List[FieldShape] = { - import q.reflect.* - params(tpe).map { - case (name, fieldType, hasDefault) => - val (underlying, isOptional) = fieldType.dealias.asType match { - case '[Option[a]] => (TypeRepr.of[a], true) - case _ => (fieldType, false) - } - // For field-level shape, pass inField = true so Option is carried via isOptional flag - FieldShape(name, shapeOf(underlying, inField = true), hasDefault, isOptional) - } - } - - private def isAtomicKey(using q: Quotes)(tpe: q.reflect.TypeRepr): Boolean = { - import q.reflect.* - tpe =:= TypeRepr.of[String] || tpe =:= TypeRepr.of[Int] || tpe =:= TypeRepr.of[Long] || - tpe =:= TypeRepr.of[Short] || tpe =:= TypeRepr.of[Byte] || tpe =:= TypeRepr.of[Boolean] - } -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaConforms.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaConforms.scala deleted file mode 100644 index 78080377..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaConforms.scala +++ /dev/null @@ -1,30 +0,0 @@ -package com.flowforge.core.contracts -import scala.annotation.implicitNotFound - -/** - * Evidence that an output type `Out` conforms to a declared data contract `Contract` under a schema‑evolution - * policy `P` (e.g., Exact, Backward, Forward). - * - * This evidence is materialized at compile time by a macro that deeply compares normalized shapes of `Out` - * and `Contract` and aborts compilation with a precise, path‑aware message on drift. - * - * Typical use: {{@example import com.flowforge.core.contracts._ import - * com.flowforge.core.contracts.SchemaPolicy._ final case class User(id: Long, email: String) // Contract - * types are generated in modules/contracts-sdk from Avro (or authored directly) type UserV1 = User // - * Requires compile‑time evidence; fails to compile on mismatch implicitly[SchemaConforms[User, UserV1, - * Exact]] }} - */ -@implicitNotFound(""" -FlowForge: Contract drift (policy: ${P}) -Out: ${Out} vs Contract: ${Contract} -Missing: <> | Extra: <> | Mismatched: <> -""") -trait SchemaConforms[Out, Contract, P <: SchemaPolicy] - -/** - * The evidence itself has no members, so everything here is the materializer, and the materializer is the one - * part of contract checking that cannot be written once: it is a macro, and the two Scala versions spell - * macros differently. [[SchemaConformsMaterializer]] is supplied per version from its own source directory. - * Both spellings end up calling the same comparison in `internal.ShapeDiff`. - */ -object SchemaConforms extends SchemaConformsMaterializer diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaPolicy.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaPolicy.scala deleted file mode 100644 index 8ed47bf9..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/SchemaPolicy.scala +++ /dev/null @@ -1,51 +0,0 @@ -package com.flowforge.core.contracts - -/** - * Schema comparison policies for compile-time contract validation. - * - * Clean enum-based design. Each policy defines how two schemas should be compared for conformance at compile - * time. - * - * Policy behavior: - * - [[SchemaPolicy.Exact]]: Perfect match (unordered, case-sensitive) - * - [[SchemaPolicy.ExactUnordered]]: Back-compat alias for [[SchemaPolicy.Exact]] - * - [[SchemaPolicy.ExactUnorderedCI]]: Perfect match, case-insensitive names, unordered - * - [[SchemaPolicy.ExactOrdered]]: Perfect match with enforced field order - * - [[SchemaPolicy.ExactOrderedCI]]: Perfect match, case-insensitive + ordered - * - [[SchemaPolicy.ExactByPosition]]: Match by position only, names ignored - * - [[SchemaPolicy.Backward]]: Producer can have extras; missing allowed if optional/default - * - [[SchemaPolicy.Forward]]: Producer subset of contract; missing contract fields allowed - * - [[SchemaPolicy.Full]]: Escape hatch: accepts everything (development/testing) - */ -sealed trait SchemaPolicy - -/** - * Each policy appears twice: as a sealed trait the macros match on at the type level, and as a case object - * for code that passes a policy as a value. The two are linked by the case object extending the trait of the - * same name, so a policy named in either position means the same thing. - */ -object SchemaPolicy { - // Sealed traits for type-level usage in macros - sealed trait Exact extends SchemaPolicy - // Back-compat: unordered, case-sensitive equality of fields - sealed trait ExactUnordered extends SchemaPolicy - sealed trait ExactUnorderedCI extends SchemaPolicy - sealed trait ExactOrdered extends SchemaPolicy - sealed trait ExactOrderedCI extends SchemaPolicy - sealed trait ExactByPosition extends SchemaPolicy - sealed trait Backward extends SchemaPolicy - sealed trait Forward extends SchemaPolicy - sealed trait Full extends SchemaPolicy - - // Case objects for runtime usage - implement the traits - case object Exact extends SchemaPolicy.Exact - // Back-compat alias. Semantics match Exact (unordered, case-sensitive) - case object ExactUnordered extends SchemaPolicy.ExactUnordered - case object ExactUnorderedCI extends SchemaPolicy.ExactUnorderedCI - case object ExactOrdered extends SchemaPolicy.ExactOrdered - case object ExactOrderedCI extends SchemaPolicy.ExactOrderedCI - case object ExactByPosition extends SchemaPolicy.ExactByPosition - case object Backward extends SchemaPolicy.Backward - case object Forward extends SchemaPolicy.Forward - case object Full extends SchemaPolicy.Full -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/derive/Shape.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/derive/Shape.scala deleted file mode 100644 index ad798cba..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/derive/Shape.scala +++ /dev/null @@ -1,37 +0,0 @@ -package com.flowforge.core.contracts.derive - -/** - * Compile-time field metadata for a case class. - * - * `tpe` is the field's type as the compiler renders it, which differs between Scala versions; treat it as a - * label, not an identity. `isOptional` is the reliable test for `Option`. - */ -final case class Field( - name: String, - tpe: String, - hasDefault: Boolean, - isOptional: Boolean) - -trait Shape[T] { def fields: List[Field] } - -/** - * Instances for the types that have no fields, plus derivation for the ones that do. - * - * Derivation arrives through [[ShapeDerivation]], which each Scala version supplies from its own source - * directory: Magnolia on 2.13, a quotes macro on 3. Being inherited also makes it lower priority than the - * instances declared here, so `Shape[String]` resolves to `stringShape` rather than deriving an empty shape. - */ -object Shape extends ShapeDerivation { - - // Primitive instances - implicit val stringShape: Shape[String] = new Shape[String] { val fields = List.empty } - implicit val intShape: Shape[Int] = new Shape[Int] { val fields = List.empty } - implicit val longShape: Shape[Long] = new Shape[Long] { val fields = List.empty } - implicit val booleanShape: Shape[Boolean] = new Shape[Boolean] { val fields = List.empty } - implicit val doubleShape: Shape[Double] = new Shape[Double] { val fields = List.empty } - - // Collection instances - implicit def listShape[A]: Shape[List[A]] = new Shape[List[A]] { val fields = List.empty } - implicit def mapShape[K, V]: Shape[Map[K, V]] = new Shape[Map[K, V]] { val fields = List.empty } - implicit def optionShape[A]: Shape[Option[A]] = new Shape[Option[A]] { val fields = List.empty } -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/derive/package.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/derive/package.scala new file mode 100644 index 00000000..16e9c63d --- /dev/null +++ b/modules/core/src/main/scala/com/flowforge/core/contracts/derive/package.scala @@ -0,0 +1,17 @@ +package com.flowforge.core.contracts + +/** + * Field metadata for a case class, derived at compile time. + * + * Aliases for `ctdc.derive`, for the same reason as the aliases in [[com.flowforge.core.contracts]]: the + * engine is a library, but FlowForge callers should only need one namespace. Because these are aliases and + * not wrappers, `Shape.gen[A]` and implicit search behave exactly as they do in `ctdc`. + */ +package object derive { + + type Field = ctdc.derive.Field + val Field: ctdc.derive.Field.type = ctdc.derive.Field + + type Shape[T] = ctdc.derive.Shape[T] + val Shape: ctdc.derive.Shape.type = ctdc.derive.Shape +} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ComparisonRules.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ComparisonRules.scala deleted file mode 100644 index 7d04a35a..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ComparisonRules.scala +++ /dev/null @@ -1,93 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.SchemaPolicy - -/** How producer fields are lined up against contract fields before anything is compared. */ -sealed trait FieldMatching - -object FieldMatching { - case object ByName extends FieldMatching - case object ByNameOrdered extends FieldMatching - case object ByPosition extends FieldMatching -} - -/** Whether a field name has to match exactly or only up to case. */ -sealed trait NameCasing - -object NameCasing { - case object Sensitive extends NameCasing - case object Insensitive extends NameCasing -} - -/** Which differences survive once the fields are lined up. */ -sealed trait Tolerance - -object Tolerance { - - /** Every difference is drift. */ - case object Strict extends Tolerance - - /** The producer may add fields, and may omit a contract field that is optional or has a default. */ - case object Backward extends Tolerance - - /** The producer may omit fields, so it can be a subset of the contract. */ - case object Forward extends Tolerance - - /** Escape hatch: comparison still runs, but nothing it finds is drift. */ - case object Permissive extends Tolerance -} - -/** - * A policy flattened into the three independent questions comparison actually asks. - * - * [[com.flowforge.core.contracts.SchemaPolicy]] is the vocabulary users write; this is the vocabulary the - * comparison reads. Keeping them apart is what lets the comparison be a pure function, and it makes the - * contradictory combinations unconstructible: there is no way to ask for "by position and also ordered by - * name", which a set of independent booleans would have allowed. - */ -final case class ComparisonRules( - matching: FieldMatching, - casing: NameCasing, - tolerance: Tolerance) { - - /** Field names reduced to the form this policy compares them in. */ - def normalize(name: String): String = casing match { - case NameCasing.Sensitive => name - case NameCasing.Insensitive => name.toLowerCase - } - - def sameName(left: String, right: String): Boolean = normalize(left) == normalize(right) -} - -object ComparisonRules { - - import FieldMatching._ - import NameCasing._ - - /** - * The rules each policy stands for. - * - * Total over the policy ADT on purpose: a new policy that nobody mapped is a compile error here, not a - * silent fallback to some default at the call site. - */ - def of(policy: SchemaPolicy): ComparisonRules = policy match { - case SchemaPolicy.Exact => ComparisonRules(ByName, Sensitive, Tolerance.Strict) - case SchemaPolicy.ExactUnordered => ComparisonRules(ByName, Sensitive, Tolerance.Strict) - case SchemaPolicy.ExactUnorderedCI => ComparisonRules(ByName, Insensitive, Tolerance.Strict) - case SchemaPolicy.ExactOrdered => ComparisonRules(ByNameOrdered, Sensitive, Tolerance.Strict) - case SchemaPolicy.ExactOrderedCI => ComparisonRules(ByNameOrdered, Insensitive, Tolerance.Strict) - case SchemaPolicy.ExactByPosition => ComparisonRules(ByPosition, Sensitive, Tolerance.Strict) - case SchemaPolicy.Backward => ComparisonRules(ByName, Sensitive, Tolerance.Backward) - case SchemaPolicy.Forward => ComparisonRules(ByName, Sensitive, Tolerance.Forward) - case SchemaPolicy.Full => ComparisonRules(ByName, Sensitive, Tolerance.Permissive) - } - - /** - * What to compare under when the policy type is not one of the known policies. - * - * Reached only for an abstract `P <: SchemaPolicy`, which is generic code that has not fixed its policy - * yet. Strict name matching is the safe default: it can report drift that a looser policy would have - * accepted, but it never passes a producer that the requested policy would have rejected. - */ - val strictest: ComparisonRules = ComparisonRules(ByName, Sensitive, Tolerance.Strict) -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/SchemaAST.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/internal/SchemaAST.scala deleted file mode 100644 index 2d4eca56..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/SchemaAST.scala +++ /dev/null @@ -1,127 +0,0 @@ -package com.flowforge.core.contracts.internal - -/** - * Normalized structural shape for compile-time comparison. - * - * Clean, minimal ADT focused on the essentials needed for deep schema comparison. - */ -sealed trait TypeShape - -object TypeShape { - final case class PrimitiveShape(name: String) extends TypeShape - final case class SequenceShape(elem: TypeShape) extends TypeShape - final case class MapShape(key: PrimitiveShape, value: TypeShape) extends TypeShape - // Represents nested optionality (e.g., List[Option[A]]). Field-level optionality remains on FieldShape. - final case class OptionalShape(inner: TypeShape) extends TypeShape - final case class FieldShape( - name: String, - shape: TypeShape, - hasDefault: Boolean, - isOptional: Boolean) - extends TypeShape - final case class StructShape(fields: List[FieldShape]) extends TypeShape - - /** - * Prefixes that carry no information for a reader of a drift report. - * - * Matched anywhere in the name rather than only at the front, because a type argument carries its own - * prefix: `Option[scala.Predef.String]` has to reduce to `Option[String]`, which stripping a leading prefix - * cannot do. The uppercase lookahead is what keeps the reduction to type names only, so - * `scala.collection.immutable.Map` is left alone instead of becoming `collection.immutable.Map`. The - * alternation is ordered longest-first so that `scala.Predef.` wins over the `scala.` that starts it. - */ - private val redundantPrefixes = """(?:scala\.Predef\.|scala\.|java\.lang\.)(?=[A-Z])""".r - - /** - * A rendered type name without the prefixes a reader does not need. - * - * The two macros print the same type differently - Scala 3 reflection says `scala.Predef.String` where - * Scala 2 says `java.lang.String` - so a drift report would read differently depending on which compiler - * produced it. Names are reduced here, at the one place both macros build a shape through. - */ - def simpleName(rendered: String): String = redundantPrefixes.replaceAllIn(rendered, "") - - /** - * A shape as it reads in a drift report. - * - * Deliberately not the Scala source spelling of the type: what a report needs to say is what the comparison - * actually looked at, which is the normalized shape rather than the collection class the producer happened - * to use. - */ - def render(shape: TypeShape): String = shape match { - case PrimitiveShape(name) => simpleName(name) - case OptionalShape(inner) => s"optional ${render(inner)}" - case SequenceShape(elem) => s"seq[${render(elem)}]" - case MapShape(key, value) => s"map[${render(key)} -> ${render(value)}]" - case FieldShape(name, inner, _, _) => s"$name: ${render(inner)}" - case StructShape(fields) => - fields.map(f => s"${f.name}: ${render(f.shape)}").mkString("{", ", ", "}") - } - - /** A field as it reads in a drift report, with what the contract says about absent values. */ - def renderField(field: FieldShape): String = { - val optional = if (field.isOptional) " (optional)" else "" - val default = if (field.hasDefault) " (default)" else "" - s"${render(field.shape)}$optional$default" - } - - /** Kept for the legacy [[SchemaAST]] alias only; drift reports go through [[render]]. */ - def pretty(shape: TypeShape): String = shape match { - case PrimitiveShape(name) => name - case SequenceShape(elem) => s"List[${pretty(elem)}]" - case MapShape(key, value) => s"Map[${pretty(key)}, ${pretty(value)}]" - case OptionalShape(inner) => s"Option[${pretty(inner)}]" - case FieldShape(name, tpe, hasDefault, isOptional) => - val opt = if (isOptional) " (optional)" else "" - val dflt = if (hasDefault) " (default)" else "" - s"$name: ${pretty(tpe)}$opt$dflt" - case StructShape(fields) => - fields.map(f => f.name + ":" + pretty(f.shape)).mkString("{", ",", "}") - } -} - -// Legacy compatibility - keep SchemaAST as alias until migration complete - -@deprecated("Use TypeShape instead", "0.2.0") -object SchemaAST { - // Legacy type aliases for backward compatibility - type Record = TypeShape.StructShape - type Field = TypeShape.FieldShape - type Primitive = TypeShape.PrimitiveShape - type OptionT = TypeShape.OptionalShape - type ArrayT = TypeShape.SequenceShape - type MapT = TypeShape.MapShape - - // Factory methods for backward compatibility - def Record(name: String, fields: List[TypeShape.FieldShape]): TypeShape.StructShape = - TypeShape.StructShape(fields) - def Field( - name: String, - tpe: TypeShape, - hasDefault: Boolean, - isOptional: Boolean, - ): TypeShape.FieldShape = - TypeShape.FieldShape(name, tpe, hasDefault, isOptional) - def Primitive(tag: String): TypeShape.PrimitiveShape = - TypeShape.PrimitiveShape(tag) - def OptionT(value: TypeShape): TypeShape.OptionalShape = - TypeShape.OptionalShape(value) - def ArrayT(elem: TypeShape): TypeShape.SequenceShape = - TypeShape.SequenceShape(elem) - def MapT(key: TypeShape.PrimitiveShape, value: TypeShape): TypeShape.MapShape = - TypeShape.MapShape(key, value) - - object PrimitiveTags { - val Str = "String" - val Int = "Int" - val Long = "Long" - val Double = "Double" - val Float = "Float" - val Boolean = "Boolean" - val Instant = "Instant" - val Decimal = "BigDecimal" - val Unknown = "Unknown" - } - - def pretty(shape: TypeShape, indent: Int = 0): String = TypeShape.pretty(shape) -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ShapeDiff.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ShapeDiff.scala deleted file mode 100644 index ca00e675..00000000 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/internal/ShapeDiff.scala +++ /dev/null @@ -1,227 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.internal.TypeShape._ - -/** - * Policy-aware comparison of two normalized shapes. - * - * Everything here is a pure function of its arguments. That is the point: each Scala version's macro only - * turns the two types into [[TypeShape]]s and turns a report into a compile error, so the part of contract - * checking that can actually be wrong is testable without invoking a compiler, and both compilers answer - * alike because they answer through the same code. - */ -object ShapeDiff { - - /** A field the contract requires and the producer does not have. */ - final case class Missing(path: String, field: FieldShape) - - /** A field the producer has and the contract does not mention. */ - final case class Extra(path: String, name: String) - - /** A position where both sides have something, but not the same thing. */ - final case class Mismatch( - path: String, - expected: String, - found: String) - - /** What a policy still considers drift, after its own tolerances are applied. */ - final case class Drift( - missing: List[Missing], - extra: List[Extra], - mismatched: List[Mismatch]) { - def isEmpty: Boolean = missing.isEmpty && extra.isEmpty && mismatched.isEmpty - def nonEmpty: Boolean = !isEmpty - - def ++(other: Drift): Drift = - Drift(missing ++ other.missing, extra ++ other.extra, mismatched ++ other.mismatched) - } - - object Drift { - val empty: Drift = Drift(Nil, Nil, Nil) - } - - /** - * The compile error for the drift between `out` and `contract`, or None when the producer conforms. - * - * The policy is passed twice over, as `rules` and as `policyName`, because they answer different questions: - * the rules decide the comparison, the name only appears in the message. Nothing here parses the name, so a - * policy can never be mis-dispatched by how its type happens to print. - */ - def report( - policyName: String, - outName: String, - contractName: String, - rules: ComparisonRules, - out: TypeShape, - contract: TypeShape, - ): Option[String] = { - val drift = diff(rules, out, contract) - if (drift.isEmpty) None else Some(renderReport(policyName, outName, contractName, drift)) - } - - /** Every difference between producer and contract that the policy does not tolerate. */ - def diff( - rules: ComparisonRules, - out: TypeShape, - contract: TypeShape, - ): Drift = tolerate(rules.tolerance, compare(rules, "", out, contract)) - - private def compare( - rules: ComparisonRules, - path: String, - out: TypeShape, - contract: TypeShape, - ): Drift = - (out, contract) match { - case (OptionalShape(o), OptionalShape(c)) => compare(rules, path, o, c) - - // Optionality nested inside a collection or a map is load-bearing, so it is compared rather than - // normalized away. Only field-level optionality is ignored, and that is unwrapped before it gets here. - case (OptionalShape(o), c) => mismatchAt(path, render(c), render(OptionalShape(o))) - case (o, OptionalShape(c)) => mismatchAt(path, render(OptionalShape(c)), render(o)) - - case (PrimitiveShape(o), PrimitiveShape(c)) => - if (o == c) Drift.empty else mismatchAt(path, c, o) - - case (SequenceShape(o), SequenceShape(c)) => compare(rules, s"$path[]", o, c) - - case (MapShape(keyOut, valueOut), MapShape(keyContract, valueContract)) => - val keyDrift = - if (rules.sameName(keyOut.name, keyContract.name)) Drift.empty - else mismatchAt(s"$path", keyContract.name, keyOut.name) - keyDrift ++ compare(rules, s"$path", valueOut, valueContract) - - case (StructShape(o), StructShape(c)) => compareStructs(rules, path, o, c) - - case (o, c) => mismatchAt(path, render(c), render(o)) - } - - private def compareStructs( - rules: ComparisonRules, - path: String, - out: List[FieldShape], - contract: List[FieldShape], - ): Drift = rules.matching match { - case FieldMatching.ByName => compareByName(rules, path, out, contract) - case FieldMatching.ByNameOrdered => compareByNameOrdered(rules, path, out, contract) - case FieldMatching.ByPosition => compareByPosition(rules, path, out, contract) - } - - private def compareByName( - rules: ComparisonRules, - path: String, - out: List[FieldShape], - contract: List[FieldShape], - ): Drift = { - val outByName = out.map(f => rules.normalize(f.name) -> f).toMap - val contractNames = contract.map(f => rules.normalize(f.name)).toSet - - val missing = contract.collect { - case f if !outByName.contains(rules.normalize(f.name)) => Missing(pathOf(path, f.name), f) - } - val extra = out.collect { - case f if !contractNames.contains(rules.normalize(f.name)) => Extra(pathOf(path, f.name), f.name) - } - val nested = contract.foldLeft(Drift.empty) { (acc, f) => - outByName - .get(rules.normalize(f.name)) - .fold(acc)(o => acc ++ compare(rules, pathOf(path, f.name), o.shape, f.shape)) - } - - Drift(missing, extra, Nil) ++ nested - } - - private def compareByNameOrdered( - rules: ComparisonRules, - path: String, - out: List[FieldShape], - contract: List[FieldShape], - ): Drift = { - val paired = out.zip(contract).zipWithIndex - - val nameMismatches = paired.collect { - case ((o, c), index) if !rules.sameName(o.name, c.name) => - Mismatch(pathOf(path, s"@$index(name)"), c.name, o.name) - } - val nested = paired.foldLeft(Drift.empty) { - case (acc, ((o, c), _)) => acc ++ compare(rules, pathOf(path, c.name), o.shape, c.shape) - } - - nested ++ Drift( - missing = contract.drop(paired.length).map(f => Missing(pathOf(path, f.name), f)), - extra = out.drop(paired.length).map(f => Extra(pathOf(path, f.name), f.name)), - mismatched = nameMismatches, - ) - } - - private def compareByPosition( - rules: ComparisonRules, - path: String, - out: List[FieldShape], - contract: List[FieldShape], - ): Drift = { - val paired = out.zip(contract).zipWithIndex - - // The paired prefix is compared even when the counts differ, so one compile reports every problem - // rather than only the count and then the next one on the following compile. - val nested = paired.foldLeft(Drift.empty) { - case (acc, ((o, c), index)) => acc ++ compare(rules, pathOf(path, s"@$index"), o.shape, c.shape) - } - - // Names are not compared at all here, so a count difference can only be reported at the first - // unpaired index. - val boundary = pathOf(path, s"@${paired.length}") - val tail = Drift( - missing = contract.drop(paired.length).map(f => Missing(boundary, f)), - extra = out.drop(paired.length).map(f => Extra(boundary, f.name)), - mismatched = Nil, - ) - - tail ++ nested - } - - /** Drop the differences the policy tolerates. */ - private def tolerate(tolerance: Tolerance, drift: Drift): Drift = tolerance match { - case Tolerance.Strict => drift - - case Tolerance.Backward => - // A contract field the producer omits is only acceptable when the contract itself says the value may - // be absent. - drift.copy( - missing = drift.missing.filterNot(m => m.field.hasDefault || m.field.isOptional), - extra = Nil, - ) - - case Tolerance.Forward => drift.copy(missing = Nil) - - case Tolerance.Permissive => Drift.empty - } - - private def renderReport( - policyName: String, - outName: String, - contractName: String, - drift: Drift, - ): String = { - val missing = drift.missing.map(m => s"${m.path} : ${renderField(m.field)}").mkString(", ") - val extra = drift.extra.map(_.path).mkString(", ") - val mismatched = - drift.mismatched.map(m => s"${m.path} expected ${m.expected}, found ${m.found}").mkString("; ") - - s"""Compile-time contract drift (policy: $policyName). - |Out: $outName vs Contract: $contractName - |Missing attributes: $missing - |Extra attributes: $extra - |Mismatch attributes: $mismatched - |""".stripMargin - } - - private def mismatchAt( - path: String, - expected: String, - found: String, - ): Drift = Drift(Nil, Nil, List(Mismatch(path, expected, found))) - - /** A child path under `base`, with no leading separator at the root. */ - private def pathOf(base: String, segment: String): String = s"$base.$segment".stripPrefix(".") -} diff --git a/modules/core/src/main/scala/com/flowforge/core/contracts/package.scala b/modules/core/src/main/scala/com/flowforge/core/contracts/package.scala index 789502cc..21be871c 100644 --- a/modules/core/src/main/scala/com/flowforge/core/contracts/package.scala +++ b/modules/core/src/main/scala/com/flowforge/core/contracts/package.scala @@ -1,7 +1,7 @@ package com.flowforge.core /** - * =Compile‑time Contracts= + * =Compile-time Contracts= * * Tools to prove, at compile time, that a producer type `Out` conforms to a declared `Contract` under a * policy `P`. @@ -9,15 +9,44 @@ package com.flowforge.core * ==Key types== * - [[com.flowforge.core.contracts.SchemaConforms]] - evidence that `Out` conforms to `Contract` under `P`. * - [[com.flowforge.core.contracts.SchemaPolicy]] - how conformance is checked (Exact, Backward, Forward, - * Ordered, By‑position, etc.). + * Ordered, By-position, etc.). * * ==Usage== - * {{@example import com.flowforge.core.contracts._ final case class V1(id: Long, email: String) final case - * class V2(id: Long, email: String, age: Int) + * {{{ + * import com.flowforge.core.contracts._ * - * // Backward‑compatible: producer may add fields implicitly[SchemaConforms[V2, V1, SchemaPolicy.Backward]] - * }} + * final case class V1(id: Long, email: String) + * final case class V2(id: Long, email: String, age: Int) * - * Error messages include missing/extra/mismatch sections with path information; see docs/how‑it‑fails.md. + * // Backward-compatible: the producer may add fields. + * implicitly[SchemaConforms[V2, V1, SchemaPolicy.Backward]] + * }}} + * + * Error messages include missing/extra/mismatch sections with path information; see docs/how-it-fails.md. + * + * ==Where the engine lives== + * The comparison engine and its two macro front ends are the `ctdc-core` library + * (github.com/vim89/compile-time-data-contracts), which FlowForge depends on rather than carrying its own + * copy. The names below are aliases, not wrappers: they denote the same types, so implicit search reaches + * `ctdc`'s materializer directly and the drift report is unchanged. They exist so that callers keep a single + * FlowForge namespace and so the published FlowForge API does not move when the library is reorganized. */ -package object contracts +package object contracts { + + type SchemaPolicy = ctdc.SchemaPolicy + val SchemaPolicy: ctdc.SchemaPolicy.type = ctdc.SchemaPolicy + + type SchemaConforms[Out, Contract, P <: SchemaPolicy] = ctdc.SchemaConforms[Out, Contract, P] + val SchemaConforms: ctdc.SchemaConforms.type = ctdc.SchemaConforms + + /** + * Ask for contract evidence at a point of your choosing. + * + * Equivalent to `implicitly`, but named for what it proves, so a contract can be asserted where it is + * declared rather than only where some method happens to require it. + */ + def conforms[Out, Contract, P <: SchemaPolicy]( + implicit + ev: SchemaConforms[Out, Contract, P], + ): SchemaConforms[Out, Contract, P] = ev +} diff --git a/modules/core/src/test/scala/com/flowforge/core/contracts/internal/ShapeDiffPolicySpec.scala b/modules/core/src/test/scala/com/flowforge/core/contracts/internal/ShapeDiffPolicySpec.scala deleted file mode 100644 index b9880f93..00000000 --- a/modules/core/src/test/scala/com/flowforge/core/contracts/internal/ShapeDiffPolicySpec.scala +++ /dev/null @@ -1,291 +0,0 @@ -package com.flowforge.core.contracts.internal - -import com.flowforge.core.contracts.SchemaPolicy -import com.flowforge.core.contracts.internal.TypeShape._ -import org.scalatest.funspec.AnyFunSpec -import org.scalatest.matchers.should.Matchers - -/** - * What each policy accepts and rejects, checked one policy at a time. - * - * The compile-fail suite already proves the macros reject what they should, but it can only ask the compiler, - * so it cannot cover every policy against every kind of drift without a case class and a test file per - * combination. [[ShapeDiff]] is the same comparison without a compiler in front of it, so the matrix fits in - * one file and a policy's behaviour is stated rather than inferred from which files fail to compile. - * - * The reference is the standalone compile-time-data-contracts work these policies came from. FlowForge agrees - * with it on every policy except `Exact`, which is deliberate and is recorded in - * [[ShapeDiffPolicySpec.exactIsCaseSensitive]] below. - */ -class ShapeDiffPolicySpec extends AnyFunSpec with Matchers { - - import ShapeDiffPolicySpec._ - - private def drift( - policy: SchemaPolicy, - out: TypeShape, - contract: TypeShape, - ): ShapeDiff.Drift = - ShapeDiff.diff(ComparisonRules.of(policy), out, contract) - - private def conforms( - policy: SchemaPolicy, - out: TypeShape, - contract: TypeShape, - ): Boolean = - drift(policy, out, contract).isEmpty - - describe("Exact") { - it("accepts the same fields in a different order") { - conforms(SchemaPolicy.Exact, reordered, user) shouldBe true - } - - it("rejects a missing field") { - drift(SchemaPolicy.Exact, withoutEmail, user).missing.map(_.path) shouldBe List("email") - } - - it("rejects an extra field") { - drift(SchemaPolicy.Exact, withAge, user).extra.map(_.path) shouldBe List("age") - } - - it("rejects a changed field type") { - drift(SchemaPolicy.Exact, idAsString, user).mismatched.map(_.path) shouldBe List("id") - } - - it(exactIsCaseSensitive) { - conforms(SchemaPolicy.Exact, differentCase, user) shouldBe false - } - } - - describe("ExactUnordered") { - it("behaves like Exact, which is what makes it a back-compat alias") { - val cases = List(reordered, withoutEmail, withAge, idAsString, differentCase) - cases.map(conforms(SchemaPolicy.ExactUnordered, _, user)) shouldBe - cases.map(conforms(SchemaPolicy.Exact, _, user)) - } - } - - describe("ExactUnorderedCI") { - it("accepts field names that differ only in case") { - conforms(SchemaPolicy.ExactUnorderedCI, differentCase, user) shouldBe true - } - - it("still rejects a missing field") { - conforms(SchemaPolicy.ExactUnorderedCI, withoutEmail, user) shouldBe false - } - - it("accepts a different order, since it is unordered") { - conforms(SchemaPolicy.ExactUnorderedCI, reordered, user) shouldBe true - } - } - - describe("ExactOrdered") { - it("accepts the fields in the contract's order") { - conforms(SchemaPolicy.ExactOrdered, user, user) shouldBe true - } - - it("rejects the same fields in a different order") { - drift(SchemaPolicy.ExactOrdered, reordered, user).mismatched.map(_.path) should contain( - "@1(name)", - ) - } - - it("rejects field names that differ only in case") { - conforms(SchemaPolicy.ExactOrdered, differentCase, user) shouldBe false - } - } - - describe("ExactOrderedCI") { - it("accepts field names that differ only in case, in the contract's order") { - conforms(SchemaPolicy.ExactOrderedCI, differentCase, user) shouldBe true - } - - it("rejects a different order") { - conforms(SchemaPolicy.ExactOrderedCI, reordered, user) shouldBe false - } - } - - describe("ExactByPosition") { - it("accepts renamed fields whose types line up by position") { - conforms(SchemaPolicy.ExactByPosition, renamed, user) shouldBe true - } - - it("reports a producer field past the end of the contract as extra") { - // Reported at the first unpaired index rather than by name, because names are not compared under this - // policy at all, so there is no name to report it under. - val found = drift(SchemaPolicy.ExactByPosition, withAge, user).extra - found.map(_.name) shouldBe List("age") - found.map(_.path) shouldBe List("@3") - } - - it("reports a contract field the producer does not reach as missing") { - val found = drift(SchemaPolicy.ExactByPosition, withoutEmail, user).missing - found.map(_.field.name) shouldBe List("email") - found.map(_.path) shouldBe List("@2") - } - - it("still compares the positions both sides have when the counts differ") { - // A count difference used to be the whole report, so a type break at a position the two sides share - // only surfaced on the next compile, after the count was fixed. - val found = drift(SchemaPolicy.ExactByPosition, idAsStringWithAge, user) - found.extra.map(_.name) shouldBe List("age") - found.mismatched.map(_.path) shouldBe List("@0") - } - - it("rejects a type that differs at one position") { - conforms(SchemaPolicy.ExactByPosition, idAsString, user) shouldBe false - } - } - - describe("Backward") { - it("accepts extra producer fields") { - conforms(SchemaPolicy.Backward, withAge, user) shouldBe true - } - - it("rejects a missing required field") { - conforms(SchemaPolicy.Backward, withoutEmail, user) shouldBe false - } - - it("accepts a missing field the contract declares optional") { - conforms(SchemaPolicy.Backward, withoutNickname, userWithOptionalNickname) shouldBe true - } - - it("accepts a missing field the contract gives a default") { - conforms(SchemaPolicy.Backward, withoutNickname, userWithDefaultedNickname) shouldBe true - } - - it("still rejects a changed field type") { - conforms(SchemaPolicy.Backward, idAsString, user) shouldBe false - } - } - - describe("Forward") { - it("accepts a producer that omits contract fields") { - conforms(SchemaPolicy.Forward, withoutEmail, user) shouldBe true - } - - it("rejects extra producer fields") { - conforms(SchemaPolicy.Forward, withAge, user) shouldBe false - } - - it("still rejects a changed field type") { - conforms(SchemaPolicy.Forward, idAsString, user) shouldBe false - } - } - - describe("Full") { - it("accepts every kind of drift, which is why it is an escape hatch") { - val cases = List(withoutEmail, withAge, idAsString, differentCase, renamed) - cases.map(conforms(SchemaPolicy.Full, _, user)) shouldBe cases.map(_ => true) - } - } - - describe("nested optionality") { - it("rejects a producer that drops optionality inside a list") { - conforms(SchemaPolicy.Exact, listOfInt, listOfOptionalInt) shouldBe false - } - - it("rejects a producer that drops optionality inside a map value") { - conforms(SchemaPolicy.Exact, mapOfInt, mapOfOptionalInt) shouldBe false - } - - it("is not relaxed by Backward, which only relaxes whole fields") { - conforms(SchemaPolicy.Backward, listOfInt, listOfOptionalInt) shouldBe false - } - - it("reports the producer's own type as what it found") { - // The message used to print the contract's type on both sides, so a producer holding an optional Int - // against a required String was reported as having found `optional String`, a type it never had. - val found = drift(SchemaPolicy.Exact, OptionalShape(PrimitiveShape("Int")), PrimitiveShape("String")) - found.mismatched.map(m => (m.expected, m.found)) shouldBe List(("String", "optional Int")) - } - - it("reports the contract's own type as what it expected") { - val found = drift(SchemaPolicy.Exact, PrimitiveShape("Int"), OptionalShape(PrimitiveShape("String"))) - found.mismatched.map(m => (m.expected, m.found)) shouldBe List(("optional String", "Int")) - } - } - - describe("an unresolved policy type") { - it("compares exactly, so an abstract policy cannot widen what is accepted") { - ComparisonRules.strictest shouldBe ComparisonRules.of(SchemaPolicy.Exact) - } - } - - describe("the policy mapping") { - it("states each policy as one matching, one casing and one tolerance") { - // Written out in full because this table is the whole definition of what a policy means. The three - // axes are separate types rather than independent booleans, so a policy cannot ask to match both by - // position and by ordered name, or to be both backward and forward tolerant. - val expected = List( - SchemaPolicy.Exact -> (FieldMatching.ByName, NameCasing.Sensitive, Tolerance.Strict), - SchemaPolicy.ExactUnordered -> (FieldMatching.ByName, NameCasing.Sensitive, Tolerance.Strict), - SchemaPolicy.ExactUnorderedCI -> (FieldMatching.ByName, NameCasing.Insensitive, Tolerance.Strict), - SchemaPolicy.ExactOrdered -> (FieldMatching.ByNameOrdered, NameCasing.Sensitive, Tolerance.Strict), - SchemaPolicy.ExactOrderedCI -> - (FieldMatching.ByNameOrdered, NameCasing.Insensitive, Tolerance.Strict), - SchemaPolicy.ExactByPosition -> (FieldMatching.ByPosition, NameCasing.Sensitive, Tolerance.Strict), - SchemaPolicy.Backward -> (FieldMatching.ByName, NameCasing.Sensitive, Tolerance.Backward), - SchemaPolicy.Forward -> (FieldMatching.ByName, NameCasing.Sensitive, Tolerance.Forward), - SchemaPolicy.Full -> (FieldMatching.ByName, NameCasing.Sensitive, Tolerance.Permissive), - ) - - expected.map { case (policy, _) => ComparisonRules.of(policy) } shouldBe - expected.map { - case (_, (matching, casing, tolerance)) => - ComparisonRules(matching, casing, tolerance) - } - } - } -} - -object ShapeDiffPolicySpec { - - /** - * The one intentional difference from compile-time-data-contracts. - * - * There, `Exact` matches field names case-insensitively. In FlowForge `Exact` is case-sensitive and the - * case-insensitive comparison is its own policy, `ExactUnorderedCI`, so a producer renaming `userId` to - * `userid` is drift unless the contract says that is acceptable. A column name's case is load-bearing in - * some of the stores FlowForge writes to, so accepting case drift by default would hide a real break; the - * case-insensitive behaviour is still available, it just has to be asked for by name. - */ - val exactIsCaseSensitive = - "rejects field names that differ only in case, unlike compile-time-data-contracts" - - private def field(name: String, shape: TypeShape): FieldShape = - FieldShape(name, shape, hasDefault = false, isOptional = false) - - private val id = field("id", PrimitiveShape("Long")) - private val name = field("name", PrimitiveShape("String")) - private val email = field("email", PrimitiveShape("String")) - - private val user = StructShape(List(id, name, email)) - private val reordered = StructShape(List(id, email, name)) - private val withAge = StructShape(List(id, name, email, field("age", PrimitiveShape("Int")))) - private val withoutEmail = StructShape(List(id, name)) - private val idAsString = StructShape(List(field("id", PrimitiveShape("String")), name, email)) - private val differentCase = - StructShape(List(field("Id", PrimitiveShape("Long")), field("NAME", PrimitiveShape("String")), email)) - private val idAsStringWithAge = - StructShape(List(field("id", PrimitiveShape("String")), name, email, field("age", PrimitiveShape("Int")))) - private val renamed = StructShape( - List( - field("identifier", PrimitiveShape("Long")), - field("label", PrimitiveShape("String")), - field("contact", PrimitiveShape("String")), - ), - ) - - private val nickname = PrimitiveShape("String") - private val userWithOptionalNickname = - StructShape(List(id, FieldShape("nickname", nickname, hasDefault = false, isOptional = true))) - private val userWithDefaultedNickname = - StructShape(List(id, FieldShape("nickname", nickname, hasDefault = true, isOptional = false))) - private val withoutNickname = StructShape(List(id)) - - private val listOfInt = SequenceShape(PrimitiveShape("Int")) - private val listOfOptionalInt = SequenceShape(OptionalShape(PrimitiveShape("Int"))) - private val mapOfInt = MapShape(PrimitiveShape("String"), PrimitiveShape("Int")) - private val mapOfOptionalInt = MapShape(PrimitiveShape("String"), OptionalShape(PrimitiveShape("Int"))) -} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4297b349..b632fc61 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -7,7 +7,11 @@ object Dependencies { object Versions { // Scala ecosystem val scala213 = "2.13.16" - val scala3 = "3.3.3" + // 3.3.6 and not 3.3.3: ctdc-core is built with 3.3.6 and an older compiler cannot read newer TASTy. + val scala3 = "3.3.6" + + // The compile-time contract engine, maintained at github.com/vim89/compile-time-data-contracts. + val ctdc = "0.1.0-SNAPSHOT" // Core functional libraries val cats = "2.10.0" @@ -220,7 +224,10 @@ object Dependencies { deps ++ providedLibs.map(_ % "provided") def forModule(moduleName: String): Seq[ModuleID] = moduleName match { - case "core" => Core.all ++ Monitoring.prometheus ++ Testing.unit + case "core" => + Core.all ++ Monitoring.prometheus ++ Testing.unit ++ Seq( + "com.vitthalmirji" %% "ctdc-core" % Versions.ctdc, + ) // Infrastructure Layer modules case "infrastructure" => Core.all ++ effectSystems ++ Testing.integration ++ Monitoring.all ++ Seq( From ba7e581ad7eb9ca8c7992b290dbfe15aa61ab860 Mon Sep 17 00:00:00 2001 From: Vitthal Mirji Date: Wed, 7 Oct 2026 10:03:57 +0200 Subject: [PATCH 2/4] docs: note that the contract engine now comes from ctdc-core --- README.md | 2 +- docs/contracts/policy-cookbook.md | 2 +- docs/how-it-fails.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c3e0061e..f1e8f250 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ You get compile‑time guarantees (not CI or runtime heuristics), a small opinio ## How (principles) -- **Compile‑time contracts:** `SchemaConforms[Out, Contract, Policy]` proves compatibility; policies include **Exact**, **Backward**, **Forward** (+ Ordered/CI/ByPosition). See [docs/how-it-fails.md](docs/how-it-fails.md). +- **Compile‑time contracts:** `SchemaConforms[Out, Contract, Policy]` proves compatibility; policies include **Exact**, **Backward**, **Forward** (+ Ordered/CI/ByPosition). See [docs/how-it-fails.md](docs/how-it-fails.md). The engine behind it is [ctdc-core](https://github.com/vim89/compile-time-data-contracts), a standalone library; `com.flowforge.core.contracts` aliases its types so there is one implementation to maintain. - **Typestate builder:** `build()` exists only when source, transforms, and sink are present. Incomplete pipelines are unbuildable. - **Pure vs effect boundary:** transforms are pure functions; `F[_]` only at IO edges; engines plug into a single algebra. - **Pictures over prose:** see [flowchart.svg](docs/diagrams/compile-time-contracts/flowchart.svg) and [optionality.md](docs/diagrams/compile-time-contracts/optionality.md). diff --git a/docs/contracts/policy-cookbook.md b/docs/contracts/policy-cookbook.md index 7a52ceca..4bcbb7d7 100644 --- a/docs/contracts/policy-cookbook.md +++ b/docs/contracts/policy-cookbook.md @@ -8,7 +8,7 @@ Notes - All examples use `PipelineBuilder` + `TypedSource/TypedSink` so the compiler must materialize `SchemaConforms[Out, Contract, P]` evidence. - On mismatch, compilation aborts with a diff like: - FlowForge: Contract drift (policy: com.flowforge.core.contracts.SchemaPolicy.Exact). + FlowForge: Contract drift (policy: ctdc.SchemaPolicy.Exact). Out: Producer vs Contract: Contract Missing: email:string Extra: age:int diff --git a/docs/how-it-fails.md b/docs/how-it-fails.md index a0fd19a8..2ff06220 100644 --- a/docs/how-it-fails.md +++ b/docs/how-it-fails.md @@ -127,7 +127,7 @@ implicitly[SchemaConforms[Out, Contract, SchemaPolicy.Exact]] Indicative error message shape: ``` -Compile-time contract drift (policy: com.flowforge.core.contracts.SchemaPolicy.Exact). +Compile-time contract drift (policy: ctdc.SchemaPolicy.Exact). Out: List[Option[Int]] vs Contract: List[Int] Mismatch attributes: []? expected Int, found optional Int ``` From 7c20bbe0a2b2c398cd9940de72e11aa2e6afcb82 Mon Sep 17 00:00:00 2001 From: Vitthal Mirji Date: Wed, 7 Oct 2026 10:56:06 +0200 Subject: [PATCH 3/4] build: resolve ctdc-core from github packages --- .github/workflows/ci.yml | 13 +++++++++++++ build.sbt | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ecedd7a..602ed592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# GitHub Packages authenticates reads, so sbt needs a token to resolve ctdc-core. The repo token works +# once the package grants this repo access; CTDC_PACKAGES_TOKEN is the override if a wider token is needed. +env: + GITHUB_TOKEN: ${{ secrets.CTDC_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + jobs: fixture-sync: name: Spec fixture sync @@ -32,6 +37,7 @@ jobs: needs: fixture-sync permissions: contents: read + packages: read if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" steps: - uses: actions/checkout@v6 @@ -161,6 +167,7 @@ jobs: runs-on: ${{ matrix.os }} permissions: contents: read + packages: read strategy: fail-fast: false matrix: @@ -214,6 +221,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read strategy: fail-fast: false max-parallel: 2 @@ -240,6 +248,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" steps: - uses: actions/checkout@v6 @@ -263,6 +272,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read if: | github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-integration-tests')) || @@ -284,6 +294,7 @@ jobs: needs: quick-checks permissions: contents: read + packages: read if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" steps: - uses: actions/checkout@v6 @@ -309,6 +320,7 @@ jobs: needs: test permissions: contents: read + packages: read if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" steps: - uses: actions/checkout@v6 @@ -330,6 +342,7 @@ jobs: needs: [test] permissions: contents: read + packages: read if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" steps: - uses: actions/checkout@v6 diff --git a/build.sbt b/build.sbt index c8b749a9..245014bb 100644 --- a/build.sbt +++ b/build.sbt @@ -38,15 +38,26 @@ ThisBuild / coverageFailOnMinimum := enforceCoverageThreshold ThisBuild / coverageHighlighting := true // ===== REPOSITORY RESOLVERS ===== -resolvers ++= Resolver.sonatypeOssRepos("public") ++ Seq( +// ThisBuild and not the root project: every module is defined separately below, so a root-only setting +// would not reach them and `core` could not resolve ctdc-core. +ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("public") ++ Seq( Resolver.mavenCentral, "Confluent" at "https://packages.confluent.io/maven/", "Apache Releases" at "https://repository.apache.org/content/repositories/releases/", "Google Cloud" at "https://maven-central.storage-download.googleapis.com/maven2/", "AWS SDK" at "https://repo1.maven.org/maven2/software/amazon/awssdk/", "Spark Packages" at "https://repos.spark-packages.org/", + // The contract engine, published from github.com/vim89/compile-time-data-contracts. + "ctdc GitHub Packages" at "https://maven.pkg.github.com/vim89/compile-time-data-contracts", ) +// GitHub Packages authenticates reads, not just writes, so resolving ctdc-core needs a token. Absent +// locally, where ctdc is resolved from `~/.ivy2/local` after a `publishLocal` instead. +ThisBuild / credentials ++= (for { + user <- sys.env.get("GITHUB_ACTOR") + token <- sys.env.get("GITHUB_TOKEN") +} yield Credentials("GitHub Package Registry", "maven.pkg.github.com", user, token)).toSeq + // Compiler settings for all projects val scala3CompilerOptions = Seq( From a4eee6551b0aa40fa93e5e21c1cdfb5a726670a5 Mon Sep 17 00:00:00 2001 From: Vitthal Mirji Date: Wed, 7 Oct 2026 10:56:06 +0200 Subject: [PATCH 4/4] ci: build scala 3 with 3.3.6 --- .github/workflows/ci.yml | 4 ++-- docs/migration/guide.md | 5 +++-- docs/plan/v1.0-readiness.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 602ed592..6f6656cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: # Core's src/main/scala-3 is only on the source path under Scala 3, so the check above never sees it. - name: scalafmt check (Scala 3 sources) - run: sbt "++3.3.3 core/scalafmtCheckAll" + run: sbt "++3.3.6 core/scalafmtCheckAll" - name: Guard - ban println in production sources run: | @@ -187,7 +187,7 @@ jobs: # Core is the only module built for Scala 3, so the command is core only. The whole aggregate # cannot run here: the modules that stay on 2.13 fail dependency resolution under ++3. - os: ubuntu-latest - scala: 3.3.3 + scala: 3.3.6 java: 17 command: core/testQuick if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip release]')" diff --git a/docs/migration/guide.md b/docs/migration/guide.md index c6e9884c..5a311e98 100644 --- a/docs/migration/guide.md +++ b/docs/migration/guide.md @@ -2,8 +2,9 @@ Plan for moving FlowForge's compile-time contracts from Scala 2.13 to Scala 3. -> Status: part plan, part record. `core` now cross-builds Scala 2.13.16 and 3.3.3, with the Scala 3 -> derivation in `modules/core/src/main/scala-3`, and CI runs `core/testQuick` on 3.3.3. Every other +> Status: part plan, part record. `core` now cross-builds Scala 2.13.16 and 3.3.6, with the per-version +> derivation living in the ctdc-core library rather than in FlowForge, and CI runs `core/testQuick` on +> 3.3.6. Every other > module is 2.13.16 only. Snippets below that are marked proposed are still proposals. Readiness for > v1.0 is tracked in [v1.0 readiness](../plan/v1.0-readiness.md). diff --git a/docs/plan/v1.0-readiness.md b/docs/plan/v1.0-readiness.md index 6adac667..0fb9760f 100644 --- a/docs/plan/v1.0-readiness.md +++ b/docs/plan/v1.0-readiness.md @@ -9,7 +9,7 @@ change before the version number means anything. ## Where the build stands - Scala 2.13.16 is the only version the whole build is cross-built for. `core` also builds and tests on - 3.3.3, which CI runs as `core/testQuick`. Nothing is cross-published, so the Scala 3 build comes from + 3.3.6, which CI runs as `core/testQuick`. Nothing is cross-published, so the Scala 3 build comes from source. - JDK 17 is the baseline. CI runs 17. Spark 3.5.x does not support a newer JDK. - Spark 3.5.x is the engine that runs. `engines-spark/test` runs on every CI build; the Delta integration