From d927b286b4a2a047737ccb76b8b647403be31159 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 13 Sep 2026 11:40:09 +0100 Subject: [PATCH 01/48] feat(evolution): compute per-type forward-compatibility ranges (tiered) BaboonComparator now classifies, per evolution step and per type, whether an older version's codec can decode newer-version data: IDENTICAL (byte-identical), PREFIX_ANY_MODE / PREFIX_COMPACT (appended-only fields; top-level framed UEBA reads), JSON_ADDITIVE (tolerant JSON readers), or nothing. Step tiers are composed into contiguous suffix runs (BaboonEvolution.typesForwardReadable) via an order-sensitive own-structure check plus a fixpoint over codec-relevant dependencies; emitted in baboon-meta.json as "forwardReadable". Scala + TypeScript backends emit the metadata as baboonForwardReadable (per-type constant + BaboonGenerated member + BaboonMetadata lookup), with end-to-end stub specs proving each tier against real cross-version blobs, including negative controls. Design: docs/drafts/20260911-0937-forward-compat-metadata.md. Note: ForwardCompatComparatorTest documents a pre-existing sameIn overclaim (deepSchemaRepr sorts flattened dep reprs, erasing member order inside deps; a reordered-enum host stays 'unmodified' although its UEBA bytes change). Co-Authored-By: Claude Fable 5 --- .../lsp/state/JvmBaboonCompilerTest.scala | 2 +- .../tests/ForwardCompatComparatorTest.scala | 147 ++++++++ .../BaboonFamilyManagerIncrementalTest.scala | 2 +- .../scala/BaboonRuntimeShared.scala | 13 + .../typescript/BaboonSharedRuntime.ts | 10 + .../translator/scl/ScBaboonTranslator.scala | 20 +- .../translator/scl/ScDomainTreeTools.scala | 11 +- .../typescript/TsBaboonTranslator.scala | 1 + .../typescript/TsDomainTreeTools.scala | 10 +- .../baboon/typer/BaboonComparator.scala | 189 ++++++++++- .../baboon/typer/model/BaboonEvolution.scala | 1 + .../baboon/typer/model/ForwardCompat.scala | 50 +++ .../baboon/util/BaboonMetagen.scala | 12 + .../septimalmind/baboon/util/Metadata.scala | 8 + .../baboon/fwd-e2e-ok/fwd-e2e-1.baboon | 44 +++ .../baboon/fwd-e2e-ok/fwd-e2e-2.baboon | 27 ++ .../test/resources/fwd-compat-ok/fwd-1.baboon | 90 +++++ .../test/resources/fwd-compat-ok/fwd-2.baboon | 67 ++++ .../test/resources/fwd-compat-ok/fwd-3.baboon | 19 ++ .../20260911-0937-forward-compat-metadata.md | 316 ++++++++++++++++++ .../scala/runtime/ForwardCompatSpec.scala | 107 ++++++ .../src/runtime-tests/AnyMetaCodec.test.ts | 5 + .../src/runtime-tests/AnyRoundTrip.test.ts | 1 + .../BaboonCodecsFacadeLatest.test.ts | 1 + .../src/runtime-tests/BaboonExt.test.ts | 10 + .../src/runtime-tests/ForwardCompat.test.ts | 98 ++++++ 26 files changed, 1255 insertions(+), 6 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-1.baboon create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-2.baboon create mode 100644 baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon create mode 100644 baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon create mode 100644 baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon create mode 100644 docs/drafts/20260911-0937-forward-compat-metadata.md create mode 100644 test/sc-stub/src/test/scala/runtime/ForwardCompatSpec.scala create mode 100644 test/ts-stub/src/runtime-tests/ForwardCompat.test.ts diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/state/JvmBaboonCompilerTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/state/JvmBaboonCompilerTest.scala index 8c7fa3eaa..695c282c4 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/state/JvmBaboonCompilerTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/state/JvmBaboonCompilerTest.scala @@ -133,7 +133,7 @@ class JvmBaboonCompilerTest extends AnyWordSpec with Matchers { Map.empty, List.empty, ) - val evolution = BaboonEvolution(pkg, version, Map.empty, Map.empty, Map.empty) + val evolution = BaboonEvolution(pkg, version, Map.empty, Map.empty, Map.empty, Map.empty) val lineage = BaboonLineage(pkg, NEMap.from(Map(version -> domain)).getOrElse(throw new IllegalStateException("Empty lineage")), evolution) BaboonFamily(NEMap.from(Map(pkg -> lineage)).getOrElse(throw new IllegalStateException("Empty family")), BaboonFamilyCache.empty) } diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala new file mode 100644 index 000000000..a223d2d81 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala @@ -0,0 +1,147 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.BaboonLoader +import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.typer.model.* +import izumi.functional.bio.Error2 +import izumi.fundamentals.collections.nonempty.NEList +import izumi.fundamentals.platform.files.IzFiles +import izumi.fundamentals.platform.resources.IzResources +import izumi.reflect.TagKK + +final class ForwardCompatComparatorTest extends ForwardCompatComparatorTestBase[Either] + +abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + import ForwardCompatTier.* + + private def loadFwd(loader: BaboonLoader[F]): F[NEList[BaboonIssue], BaboonFamily] = { + val root = IzResources + .getPath("fwd-compat-ok") + .get + .asInstanceOf[IzResources.LoadablePathReference] + .path + val baboons = IzFiles + .walk(root.toFile) + .toList + .filter(p => p.toFile.isFile && p.toFile.getName.endsWith(".baboon")) + loader.load(baboons) + } + + private def evolutionOf(family: BaboonFamily): BaboonEvolution = { + family.domains.toMap.values.find(_.pkg.toString == "fwdcompat.model").get.evolution + } + + private def runOf(evo: BaboonEvolution, version: String, typeName: String): List[(String, ForwardCompatTier)] = { + val entries = evo.typesForwardReadable(Version.parse(version)).collect { + case (id: TypeId.User, fr) if id.name.name == typeName => fr + }.toList + assert(entries.size == 1, s"expected exactly one type named $typeName in $version, got ${entries.size}") + entries.head.readable.toList.map { case (v, t) => (v.toString, t) } + } + + "forward-compat comparator" should { + "classify per-type forward-readability runs across the version chain" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + + // fixed-length append: readable in both UEBA modes (top-level framed reads) + assert(runOf(evo, "1.0.0", "AppendFixed") == List(("1.0.0", Identical), ("1.1.0", PrefixAnyMode), ("1.2.0", PrefixAnyMode))) + + // variable-length appends: compact blobs only, composed across two steps + assert(runOf(evo, "1.0.0", "AppendVar") == List(("1.0.0", Identical), ("1.1.0", PrefixCompact), ("1.2.0", PrefixCompact))) + assert(runOf(evo, "1.1.0", "AppendVar") == List(("1.1.0", Identical), ("1.2.0", PrefixCompact))) + + // mid-position insertion: JSON only; run ends when a field is removed in 1.2.0 + assert(runOf(evo, "1.0.0", "MidInsert") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive))) + assert(runOf(evo, "1.1.0", "MidInsert") == List(("1.1.0", Identical))) + + // field removal / field type change: not forward-readable + assert(runOf(evo, "1.0.0", "Removed") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "Changed") == List(("1.0.0", Identical))) + + // own structure unchanged but a dependency appends: capped at JSON + assert(runOf(evo, "1.0.0", "HostOfAppend") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + + // fully unchanged closure: byte-identical throughout + assert(runOf(evo, "1.0.0", "HostOfUnchanged") == List(("1.0.0", Identical), ("1.1.0", Identical), ("1.2.0", Identical))) + assert(runOf(evo, "1.0.0", "Stable") == List(("1.0.0", Identical), ("1.1.0", Identical), ("1.2.0", Identical))) + + // enum gains a member: unreadable (new values would throw), host follows + assert(runOf(evo, "1.0.0", "EnumGrows") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "EnumGrowsHost") == List(("1.0.0", Identical))) + + // enum members reordered: JSON encodes names, UEBA discriminants are positional + assert(runOf(evo, "1.0.0", "EnumReorder") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "EnumReorderHost") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + + // recursive DTO appending a field: self-dependency demotes PREFIX to JSON + assert(runOf(evo, "1.0.0", "Recur") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + + // ADT branch appends a field: the branch itself is prefix-readable, + // the ADT (whose decode nests the branch) is capped at JSON + assert(runOf(evo, "1.0.0", "L") == List(("1.0.0", Identical), ("1.1.0", PrefixCompact), ("1.2.0", PrefixCompact))) + assert(runOf(evo, "1.0.0", "Sum") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + } + } + + "maintain structural invariants and consistency with sameIn ranges" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + + evo.typesForwardReadable.foreach { + case (version, types) => + types.foreach { + case (id, fr) => + assert(fr.typeId == id) + assert(fr.in == version) + // head is always the own version at IDENTICAL + assert(fr.readable.head == ((version, Identical))) + // versions strictly ascending + val vs = fr.readable.toList.map(_._1) + assert(vs == vs.sorted(Version.ordering) && vs.distinct == vs) + // tiers non-increasing + val weights = fr.readable.toList.map(_._2.weight) + assert(weights.zip(weights.tail).forall { case (a, b) => a >= b }) + } + } + + // Soundness direction: an IDENTICAL forward tier implies byte-identity, + // so the version must be a sameIn twin. The converse does NOT hold: + // `deepSchemaRepr` sorts the flattened dependency representation lines + // per field (BaboonTyper), erasing member/field ORDER inside + // dependencies — so sameIn overclaims byte-identity when a dependency + // was merely reordered (see EnumReorderHost below). + evo.typesForwardReadable.foreach { + case (version, types) => + types.foreach { + case (id, fr) => + val twins = evo.typesUnchangedSince(version)(id).higherTwins(version).toSet + fr.readable.toList.drop(1).foreach { + case (v, tier) => + if (tier == Identical) { + assert(twins.contains(v), s"$id@$version: forward-Identical $v must be a sameIn twin") + } + } + } + } + + // Regression documentation of the known sameIn overclaim: the host of a + // reordered enum stays `unmodified` (order-erased deepId) although its + // UEBA bytes change; the forward metadata correctly demotes it to JSON. + val reorderHostId = evo.typesForwardReadable(Version.parse("1.0.0")).keys.collectFirst { + case id: TypeId.User if id.name.name == "EnumReorderHost" => id: TypeId + }.get + val reorderHostTwins = evo.typesUnchangedSince(Version.parse("1.0.0"))(reorderHostId).higherTwins(Version.parse("1.0.0")) + assert(reorderHostTwins.contains(Version.parse("1.1.0")), "precondition: sameIn still overclaims for EnumReorderHost") + assert(runOf(evo, "1.0.0", "EnumReorderHost").tail.forall(_._2 == JsonAdditive)) + } + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/typer/BaboonFamilyManagerIncrementalTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/typer/BaboonFamilyManagerIncrementalTest.scala index 9534971d1..da6da3a8d 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/typer/BaboonFamilyManagerIncrementalTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/typer/BaboonFamilyManagerIncrementalTest.scala @@ -171,7 +171,7 @@ class BaboonFamilyManagerIncrementalTest extends AnyWordSpec with Matchers { ): F[NEList[BaboonIssue], BaboonEvolution] = { val F = implicitly[Error2[F]] val latest = versions.keySet.toList.sorted(Version.ordering).last - F.pure(BaboonEvolution(pkg, latest, Map.empty, Map.empty, Map.empty)) + F.pure(BaboonEvolution(pkg, latest, Map.empty, Map.empty, Map.empty, Map.empty)) } override def compare( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala index 1528e11c8..21179a2e8 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala @@ -11,6 +11,14 @@ package baboon.runtime.shared { def baboonDomainVersion: String def baboonDomainIdentifier: String def baboonSameInVersions: List[String] + + /** Forward-readability: newer domain versions whose encoded data THIS + * version's codec can decode, mapped to the guarantee tier + * ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + * The prefix-* tiers hold only for top-level framed UEBA reads where the + * caller discards the cursor after decoding. + */ + def baboonForwardReadable: Map[String, String] def baboonTypeIdentifier: String final def domainVersion: BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -25,6 +33,11 @@ package baboon.runtime.shared { trait BaboonMeta { def sameInVersions(typeId: String): List[String] + + /** Forward-readability per type: newer version -> guarantee tier + * (see [[BaboonGenerated.baboonForwardReadable]]). + */ + def forwardReadableVersions(typeId: String): Map[String, String] } trait BaboonEnum[T] { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 508a911a6..9d8f3cfdc 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -638,6 +638,14 @@ export interface BaboonGenerated { baboonDomainVersion(): string baboonDomainIdentifier(): string baboonSameInVersions(): string[] + /** + * Forward-readability: newer domain versions whose encoded data THIS version's codec can + * decode, mapped to the guarantee tier + * ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + * The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + * the cursor after decoding. + */ + baboonForwardReadable(): { readonly [version: string]: string } baboonTypeIdentifier(): string } @@ -1240,6 +1248,8 @@ export interface BaboonJsonCodec extends BaboonCodecData { export interface BaboonMeta { sameInVersions(typeId: string): string[]; + /** Forward-readability per type: newer version -> guarantee tier (see BaboonGenerated.baboonForwardReadable). */ + forwardReadableVersions(typeId: string): { readonly [version: string]: string }; } // --- AbstractBaboonCodecs registry base --- diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala index 1c061ec3e..1505a5365 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala @@ -179,15 +179,33 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( q"""unmodified.put("${tid.toString}", $scList(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}))""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}" -> "${tier.wireName}"""" }.mkString(", ") + q"""forwardReadable.put("${tid.toString}", ${ScTypes.scMap.fullyQualified}($pairs))""" + } + val metaTree = q"""object BaboonMetadata extends $baboonMeta { | private val unmodified = ${scMutMap.fullyQualified}.empty[$scString, $scList[$scString]] - | + | | ${entries.joinN().shift(2).trim} | + | private val forwardReadable = ${scMutMap.fullyQualified}.empty[$scString, ${ScTypes.scMap.fullyQualified}[$scString, $scString]] + | + | ${forwardEntries.joinN().shift(2).trim} + | | def sameInVersions(typeId: String): List[String] = { | unmodified(typeId) | } + | + | def forwardReadableVersions(typeId: String): ${ScTypes.scMap.fullyQualified}[$scString, $scString] = { + | forwardReadable(typeId) + | } |}""".stripMargin val meta = scTreeTools.inNs(pkg.parts.toSeq, metaTree) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala index f2b4636ed..48ce12cff 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala @@ -81,7 +81,16 @@ object ScDomainTreeTools { q"$scList(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - List(sameInVersion) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { + case (v, tier) => s""""${v.v.toString}" -> "${tier.wireName}"""" + } + val forwardReadable = MetaField( + q"def baboonForwardReadable: ${ScTypes.scMap}[$scString, $scString]", + q"${ScTypes.scMap}(${forwardEntries.mkString(", ")})", + q"$ref.baboonForwardReadable", + ) + List(sameInVersion, forwardReadable) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index 999e7b744..8b195a6f9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -297,6 +297,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // Meta class sb.append(s"class ${verClassName}Meta implements BaboonMeta {\n") sb.append(s""" public sameInVersions(_typeId: string): string[] { return ['$versionStr']; }\n""") + sb.append(s""" public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { '$versionStr': 'identical' }; }\n""") sb.append( "}\n\n") } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala index 77406d7c3..66fca4924 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala @@ -28,15 +28,23 @@ object TsDomainTreeTools { private def sameInVersion(defn: DomainMember.User, source: TsType): List[TextTree[TsValue]] = { val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardLiteral = + forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString("{ ", ", ", " }") if (defn.isAdt) { List( - q"BaboonSameInVersions: [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]" + q"BaboonSameInVersions: [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]", + q"BaboonForwardReadable: $forwardLiteral as { readonly [version: string]: string }", ) } else { List( q"""public static readonly BaboonSameInVersions = [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}] |public baboonSameInVersions() { | return $source.BaboonSameInVersions + |} + |public static readonly BaboonForwardReadable: { readonly [version: string]: string } = $forwardLiteral + |public baboonForwardReadable() { + | return $source.BaboonForwardReadable |}""".stripMargin ) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index 79fe5334d..deae83519 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -90,8 +90,9 @@ object BaboonComparator { previousVersions, sortedVersions.reverse, ) + forwardReadable = computeForwardReadable(versions, sortedVersions.reverse) } yield { - BaboonEvolution(pkg, pinnacleVersion, diffMap, rulesetMap, minVersions) + BaboonEvolution(pkg, pinnacleVersion, diffMap, rulesetMap, minVersions, forwardReadable) } } @@ -162,6 +163,192 @@ object BaboonComparator { } } + /** Forward-readability ranges (see docs/drafts/20260911-0937-forward-compat-metadata.md). + * + * For each version V and each type present in V, computes the ascending + * contiguous run of versions W >= V whose encoded data the V-version codec + * can decode, with the strongest guarantee tier per W. The chain tier is + * the minimum of the per-step tiers (composition argument in the doc). + */ + private def computeForwardReadable( + domainVersions: NEMap[Version, Domain], + ascendingVersions: List[Version], + ): Map[Version, Map[TypeId, ForwardReadable]] = { + val steps: Map[Version, Map[TypeId, ForwardCompatTier]] = + ascendingVersions + .sliding(2) + .collect { + case from :: to :: Nil => + (from, stepForwardTiers(domainVersions(from), domainVersions(to))) + } + .toMap + + ascendingVersions.reverse + .foldLeft(Map.empty[Version, Map[TypeId, ForwardReadable]]) { + case (acc, version) => + val successor = steps.get(version).flatMap { + stepTiers => + // the version right above `version`, already computed (newest-first fold) + ascendingVersions.dropWhile(_ != version).drop(1).headOption.map(next => (stepTiers, acc(next))) + } + + val entries = domainVersions(version).defs.meta.nodes.map { + case (id, _) => + val tail = successor match { + case Some((stepTiers, nextRuns)) => + (stepTiers.get(id), nextRuns.get(id)) match { + case (Some(stepTier), Some(nextRun)) => + nextRun.readable.toList.map { case (v, t) => (v, ForwardCompatTier.min(t, stepTier)) } + case _ => + List.empty + } + case None => + List.empty + } + (id, ForwardReadable(id, version, NEList.unsafeFrom((version, ForwardCompatTier.Identical: ForwardCompatTier) :: tail))) + } + + acc.updated(version, entries) + } + } + + /** Per-step (prev -> last) forward-readability tier for every type kept under + * the same TypeId in both versions. Absent key = not forward-readable. + * Combines the type's own structural tier with a fixpoint over its + * codec-relevant dependencies in the OLD version (the types the old decoder + * actually touches): PREFIX_* requires all dependencies byte-identical; + * JSON_ADDITIVE requires all dependencies at least JSON-readable. + */ + private def stepForwardTiers(prev: Domain, last: Domain): Map[TypeId, ForwardCompatTier] = { + import ForwardCompatTier.* + + val kept = prev.defs.meta.nodes.keySet.intersect(last.defs.meta.nodes.keySet) + + val own: Map[TypeId, Option[ForwardCompatTier]] = kept.map { + id => + val tier = (prev.defs.meta.nodes(id), last.defs.meta.nodes(id)) match { + case (_: DomainMember.Builtin, _: DomainMember.Builtin) => + Some(Identical) + case (o: DomainMember.User, n: DomainMember.User) => + ownForwardTier(o.defn, n.defn, prev, last) + case _ => + None + } + (id, tier) + }.toMap + + // codec-relevant direct dependencies in the OLD version: what the old decoder touches + val deps: Map[TypeId, Set[TypeId]] = kept.map { + id => + val d = prev.defs.meta.nodes(id) match { + case _: DomainMember.Builtin => Set.empty[TypeId] + case u: DomainMember.User => + u.defn match { + case d: Typedef.Dto => d.fields.flatMap(f => enquiries.explode(f.tpe)).toSet + case a: Typedef.Adt => a.dataMembers(prev).toSet[TypeId] + case _: Typedef.Enum => Set.empty[TypeId] + case _: Typedef.Foreign => Set.empty[TypeId] + case _: Typedef.Contract => Set.empty[TypeId] + case _: Typedef.Service => Set.empty[TypeId] + } + } + (id, d) + }.toMap + + // monotone-descending fixpoint on a finite lattice: terminates + var current = own + var changed = true + while (changed) { + changed = false + current = current.map { + case (id, tier) => + val next = tier.flatMap { + ownTier => + val depTiers = deps(id).toList.map(dep => current.getOrElse(dep, None)) + if (depTiers.exists(_.isEmpty)) { + None + } else if (depTiers.forall(_.contains(Identical))) { + Some(ownTier) + } else { + // some dependency is readable but not byte-identical: JSON-only + Some(ForwardCompatTier.min(ownTier, JsonAdditive)) + } + } + if (next != tier) changed = true + (id, next) + } + } + + current.collect { case (id, Some(tier)) => (id, tier) } + } + + /** The type's OWN structural forward tier for one step, ignoring dependencies. + * Order-sensitive where the UEBA wire is (field order, enum/ADT member order). + */ + private def ownForwardTier(o: Typedef.User, n: Typedef.User, prev: Domain, last: Domain): Option[ForwardCompatTier] = { + import ForwardCompatTier.* + (o, n) match { + case (d1: Typedef.Dto, d2: Typedef.Dto) => + val f1 = d1.fields.map(f => (f.name, f.tpe)) + val f2 = d2.fields.map(f => (f.name, f.tpe)) + if (f1 == f2) { + Some(Identical) + } else if (f2.startsWith(f1)) { + val appended = d2.fields.drop(f1.size) + val allFixed = appended.forall { + f => + last.refMeta(f.tpe).len match { + case _: BinReprLen.Fixed => true + case _ => false + } + } + Some(if (allFixed) PrefixAnyMode else PrefixCompact) + } else if (f1.toSet.subsetOf(f2.toSet)) { + // additions at arbitrary positions and/or reordering: JSON readers + // look fields up by key; UEBA is positional and breaks + Some(JsonAdditive) + } else { + None + } + + case (e1: Typedef.Enum, e2: Typedef.Enum) => + val m1 = e1.members.toList.map(m => (m.name, m.const)) + val m2 = e2.members.toList.map(m => (m.name, m.const)) + if (m1 == m2) { + Some(Identical) + } else if (e1.members.toList.map(_.name).toSet == e2.members.toList.map(_.name).toSet) { + // same member set, different order or consts: JSON encodes names; + // UEBA discriminants are positional and break + Some(JsonAdditive) + } else { + // added members are unreadable by the old decoder when actually sent; + // removed/renamed members shift UEBA discriminants + None + } + + case (a1: Typedef.Adt, a2: Typedef.Adt) => + val b1 = a1.dataMembers(prev) + val b2 = a2.dataMembers(last) + if (b1 == b2) { + Some(Identical) + } else if (b2.toSet.subsetOf(b1.toSet)) { + // branches only removed: the new writer emits only branches the old + // reader knows by name (JSON); UEBA branch indices are positional and shift + Some(JsonAdditive) + } else { + None + } + + case (f1: Typedef.Foreign, f2: Typedef.Foreign) => + // hand-written codecs: only byte-level sameness is derivable + if (f1 == f2) Some(Identical) else None + + case _ => + // Contract/Service carry no codecs; kind changes are unreadable + None + } + } + override def compare( last: Domain, prev: Domain, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala index 0ad0c1607..6794966c7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala @@ -8,6 +8,7 @@ case class BaboonEvolution( diffs: Map[EvolutionStep, BaboonDiff], rules: Map[EvolutionStep, BaboonRuleset], typesUnchangedSince: Map[Version, Map[TypeId, UnmodifiedSince]], + typesForwardReadable: Map[Version, Map[TypeId, ForwardReadable]], ) { override def toString: String = { diffs.map { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala new file mode 100644 index 000000000..ccb035289 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala @@ -0,0 +1,50 @@ +package io.septimalmind.baboon.typer.model + +import izumi.fundamentals.collections.nonempty.NEList + +/** Forward-readability guarantee: whether a codec generated for an OLDER version + * can decode data produced by a NEWER version's codec, for any value the newer + * version can emit. Tiers are linearly ordered by strength; each tier implies + * every guarantee of the tiers below it. + * + * See docs/drafts/20260911-0937-forward-compat-metadata.md for the design and + * the soundness argument. + */ +sealed abstract class ForwardCompatTier(val weight: Int, val wireName: String) + +object ForwardCompatTier { + /** Byte-identical encoding: both wire formats, any nesting position. */ + case object Identical extends ForwardCompatTier(4, "identical") + + /** UEBA: prefix-read of compact AND indexed blobs (all appended fields are + * fixed-length, so the index shape is unchanged); sound only for top-level + * framed reads where the caller discards the cursor. JSON: any position. + */ + case object PrefixAnyMode extends ForwardCompatTier(3, "prefix-any-mode") + + /** UEBA: prefix-read of compact blobs only (some appended field is + * variable-length; an indexed blob would desync the reader's index parse); + * top-level framed reads only. JSON: any position. + */ + case object PrefixCompact extends ForwardCompatTier(2, "prefix-compact") + + /** JSON only (tolerant readers ignore unknown keys), any nesting position. */ + case object JsonAdditive extends ForwardCompatTier(1, "json-additive") + + implicit val ordering: Ordering[ForwardCompatTier] = Ordering.by(_.weight) + + def min(a: ForwardCompatTier, b: ForwardCompatTier): ForwardCompatTier = + if (a.weight <= b.weight) a else b + + val all: List[ForwardCompatTier] = List(Identical, PrefixAnyMode, PrefixCompact, JsonAdditive) +} + +/** For a type as seen by version `in`: the ascending contiguous list of versions + * whose blobs the `in`-version codec can read, with the strongest guarantee tier + * for each. Invariants: `readable.head == (in, Identical)`; versions strictly + * ascending; tiers non-increasing. + */ +case class ForwardReadable(typeId: TypeId, in: Version, readable: NEList[(Version, ForwardCompatTier)]) { + def tierFor(version: Version): Option[ForwardCompatTier] = + readable.toList.collectFirst { case (v, t) if v == version => t } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/BaboonMetagen.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/BaboonMetagen.scala index 07ea67074..d7c1ef28e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/BaboonMetagen.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/BaboonMetagen.scala @@ -27,6 +27,18 @@ object BaboonMetagen { case (pkg, line) => (pkg.toString, line.evolution.typesUnchangedSince.flatMap(_._2.values).asJson) }*), + "forwardReadable" -> Json.obj(family.domains.toSeq.map { + case (pkg, line) => + ( + pkg.toString, + Json.obj(line.evolution.typesForwardReadable.toSeq.map { + case (version, types) => + version.v.toString -> Json.obj(types.toSeq.collect { + case (id: TypeId.User, fr) => ((id: TypeId).toString, fr.asJson) + }*) + }*), + ) + }*), "underivable" -> Json.obj(family.domains.toSeq.map { case (pkg, line) => ( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala index 86afc94dc..df73e8a6a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala @@ -24,6 +24,14 @@ object BaboonDomainCodecs { implicit def nelist[T: Encoder]: Encoder[NEList[T]] = Encoder.encodeList[T].contramap(_.toList) + implicit lazy val forwardCompatTierEncoder: Encoder[ForwardCompatTier] = Encoder.encodeString.contramap(_.wireName) + implicit lazy val forwardReadableEncoder: Encoder[ForwardReadable] = Encoder.encodeJson.contramap { + fr => + Json.fromValues(fr.readable.toList.map { + case (v, tier) => Json.obj("v" -> versionEncoder(v), "tier" -> forwardCompatTierEncoder(tier)) + }) + } + implicit lazy val fieldEncoder: Encoder[Field] = Encoder.encodeJson.contramap { f => Json.obj("field" -> Json.fromString(f.name.name), "type" -> Json.fromString(f.tpe.toString)) diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-1.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-1.baboon new file mode 100644 index 000000000..fd77a8cb7 --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-1.baboon @@ -0,0 +1,44 @@ +// Forward-compatibility end-to-end fixtures (docs/drafts/20260911-0937-forward-compat-metadata.md). +// +// Lives INSIDE the shared model-dir so every language stub generates codecs for +// both versions; the hand-written ForwardCompat runtime specs (sc-stub, ts-stub) +// decode NEW-version blobs with OLD-version codecs and assert the guarantees the +// emitted forward metadata promises. +// +// Every evolution step here MUST stay auto-derivable (appended/inserted fields +// are `opt[...]`): stub projects carry no manual conversion implementations. + +model fwde2e.fwd + +version "1.0.0" + +// v2 appends a variable-length opt field -> PREFIX_COMPACT: +// the 1.0.0 codec prefix-reads a COMPACT 2.0.0 UEBA blob at top level, +// and reads 2.0.0 JSON at any position. +root data FwdAppendVar { + a: i32 + b: str +} + +// v2 inserts an opt field mid-position -> JSON_ADDITIVE: +// JSON reads fine, UEBA positional layout breaks. +root data FwdMidInsert { + a: i32 + z: str +} + +// never changes -> IDENTICAL throughout. +root data FwdStable { + s: str +} + +enum FwdEnumGrows { + A + B +} + +// v2 adds an enum member -> NOT forward-readable (negative control: +// decoding a 2.0.0 value carrying the new member with the 1.0.0 codec throws). +root data FwdEnumHost { + e: FwdEnumGrows +} diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-2.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-2.baboon new file mode 100644 index 000000000..ef581752f --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/fwd-e2e-2.baboon @@ -0,0 +1,27 @@ +model fwde2e.fwd + +version "2.0.0" + +import "1.0.0" { * } without { + FwdAppendVar + FwdMidInsert + FwdEnumGrows +} + +root data FwdAppendVar { + a: i32 + b: str + t: opt[str] +} + +root data FwdMidInsert { + a: i32 + m: opt[i32] + z: str +} + +enum FwdEnumGrows { + A + B + C +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon new file mode 100644 index 000000000..03ee6d18d --- /dev/null +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon @@ -0,0 +1,90 @@ +// Forward-compatibility comparator fixtures (see +// docs/drafts/20260911-0937-forward-compat-metadata.md). +// +// Intentionally OUTSIDE the shared `baboon-compiler/src/test/resources/baboon/` +// model-dir (D9): these models exist only for the JVM-level +// ForwardCompatComparatorTest and contain underivable evolution steps that +// would force manual conversion stubs in every language lane. + +model fwdcompat.model + +version "1.0.0" + +// v2 appends a fixed-length field -> PREFIX_ANY_MODE +root data AppendFixed { + a: i32 + b: str +} + +// v2 and v3 append variable-length fields -> PREFIX_COMPACT +root data AppendVar { + a: i32 +} + +// v2 inserts a field in the middle -> JSON_ADDITIVE; v3 removes a field -> range ends +root data MidInsert { + a: i32 + z: str +} + +// v2 removes a field -> NONE +root data Removed { + a: i32 + b: str +} + +// v2 changes a field type (precision expansion; still narrowing when read forward) -> NONE +root data Changed { + a: i32 +} + +// own structure never changes; dep AppendVar appends -> capped at JSON_ADDITIVE +root data HostOfAppend { + dep: AppendVar + x: i32 +} + +data Stable { + s: str +} + +// fully unchanged closure -> IDENTICAL throughout +root data HostOfUnchanged { + dep: Stable +} + +enum EnumGrows { + A + B +} + +// dep enum gains a member -> NONE +root data EnumGrowsHost { + e: EnumGrows +} + +enum EnumReorder { + A + B +} + +// dep enum members reordered (positional UEBA discriminants shift) -> JSON_ADDITIVE +root data EnumReorderHost { + e: EnumReorder +} + +// recursive DTO appending a field: self-dependency must demote PREFIX to JSON_ADDITIVE +root data Recur { + v: i32 + next: opt[Recur] +} + +// branch L appends a field in v2: L itself is PREFIX_COMPACT, the ADT is capped at JSON_ADDITIVE +root adt Sum { + data L { + lv: i32 + } + data R { + rv: str + } +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon new file mode 100644 index 000000000..171485fa3 --- /dev/null +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon @@ -0,0 +1,67 @@ +model fwdcompat.model + +version "1.1.0" + +import "1.0.0" { * } without { + AppendFixed + AppendVar + MidInsert + Removed + Changed + EnumGrows + EnumReorder + Recur + Sum +} + +root data AppendFixed { + a: i32 + b: str + c: i64 +} + +root data AppendVar { + a: i32 + s: str +} + +root data MidInsert { + a: i32 + m: i32 + z: str +} + +root data Removed { + a: i32 +} + +root data Changed { + a: i64 +} + +enum EnumGrows { + A + B + C +} + +enum EnumReorder { + B + A +} + +root data Recur { + v: i32 + next: opt[Recur] + w: opt[str] +} + +root adt Sum { + data L { + lv: i32 + l2: str + } + data R { + rv: str + } +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon new file mode 100644 index 000000000..9219b51e2 --- /dev/null +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon @@ -0,0 +1,19 @@ +model fwdcompat.model + +version "1.2.0" + +import "1.1.0" { * } without { + AppendVar + MidInsert +} + +root data AppendVar { + a: i32 + s: str + t: opt[i32] +} + +root data MidInsert { + a: i32 + m: i32 +} diff --git a/docs/drafts/20260911-0937-forward-compat-metadata.md b/docs/drafts/20260911-0937-forward-compat-metadata.md new file mode 100644 index 000000000..1f0f68ac3 --- /dev/null +++ b/docs/drafts/20260911-0937-forward-compat-metadata.md @@ -0,0 +1,316 @@ +# Forward-compatibility metadata: design & implementation plan + +Status: DRAFT (design agreed in discussion 2026-09-10/11, not yet implemented) + +## 1. Problem + +Clients already use the `sameIn` ranges (`BaboonEvolution.typesUnchangedSince`, +emitted as `baboonSameInVersions` per type and as the domain-wide `unmodified` +map) to implement forward compatibility: a codec generated for version V can +read a blob produced by version W > V when the type is byte-identical across +V..W. + +Byte-identity is not the only legitimate forward-compat case. The motivating +example: v2 of a root DTO appends new fields after v1's fields. We want the +compiler to (1) classify which cross-version reads are safe, (2) compute +per-type forward-compatibility ranges, and (3) emit them alongside the +existing `sameIn` ranges. + +"Forward-readable" throughout means: **codec for T@V can decode data emitted +by codec for T@W (W > V), guaranteed for any value the newer version can +produce.** Data-dependent safety (e.g. "works unless the new enum member is +actually sent") does not qualify. + +## 2. Evidence audit (what the codecs actually do) + +### JSON — decoders are tolerant readers + +All generated JSON decoders look up known keys and never enumerate the object: + +- C#: `asObject["field"]` per known field (`CSJsonCodecGenerator.scala:240`); + missing/null token for `opt` decodes to `None` (`BaboonTools.cs:48-58`). +- TypeScript: `obj["fld"]` per field; `opt` treats `undefined`/`null` as + absent (`TsJsonCodecGenerator.scala:124-133`). +- Scala: circe cursor `downField` walk (`ScJsonCodecGenerator.scala:280-294`). +- Rust: serde derive, no `deny_unknown_fields` anywhere → unknown keys ignored. +- Remaining backends (py/kt/kmp/jv/dt/sw) follow the same key-lookup shape + (cross-language parity is enforced by the acceptance matrix); **PR-F1 must + spot-verify each before the tier is finalized.** + +⇒ Unknown extra keys are ignored in every backend; additive DTO changes are +JSON-forward-readable, at any nesting depth, and the property composes through +the dependency closure and across version steps. + +### UEBA — sequential, schema-derived framing + +- Compact mode (the default: `BaboonCodecContext.Default = Compact`, + `BaboonCodecs.cs:53`): DTO body = 1 header byte + fields concatenated in + declaration order. No count, no lengths. +- Indexed mode: header bit 0 set; index entries `(offset:u32, len:u32)` are + written **only for variable-length fields** (`CSUEBACodecGenerator.scala:104-110`). + The entry count is NOT on the wire — the reader loops its own compile-time + `IndexElementsCount` (`BaboonCodecs.cs:155-180`). +- The generated decoder reads the index but decodes fields **sequentially**, + never seeking (`CSUEBACodecGenerator.scala:361-373`). +- `ReadIndex` keys off the **wire's** header bit, not the reader's ctx, so a + reader handles either mode of blob. + +⇒ Consequences for a v1 reader of a v2 blob with appended fields D, E: + +| context | compact blob | indexed blob | +|---|---|---| +| top-level, cursor discarded after decode | **decodes correctly**, leaves D,E unread | correct iff D,E all fixed-length (index shape unchanged); desyncs before field A if any appended field is var-len (reader under-reads the index) | +| nested field / collection element / anything read after it from the same stream | desyncs the outer decode | desyncs | + +⇒ UEBA prefix-reads are sound only for the *outermost* decoded type, because +every nested decode reuses the cursor by construction. Everything *inside* the +prefix must be byte-identical. + +## 3. Safe-case classification (per evolution step, per type) + +Tiers form a strict linear order of guarantees. Each higher tier implies all +guarantees of the lower ones (appended-only ⊂ additive-anywhere; closure- +unchanged ⊂ closure-additive): + +| tier | wire guarantee | conditions on step V→V+1 | +|---|---|---| +| `IDENTICAL` | both formats, any position | existing `unmodified` classification (shallowId+deepId equal) | +| `PREFIX_ANY_MODE` | UEBA compact+indexed **top-level framed reads only, cursor discarded**; JSON any position | own diff is appended-only (old field sequence is a *prefix* of the new one, name+type identical); all appended fields have `BinReprLen.Fixed`; every other type in T's old-version transitive closure is `unmodified` in this step | +| `PREFIX_COMPACT` | UEBA **compact blobs only**, top-level framed reads, cursor discarded; JSON any position | as above, but appended fields may be variable-length | +| `JSON_ADDITIVE` | JSON only, any position | own diff ops ⊆ {AddField, KeepField} (additions at any position); every type in T's old-version closure is `unmodified` or ≥ `JSON_ADDITIVE` in this step | +| `NONE` | — | anything else | + +Explicitly NOT forward-readable (and why): + +- **RemoveField** — old reader can't find the key (JSON) / bytes missing (UEBA). + Removing an `opt` field happens to decode as `None` in all checked backends, + but is silently lossy; excluded by decision (can become a separate tier later). +- **ChangeField / precision expansion** — forward direction is narrowing. +- **Field renames** — break JSON (key change). They are byte-invisible to UEBA, + which would violate the linear tier order; by decision renames break all + forward tiers (consistent with today's `sameIn`, which they also break). + Refining to per-format ranges is a possible follow-up, not in scope. +- **Type renames** (`was[]`) — break all tiers, same rationale. +- **Enum member addition** — old decoder throws on the new name (JSON) / + positional byte (UEBA). Only data-dependent-safe → excluded. +- **ADT branch addition** — same (unknown branch key / branch-index byte). +- **Foreign type changes** — hand-written codecs, no guarantee derivable. + Unchanged foreign types participate normally via `IDENTICAL`. +- **Service/Contract** — no codecs; not part of the metadata. + +Notes: +- `data`↔`id` flips are shallowId-invariant (see `BaboonEnquiries.scala:317`) + and land in `IDENTICAL` automatically. +- Fields of `any` type: type-identity is required like any other field. PR-F1 + must verify the `AnyOpaque` envelope decode has no writer-version dependency; + if unclear, types with `any` in the closure are conservatively capped at + `IDENTICAL` and a defect is filed. + +### Composition (why contiguous ranges are valid) + +For a chain V→…→W, chainTier(T) = min over steps of stepTier(T): + +- appended-only ∘ appended-only = appended-only (prefix property transitive); +- additive ∘ additive = additive (field sets grow monotonically, shared field + types are step-invariant); +- closure conditions compose because each step's condition covers the (only + growing) old-version closure; +- the decode-time argument is inductive: the old decoder touches only + old-closure types, each of which is step-safe at the same or higher tier. + +⇒ tiers are non-increasing in W. This is an invariant to assert in code: +per (type, version), the tier sequence over subsequent versions never rises. + +## 4. Metadata model + +New model types in `typer/model` (sibling of `UnmodifiedSince`, +`Typedef.scala:5`): + +```scala +sealed trait ForwardCompatTier // IDENTICAL, PREFIX_ANY_MODE, PREFIX_COMPACT, JSON_ADDITIVE + // linearly ordered; NONE is represented by absence + +case class ForwardReadable( + typeId: TypeId, + in: Version, // reader version V + readable: NEList[(Version, ForwardCompatTier)] // ascending; head = (V, IDENTICAL); + // tiers non-increasing; list stops + // before the first NONE step +) +``` + +`BaboonEvolution` gains: + +```scala +typesForwardReadable: Map[Version, Map[TypeId, ForwardReadable]] +``` + +Relationship invariant (cheap sanity check in tests): the `IDENTICAL` prefix of +`readable` equals the suffix of the type's `sameIn` run at/above V. + +## 5. Computation (`BaboonComparator`) + +1. **Per-step classification.** In `compare` (or a sibling pass keyed by + `EvolutionStep`), derive `stepTier: Map[TypeId, ForwardCompatTier]`: + - Reuse `BaboonChanges` for `IDENTICAL` (the `unmodified` set). + - For changed DTOs, inspect the already-computed `TypedefDiff.DtoDiff` ops: + additive-only ⇒ candidate `JSON_ADDITIVE`; additionally compare old/new + field sequences for the prefix property and appended fields' `BinReprLen` + (via `domain.refMeta`) ⇒ candidate `PREFIX_*`. Changed enums/ADTs/foreigns + ⇒ `NONE`. + - Renamed types (in `changes.renamed`) ⇒ `NONE`. + - **Closure fixpoint** over the old version's dependency graph + (`defs.predecessors` / `enquiries.explode`): iterate to fixpoint lowering + each type's tier to the minimum permitted by its direct refs' tiers + (PREFIX_* requires refs = `IDENTICAL`; JSON_ADDITIVE requires refs ≥ + `JSON_ADDITIVE`). Terminates: tiers only decrease, finite lattice. + Recursive types are handled by the fixpoint with no special casing. +2. **Suffix-run assembly**, mirroring `computeMinVersions` + (`BaboonComparator.scala:101-165`) but walking versions **newest→oldest**: + `readable(T, V) = (V, IDENTICAL) :: (stepTier(T, V→V+1) match { NONE => Nil; + t => readable(T, V+1).map(cap at t) })`. Reuse the shared-buffer trick or + plain immutable lists (version counts are small); capping preserves the + non-increasing invariant by construction. +3. Wire into `evolve` next to `computeMinVersions`; result goes into the + extended `BaboonEvolution`. + +Non-goals in this pass: no changes to `BaboonRules`/conversions, no changes to +any wire format. + +## 6. Emission + +### 6.1 `baboon-meta.json` (`BaboonMetagen.scala`) + +New top-level key alongside `"unmodified"`: + +```json +"forwardReadable": { + "": { + "": { + "": [ {"v": "1.1.0", "tier": "identical"}, + {"v": "1.2.0", "tier": "json-additive"} ] + } + } +} +``` + +Tier strings: `identical`, `prefix-any-mode`, `prefix-compact`, +`json-additive`. Circe encoders next to `Metadata.scala:23`. + +### 6.2 Generated code — per-type constant + domain-wide map, all 9 backends + +Mirror of the `baboonSameInVersions` surfaces. Suggested member name: +`baboonForwardReadable` (list of (version, tier) pairs in each language's +idiom; tier as string enum for uniformity with the JSON emission). + +| backend | per-type site | domain-wide site | runtime interface | +|---|---|---|---| +| C# | `CSDomainTreeTools.scala:66` (`makeCodecMeta`) | `CSBaboonTranslator.scala:317` | `cs/BaboonRuntimeShared.cs` (`IBaboonGenerated`) | +| Scala | `ScDomainTreeTools.scala` | `ScBaboonTranslator.scala` | `scala/BaboonRuntimeShared.scala:13` (`BaboonGenerated`) | +| TypeScript | `TsDomainTreeTools.scala:30-38` | `TsBaboonTranslator.scala` | `typescript` runtime | +| Kotlin (+KMP) | `KtDomainTreeTools.scala:81` | `KtBaboonTranslator.scala:192` | `kotlin`, `kotlin-kmp` runtimes | +| Java | `JvDomainTreeTools.scala` | `JvBaboonTranslator.scala:174` | `java` runtime | +| Python | `PyDomainTreeTools.scala` | `PyBaboonTranslator.scala` | `python` runtime | +| Rust | dyn-trait fns (`RsBaboonTranslator.scala:231,265`) | same file | `rust` runtime | +| Dart | `DtDomainTreeTools.scala:95` (+ `DtDefnTranslator.scala:366,478`) | `DtBaboonTranslator.scala:208` | `dart` runtime | +| Swift | `SwDomainTreeTools.scala:88` (+ `SwDefnTranslator` sites) | `SwBaboonTranslator.scala:264` | `swift` runtime | + +Emission is unconditional (parity with `sameIn`; no CLI flag). Runtime +interface members are implemented only by generated code, so extending them is +not a user-facing break. + +Reminder: `sbt clean` after touching `baboon-compiler/src/main/resources/baboon-runtime/` +(PortableResource macro caches resource contents). + +### 6.3 What emission does NOT solve + +Per-type constants only reach binaries that were **re-generated** after the +newer version was added (same as `baboonSameInVersions`, which also lists +future versions in regenerated old-version namespaces). A client binary built +before v2 existed cannot contain v2 knowledge; distributing forward-compat +ranges to already-deployed clients (e.g. via the version-negotiation endpoint) +is the application's concern. `baboon-meta.json` is the machine-readable +source for that. + +## 7. Client contract for the PREFIX_* tiers + +The compiler guarantees the *byte prefix* property; soundness of acting on it +additionally requires, at the call site: + +1. the blob is decoded as the top-level value (not read out of a larger + baboon stream); +2. the transport frames the blob (caller knows its end independently); +3. the cursor / remaining bytes are discarded after decode — no + fully-consumed assertion. + +These are usage-site properties the compiler cannot check. They must be stated +in the emitted docs and in `docs/` (see PR-F5). JSON_ADDITIVE and IDENTICAL +carry no such conditions. + +## 8. Testing + +1. **Comparator unit tests** (JVM-only suite under the hidden + `baboon-compiler/.jvm/src/test`, run via `sbt baboonJVM/test`): small + multi-version inline models asserting, per type: tier sequences, run + breaks (removal, change, rename, enum/ADT addition, foreign change, + nested-dep change), closure lowering (dep gains a mid-position field ⇒ + host capped at JSON_ADDITIVE; dep appends ⇒ host NOT prefix), fixed vs + var-len appends (PREFIX_ANY_MODE vs PREFIX_COMPACT), recursive types, + `sameIn`-consistency invariant, tier monotonicity. +2. **Metagen shape test**: golden/shape assertion for the new JSON key. +3. **Cross-version runtime proof** (per backend, in stub projects): + - JSON: encode with latest-version codec, decode with an older-version + codec for a type whose metadata says ≥ `JSON_ADDITIVE`; assert field + values survive. + - UEBA prefix: compact-encode with latest codec, decode top-level with + older codec for a `PREFIX_COMPACT` type; assert prefix fields correct. + Negative control: assert the same read desyncs/fails for a `NONE` type + (documents *why* the metadata gates it). + - Pilot in two backends first (Scala + TypeScript), then replicate. + Acceptance checks must be unconditional throws, not asserts (vacuous in + C#/Dart/Swift/JVM by default — see memory/CLAUDE.md). +4. Full gates: `mdl :build :test` per PR; `mdl --seq :ci` before push + (Kotlin daemon OOM under parallel matrix). + +Test-model placement: extend the shared `baboon-compiler/src/test/resources/baboon/` +matrix models only if no cross-namespace hazard (D9); otherwise a dedicated +model dir + lane, like `mcp-stub-ok`. + +## 9. PR breakdown + +| PR | scope | gate | +|---|---|---| +| F1 | model types, comparator classification + fixpoint + runs, `BaboonEvolution` field, metagen emission, JVM unit tests, JSON-tolerance spot-verification of py/kt/kmp/jv/dt/sw decoders, `any`-envelope version-independence check | `sbt baboonJVM/test`, `mdl :build :test` | +| F2 | runtime interface + codegen emission, pilot backends (Scala, TypeScript) + stub compile/shape tests | `mdl :build :test` (sc/ts lanes) | +| F3 | remaining backends (cs, py, rs, kt, kmp, jv, dt, sw) — mechanical parity with F2 | full `mdl :build :test` | +| F4 | cross-version runtime proofs (§8.3) in pilot backends, then matrix-wide | `mdl --seq :ci` | +| F5 | docs: forward-compat semantics + client contract (`docs/`), note in `docs/ueba-format.md` | n/a | + +Sizing note: F3 touches ~9 translators + 10 runtime dirs; keep each backend a +single commit; bundle any exhaustive-match updates per file (M29 pattern). + +## 10. Explicitly out of scope (recorded decisions) + +1. **UEBA format evolution** (on-wire index entry count, total-body length, + seeking/skipping decoder, header flag bit + coordinated rollout). This is + the only route to unconditional UEBA forward-compat for appended fields; + it is its own design because the framing change itself breaks byte-identity + with previously generated codecs even for unchanged types. +2. **`opt`-field-removal tier** (decodes as `None` everywhere checked, but + silently lossy). +3. **Per-format range splitting for renames** (UEBA-identical / JSON-broken). +4. **"Unreferenced root" static check** (belt-and-suspenders flag that a type + cannot occur in nested position in generated code) — cheap, can ride along + with F1 if desired. + +## 11. Open questions + +1. Exact member naming across backends (`baboonForwardReadable` vs + `baboonCanReadVersions`) — pick once at F2, keep uniform. +2. Should the metagen key also dump the per-step tier matrix (debugging aid), + or only the assembled ranges? (Proposal: ranges only.) +3. `docs/ueba-format.md` says enums are `i32` discriminants while the C# + generator writes a single byte (`CSUEBACodecGenerator.scala:242-267`) — + pre-existing doc/impl discrepancy noticed during this audit; resolve in F5 + (verify against other backends and the acceptance fixtures; not touched by + this plan otherwise). diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatSpec.scala new file mode 100644 index 000000000..07769332d --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatSpec.scala @@ -0,0 +1,107 @@ +// NOTE: This test references generated symbols (fwde2e.fwd.*, fwde2e.fwd.v1_0_0.*) +// which are copied into this stub only by `mdl :build :test-gen-regular-adt` +// (rsync + codegen into target/test-regular/sc-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the forward-compatibility metadata +// (docs/drafts/20260911-0937-forward-compat-metadata.md): a codec generated for +// an OLDER version decodes blobs produced by a NEWER version's codec, exactly +// where the emitted `baboonForwardReadable` metadata promises it. +package runtime + +import baboon.runtime.shared._ +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} + +@scala.annotation.nowarn("cat=deprecation") +class ForwardCompatSpec extends AnyFunSuite { + private val ctx = BaboonCodecContext.Compact + + private def uebaBytes[T](encode: (BaboonCodecContext, LEDataOutputStream, T) => Unit, value: T): Array[Byte] = { + val baos = new ByteArrayOutputStream() + val dos = new LEDataOutputStream(baos) + encode(ctx, dos, value) + baos.toByteArray + } + + test("emitted forward metadata carries the expected tiers") { + assert(fwde2e.fwd.v1_0_0.FwdAppendVar.baboonForwardReadable == Map("1.0.0" -> "identical", "2.0.0" -> "prefix-compact")) + assert(fwde2e.fwd.v1_0_0.FwdMidInsert.baboonForwardReadable == Map("1.0.0" -> "identical", "2.0.0" -> "json-additive")) + assert(fwde2e.fwd.v1_0_0.FwdStable.baboonForwardReadable == Map("1.0.0" -> "identical", "2.0.0" -> "identical")) + assert(fwde2e.fwd.v1_0_0.FwdEnumHost.baboonForwardReadable == Map("1.0.0" -> "identical")) + + // latest version: no successors yet + assert(fwde2e.fwd.FwdAppendVar.baboonForwardReadable == Map("2.0.0" -> "identical")) + + // domain-wide metadata object agrees with the per-type constants + val meta = fwde2e.fwd.v1_0_0.BaboonMetadata + assert(meta.forwardReadableVersions(fwde2e.fwd.v1_0_0.FwdAppendVar.baboonTypeIdentifier) == fwde2e.fwd.v1_0_0.FwdAppendVar.baboonForwardReadable) + } + + test("PREFIX_COMPACT: old UEBA codec prefix-reads a compact blob with appended fields") { + val value = fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended")) + val bytes = uebaBytes[fwde2e.fwd.FwdAppendVar](fwde2e.fwd.FwdAppendVar_UEBACodec.instance.encode, value) + + val bais = new ByteArrayInputStream(bytes) + val dis = new LEDataInputStream(bais) + val decoded = fwde2e.fwd.v1_0_0.FwdAppendVar_UEBACodec.instance.decode(ctx, dis).toTry.get + + assert(decoded.a == 42) + assert(decoded.b == "hello") + // prefix semantics: the appended field's bytes are left unconsumed + assert(bais.available() > 0, "appended-field bytes must remain after the prefix read") + } + + test("JSON_ADDITIVE: old JSON codec reads mid-inserted and appended fields at any position") { + val inserted = fwde2e.fwd.FwdMidInsert(7, Some(99), "zed") + val insertedJson = fwde2e.fwd.FwdMidInsert_JsonCodec.instance.encode(ctx, inserted) + val decMid = fwde2e.fwd.v1_0_0.FwdMidInsert_JsonCodec.instance.decode(ctx, insertedJson).toTry.get + assert(decMid.a == 7) + assert(decMid.z == "zed") + + val appended = fwde2e.fwd.FwdAppendVar(1, "b", Some("t")) + val appendedJson = fwde2e.fwd.FwdAppendVar_JsonCodec.instance.encode(ctx, appended) + val decApp = fwde2e.fwd.v1_0_0.FwdAppendVar_JsonCodec.instance.decode(ctx, appendedJson).toTry.get + assert(decApp.a == 1) + assert(decApp.b == "b") + } + + test("JSON_ADDITIVE does NOT extend to UEBA: mid-inserted field desyncs the positional read") { + val value = fwde2e.fwd.FwdMidInsert(7, Some(99), "zed") + val bytes = uebaBytes[fwde2e.fwd.FwdMidInsert](fwde2e.fwd.FwdMidInsert_UEBACodec.instance.encode, value) + + val bais = new ByteArrayInputStream(bytes) + val dis = new LEDataInputStream(bais) + val result = scala.util.Try(fwde2e.fwd.v1_0_0.FwdMidInsert_UEBACodec.instance.decode(ctx, dis).toTry.get) + // the old codec reads the inserted opt bytes as the `z` string: garbage or failure, never the real value + assert(result.isFailure || result.get.z != "zed") + } + + test("IDENTICAL: unchanged type round-trips through the old codec byte-identically") { + val value = fwde2e.fwd.FwdStable("same") + val bytes = uebaBytes[fwde2e.fwd.FwdStable](fwde2e.fwd.FwdStable_UEBACodec.instance.encode, value) + + val bais = new ByteArrayInputStream(bytes) + val dis = new LEDataInputStream(bais) + val decoded = fwde2e.fwd.v1_0_0.FwdStable_UEBACodec.instance.decode(ctx, dis).toTry.get + assert(decoded.s == "same") + assert(bais.available() == 0, "identical type must consume the blob exactly") + + val json = fwde2e.fwd.FwdStable_JsonCodec.instance.encode(ctx, value) + assert(fwde2e.fwd.v1_0_0.FwdStable_JsonCodec.instance.decode(ctx, json).toTry.get.s == "same") + } + + test("negative control: a grown enum value is NOT readable by the old codec (metadata promises nothing)") { + val value = fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C) + + val json = fwde2e.fwd.FwdEnumHost_JsonCodec.instance.encode(ctx, value) + val jsonDec = fwde2e.fwd.v1_0_0.FwdEnumHost_JsonCodec.instance.decode(ctx, json) + assert(jsonDec.isLeft, "old JSON codec must reject the unknown enum member") + + val bytes = uebaBytes[fwde2e.fwd.FwdEnumHost](fwde2e.fwd.FwdEnumHost_UEBACodec.instance.encode, value) + val bais = new ByteArrayInputStream(bytes) + val dis = new LEDataInputStream(bais) + val uebaDec = scala.util.Try(fwde2e.fwd.v1_0_0.FwdEnumHost_UEBACodec.instance.decode(ctx, dis).toTry.get) + assert(uebaDec.isFailure, "old UEBA codec must reject the unknown enum discriminant") + } +} diff --git a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts index 90a77d363..aa33ff1bf 100644 --- a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts +++ b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts @@ -372,6 +372,9 @@ class StubJson extends AbstractBaboonJsonCodecs { class StubMeta implements BaboonMeta { constructor(private readonly versions: string[]) {} public sameInVersions(_typeId: string): string[] { return this.versions; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { + return Object.fromEntries(this.versions.map(v => [v, "identical"])); + } } class StubConv implements AbstractBaboonConversions { @@ -383,6 +386,7 @@ class StubGenerated implements BaboonGenerated { public baboonDomainVersion(): string { return "1.0.0"; } public baboonDomainIdentifier(): string { return "dom"; } public baboonSameInVersions(): string[] { return ["1.0.0"]; } + public baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } public baboonTypeIdentifier(): string { return "T"; } } @@ -446,6 +450,7 @@ class StubAdtBranchGenerated implements BaboonGenerated { public baboonDomainVersion(): string { return "1.0.0"; } public baboonDomainIdentifier(): string { return "dom"; } public baboonSameInVersions(): string[] { return ["1.0.0"]; } + public baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } public baboonTypeIdentifier(): string { return "BranchT"; } } diff --git a/test/ts-stub/src/runtime-tests/AnyRoundTrip.test.ts b/test/ts-stub/src/runtime-tests/AnyRoundTrip.test.ts index ac63f0be7..4c3a52e06 100644 --- a/test/ts-stub/src/runtime-tests/AnyRoundTrip.test.ts +++ b/test/ts-stub/src/runtime-tests/AnyRoundTrip.test.ts @@ -119,6 +119,7 @@ class MyOkConversions implements AbstractBaboonConversions { class MyOkMeta implements BaboonMeta { public sameInVersions(_typeId: string): string[] { return [VERSION_STR]; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { [VERSION_STR]: "identical" }; } } // Fresh per-test facade: registers Holder/Inner codecs from the my.ok domain so the diff --git a/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts b/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts index 3289a8af0..8353c0339 100644 --- a/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts +++ b/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts @@ -60,6 +60,7 @@ class MyOkConversions implements AbstractBaboonConversions { class MyOkMeta implements BaboonMeta { public sameInVersions(_typeId: string): string[] { return [VERSION_STR]; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { [VERSION_STR]: "identical" }; } } function freshFacade(): BaboonCodecsFacade { diff --git a/test/ts-stub/src/runtime-tests/BaboonExt.test.ts b/test/ts-stub/src/runtime-tests/BaboonExt.test.ts index e87df9960..99b3027cc 100644 --- a/test/ts-stub/src/runtime-tests/BaboonExt.test.ts +++ b/test/ts-stub/src/runtime-tests/BaboonExt.test.ts @@ -24,6 +24,7 @@ class FakeInner implements BaboonGenerated { baboonDomainVersion(): string { return "1.0.0"; } baboonDomainIdentifier(): string { return "my.ok"; } baboonSameInVersions(): string[] { return ["1.0.0"]; } + baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } baboonTypeIdentifier(): string { return "my.ok/:#Inner"; } } @@ -32,6 +33,7 @@ class FakeEvolved implements BaboonGenerated { baboonDomainIdentifier(): string { return "my.ok"; } // Stable since 1.0.0 despite domain now being at 3.0.0. baboonSameInVersions(): string[] { return ["1.0.0", "2.0.0", "3.0.0"]; } + baboonForwardReadable(): { readonly [version: string]: string } { return { "3.0.0": "identical" }; } baboonTypeIdentifier(): string { return "my.ok/:#Evolved"; } } @@ -39,6 +41,7 @@ class FakeEmptySameIn implements BaboonGenerated { baboonDomainVersion(): string { return "1.0.0"; } baboonDomainIdentifier(): string { return "my.ok"; } baboonSameInVersions(): string[] { return []; } + baboonForwardReadable(): { readonly [version: string]: string } { return {}; } baboonTypeIdentifier(): string { return "my.ok/:#Empty"; } } @@ -54,6 +57,13 @@ class FakeMeta implements BaboonMeta { }; return table[typeId] ?? []; } + forwardReadableVersions(typeId: string): { readonly [version: string]: string } { + const table: Record = { + "my.ok/:#Inner": { "1.0.0": "identical" }, + "my.ok/:#Evolved": { "3.0.0": "identical" }, + }; + return table[typeId] ?? {}; + } } // --------------------------------------------------------------------------- diff --git a/test/ts-stub/src/runtime-tests/ForwardCompat.test.ts b/test/ts-stub/src/runtime-tests/ForwardCompat.test.ts new file mode 100644 index 000000000..6c3115917 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/ForwardCompat.test.ts @@ -0,0 +1,98 @@ +// NOTE: This test references generated symbols (src/generated/fwde2e/**) which are +// copied into this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen +// into target/test-regular/ts-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the forward-compatibility metadata +// (docs/drafts/20260911-0937-forward-compat-metadata.md): a codec generated for an +// OLDER version decodes blobs produced by a NEWER version's codec, exactly where the +// emitted `BaboonForwardReadable` metadata promises it. +import { describe, expect, test } from 'vitest'; +import { BaboonBinReader, BaboonBinWriter, BaboonCodecContext, BaboonGenerated } from '../generated/BaboonSharedRuntime'; + +import { FwdAppendVar, FwdAppendVar_UEBACodec, FwdAppendVar_JsonCodec } from '../generated/fwde2e/fwd/FwdAppendVar'; +import { FwdMidInsert, FwdMidInsert_UEBACodec, FwdMidInsert_JsonCodec } from '../generated/fwde2e/fwd/FwdMidInsert'; +import { FwdStable, FwdStable_UEBACodec, FwdStable_JsonCodec } from '../generated/fwde2e/fwd/FwdStable'; +import { FwdEnumHost, FwdEnumHost_UEBACodec, FwdEnumHost_JsonCodec } from '../generated/fwde2e/fwd/FwdEnumHost'; +import { FwdEnumGrows } from '../generated/fwde2e/fwd/FwdEnumGrows'; + +import { FwdAppendVar as FwdAppendVarV1, FwdAppendVar_UEBACodec as FwdAppendVarV1_UEBACodec, FwdAppendVar_JsonCodec as FwdAppendVarV1_JsonCodec } from '../generated/fwde2e/fwd/v1_0_0/FwdAppendVar'; +import { FwdMidInsert as FwdMidInsertV1, FwdMidInsert_UEBACodec as FwdMidInsertV1_UEBACodec, FwdMidInsert_JsonCodec as FwdMidInsertV1_JsonCodec } from '../generated/fwde2e/fwd/v1_0_0/FwdMidInsert'; +import { FwdStable as FwdStableV1, FwdStable_UEBACodec as FwdStableV1_UEBACodec, FwdStable_JsonCodec as FwdStableV1_JsonCodec } from '../generated/fwde2e/fwd/v1_0_0/FwdStable'; +import { FwdEnumHost as FwdEnumHostV1, FwdEnumHost_UEBACodec as FwdEnumHostV1_UEBACodec, FwdEnumHost_JsonCodec as FwdEnumHostV1_JsonCodec } from '../generated/fwde2e/fwd/v1_0_0/FwdEnumHost'; + +const ctx = BaboonCodecContext.Compact; + +function uebaBytes(codec: { encode(ctx: BaboonCodecContext, value: T, writer: BaboonBinWriter): void }, value: T): Uint8Array { + const writer = new BaboonBinWriter(); + codec.encode(ctx, value, writer); + return writer.toBytes(); +} + +describe('forward-compat metadata (fwde2e.fwd)', () => { + test('emitted forward metadata carries the expected tiers', () => { + expect(FwdAppendVarV1.BaboonForwardReadable).toStrictEqual({ '1.0.0': 'identical', '2.0.0': 'prefix-compact' }); + expect(FwdMidInsertV1.BaboonForwardReadable).toStrictEqual({ '1.0.0': 'identical', '2.0.0': 'json-additive' }); + expect(FwdStableV1.BaboonForwardReadable).toStrictEqual({ '1.0.0': 'identical', '2.0.0': 'identical' }); + expect(FwdEnumHostV1.BaboonForwardReadable).toStrictEqual({ '1.0.0': 'identical' }); + // latest version: no successors yet + expect(FwdAppendVar.BaboonForwardReadable).toStrictEqual({ '2.0.0': 'identical' }); + // instance accessor mirrors the static + const v1: BaboonGenerated = new FwdAppendVarV1(1, 'x'); + expect(v1.baboonForwardReadable()).toStrictEqual(FwdAppendVarV1.BaboonForwardReadable); + }); + + test('PREFIX_COMPACT: old UEBA codec prefix-reads a compact blob with appended fields', () => { + const value = new FwdAppendVar(42, 'hello', 'appended'); + const reader = new BaboonBinReader(uebaBytes(FwdAppendVar_UEBACodec.instance, value)); + const decoded = FwdAppendVarV1_UEBACodec.instance.decode(ctx, reader); + expect(decoded.a).toBe(42); + expect(decoded.b).toBe('hello'); + // prefix semantics: the appended field's bytes are left unconsumed + expect(() => reader.readByte()).not.toThrow(); + }); + + test('JSON_ADDITIVE: old JSON codec reads mid-inserted and appended fields at any position', () => { + const inserted = new FwdMidInsert(7, 99, 'zed'); + const decMid = FwdMidInsertV1_JsonCodec.instance.decode(ctx, FwdMidInsert_JsonCodec.instance.encode(ctx, inserted)); + expect(decMid.a).toBe(7); + expect(decMid.z).toBe('zed'); + + const appended = new FwdAppendVar(1, 'b', 't'); + const decApp = FwdAppendVarV1_JsonCodec.instance.decode(ctx, FwdAppendVar_JsonCodec.instance.encode(ctx, appended)); + expect(decApp.a).toBe(1); + expect(decApp.b).toBe('b'); + }); + + test('JSON_ADDITIVE does NOT extend to UEBA: mid-inserted field desyncs the positional read', () => { + const value = new FwdMidInsert(7, 99, 'zed'); + const reader = new BaboonBinReader(uebaBytes(FwdMidInsert_UEBACodec.instance, value)); + let desynced = false; + try { + const decoded = FwdMidInsertV1_UEBACodec.instance.decode(ctx, reader); + // the old codec reads the inserted opt bytes as the `z` string: garbage or failure + desynced = decoded.z !== 'zed'; + } catch { + desynced = true; + } + expect(desynced).toBe(true); + }); + + test('IDENTICAL: unchanged type reads exactly through the old codec', () => { + const value = new FwdStable('same'); + const reader = new BaboonBinReader(uebaBytes(FwdStable_UEBACodec.instance, value)); + const decoded = FwdStableV1_UEBACodec.instance.decode(ctx, reader); + expect(decoded.s).toBe('same'); + // identical type must consume the blob exactly + expect(() => reader.readByte()).toThrow(); + + const json = FwdStable_JsonCodec.instance.encode(ctx, value); + expect(FwdStableV1_JsonCodec.instance.decode(ctx, json).s).toBe('same'); + }); + + test('negative control: a grown enum value is NOT readable by the old codec', () => { + const value = new FwdEnumHost(FwdEnumGrows.C); + + expect(() => FwdEnumHostV1_JsonCodec.instance.decode(ctx, FwdEnumHost_JsonCodec.instance.encode(ctx, value))).toThrow(); + expect(() => FwdEnumHostV1_UEBACodec.instance.decode(ctx, new BaboonBinReader(uebaBytes(FwdEnumHost_UEBACodec.instance, value)))).toThrow(); + }); +}); From e5f6015efbb081d58698999c598904418e7def6f Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 13 Sep 2026 11:54:52 +0100 Subject: [PATCH 02/48] feat(evolution): emit forward-compat metadata in all 9 backends; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C#, Python, Rust, Kotlin (+KMP), Java, Dart and Swift now emit the baboonForwardReadable version->tier map as a per-type constant, a BaboonGenerated(-equivalent) member, and a forwardReadableVersions(typeId) registry lookup, mirroring the existing baboonSameInVersions surfaces. Hand-written runtime-test stubs implementing the extended interfaces are updated (C#/Kotlin/KMP/Java/Dart); Swift and Python use source-compatible defaults overridden by generated code; Rust emits the real table where it previously had only degenerate own-version sameIn data. docs/forward-compat.md documents tiers, the prefix-* client contract, and metadata locations. docs/ueba-format.md: corrected the enum wire description (single positional u8, not i32 of the discriminant; const values never hit the wire) — verified against the C#, Scala, TypeScript and Rust generators. Co-Authored-By: Claude Fable 5 --- .../baboon-runtime/cs/BaboonRuntimeShared.cs | 15 +++ .../baboon-runtime/dart/baboon_runtime.dart | 12 ++ .../baboon-runtime/java/BaboonMeta.java | 10 ++ .../kotlin-kmp/BaboonRuntimeShared.kt | 12 ++ .../kotlin/BaboonRuntimeShared.kt | 12 ++ .../python/baboon_runtime_shared.py | 16 +++ .../rust/baboon_codecs_facade.rs | 14 +++ .../baboon-runtime/swift/baboon_runtime.swift | 23 ++++ .../csharp/CSBaboonTranslator.scala | 18 +++ .../translator/csharp/CSDomainTreeTools.scala | 11 +- .../translator/dart/DtBaboonTranslator.scala | 18 +++ .../translator/dart/DtDefnTranslator.scala | 2 + .../translator/dart/DtDomainTreeTools.scala | 14 ++- .../translator/java/JvBaboonTranslator.scala | 17 +++ .../translator/java/JvDomainTreeTools.scala | 13 ++- .../kotlin/KtBaboonTranslator.scala | 16 +++ .../translator/kotlin/KtDomainTreeTools.scala | 13 ++- .../python/PyBaboonTranslator.scala | 16 +++ .../translator/python/PyDomainTreeTools.scala | 5 +- .../translator/rust/RsBaboonTranslator.scala | 15 +++ .../translator/swift/SwBaboonTranslator.scala | 18 +++ .../translator/swift/SwDefnTranslator.scala | 3 + .../translator/swift/SwDomainTreeTools.scala | 13 ++- docs/forward-compat.md | 107 ++++++++++++++++++ docs/ueba-format.md | 2 +- test/cs-stub/BaboonTests/AnyMetaCodecTests.cs | 1 + .../test/runtime/any_meta_codec_test.dart | 6 + .../dt-stub/test/runtime/baboon_ext_test.dart | 5 + .../test/java/runtime/AnyMetaCodecTest.java | 2 + .../test/kotlin/runtime/AnyMetaCodecTest.kt | 1 + .../test/kotlin/runtime/AnyMetaCodecTest.kt | 1 + 31 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 docs/forward-compat.md diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs index 6a80feef0..73029a57b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs @@ -51,6 +51,15 @@ public interface IBaboonGenerated public string BaboonDomainVersion(); public string BaboonDomainIdentifier(); public IReadOnlyList BaboonSameInVersions(); + + /// + /// Forward-readability: newer domain versions whose encoded data THIS version's codec can + /// decode, mapped to the guarantee tier + /// ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + /// the cursor after decoding. + /// + public IReadOnlyDictionary BaboonForwardReadable(); public string BaboonTypeIdentifier(); } @@ -63,6 +72,12 @@ public interface IBaboonAdtMemberMeta public interface IBaboonMeta { public IReadOnlyList SameInVersions(string typeIdString); + + /// + /// Forward-readability per type: newer version -> guarantee tier + /// (see ). + /// + public IReadOnlyDictionary ForwardReadableVersions(string typeIdString); } public interface IBaboonGeneratedLatest : IBaboonGenerated diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index 85310e7ad..166d46ec3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -1194,6 +1194,11 @@ class BaboonConversionNotFound extends BaboonCodecException { /// implementation provides this. abstract class BaboonMeta { List sameInVersions(String typeId); + + /// Forward-readability per type: newer version -> guarantee tier (see + /// [BaboonMetaProvider.baboonForwardReadable]). Concrete default keeps subclassing + /// stubs working; generated metadata overrides it. + Map forwardReadableVersions(String typeId) => {}; } /// Semver-shaped 3-tuple for `getCodec`'s version-window math. PR-19-D01 lesson: regex literals @@ -1429,6 +1434,13 @@ abstract class BaboonMetaProvider { String get baboonDomainIdentifier; String get baboonTypeIdentifier; List get baboonSameInVersions; + + /// Forward-readability: newer domain versions whose encoded data THIS version's codec can + /// decode, mapped to the guarantee tier + /// ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + /// the cursor after decoding. + Map get baboonForwardReadable; } /// Implemented by generated ADT branches. Mirrors Kotlin's `BaboonAdtMemberMeta` for the diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMeta.java index 7f2a7d321..327383ef9 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMeta.java @@ -1,7 +1,17 @@ package baboon.runtime.shared; import java.util.List; +import java.util.Map; public interface BaboonMeta { List sameInVersions(String typeId); + + /** + * Forward-readability per type: newer domain versions whose encoded data the keyed version's + * codec can decode, mapped to the guarantee tier + * ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + * The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + * the cursor after decoding. + */ + Map forwardReadableVersions(String typeId); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt index 569220bc9..4ea97fc23 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt @@ -6,6 +6,15 @@ interface BaboonGenerated { val baboonDomainVersion: String val baboonDomainIdentifier: String val baboonSameInVersions: List + + /** + * Forward-readability: newer domain versions whose encoded data THIS version's codec can + * decode, mapped to the guarantee tier + * ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + * The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + * the cursor after decoding. + */ + val baboonForwardReadable: Map val baboonTypeIdentifier: String fun domainVersion(): BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -24,6 +33,9 @@ interface BaboonAdtMemberMeta { interface BaboonMeta { fun sameInVersions(typeId: String): List + + /** Forward-readability per type: newer version -> guarantee tier (see BaboonGenerated.baboonForwardReadable). */ + fun forwardReadableVersions(typeId: String): Map } interface BaboonEnum { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt index 99390dbe1..fb8ce4d47 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt @@ -6,6 +6,15 @@ interface BaboonGenerated { val baboonDomainVersion: String val baboonDomainIdentifier: String val baboonSameInVersions: List + + /** + * Forward-readability: newer domain versions whose encoded data THIS version's codec can + * decode, mapped to the guarantee tier + * ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + * The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + * the cursor after decoding. + */ + val baboonForwardReadable: Map val baboonTypeIdentifier: String fun domainVersion(): BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -24,6 +33,9 @@ interface BaboonAdtMemberMeta { interface BaboonMeta { fun sameInVersions(typeId: String): List + + /** Forward-readability per type: newer version -> guarantee tier (see BaboonGenerated.baboonForwardReadable). */ + fun forwardReadableVersions(typeId: String): Map } interface BaboonEnum { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index e713d60e6..f615b8e24 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -42,6 +42,16 @@ def baboon_type_identifier(self) -> str: def baboon_same_in_versions(self) -> list[str]: raise NotImplementedError + # Forward-readability: newer domain versions whose encoded data THIS version's + # codec can decode, mapped to the guarantee tier + # ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + # The prefix-* tiers hold only for top-level framed UEBA reads where the caller + # discards the cursor after decoding. Non-abstract default keeps hand-written + # stubs working; generated classes override it with a ClassVar. + @property + def baboon_forward_readable(self) -> dict[str, str]: + return {} + class BaboonAdtMemberMeta(ABC): @property @abstractmethod @@ -60,6 +70,12 @@ class BaboonMeta(ABC): def same_in_versions(self) -> list[str]: raise NotImplementedError + # Forward-readability per type: newer version -> guarantee tier (see + # BaboonGenerated.baboon_forward_readable). Non-abstract default keeps + # hand-written stubs working; generated BaboonMetadata overrides it. + def forward_readable_versions(self, type_id_string: str) -> dict[str, str]: + return {} + class BaboonGeneratedLatest(BaboonGenerated): pass diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index cc9ab5069..d1e807336 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -25,6 +25,14 @@ pub trait BaboonGeneratedDyn: std::any::Any + Send + Sync { /// Codegen invariant: the slice is never empty (`first()` is called for `domainVersionMinCompat` /// in `BaboonTypeMeta::from`); a violation panics with a fail-fast message. fn baboon_same_in_versions_dyn(&self) -> Vec; + /// Forward-readability: newer domain versions whose encoded data THIS version's codec can + /// decode, as `(version, tier)` pairs with tier one of + /// "identical" | "prefix-any-mode" | "prefix-compact" | "json-additive". + /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + /// the cursor after decoding. Default is empty; generated impls override with the real table. + fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { + Vec::new() + } fn as_any(&self) -> &dyn std::any::Any; /// Consume the box and recover an `Any` for downcasting via `Box::downcast`. Mirrors /// the C# `if (current is TTo result)` pattern used in `Convert`. @@ -130,6 +138,12 @@ pub trait BaboonAnyMeta: Send + Sync { /// Returns versions in which `type_id` is structurally identical (mirrors Scala's /// `BaboonMeta.sameInVersions`). Used for the `getCodecMaxCompat` resolution path. fn same_in_versions(&self, type_id: &str) -> Vec; + /// Forward-readability per type: `(newer version, guarantee tier)` pairs (see + /// `BaboonGeneratedDyn::baboon_forward_readable_dyn`). Default is empty; generated + /// metas override with the real table. + fn forward_readable_versions(&self, _type_id: &str) -> Vec<(String, String)> { + Vec::new() + } } // --- Domain version key --- diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 7cb46db20..2daff9102 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -79,6 +79,16 @@ public protocol BaboonMeta { // Until then, the facade only consumes this method via the Lazy-stored value, so missing // conformance only manifests when codecs from older versions are looked up. func sameInVersions(_ typeId: String) -> [String] + + /// Forward-readability per type: newer version -> guarantee tier (see + /// `BaboonMetaProvider.baboonForwardReadable`). + func forwardReadableVersions(_ typeId: String) -> [String: String] +} + +// Default keeps hand-written conforming stubs source-compatible; generated metadata +// registries override it with the real table. +public extension BaboonMeta { + func forwardReadableVersions(_ typeId: String) -> [String: String] { [:] } } // --- Codec Context --- @@ -1256,6 +1266,19 @@ public protocol BaboonMetaProvider { var baboonDomainIdentifier: String { get } var baboonTypeIdentifier: String { get } var baboonSameInVersions: [String] { get } + + /// Forward-readability: newer domain versions whose encoded data THIS version's codec can + /// decode, mapped to the guarantee tier + /// ("identical" | "prefix-any-mode" | "prefix-compact" | "json-additive"). + /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards + /// the cursor after decoding. + var baboonForwardReadable: [String: String] { get } +} + +// Default keeps hand-written conforming stubs source-compatible; generated types +// override it with the real per-type table. +public extension BaboonMetaProvider { + var baboonForwardReadable: [String: String] { [:] } } // Implemented by generated ADT branches. Mirrors Kotlin/Dart `BaboonAdtMember` for the diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala index b99866c53..ae432240c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala @@ -322,12 +322,23 @@ class CSBaboonTranslator[F[+_, +_]: Error2]( q"""_unmodified.Add("${tid.toString}", new $csList<$csString> { ${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")} });""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s"""{ "${v.v.toString}", "${tier.wireName}" }""" }.mkString(", ") + q"""_forwardReadable.Add("${tid.toString}", new $csDictionary<$csString, $csString> { $pairs });""" + } + val metaTree = q"""public sealed class BaboonMeta : $iBaboonMeta |{ | private BaboonMeta() | { | ${entries.join("\n").shift(8).trim} + | ${forwardEntries.join("\n").shift(8).trim} | } | | public $csIReadOnlyList<$csString> SameInVersions($csString typeIdString) @@ -335,8 +346,15 @@ class CSBaboonTranslator[F[+_, +_]: Error2]( | return _unmodified[typeIdString]; | } | + | public $csIReadOnlyDictionary<$csString, $csString> ForwardReadableVersions($csString typeIdString) + | { + | return _forwardReadable[typeIdString]; + | } + | | private readonly $csIDictionary<$csString, $csList<$csString>> _unmodified = new $csDictionary<$csString, $csList<$csString>>(); | + | private readonly $csIDictionary<$csString, $csDictionary<$csString, $csString>> _forwardReadable = new $csDictionary<$csString, $csDictionary<$csString, $csString>>(); + | | private static readonly $csLazy LazyInstance = new $csLazy(() => new BaboonMeta()); | | public static BaboonMeta Instance { get { return LazyInstance.Value; } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala index b294475c6..6125b4f8c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala @@ -1,6 +1,6 @@ package io.septimalmind.baboon.translator.csharp -import io.septimalmind.baboon.translator.csharp.CSTypes.{csIReadOnlyList, csList, csString, csTpe} +import io.septimalmind.baboon.translator.csharp.CSTypes.{csDictionary, csIReadOnlyDictionary, csIReadOnlyList, csList, csString, csTpe} import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -64,11 +64,18 @@ object CSDomainTreeTools { val unmodifiedMethods = if (!isCodec) { val unmodifiedSince = evo.typesUnchangedSince(version)(defn.id) + val forward = evo.typesForwardReadable(version)(defn.id) + val forwardEntries = forward.readable.toList + .map { case (v, tier) => s"""{ "${v.v.toString}", "${tier.wireName}" }""" } + .mkString(", ") List( q"""public${propFix}static readonly $csIReadOnlyList<$csString> BaboonSameInVersionsValue = new $csList<$csString> { ${unmodifiedSince.sameIn .map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")} }; |public$methodFix$csIReadOnlyList<$csString> BaboonSameInVersions() => BaboonSameInVersionsValue; - |""".stripMargin + |""".stripMargin, + q"""public${propFix}static readonly $csIReadOnlyDictionary<$csString, $csString> BaboonForwardReadableValue = new $csDictionary<$csString, $csString> { $forwardEntries }; + |public$methodFix$csIReadOnlyDictionary<$csString, $csString> BaboonForwardReadable() => BaboonForwardReadableValue; + |""".stripMargin, ) } else { List.empty diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala index a45419115..8587f87e7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala @@ -213,15 +213,33 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( q"""'${tid.toString}': [${version.sameIn.map(_.v.toString).map(s => q"'$s'").toList.join(", ")}],""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s"'${v.v.toString}': '${tier.wireName}'" }.mkString(", ") + q"""'${tid.toString}': {$pairs},""" + } + val metaTree = q"""class BaboonMetadata { | static const Map> _unmodified = { | ${entries.joinN().shift(4).trim} | }; | + | static const Map> _forwardReadable = { + | ${forwardEntries.joinN().shift(4).trim} + | }; + | | List sameInVersions(String typeId) { | return _unmodified[typeId] ?? []; | } + | + | Map forwardReadableVersions(String typeId) { + | return _forwardReadable[typeId] ?? {}; + | } |}""".stripMargin val metaOutput = DtDefnTranslator.Output(s"$basename/baboon_metadata.dart", metaTree, pkg, CompilerProduct.Definition) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala index 72c8ce223..2d3c79f4d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala @@ -364,6 +364,7 @@ object DtDefnTranslator { "baboonDomainIdentifier", "baboonTypeIdentifier", "baboonSameInVersions", + "baboonForwardReadable", "baboonAdtTypeIdentifier", ) val instanceGetters = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.valueGetter) @@ -476,6 +477,7 @@ object DtDefnTranslator { "baboonDomainIdentifier", "baboonTypeIdentifier", "baboonSameInVersions", + "baboonForwardReadable", ) val instanceGetters = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.valueGetter) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala index 4bad097bb..5274a7407 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala @@ -101,7 +101,19 @@ object DtDomainTreeTools { q"List", isCodecData = false, ) - List(sameInVersion) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { + case (v, tier) => s"'${v.v.toString}': '${tier.wireName}'" + } + val forwardReadable = MetaField( + q"static const Map baboonForwardReadableConst", + q"{${forwardEntries.mkString(", ")}}", + q"$ref.baboonForwardReadableConst", + "baboonForwardReadable", + q"Map", + isCodecData = false, + ) + List(sameInVersion, forwardReadable) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala index 571007393..e4a81f57f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala @@ -179,18 +179,35 @@ class JvBaboonTranslator[F[+_, +_]: Error2]( q"""unmodified.put("${tid.toString}", $jvList.of(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}));""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s"""java.util.Map.entry("${v.v.toString}", "${tier.wireName}")""" }.mkString(", ") + q"""forwardReadable.put("${tid.toString}", $jvMap.ofEntries($pairs));""" + } + val metaTree = q"""public final class BaboonMetadata implements $baboonMeta { | private static final $jvMap> unmodified = new java.util.HashMap<>(); + | private static final $jvMap> forwardReadable = new java.util.HashMap<>(); | | static { | ${entries.joinN().shift(4).trim} + | ${forwardEntries.joinN().shift(4).trim} | } | | @Override | public $jvList sameInVersions(String typeId) { | return unmodified.getOrDefault(typeId, $jvList.of()); | } + | + | @Override + | public $jvMap forwardReadableVersions(String typeId) { + | return forwardReadable.getOrDefault(typeId, $jvMap.of()); + | } |}""".stripMargin val meta = jvTreeTools.inPkg(pkg.parts.toSeq, metaTree) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala index 55171a53f..7a913a03d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.java import io.septimalmind.baboon.translator.java.JvDomainTreeTools.MetaField -import io.septimalmind.baboon.translator.java.JvTypes.{javaClass, jvList, jvString} +import io.septimalmind.baboon.translator.java.JvTypes.{javaClass, jvList, jvMap, jvString} import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote @@ -81,7 +81,16 @@ object JvDomainTreeTools { q"$jvList.of(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - List(sameInVersion) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { + case (v, tier) => s"""java.util.Map.entry("${v.v.toString}", "${tier.wireName}")""" + } + val forwardReadable = MetaField( + q"public static final $jvMap<$jvString, $jvString> baboonForwardReadable", + q"$jvMap.ofEntries(${forwardEntries.mkString(", ")})", + q"$ref.baboonForwardReadable", + ) + List(sameInVersion, forwardReadable) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala index 3615d862c..b4df2399b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala @@ -197,17 +197,33 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( q"""unmodified["${tid.toString}"] = listOf(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")})""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}" to "${tier.wireName}"""" }.mkString(", ") + q"""forwardReadable["${tid.toString}"] = mapOf($pairs)""" + } + val metaTree = q"""object BaboonMetadata : $baboonMeta { | private val unmodified = mutableMapOf>() + | private val forwardReadable = mutableMapOf>() | | init { | ${entries.joinN().shift(4).trim} + | ${forwardEntries.joinN().shift(4).trim} | } | | override fun sameInVersions(typeId: String): List { | return unmodified[typeId] ?: emptyList() | } + | + | override fun forwardReadableVersions(typeId: String): Map { + | return forwardReadable[typeId] ?: emptyMap() + | } |}""".stripMargin val meta = ktTreeTools.inPkg(pkg.parts.toSeq, metaTree) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala index ad4036f10..967a690be 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.kotlin import io.septimalmind.baboon.translator.kotlin.KtDomainTreeTools.MetaField -import io.septimalmind.baboon.translator.kotlin.KtTypes.{ktList, ktString} +import io.septimalmind.baboon.translator.kotlin.KtTypes.{ktList, ktMap, ktString} import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote @@ -84,7 +84,16 @@ object KtDomainTreeTools { q"listOf(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - List(sameInVersion) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { + case (v, tier) => s""""${v.v.toString}" to "${tier.wireName}"""" + } + val forwardReadable = MetaField( + q"val baboonForwardReadable: $ktMap<$ktString, $ktString>", + q"mapOf(${forwardEntries.mkString(", ")})", + q"$ref.baboonForwardReadable", + ) + List(sameInVersion, forwardReadable) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala index 481fc1c77..3e5ed1533 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala @@ -426,16 +426,32 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( q""""${tid.toString}": [${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}]""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") + q""""${tid.toString}": {$pairs}""" + } + val metaTree = q"""class BaboonMetadata($baboonMeta): | def __init__(self) -> None: | self.unmodified: dict[str, list[str]] = { | ${entries.join(",\n").shift(12).trim} | } + | self.forward_readable: dict[str, dict[str, str]] = { + | ${forwardEntries.join(",\n").shift(12).trim} + | } | | def unmodified_since(self, type_id_string: $pyStr) -> $pyList[$pyStr]: | return self.unmodified.get(type_id_string, []) | + | def forward_readable_versions(self, type_id_string: $pyStr) -> $pyDict[$pyStr, $pyStr]: + | return self.forward_readable.get(type_id_string, {}) + | |""".stripMargin val metaOutput = PyDefnTranslator.Output( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala index 19bbf3e41..2c8e5e554 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala @@ -44,8 +44,11 @@ object PyDomainTreeTools { val unmodifiedMethods = if (!isCodec) { val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") List( - q"""baboon_same_in_versions: $pyClassVar[$pyList[$pyStr]] = [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]""".stripMargin + q"""baboon_same_in_versions: $pyClassVar[$pyList[$pyStr]] = [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]""".stripMargin, + q"""baboon_forward_readable: $pyClassVar[$pyDict[$pyStr, $pyStr]] = {$forwardEntries}""".stripMargin, ) } else Nil diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index 5d67a1e78..7aecb0330 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -219,6 +219,12 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( val versionStr = domain.version.v.toString val verSuffix = versionStr.replace('.', '_') val types = collectTypes(domain) + val fwdByTypeId = lineage.evolution.typesForwardReadable(domain.version).map { case (tid, fr) => (tid.toString, fr) } + def fwdPairs(typeId: String): String = { + fwdByTypeId.get(typeId).toList.flatMap(_.readable.toList).map { + case (v, tier) => s"""("${v.v.toString}".to_string(), "${tier.wireName}".to_string())""" + }.mkString(", ") + } for ((fullPath, dynBase, typeId, _) <- types) { val binCodec = s"${dynBase}V${verSuffix}BinCodec" @@ -229,6 +235,7 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( sb.append(s""" fn baboon_domain_identifier_dyn(&self) -> &str { "$domainIdStr" }\n""") sb.append(s""" fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" }\n""") sb.append(s""" fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["$versionStr".to_string()] }\n""") + sb.append(s""" fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] }\n""") sb.append( " fn as_any(&self) -> &dyn std::any::Any { self }\n") sb.append( " fn into_any(self: Box) -> Box { self }\n") sb.append( "}\n") @@ -265,6 +272,14 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( sb.append( " fn same_in_versions(&self, _type_id: &str) -> Vec {\n") sb.append(s""" vec!["$versionStr".to_string()]\n""") sb.append( " }\n") + sb.append( " fn forward_readable_versions(&self, type_id: &str) -> Vec<(String, String)> {\n") + sb.append( " match type_id {\n") + for ((_, _, typeId, _) <- types) { + sb.append(s""" "$typeId" => vec![${fwdPairs(typeId)}],\n""") + } + sb.append( " _ => Vec::new(),\n") + sb.append( " }\n") + sb.append( " }\n") sb.append( "}\n") // JSON codecs factory diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala index 5f4166b35..950c9f8ce 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala @@ -269,15 +269,33 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( q""""${tid.toString}": [${version.sameIn.map(_.v.toString).map(s => q""""$s"""").toList.join(", ")}],""" } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version) + .toList + .sortBy(_._1.toString) + .map { + case (tid, fr) => + val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") + q""""${tid.toString}": [$pairs],""" + } + val metaTree = q"""public class $metadataClassName { | public static let unmodified: [String: [String]] = [ | ${entries.joinN().shift(8).trim} | ] | + | public static let forwardReadable: [String: [String: String]] = [ + | ${forwardEntries.joinN().shift(8).trim} + | ] + | | public func sameInVersions(_ typeId: String) -> [String] { | return $metadataClassName.unmodified[typeId] ?? [] | } + | + | public func forwardReadableVersions(_ typeId: String) -> [String: String] { + | return $metadataClassName.forwardReadable[typeId] ?? [:] + | } |}""".stripMargin val metaOutput = SwDefnTranslator.Output( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala index bf3ee815a..55ce7792a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala @@ -462,6 +462,7 @@ object SwDefnTranslator { "baboonDomainIdentifier", "baboonTypeIdentifier", "baboonSameInVersions", + "baboonForwardReadable", "baboonAdtTypeIdentifier", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) @@ -843,6 +844,7 @@ object SwDefnTranslator { "baboonDomainIdentifier", "baboonTypeIdentifier", "baboonSameInVersions", + "baboonForwardReadable", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) @@ -910,6 +912,7 @@ object SwDefnTranslator { "baboonDomainIdentifier", "baboonTypeIdentifier", "baboonSameInVersions", + "baboonForwardReadable", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala index e01e171f7..cded928de 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala @@ -93,7 +93,18 @@ object SwDomainTreeTools { "baboonSameInVersions", "[String]", ) - List(sameInVersion) + val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forwardEntries = forward.readable.toList.map { + case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" + } + val forwardReadable = MetaField( + q"public static let baboonForwardReadable: [String: String]", + q"[${forwardEntries.mkString(", ")}]", + q"$ref.baboonForwardReadable", + "baboonForwardReadable", + "[String: String]", + ) + List(sameInVersion, forwardReadable) } } } diff --git a/docs/forward-compat.md b/docs/forward-compat.md new file mode 100644 index 000000000..e3704bc40 --- /dev/null +++ b/docs/forward-compat.md @@ -0,0 +1,107 @@ +# Forward-compatibility metadata + +Design history: [drafts/20260911-0937-forward-compat-metadata.md](drafts/20260911-0937-forward-compat-metadata.md). + +Baboon has long emitted `sameIn` ranges (`baboonSameInVersions`, the `unmodified` +block of `baboon-meta.json`): the contiguous versions in which a type's encoding +is byte-identical. Clients use them to let an older version's codec read +newer-version blobs when nothing changed. + +The compiler additionally computes **forward-readability ranges**: for every +type in every version V, the ascending contiguous run of versions W ≥ V whose +encoded data the V-version codec can decode, each with a **guarantee tier**. +"Can decode" is a guarantee over *every* value the newer version can emit — +data-dependent luck (e.g. "works unless the new enum member is sent") never +qualifies. + +## Tiers + +Linearly ordered; every tier implies all guarantees of the tiers below it. + +| tier (wire name) | guarantee | +|---|---| +| `identical` | byte-identical encoding: both formats, any nesting position | +| `prefix-any-mode` | UEBA: prefix-read of compact **and** indexed blobs (all appended fields fixed-length); JSON: any position | +| `prefix-compact` | UEBA: prefix-read of **compact** blobs only (some appended field is variable-length); JSON: any position | +| `json-additive` | JSON only, any nesting position | +| *(absent)* | no guarantee | + +What earns each tier (per evolution step, composed over contiguous chains by +taking the minimum): + +- `identical` — own fields ordered-equal and every codec-relevant dependency + identical. +- `prefix-*` — the old field sequence is a strict prefix of the new one + (names and types equal, additions only at the tail), **and every other type + in the old version's dependency closure is byte-identical**. Any nested + change would desync the outer sequential read. +- `json-additive` — all old fields present with identical types (additions at + any position, reordering allowed), dependencies at least `json-additive`. + Sound because every generated JSON decoder is a tolerant reader (key lookup; + unknown keys ignored — verified in all 9 backends, including serde without + `deny_unknown_fields` and pydantic's default `extra='ignore'`). + +Never forward-readable: field removals or type changes (forward reads narrow), +field renames (JSON key changes), enum member additions/reordering (positional +`u8` discriminants / unknown names), ADT branch additions, foreign-type +changes, type renames. + +## The prefix-* client contract + +The compiler guarantees the byte-prefix property. Acting on it is sound ONLY +when, at the call site: + +1. the blob is decoded as the **top-level** value (never from a position inside + a larger baboon stream — nested fields, collection elements); +2. the transport frames the blob (its end is known independently); +3. the cursor / remaining bytes are **discarded** after decoding — no + fully-consumed assertion. + +These are usage-site properties the compiler cannot check. `identical` and +`json-additive` carry no such conditions. + +## Where the metadata lives + +- `baboon-meta.json` (`--meta-write-evolution-json`): top-level + `"forwardReadable"` key — `pkg → version → typeId → [{v, tier}, ...]`. + The list is ascending, starts with the own version at `identical`, and tiers + are non-increasing. This is the machine-readable source for distributing + ranges to deployed clients (e.g. via a version-negotiation endpoint): + per-type constants only reach binaries regenerated after the newer version + was added. +- Generated code, per type (sibling of `baboonSameInVersions`): a + `version → tier` map named `baboonForwardReadable` (language-idiomatic + casing/shape: `BaboonForwardReadableValue`+`BaboonForwardReadable()` in C#, + `BaboonForwardReadable` static + `baboonForwardReadable()` in TypeScript, + `baboon_forward_readable` ClassVar in Python, `baboon_forward_readable_dyn()` + `Vec<(String, String)>` in Rust, `baboonForwardReadableConst` in Dart, etc.). +- Generated domain metadata registry: `forwardReadableVersions(typeId)` next to + `sameInVersions(typeId)`. + +## Relationship to sameIn + +`identical` forward tiers imply membership in the `sameIn` run — but not the +converse: `sameIn` classification hashes dependency structure with per-field +sorting (`deepSchemaRepr`), which erases member/field *order* inside +dependencies. A type whose dependency enum was merely reordered stays +`unmodified` even though its UEBA discriminants shift. The forward metadata is +computed order-sensitively and demotes such a type to `json-additive`; when the +two disagree, trust the forward metadata. (Tracked as a known `sameIn` +overclaim; see `ForwardCompatComparatorTest`.) + +## End-to-end coverage + +- `ForwardCompatComparatorTest` (JVM, `sbt baboonJVM/test`) — tier + classification, run composition, closure demotion, invariants; fixtures in + `baboon-compiler/src/test/resources/fwd-compat-ok/`. +- `test/sc-stub/.../ForwardCompatSpec.scala` and + `test/ts-stub/.../ForwardCompat.test.ts` — real cross-version decodes over + the shared `fwd-e2e-ok` model: old codec reads new compact-UEBA prefix and + new JSON; desync and grown-enum negative controls. + +## Out of scope (recorded) + +UEBA format evolution (on-wire index count + body length + skipping decoder) +that would make appended fields unconditionally UEBA-safe; an `opt`-removal +tier (decodes as `None` but silently lossy); per-format range splitting for +renames (byte-invisible to UEBA, breaking for JSON). diff --git a/docs/ueba-format.md b/docs/ueba-format.md index 3e124aa49..513af6817 100644 --- a/docs/ueba-format.md +++ b/docs/ueba-format.md @@ -30,7 +30,7 @@ UEBA has a sister format, [SICK](https://github.com/7mind/sick), which is an ind ## User types -- Enums: encoded as `i32` of the discriminant. +- Enums: encoded as a single `u8` — the zero-based POSITIONAL index of the member in declaration order (explicit `const` member values do not appear on the wire). Verified against the C#, Scala, TypeScript and Rust generators; consequently member reordering or mid-list insertion changes existing discriminants. - DTO/ADT/contract branches: fields encoded in declaration order. - ADT branches: written as the branch payload followed by branch metadata when wrapped codecs are enabled; by default branches are emitted without an envelope (caller knows the concrete branch). diff --git a/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs b/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs index 87c01adb0..698ffe93b 100644 --- a/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs +++ b/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs @@ -656,6 +656,7 @@ public void JsonToUebaBytes_StaticFallback_MetaWinsOverStatic() private sealed class StubMeta : IBaboonMeta { public IReadOnlyList SameInVersions(string typeIdString) => Array.Empty(); + public IReadOnlyDictionary ForwardReadableVersions(string typeIdString) => new Dictionary(); } private sealed class StubJsonCodecs : AbstractBaboonJsonCodecs diff --git a/test/dt-stub/test/runtime/any_meta_codec_test.dart b/test/dt-stub/test/runtime/any_meta_codec_test.dart index 95613b5c2..bdc13f87d 100644 --- a/test/dt-stub/test/runtime/any_meta_codec_test.dart +++ b/test/dt-stub/test/runtime/any_meta_codec_test.dart @@ -36,6 +36,8 @@ class _StubGenerated implements BaboonGenerated, BaboonMetaProvider { String get baboonTypeIdentifier => 'T'; @override List get baboonSameInVersions => ['1.0.0']; + @override + Map get baboonForwardReadable => const {'1.0.0': 'identical'}; } class _StubAdtBranchGenerated implements BaboonGenerated, BaboonMetaProvider, BaboonAdtMember { @@ -49,6 +51,8 @@ class _StubAdtBranchGenerated implements BaboonGenerated, BaboonMetaProvider, Ba String get baboonTypeIdentifier => 'BranchT'; @override List get baboonSameInVersions => ['1.0.0']; + @override + Map get baboonForwardReadable => const {'1.0.0': 'identical'}; } class _StubBinCodec extends BaboonBinCodecBase implements BaboonCodecData { @@ -785,6 +789,8 @@ class _StubGeneratedLatest implements BaboonGeneratedLatest, BaboonMetaProvider String get baboonTypeIdentifier => 'T'; @override List get baboonSameInVersions => ['1.0.0']; + @override + Map get baboonForwardReadable => const {'1.0.0': 'identical'}; } class _StubLatestBinCodec extends BaboonBinCodecBase implements BaboonCodecData { diff --git a/test/dt-stub/test/runtime/baboon_ext_test.dart b/test/dt-stub/test/runtime/baboon_ext_test.dart index de04560a4..d430f5d07 100644 --- a/test/dt-stub/test/runtime/baboon_ext_test.dart +++ b/test/dt-stub/test/runtime/baboon_ext_test.dart @@ -20,6 +20,8 @@ class _StubMetaProvider implements BaboonMetaProvider { final String baboonTypeIdentifier; @override final List baboonSameInVersions; + @override + Map get baboonForwardReadable => const {}; const _StubMetaProvider({ required this.baboonDomainVersion, @@ -37,6 +39,9 @@ class _StubDomainMeta implements BaboonMeta { @override List sameInVersions(String typeId) => _versions; + + @override + Map forwardReadableVersions(String typeId) => const {}; } void main() { diff --git a/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java b/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java index ad088144b..c0fed7868 100644 --- a/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java +++ b/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java @@ -573,6 +573,8 @@ private static final class StubMeta implements BaboonMeta { StubMeta(List v) { this.versions = v; } @Override public List sameInVersions(String typeId) { return versions; } + @Override + public java.util.Map forwardReadableVersions(String typeId) { return java.util.Map.of(); } } private static final class StubConv extends AbstractBaboonConversions { diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt index e54948546..58fdd1fa3 100644 --- a/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt @@ -494,6 +494,7 @@ class AnyMetaCodecTest { private class StubMeta : BaboonMeta { override fun sameInVersions(typeId: String): List = emptyList() + override fun forwardReadableVersions(typeId: String): Map = emptyMap() } private class StubJsonCodecs : AbstractBaboonJsonCodecs() diff --git a/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt b/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt index 934cffc1b..e4a56dd48 100644 --- a/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt +++ b/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt @@ -502,6 +502,7 @@ class AnyMetaCodecTest { private class StubMeta : BaboonMeta { override fun sameInVersions(typeId: String): List = emptyList() + override fun forwardReadableVersions(typeId: String): Map = emptyMap() } private class StubJsonCodecs : AbstractBaboonJsonCodecs() From e3a433520932d746e7d61b4f39f7e4b85b589a68 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 13 Sep 2026 11:56:12 +0100 Subject: [PATCH 03/48] docs: mark forward-compat plan as implemented, record resolved decisions Co-Authored-By: Claude Fable 5 --- docs/drafts/20260911-0937-forward-compat-metadata.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/drafts/20260911-0937-forward-compat-metadata.md b/docs/drafts/20260911-0937-forward-compat-metadata.md index 1f0f68ac3..8f368441b 100644 --- a/docs/drafts/20260911-0937-forward-compat-metadata.md +++ b/docs/drafts/20260911-0937-forward-compat-metadata.md @@ -1,6 +1,15 @@ # Forward-compatibility metadata: design & implementation plan -Status: DRAFT (design agreed in discussion 2026-09-10/11, not yet implemented) +Status: IMPLEMENTED (2026-09-13) — see docs/forward-compat.md for the user-facing +reference. Resolved during implementation: metadata name is `baboonForwardReadable`, +shape is a version->tier map (order is a compiler-side invariant, clients look up by +writer version); metagen key `forwardReadable` emits user types only; tier vocabulary +`identical` / `prefix-any-mode` / `prefix-compact` / `json-additive`. PREFIX_ANY_MODE +has comparator-level coverage only (fixed-length appends are never auto-derivable, so +no stub-matrix e2e without manual conversions). Discovered en route: `sameIn` +overclaims byte-identity when a dependency is merely reordered (deepSchemaRepr sorts +flattened dep reprs) — documented in ForwardCompatComparatorTest and +docs/forward-compat.md; the enum wire is a positional u8 (ueba-format.md corrected). ## 1. Problem From c82d86e45702d213242d0d16fd8e0406e1ee299d Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 13 Sep 2026 17:11:49 +0100 Subject: [PATCH 04/48] fix(evolution): make deep schema hashing order- and structure-sensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sameIn/unmodified overclaimed byte-identity: deepSchemaRepr sorted the flattened dependency repr lines per field (and sorted ADT branch reprs), erasing member/field/branch order inside dependencies and type-constructor argument order — while UEBA is positional (enum discriminants, ADT branch indices, field bytes, map K/V order). A host of a reordered dep enum, a reordered dep ADT, or a dep with swapped map arguments classified as 'unmodified', so sameIn ranges promised byte-identity the wire does not have. Fix: dependency reprs stay contiguous and internally ordered (determinism now comes from sorting the dependency IDs instead of the flattened lines), each field line carries its full type-ref rendering, and ADT branch order — the UEBA discriminant order — is preserved. Reproduced fail-first in ForwardCompatComparatorTest (EnumReorderHost / SumReorder / SumReorderHost / MapSwapHost fixtures), which now pins the corrected classification. Consequences: - Lockfiles persist deepId-derived signatures: Locks now carries a 'scheme' marker (2 = this hashing; absent/1 = legacy). A stale-scheme lockfile is incomparable — enforcement is skipped for that one run and the file is re-signed in place even under create-only (T156 matrix extended). - The M20 manual→sugared ADT rewrite is honestly classified: the sugared expansion reorders branches, shifting positional UEBA discriminants, so the step is deepModified with a derivable CopyAdtBranchByName conversion. PR-D's branch sort had masked this wire break; M20AdtEvolutionTest updated to assert the conversion-derivability guarantee instead of byte-identity. Also adds .jvmopts (-Xmx8g -Xss8m): sbt's 1024 MB launcher default OOMs the Scala.js parallel optimizer on many-core machines during 'sbt +test' (reproduced on a clean clone of main, pre-existing). Co-Authored-By: Claude Fable 5 --- .jvmopts | 2 + .../septimalmind/baboon/BaboonCompiler.scala | 29 ++++++++- .../baboon/LockfileManagerImpl.scala | 34 ++++++---- .../tests/ForwardCompatComparatorTest.scala | 45 ++++++++++--- .../baboon/tests/M20AdtEvolutionTest.scala | 46 ++++++------- ...5LocksDeterministicSerializationTest.scala | 3 +- .../T156LockfileEnforcementMatrixTest.scala | 26 +++++++- .../baboon/typer/BaboonTyper.scala | 64 +++++++++---------- .../test/resources/fwd-compat-ok/fwd-1.baboon | 25 ++++++++ .../test/resources/fwd-compat-ok/fwd-2.baboon | 15 +++++ .../20260911-0937-forward-compat-metadata.md | 7 +- docs/forward-compat.md | 32 +++++++--- 12 files changed, 234 insertions(+), 94 deletions(-) create mode 100644 .jvmopts diff --git a/.jvmopts b/.jvmopts new file mode 100644 index 000000000..192f2069c --- /dev/null +++ b/.jvmopts @@ -0,0 +1,2 @@ +-Xmx8g +-Xss8m diff --git a/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/BaboonCompiler.scala b/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/BaboonCompiler.scala index 766da48f1..9be8136c4 100644 --- a/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/BaboonCompiler.scala +++ b/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/BaboonCompiler.scala @@ -23,9 +23,23 @@ case class VersionLock( ) case class Locks( - locks: Map[Pkg, List[VersionLock]] + locks: Map[Pkg, List[VersionLock]], + scheme: Int, ) +object Locks { + /** Version of the signature-hashing scheme. Signatures produced by different + * schemes are incomparable: a mismatch means "cannot check drift", never + * "drift detected". History: + * 1 — original deepId hashing (order-erasing: dependency repr lines were + * sorted per field); + * 2 — order- and structure-sensitive deep hashing (dependency reprs kept + * contiguous and ordered, field type-refs included, ADT branch order + * preserved). + */ + final val CurrentScheme: Int = 2 +} + object LockCodecs { implicit lazy val pkgKeyEncoder: KeyEncoder[Pkg] = KeyEncoder.encodeKeyString.contramap(_.toString) implicit lazy val pkgKeyDecoder: KeyDecoder[Pkg] = KeyDecoder.decodeKeyString.map(s => Pkg(NEList.unsafeFrom(s.split('.').toList))) @@ -33,7 +47,16 @@ object LockCodecs { implicit lazy val sigidCodec: Codec[SigId] = Codec.from(Decoder.decodeString.map(s => SigId(s)), Encoder.encodeString.contramap(_.value)) implicit lazy val versionLockCodec: Codec[VersionLock] = deriveCodec - private lazy val locksDecoder: Decoder[Locks] = deriveCodec[Locks] + // pre-scheme lockfiles carry no "scheme" key: treat them as scheme 1 + private lazy val locksDecoder: Decoder[Locks] = Decoder.instance { + c => + for { + locks <- c.downField("locks").as[Map[Pkg, List[VersionLock]]] + scheme <- c.downField("scheme").as[Option[Int]] + } yield { + Locks(locks, scheme.getOrElse(1)) + } + } private lazy val locksEncoder: Encoder[Locks] = Encoder.instance { locks => val fields = locks.locks.toSeq .sortBy { case (pkg, _) => pkg.toString } @@ -41,7 +64,7 @@ object LockCodecs { val sortedVersions = versions.sortBy(_.version)(using Version.ordering).map(versionLockCodec.apply) pkgKeyEncoder(pkg) -> Json.arr(sortedVersions*) } - Json.obj("locks" -> Json.obj(fields*)) + Json.obj("scheme" -> Json.fromInt(locks.scheme), "locks" -> Json.obj(fields*)) } implicit lazy val lockscodec: Codec[Locks] = Codec.from(locksDecoder, locksEncoder) diff --git a/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/LockfileManagerImpl.scala b/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/LockfileManagerImpl.scala index d5d8e26d8..01fdba55a 100644 --- a/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/LockfileManagerImpl.scala +++ b/baboon-compiler/.jvm/src/main/scala/io/septimalmind/baboon/LockfileManagerImpl.scala @@ -16,14 +16,17 @@ class LockfileManagerImpl[F[+_, +_]: Error2: MaybeSuspend2]( enq: BaboonEnquiries, ) extends LockfileManager[F] { def validateLock(model: BaboonFamily): F[NEList[BaboonIssue], Unit] = { - val currentSigs = Locks(model.domains.map { - case (pkg, lineage) => - val versions = lineage.versions.toSeq.map { - case (ver, dom) => - VersionLock(ver, sigOf(dom)) - }.toList - (pkg, versions) - }.toMap) + val currentSigs = Locks( + model.domains.map { + case (pkg, lineage) => + val versions = lineage.versions.toSeq.map { + case (ver, dom) => + VersionLock(ver, sigOf(dom)) + }.toList + (pkg, versions) + }.toMap, + Locks.CurrentScheme, + ) // `evolution.latest` is the only model-derived input the enforcement matrix needs, // so we project it out and drive the matrix over the projection. This keeps the @@ -65,11 +68,18 @@ class LockfileManagerImpl[F[+_, +_]: Error2: MaybeSuspend2]( } yield { out }).catchAll(e => F.fail(BaboonIssue.of(IOIssue.CantReadInput(lockfilePath.toString, e)))) - _ <- compareSigs(latestOf, currentSigs, existingSigs, options.lockfileEnforcement) - // Reached only when enforcement passed (or was None): force rewrites, create-only leaves untouched. + // Signatures from a different hashing scheme are incomparable: a scheme + // mismatch is a compiler upgrade, not model drift. Skip enforcement for + // that (one) run and rewrite the lockfile with current-scheme signatures + // even under CreateOnly, so the lock resumes protecting from the next run. + schemeMatches = existingSigs.scheme == currentSigs.scheme + _ <- F.when(schemeMatches)(compareSigs(latestOf, currentSigs, existingSigs, options.lockfileEnforcement)) + // Reached only when enforcement passed (or was None/skipped): force rewrites, + // create-only leaves untouched unless a scheme migration is required. _ <- options.lockfileUpdate match { - case LockfileUpdate.Force => writeCurrent(lockfilePath) - case LockfileUpdate.CreateOnly => F.unit + case LockfileUpdate.Force => writeCurrent(lockfilePath) + case LockfileUpdate.CreateOnly if !schemeMatches => writeCurrent(lockfilePath) + case LockfileUpdate.CreateOnly => F.unit } } yield {} } diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala index a223d2d81..d3e54cd97 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala @@ -88,6 +88,43 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT } } + "classify byte-affecting dependency changes as modified (sameIn must not overclaim byte-identity)" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + val v1 = Version.parse("1.0.0") + val v2 = Version.parse("1.1.0") + + def twins(typeName: String): List[Version] = { + val ids = evo.typesUnchangedSince(v1).keys.collect { + case id: TypeId.User if id.name.name == typeName => id: TypeId + }.toList + assert(ids.size == 1, s"expected exactly one type named $typeName, got $ids") + evo.typesUnchangedSince(v1)(ids.head).higherTwins(v1) + } + + // dep enum members reordered: UEBA discriminants are positional, host bytes change + assert(!twins("EnumReorderHost").contains(v2), "EnumReorderHost must not be byte-identical across a dep-enum reorder") + // ADT branch list reordered: branch-index bytes shift for the ADT and its host + assert(!twins("SumReorder").contains(v2), "SumReorder must not be byte-identical across a branch reorder") + assert(!twins("SumReorderHost").contains(v2), "SumReorderHost must not be byte-identical across a dep-ADT branch reorder") + // dep's map type arguments swapped: key/value wire order flips inside the host's bytes + assert(!twins("MapSwapHost").contains(v2), "MapSwapHost must not be byte-identical across a dep map-argument swap") + + // control: genuinely unchanged closures keep their twins + assert(twins("HostOfUnchanged").contains(v2)) + assert(twins("Stable").contains(v2)) + + // forward tiers for the new fixtures (order-sensitive computation, unaffected by hashing) + assert(runOf(evo, "1.0.0", "SumReorder") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "SumReorderHost") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "MapCarrier") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "MapSwapHost") == List(("1.0.0", Identical))) + } + } + "maintain structural invariants and consistency with sameIn ranges" in { (loader: BaboonLoader[F]) => for { @@ -132,14 +169,6 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT } } - // Regression documentation of the known sameIn overclaim: the host of a - // reordered enum stays `unmodified` (order-erased deepId) although its - // UEBA bytes change; the forward metadata correctly demotes it to JSON. - val reorderHostId = evo.typesForwardReadable(Version.parse("1.0.0")).keys.collectFirst { - case id: TypeId.User if id.name.name == "EnumReorderHost" => id: TypeId - }.get - val reorderHostTwins = evo.typesUnchangedSince(Version.parse("1.0.0"))(reorderHostId).higherTwins(Version.parse("1.0.0")) - assert(reorderHostTwins.contains(Version.parse("1.1.0")), "precondition: sameIn still overclaims for EnumReorderHost") assert(runOf(evo, "1.0.0", "EnumReorderHost").tail.forall(_._2 == JsonAdditive)) } } diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M20AdtEvolutionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M20AdtEvolutionTest.scala index 5a9916b7f..020e630bf 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M20AdtEvolutionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M20AdtEvolutionTest.scala @@ -68,21 +68,24 @@ abstract class M20AdtEvolutionTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul "M20 ADT inheritance evolution (PR-64)" should { - // Plan §5: the manual->sugared rewrite must produce no breaking change. + // Plan §5: the manual->sugared rewrite must produce no CONVERSION break. // // Branches in v1 and v2 share `TypeId` (same `Pkg`, same `Owner.Adt(SomeError)`, same // `TypeName`) per the re-emit semantics — so `BaboonComparator.diffAdts` puts every // branch in `keptMembers` (no `AddBranch`, no `RemoveBranch`), and `BaboonRules` emits // a derivable `CopyAdtBranchByName` (NOT `CustomConversionRequired`). // - // Note on `SomeError`'s classification: post-expansion `SomeError` lands in - // `unmodified` because PR-D sorted ADT branches in `BaboonTyper.deepSchemaRepr` - // before hashing. v1 sources `Forbidden, Bar` literally; v2's sugared expansion - // via `AdtInheritanceExpander` produces `Bar, Forbidden` (localMembers first then - // includeBranches). After sorting each branch repr by `mkString`, both orderings - // produce the identical deep-schema string, so deep IDs match and `SomeError` - // classifies as `unmodified`. - "manual->sugared rewrite produces no breaking change in BaboonRules / BaboonComparator" in { + // Note on `SomeError`'s classification: v1 sources `Forbidden, Bar` literally; v2's + // sugared expansion via `AdtInheritanceExpander` produces `Bar, Forbidden` + // (localMembers first then includeBranches). UEBA branch discriminants are + // POSITIONAL over `dataMembers`, so this reorder changes the wire bytes: v1 encodes + // Forbidden as 0, v2 as 1. `SomeError` therefore classifies as `deepModified` — + // NOT `unmodified` — and cross-version blobs go through the (derivable) conversion. + // PR-D originally sorted ADT branch reprs in `BaboonTyper.deepSchemaRepr` so this + // rewrite hashed as `unmodified`; that masked the wire break in the sameIn ranges + // (byte-identity metadata) and was reverted with the order-sensitive deep hashing + // (see docs/forward-compat.md, "Relationship to sameIn"). + "manual->sugared rewrite stays conversion-derivable; the branch reorder is honestly byte-modified" in { (manager: BaboonFamilyManager[F]) => for { family <- manager.load( @@ -101,16 +104,16 @@ abstract class M20AdtEvolutionTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul val evo = lineage.evolution val diff = evo.diffs(step) - // SomeError must be classified as `unmodified`: after PR-D the branch sort in - // `BaboonTyper.deepSchemaRepr` makes the manual->sugared rewrite produce identical - // deep schema IDs regardless of declaration order. - val unmodifiedIds: Set[TypeId.User] = - diff.changes.unmodified.collect { case u: TypeId.User => u } + // SomeError must be classified as `deepModified`: same branch set (same shallowId, + // which sorts members) but a different branch ORDER, i.e. different UEBA + // discriminants — the deep schema id is order-sensitive and must differ. + val deepModifiedIds: Set[TypeId.User] = + diff.changes.deepModified.collect { case u: TypeId.User => u } - val someErrorNonBreaking = unmodifiedIds.filter(_.name.name == "SomeError") + val someErrorReordered = deepModifiedIds.filter(_.name.name == "SomeError") assert( - someErrorNonBreaking.size == 1, - s"Expected SomeError in unmodified (identical deep schema IDs after branch sort); " + + someErrorReordered.size == 1, + s"Expected SomeError in deepModified (branch reorder changes positional UEBA discriminants); " + s"got unmodified=${diff.changes.unmodified}, shallowModified=${diff.changes.shallowModified}, " + s"deepModified=${diff.changes.deepModified}, fullyModified=${diff.changes.fullyModified}, " + s"removed=${diff.changes.removed}, renamed=${diff.changes.renamed}", @@ -124,13 +127,12 @@ abstract class M20AdtEvolutionTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul s"Expected re-emitted Forbidden and local Bar in unmodified; got: $unmodifiedNames", ) - // Unmodified types are absent from `diff.diffs`. If SomeError appears (it - // should not after the branch-sort fix), every op must still be KeepBranch. - val someErrorId = someErrorNonBreaking.head + // deepModified types carry a diff whose ops must all be KeepBranch (same + // branch set kept under the same TypeIds; nothing added or removed). + val someErrorId = someErrorReordered.head diff.diffs.get(someErrorId) match { case None => - // Unmodified types are absent from `diff.diffs`. Strongest possible signal. - () + fail("Expected an AdtDiff for the deepModified SomeError") case Some(TypedefDiff.AdtDiff(ops)) => val nonKeep = ops.filterNot(_.isInstanceOf[AdtOp.KeepBranch]) assert( diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T155LocksDeterministicSerializationTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T155LocksDeterministicSerializationTest.scala index bcc834e66..2eca8f19e 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T155LocksDeterministicSerializationTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T155LocksDeterministicSerializationTest.scala @@ -32,7 +32,8 @@ class T155LocksDeterministicSerializationTest extends AnyWordSpec { locks = Map( pkgZeta -> List(VersionLock(v200, SigId("sig-z2")), VersionLock(v100, SigId("sig-z1"))), pkgAlpha -> List(VersionLock(v1100, SigId("sig-a3")), VersionLock(v100, SigId("sig-a1")), VersionLock(v120, SigId("sig-a2"))), - ) + ), + scheme = Locks.CurrentScheme, ) "Locks encoder" should { diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T156LockfileEnforcementMatrixTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T156LockfileEnforcementMatrixTest.scala index 8ec965076..1ca8b5c9c 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T156LockfileEnforcementMatrixTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/T156LockfileEnforcementMatrixTest.scala @@ -49,15 +49,15 @@ class T156LockfileEnforcementMatrixTest extends AnyWordSpec with Matchers { // The model's current signatures. private def currentSigs: Locks = - Locks(Map(pkg -> List(VersionLock(vLow, SigId("cur-low")), VersionLock(vHigh, SigId("cur-high"))))) + Locks(Map(pkg -> List(VersionLock(vLow, SigId("cur-low")), VersionLock(vHigh, SigId("cur-high")))), Locks.CurrentScheme) // An on-disk lock with the SAME low sig but a DIFFERENT high sig => only the LATEST version drifted. private def existingLatestDrift: Locks = - Locks(Map(pkg -> List(VersionLock(vLow, SigId("cur-low")), VersionLock(vHigh, SigId("old-high"))))) + Locks(Map(pkg -> List(VersionLock(vLow, SigId("cur-low")), VersionLock(vHigh, SigId("old-high")))), Locks.CurrentScheme) // An on-disk lock with a DIFFERENT low sig but the SAME high sig => only a NON-LATEST version drifted. private def existingNonLatestDrift: Locks = - Locks(Map(pkg -> List(VersionLock(vLow, SigId("old-low")), VersionLock(vHigh, SigId("cur-high"))))) + Locks(Map(pkg -> List(VersionLock(vLow, SigId("old-low")), VersionLock(vHigh, SigId("cur-high")))), Locks.CurrentScheme) // An on-disk lock identical to currentSigs => no drift. private def existingNoDrift: Locks = currentSigs @@ -214,5 +214,25 @@ class T156LockfileEnforcementMatrixTest extends AnyWordSpec with Matchers { run(manager(p, LockfileUpdate.Force, LockfileEnforcement.LegacyVersions)) shouldBe Right(()) new String(readBytes(p), StandardCharsets.UTF_8) shouldBe currentSigs.asJson.spaces2 } + + // Scheme migration: signatures from another hashing scheme are incomparable, so a + // would-be "drift" must NOT be flagged, and the file is re-signed even under + // create-only so enforcement resumes from the next run. + "present + stale scheme + would-be non-latest drift + all-versions + create-only => no failure, file migrated" in { + val p = tmpLockPath() + writeLock(p, existingNonLatestDrift.copy(scheme = 1)) + run(manager(p, LockfileUpdate.CreateOnly, LockfileEnforcement.AllVersions)) shouldBe Right(()) + new String(readBytes(p), StandardCharsets.UTF_8) shouldBe currentSigs.asJson.spaces2 + } + + // Legacy files carry no "scheme" key at all: decoded as scheme 1, same migration path. + "present + pre-scheme lockfile (no scheme key) => decoded as scheme 1 and migrated" in { + val p = tmpLockPath() + import io.circe.syntax.* + val legacyJson = existingNonLatestDrift.asJson.mapObject(_.remove("scheme")).spaces2 + Files.write(p, legacyJson.getBytes(StandardCharsets.UTF_8)) + run(manager(p, LockfileUpdate.CreateOnly, LockfileEnforcement.AllVersions)) shouldBe Right(()) + new String(readBytes(p), StandardCharsets.UTF_8) shouldBe currentSigs.asJson.spaces2 + } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTyper.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTyper.scala index 182b4cd06..052a0b77d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTyper.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTyper.scala @@ -281,63 +281,59 @@ object BaboonTyper { } else { val maybedef = defs.get(id) assert(maybedef.nonEmpty, s"BUG: $id not found") + // Deep reprs must be order- and structure-sensitive wherever the wire is: + // UEBA is positional (field order, enum member order, ADT branch order) and + // type-constructor argument order (map[K,V]) changes byte layout. The + // pre-fix code sorted the FLATTENED dependency repr lines per field (for + // determinism, since `explode` returns a Set), which erased order inside + // dependencies and merged multi-dep boundaries — `unmodified`/sameIn then + // overclaimed byte-identity for hosts of reordered deps. Determinism is + // now provided by sorting the dependency IDS, keeping each dependency's + // repr contiguous and internally ordered; each field line additionally + // carries the full type-ref rendering so argument-order changes inside + // dependencies surface in every transitive host. + def fieldRepr(m: Field): List[String] = { + val exploded = enquiries + .explode(m.tpe) + .toList + .sortBy(_.toString) + .flatMap(id => deepSchemaRepr(id, defs, nseen)) + List(s"{", s"${m.name.name}:${m.tpe.toString}") ++ exploded ++ List("}") + } + maybedef.get match { case _: DomainMember.Builtin => List(s"[builtin:$self]") case u: DomainMember.User => u.defn match { case d: Typedef.Dto => - val content = d.fields.flatMap { - m => - val exploded = enquiries - .explode(m.tpe) - .toList - .flatMap(id => deepSchemaRepr(id, defs, nseen)) - .sorted - List(s"{", m.name.name) ++ exploded ++ List("}") - } + val content = d.fields.flatMap(fieldRepr) List(s"[dto:$self]") ++ content ++ List(s"/[dto:$self]") case d: Typedef.Contract => - val content = d.fields.flatMap { - m => - val exploded = enquiries - .explode(m.tpe) - .toList - .flatMap(id => deepSchemaRepr(id, defs, nseen)) - .sorted - List(s"{", m.name.name) ++ exploded ++ List("}") - } + val content = d.fields.flatMap(fieldRepr) List(s"[contract:$self]") ++ content ++ List(s"/[contract:$self]") case s: Typedef.Service => val content = s.methods.flatMap { m => - val tpes = Set(m.sig) ++ m.err ++ m.out + val tpes = List(m.sig) ++ m.err.toList ++ m.out.toList + val refRepr = tpes.map(_.toString).mkString(":") val exploded = tpes .flatMap(enquiries.explode) - .toList + .distinct + .sortBy(_.toString) .flatMap(id => deepSchemaRepr(id, defs, nseen)) - .sorted - List(s"{", m.name.name) ++ exploded ++ List("}") + List(s"{", s"${m.name.name}:$refRepr") ++ exploded ++ List("}") } List(s"[service:$self]") ++ content ++ List(s"/[service:$self]") case d: Typedef.Adt => - val content = d.fields.flatMap { - m => - val exploded = enquiries - .explode(m.tpe) - .toList - .flatMap(id => deepSchemaRepr(id, defs, nseen)) - .sorted - List(s"{", m.name.name) ++ exploded ++ List("}") - } + val content = d.fields.flatMap(fieldRepr) + // branch order is the UEBA discriminant order: preserve it val branches = List("{", "branches") ++ d.members.toList - .map(id => deepSchemaRepr(id, defs, nseen)) - .sortBy(_.mkString(" ")) - .flatten ++ + .flatMap(id => deepSchemaRepr(id, defs, nseen)) ++ List("}", "{", "contracts") ++ d.contracts .map(id => deepSchemaRepr(id, defs, nseen)) diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon index 03ee6d18d..c946a7034 100644 --- a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon @@ -88,3 +88,28 @@ root adt Sum { rv: str } } + +// v2 reorders the ADT branch list: positional UEBA discriminants shift, so +// neither the ADT nor its host may be classified byte-identical +root adt SumReorder { + data SL { + slv: i32 + } + data SR { + srv: str + } +} + +root data SumReorderHost { + s: SumReorder +} + +// v2 swaps the map's type arguments INSIDE the dep; the host's own shape is +// unchanged but its bytes are not +data MapCarrier { + mc: map[i32, str] +} + +root data MapSwapHost { + c: MapCarrier +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon index 171485fa3..5f963ff67 100644 --- a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon @@ -3,6 +3,8 @@ model fwdcompat.model version "1.1.0" import "1.0.0" { * } without { + SumReorder + MapCarrier AppendFixed AppendVar MidInsert @@ -65,3 +67,16 @@ root adt Sum { rv: str } } + +root adt SumReorder { + data SR { + srv: str + } + data SL { + slv: i32 + } +} + +data MapCarrier { + mc: map[str, i32] +} diff --git a/docs/drafts/20260911-0937-forward-compat-metadata.md b/docs/drafts/20260911-0937-forward-compat-metadata.md index 8f368441b..6341d5a3c 100644 --- a/docs/drafts/20260911-0937-forward-compat-metadata.md +++ b/docs/drafts/20260911-0937-forward-compat-metadata.md @@ -7,9 +7,10 @@ writer version); metagen key `forwardReadable` emits user types only; tier vocab `identical` / `prefix-any-mode` / `prefix-compact` / `json-additive`. PREFIX_ANY_MODE has comparator-level coverage only (fixed-length appends are never auto-derivable, so no stub-matrix e2e without manual conversions). Discovered en route: `sameIn` -overclaims byte-identity when a dependency is merely reordered (deepSchemaRepr sorts -flattened dep reprs) — documented in ForwardCompatComparatorTest and -docs/forward-compat.md; the enum wire is a positional u8 (ueba-format.md corrected). +overclaimed byte-identity when a dependency was merely reordered (deepSchemaRepr sorted +flattened dep reprs) — FIXED 2026-09-13 with order-/structure-sensitive deep hashing +(lockfile signature scheme 2 with in-place migration); see docs/forward-compat.md. +The enum wire is a positional u8 (ueba-format.md corrected). ## 1. Problem diff --git a/docs/forward-compat.md b/docs/forward-compat.md index e3704bc40..55f5f7cac 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -80,14 +80,30 @@ These are usage-site properties the compiler cannot check. `identical` and ## Relationship to sameIn -`identical` forward tiers imply membership in the `sameIn` run — but not the -converse: `sameIn` classification hashes dependency structure with per-field -sorting (`deepSchemaRepr`), which erases member/field *order* inside -dependencies. A type whose dependency enum was merely reordered stays -`unmodified` even though its UEBA discriminants shift. The forward metadata is -computed order-sensitively and demotes such a type to `json-additive`; when the -two disagree, trust the forward metadata. (Tracked as a known `sameIn` -overclaim; see `ForwardCompatComparatorTest`.) +`identical` forward tiers imply membership in the `sameIn` run. Historically +the converse could fail: `deepSchemaRepr` sorted the flattened dependency repr +lines per field (and sorted ADT branch reprs), erasing member/field/branch +*order* inside dependencies and type-constructor argument order — so a type +whose dependency enum was merely reordered (or whose dep ADT's branches were +reordered, or whose dep's `map[K,V]` arguments were swapped) stayed +`unmodified` even though its UEBA bytes changed. This was fixed by making deep +hashing order- and structure-sensitive (signature scheme 2): dependency reprs +stay contiguous and internally ordered (determinism comes from sorting the +dependency *ids*), each field line carries its full type-ref rendering, and ADT +branch order — the UEBA discriminant order — is preserved. Regression coverage: +`ForwardCompatComparatorTest` ("sameIn must not overclaim byte-identity"). + +Two consequences of scheme 2: + +- **Lockfiles** persist deepId-derived signatures. The lockfile format carries a + `scheme` marker; a lockfile written by an older compiler (scheme 1, or no + marker) is treated as incomparable — drift enforcement is skipped for that one + run and the file is re-signed in place, even under `create-only`. +- **The M20 manual→sugared ADT rewrite is honestly classified**: the sugared + expansion reorders branches (local members first), which shifts positional + UEBA discriminants, so such a step is `deepModified` (forward tier + `json-additive`), with a fully derivable `CopyAdtBranchByName` conversion — + not byte-identical, as the branch-sorted hash used to claim. ## End-to-end coverage From 0c4f434d4b7b0c40b1ea345297d856db987bd305 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 14 Sep 2026 14:03:22 +0100 Subject: [PATCH 05/48] =?UTF-8?q?feat(envelope):=20publish=20JSON=20readab?= =?UTF-8?q?le-min=20bound=20($rv)=20+=20reader=20ForwardReadPolicy=20?= =?UTF-8?q?=E2=80=94=20Scala/TS=20pilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../tests/ForwardCompatComparatorTest.scala | 42 +++++ .../scala/BaboonCodecsFacade.scala | 27 +++- .../scala/BaboonRuntimeShared.scala | 50 +++++- .../typescript/BaboonCodecsFacade.ts | 29 +++- .../typescript/BaboonSharedRuntime.ts | 44 +++++- .../translator/scl/ScDomainTreeTools.scala | 10 +- .../typescript/TsDomainTreeTools.scala | 9 ++ .../baboon/typer/model/BaboonEvolution.scala | 20 +++ docs/forward-compat.md | 23 +++ docs/json-codecs.md | 2 +- docs/spec/codec-envelope.md | 17 +- .../runtime/ForwardCompatEnvelopeSpec.scala | 102 ++++++++++++ .../src/runtime-tests/AnyMetaCodec.test.ts | 2 + .../src/runtime-tests/BaboonExt.test.ts | 3 + .../ForwardCompatEnvelope.test.ts | 149 ++++++++++++++++++ 15 files changed, 514 insertions(+), 15 deletions(-) create mode 100644 test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala create mode 100644 test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala index d3e54cd97..965fbe823 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala @@ -125,6 +125,48 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT } } + "invert forward ranges into per-writer minimum reader versions" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + val v1 = Version.parse("1.0.0") + val v2 = Version.parse("1.1.0") + val v3 = Version.parse("1.2.0") + + def idOf(typeName: String): TypeId = { + evo.typesForwardReadable(v1).keys.collectFirst { case id: TypeId.User if id.name.name == typeName => id: TypeId }.get + } + + // Every tier is always present: the writer's own version reads itself identically, + // and identity satisfies every weaker tier. + // + // written by 1.2.0: AppendVar is prefix-readable (compact) back to 1.0.0, hence JSON-readable too; + // the appended fields are variable-length, so indexed blobs are readable only by 1.2.0 itself + assert(evo.minReaders(v3, idOf("AppendVar")) == Map(Identical -> v3, PrefixAnyMode -> v3, PrefixCompact -> v1, JsonAdditive -> v1)) + // fixed-length append keeps indexed blobs readable: every tier reaches 1.0.0 except identity (unchanged only since 1.1.0) + assert(evo.minReaders(v3, idOf("AppendFixed")) == Map(Identical -> v2, PrefixAnyMode -> v1, PrefixCompact -> v1, JsonAdditive -> v1)) + // mid-insert written by 1.1.0: JSON back to 1.0.0, nothing else; written by 1.2.0 (field removed): nobody older + assert(evo.minReaders(v2, idOf("MidInsert")) == Map(Identical -> v2, PrefixAnyMode -> v2, PrefixCompact -> v2, JsonAdditive -> v1)) + assert(evo.minReaders(v3, idOf("MidInsert")) == Map(Identical -> v3, PrefixAnyMode -> v3, PrefixCompact -> v3, JsonAdditive -> v3)) + // unchanged type: every bound is the oldest version + assert(evo.minReaders(v3, idOf("Stable")).values.toSet == Set(v1)) + + // the Identical bound is exactly the sameIn head, for every type and version + evo.typesUnchangedSince.foreach { + case (version, types) => + types.foreach { + case (id, unmodified) => + assert( + evo.minReaders(version, id).get(Identical).contains(unmodified.sameIn.head), + s"$id@$version: minReaders(Identical)=${evo.minReaders(version, id).get(Identical)} vs sameIn.head=${unmodified.sameIn.head}", + ) + } + } + } + } + "maintain structural invariants and consistency with sameIn ranges" in { (loader: BaboonLoader[F]) => for { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala index eeb169e9a..008c7ec27 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala @@ -9,11 +9,27 @@ package baboon.runtime.shared { import scala.reflect.ClassTag import scala.util.Try + /** How a reader treats JSON payloads written by a NEWER domain version than it registers. + * - Lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a + * registered version — today's behavior. + * - Tolerant: additionally honor `$rv` (json-additive bound): decode with that version's + * codec, silently dropping fields this reader does not know. Re-encoding intermediaries + * must use Lossless or they truncate data for downstream consumers. + */ + sealed trait ForwardReadPolicy + object ForwardReadPolicy { + case object Lossless extends ForwardReadPolicy + case object Tolerant extends ForwardReadPolicy + } + trait BaboonCodecsFacade { type BaboonValue[T] = Either[BaboonCodecException, T] private val CONTENT_JSON_KEY = "$c" + /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant + private val versionsCodecsJson: TrieMap[BaboonDomainVersion, Lazy[AbstractBaboonJsonCodecs]] = TrieMap.empty private val versionsCodecsBin: TrieMap[BaboonDomainVersion, Lazy[AbstractBaboonUebaCodecs]] = TrieMap.empty private val versionsConversions: TrieMap[BaboonDomainVersion, Lazy[AbstractBaboonConversions]] = TrieMap.empty @@ -492,17 +508,19 @@ package baboon.runtime.shared { } private def getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonValue[BaboonBinCodec[BaboonGenerated]] = { - getCodec(versionsCodecsBin, typeMeta, exact).map(_.asInstanceOf[BaboonBinCodec[BaboonGenerated]]) + getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false).map(_.asInstanceOf[BaboonBinCodec[BaboonGenerated]]) } private def getJsonCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonValue[BaboonJsonCodec[BaboonGenerated]] = { - getCodec(versionsCodecsJson, typeMeta, exact).map(_.asInstanceOf[BaboonJsonCodec[BaboonGenerated]]) + getCodec(versionsCodecsJson, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) + .map(_.asInstanceOf[BaboonJsonCodec[BaboonGenerated]]) } private def getCodec[TCodecs <: AbstractBaboonCodecs]( versionsCodecs: TrieMap[BaboonDomainVersion, Lazy[TCodecs]], typeMeta: BaboonTypeMeta, exact: Boolean, + tolerant: Boolean, ): BaboonValue[BaboonCodecData] = { for { versions <- domainVersions @@ -512,8 +530,11 @@ package baboon.runtime.shared { minVersion = versions.head maxVersion = versions.last + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + lowerBound = if (tolerant) typeMeta.versionReadableMin else typeMeta.versionMinCompat // extract domain model version and latest version - modelVersion = typeMeta.versionMinCompat match { + modelVersion = lowerBound match { // it's a model of newer version than we have, we should find min compat version case Some(v) if typeMeta.version.version > maxVersion.version => v case _ => typeMeta.version diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala index 21179a2e8..d69bdb3d4 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala @@ -19,6 +19,13 @@ package baboon.runtime.shared { * caller discards the cursor after decoding. */ def baboonForwardReadable: Map[String, String] + + /** Writer-side inverse of [[baboonForwardReadable]]: guarantee tier -> oldest + * domain version whose codec can decode THIS version's encoding of this type. + * The "identical" bound equals `baboonSameInVersions.head`; the "json-additive" + * bound is what the JSON envelope publishes as `$rv`. + */ + def baboonMinReaderVersions: Map[String, String] def baboonTypeIdentifier: String final def domainVersion: BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -348,6 +355,12 @@ package baboon.runtime.shared { domainVersion: String, domainVersionMinCompat: String, typeIdentifier: String, + /** Oldest domain version whose JSON codec can decode the payload under the + * json-additive contract (tolerant key lookup; fields unknown to that version + * are dropped). Always <= domainVersionMinCompat. Published as `$rv` when it + * differs from the (effective) minCompat; the binary v1 envelope does not carry it. + */ + domainVersionReadableMin: String, ) { def version: BaboonDomainVersion = BaboonDomainVersion(domainIdentifier, domainVersion) def versionMinCompat: Option[BaboonDomainVersion] = { @@ -357,6 +370,13 @@ package baboon.runtime.shared { case _ => Some(BaboonDomainVersion(domainIdentifier, domainVersionMinCompat)) } } + def versionReadableMin: Option[BaboonDomainVersion] = { + domainVersionReadableMin match { + case v if v.isEmpty => versionMinCompat + case v if v == domainVersion => None + case _ => Some(BaboonDomainVersion(domainIdentifier, domainVersionReadableMin)) + } + } def writeBin(writer: LEDataOutputStream): Unit = { BaboonTypeMetaCodec.writeBin(this, writer) @@ -368,6 +388,20 @@ package baboon.runtime.shared { } object BaboonTypeMeta { + /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ + final val JSON_READABLE_TIER: String = "json-additive" + + /** Five-field form: readable-min defaults to minCompat (no forward-read beyond byte-identity). */ + def apply( + metaVersion: Byte, + domainIdentifier: String, + domainVersion: String, + domainVersionMinCompat: String, + typeIdentifier: String, + ): BaboonTypeMeta = { + BaboonTypeMeta(metaVersion, domainIdentifier, domainVersion, domainVersionMinCompat, typeIdentifier, domainVersionMinCompat) + } + /* Codecs discovery with ADTs check to ensure that ADTs is encoded with a codec type desired by the user. * * - If user is trying to encode ADT branch with the base type we should encode it with ADT meta header: @@ -381,12 +415,17 @@ package baboon.runtime.shared { case _ => value.baboonTypeIdentifier } + val readableMin = value.baboonMinReaderVersions.getOrElse( + JSON_READABLE_TIER, + throw BaboonCodecException.EncoderFailure(s"baboonMinReaderVersions lacks '$JSON_READABLE_TIER' for type ${value.baboonTypeIdentifier}"), + ) new BaboonTypeMeta( META_VERSION, value.baboonDomainIdentifier, value.baboonDomainVersion, value.baboonSameInVersions.head, typeIdentifier, + readableMin, ) } @@ -411,6 +450,7 @@ package baboon.runtime.shared { private val DOMAIN_IDENTIFIER_KEY = "$d" private val DOMAIN_VERSION_KEY = "$v" private val DOMAIN_VERSION_MIN_COMPAT_KEY = "$uv" + private val DOMAIN_VERSION_READABLE_KEY = "$rv" private val TYPE_IDENTIFIER_KEY = "$t" def writeBin(meta: BaboonTypeMeta, writer: LEDataOutputStream): Unit = { @@ -435,9 +475,13 @@ package baboon.runtime.shared { DOMAIN_VERSION_KEY -> Json.fromString(meta.domainVersion), TYPE_IDENTIFIER_KEY -> Json.fromString(meta.typeIdentifier), ) - if (meta.domainVersion != meta.domainVersionMinCompat) { + val withMinCompat = if (meta.domainVersion != meta.domainVersionMinCompat) { json.mapObject(_.add(DOMAIN_VERSION_MIN_COMPAT_KEY, Json.fromString(meta.domainVersionMinCompat))) } else json + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (meta.domainVersionReadableMin.nonEmpty && meta.domainVersionReadableMin != meta.domainVersionMinCompat) { + withMinCompat.mapObject(_.add(DOMAIN_VERSION_READABLE_KEY, Json.fromString(meta.domainVersionReadableMin))) + } else withMinCompat } def readMeta(reader: LEDataInputStream): Option[BaboonTypeMeta] = { @@ -490,12 +534,16 @@ package baboon.runtime.shared { domainVersionMinCompat <- cursor .downField(DOMAIN_VERSION_MIN_COMPAT_KEY) .focus.fold[Either[DecodingFailure, String]](Right(domainVersion))(_.as[String]) + domainVersionReadableMin <- cursor + .downField(DOMAIN_VERSION_READABLE_KEY) + .focus.fold[Either[DecodingFailure, String]](Right(domainVersionMinCompat))(_.as[String]) } yield BaboonTypeMeta( META_VERSION_1, domainIdentifier, domainVersion, domainVersionMinCompat, typeIdentifier, + domainVersionReadableMin, )).toOption } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts index 77ffa635a..d1ead3a09 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts @@ -58,7 +58,23 @@ function right(value: R): BaboonEither { return { tag: "Right", value }; } +/** + * How a reader treats JSON payloads written by a NEWER domain version than it registers. + * - Lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered + * version — the pre-`$rv` behavior. + * - Tolerant: additionally honor `$rv` (json-additive bound): decode with that version's codec, + * silently dropping fields this reader does not know. Re-encoding intermediaries must use + * Lossless or they truncate data for downstream consumers. + */ +export enum ForwardReadPolicy { + Lossless = "lossless", + Tolerant = "tolerant", +} + export class BaboonCodecsFacade { + /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + public forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant; + private readonly versionsCodecsJson: Map> = new Map(); private readonly versionsCodecsBin: Map> = new Map(); private readonly versionsConversions: Map> = new Map(); @@ -565,17 +581,18 @@ export class BaboonCodecsFacade { // ----- private dispatch -------------------------------------------------------------------- private getBinCodec(typeMeta: BaboonTypeMeta, exact: boolean): BaboonEither { - return this.getCodec(this.versionsCodecsBin, typeMeta, exact); + return this.getCodec(this.versionsCodecsBin, typeMeta, exact, false); } private getJsonCodec(typeMeta: BaboonTypeMeta, exact: boolean): BaboonEither { - return this.getCodec(this.versionsCodecsJson, typeMeta, exact); + return this.getCodec(this.versionsCodecsJson, typeMeta, exact, this.forwardReadPolicy === ForwardReadPolicy.Tolerant); } private getCodec( versionsCodecs: Map>, typeMeta: BaboonTypeMeta, exact: boolean, + tolerant: boolean, ): BaboonEither { const versions = this.domainVersions.get(typeMeta.domainIdentifier); if (versions === undefined || versions.length === 0) { @@ -586,9 +603,11 @@ export class BaboonCodecsFacade { const maxVersion = versions[versions.length - 1]!; const lookupVersion = typeMeta.versionRef(); - const minCompat = typeMeta.versionMinCompat(); - const modelVersion = (minCompat !== undefined && lookupVersion.version().compareTo(maxVersion.version()) > 0) - ? minCompat + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + const lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + const modelVersion = (lowerBound !== undefined && lookupVersion.version().compareTo(maxVersion.version()) > 0) + ? lowerBound : lookupVersion; const modelV = modelVersion.version(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 9d8f3cfdc..a04657464 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -646,6 +646,12 @@ export interface BaboonGenerated { * the cursor after decoding. */ baboonForwardReadable(): { readonly [version: string]: string } + /** + * Writer-side inverse of `baboonForwardReadable`: guarantee tier -> oldest domain version + * whose codec can decode THIS version's encoding of this type. The "identical" bound + * equals `baboonSameInVersions()[0]`; the "json-additive" bound is published as `$rv`. + */ + baboonMinReaderVersions(): { readonly [tier: string]: string } baboonTypeIdentifier(): string } @@ -1027,6 +1033,13 @@ export class BaboonTypeMeta { public readonly domainVersion: string; public readonly domainVersionMinCompat: string; public readonly typeIdentifier: string; + /** + * Oldest domain version whose JSON codec can decode the payload under the json-additive + * contract (tolerant key lookup; fields unknown to that version are dropped). Always + * <= domainVersionMinCompat. Published as `$rv` when it differs from the effective + * minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. + */ + public readonly domainVersionReadableMin: string; constructor( metaVersion: number, @@ -1034,14 +1047,18 @@ export class BaboonTypeMeta { domainVersion: string, domainVersionMinCompat: string, typeIdentifier: string, + domainVersionReadableMin: string = domainVersionMinCompat, ) { this.metaVersion = metaVersion; this.domainIdentifier = domainIdentifier; this.domainVersion = domainVersion; this.domainVersionMinCompat = domainVersionMinCompat; this.typeIdentifier = typeIdentifier; + this.domainVersionReadableMin = domainVersionReadableMin; } + public static readonly JSON_READABLE_TIER = "json-additive"; + public versionRef(): BaboonDomainVersion { return new BaboonDomainVersion(this.domainIdentifier, this.domainVersion); } @@ -1059,6 +1076,16 @@ export class BaboonTypeMeta { return new BaboonDomainVersion(this.domainIdentifier, this.domainVersionMinCompat); } + public versionReadableMin(): BaboonDomainVersion | undefined { + if (!this.domainVersionReadableMin) { + return this.versionMinCompat(); + } + if (this.domainVersionReadableMin === this.domainVersion) { + return undefined; + } + return new BaboonDomainVersion(this.domainIdentifier, this.domainVersionReadableMin); + } + public writeBin(writer: BaboonBinWriter): void { BaboonTypeMetaCodec.writeBin(this, writer); } @@ -1091,12 +1118,19 @@ export class BaboonTypeMeta { `baboonSameInVersions() is empty for type ${value.baboonTypeIdentifier()}`, ); } + const readableMin = value.baboonMinReaderVersions()[BaboonTypeMeta.JSON_READABLE_TIER]; + if (readableMin === undefined) { + throw new BaboonException( + `baboonMinReaderVersions() lacks '${BaboonTypeMeta.JSON_READABLE_TIER}' for type ${value.baboonTypeIdentifier()}`, + ); + } return new BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, value.baboonDomainIdentifier(), value.baboonDomainVersion(), sameIn[0]!, typeIdentifier, + readableMin, ); } @@ -1121,6 +1155,7 @@ export class BaboonTypeMeta { && this.domainIdentifier === other.domainIdentifier && this.domainVersion === other.domainVersion && this.domainVersionMinCompat === other.domainVersionMinCompat + && this.domainVersionReadableMin === other.domainVersionReadableMin && this.typeIdentifier === other.typeIdentifier; } } @@ -1133,6 +1168,7 @@ export class BaboonTypeMetaCodec { public static readonly DOMAIN_IDENTIFIER_KEY = "$d"; public static readonly DOMAIN_VERSION_KEY = "$v"; public static readonly DOMAIN_VERSION_MIN_COMPAT_KEY = "$uv"; + public static readonly DOMAIN_VERSION_READABLE_KEY = "$rv"; public static readonly TYPE_IDENTIFIER_KEY = "$t"; public static writeBin(meta: BaboonTypeMeta, writer: BaboonBinWriter): void { @@ -1178,6 +1214,10 @@ export class BaboonTypeMetaCodec { if (meta.domainVersion !== meta.domainVersionMinCompat) { obj[BaboonTypeMetaCodec.DOMAIN_VERSION_MIN_COMPAT_KEY] = meta.domainVersionMinCompat; } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (meta.domainVersionReadableMin && meta.domainVersionReadableMin !== meta.domainVersionMinCompat) { + obj[BaboonTypeMetaCodec.DOMAIN_VERSION_READABLE_KEY] = meta.domainVersionReadableMin; + } return obj; } @@ -1214,8 +1254,10 @@ export class BaboonTypeMetaCodec { const uvNode = obj[BaboonTypeMetaCodec.DOMAIN_VERSION_MIN_COMPAT_KEY]; const uv = typeof uvNode === "string" ? uvNode : v; + const rvNode = obj[BaboonTypeMetaCodec.DOMAIN_VERSION_READABLE_KEY]; + const rv = typeof rvNode === "string" ? rvNode : uv; - return new BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t); + return new BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t, rv); } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala index 48ce12cff..bfbfe0009 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala @@ -90,7 +90,15 @@ object ScDomainTreeTools { q"${ScTypes.scMap}(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - List(sameInVersion, forwardReadable) + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + case (tier, v) => s""""${tier.wireName}" -> "${v.v.toString}"""" + } + val minReaders = MetaField( + q"def baboonMinReaderVersions: ${ScTypes.scMap}[$scString, $scString]", + q"${ScTypes.scMap}(${minReaderEntries.mkString(", ")})", + q"$ref.baboonMinReaderVersions", + ) + List(sameInVersion, forwardReadable, minReaders) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala index 66fca4924..66b9d8248 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala @@ -31,10 +31,15 @@ object TsDomainTreeTools { val forward = evolution.typesForwardReadable(domain.version)(defn.id) val forwardLiteral = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString("{ ", ", ", " }") + val minReaderLiteral = evolution + .minReaders(domain.version, defn.id).toList.sortBy(_._1.weight) + .map { case (tier, v) => s""""${tier.wireName}": "${v.v.toString}"""" } + .mkString("{ ", ", ", " }") if (defn.isAdt) { List( q"BaboonSameInVersions: [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]", q"BaboonForwardReadable: $forwardLiteral as { readonly [version: string]: string }", + q"BaboonMinReaderVersions: $minReaderLiteral as { readonly [tier: string]: string }", ) } else { List( @@ -45,6 +50,10 @@ object TsDomainTreeTools { |public static readonly BaboonForwardReadable: { readonly [version: string]: string } = $forwardLiteral |public baboonForwardReadable() { | return $source.BaboonForwardReadable + |} + |public static readonly BaboonMinReaderVersions: { readonly [tier: string]: string } = $minReaderLiteral + |public baboonMinReaderVersions() { + | return $source.BaboonMinReaderVersions |}""".stripMargin ) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala index 6794966c7..6deea1a58 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala @@ -10,6 +10,26 @@ case class BaboonEvolution( typesUnchangedSince: Map[Version, Map[TypeId, UnmodifiedSince]], typesForwardReadable: Map[Version, Map[TypeId, ForwardReadable]], ) { + /** Inverse of [[typesForwardReadable]], from the WRITER's point of view: for a type + * as encoded by `version`, the oldest reader version that can decode it at each + * tier or better. Readers form a contiguous range per tier (a shorter chain is a + * sub-chain), so one lower bound per tier is exact. The `Identical` bound equals + * the type's `sameIn` head. + */ + def minReaders(version: Version, id: TypeId): Map[ForwardCompatTier, Version] = { + val readers = typesForwardReadable.toList.collect { + case (readerVersion, types) if readerVersion <= version => + types.get(id).flatMap(_.tierFor(version)).map(tier => (readerVersion, tier)) + }.flatten + + ForwardCompatTier.all.flatMap { + tier => + readers + .collect { case (readerVersion, readerTier) if readerTier.weight >= tier.weight => readerVersion } + .minOption(Version.ordering) + .map(v => (tier, v)) + }.toMap + } override def toString: String = { diffs.map { case (v, diff) => diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 55f5f7cac..cb40bda9f 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -78,6 +78,29 @@ These are usage-site properties the compiler cannot check. `identical` and - Generated domain metadata registry: `forwardReadableVersions(typeId)` next to `sameInVersions(typeId)`. +## Envelope integration (JSON) + +The facade envelope publishes the JSON bound as `$rv` (`domainVersionReadableMin`): +the oldest domain version whose JSON codec can decode the payload under the +`json-additive` contract. It is the writer-side inverse of the forward +ranges — generated types carry `baboonMinReaderVersions` (tier → oldest reader +version; the `identical` bound equals `baboonSameInVersions.head`, the +`json-additive` bound feeds `$rv`). `$rv` is elided when equal to the effective +`$uv`, so envelopes of unchanged types are byte-identical to before; invariant +`$rv <= $uv <= $v`. Old readers ignore the key. + +Readers choose via `ForwardReadPolicy`: + +- `Tolerant` (default): when `$v` is newer than every registered version, + resolve the codec from `$rv` (falling back to `$uv`) — the payload is decoded + with that version's codec and fields unknown to it are dropped. +- `Lossless`: ignore `$rv`; resolve from `$uv` only (pre-`$rv` behaviour). + **Re-encoding intermediaries must use this**, otherwise they truncate data + for downstream consumers that do understand the newer version. + +The binary v1 envelope carries no readable-min bound; UEBA resolution is always +lossless. Spec: `docs/spec/codec-envelope.md`. + ## Relationship to sameIn `identical` forward tiers imply membership in the `sameIn` run. Historically diff --git a/docs/json-codecs.md b/docs/json-codecs.md index 075a01f30..042d001c8 100644 --- a/docs/json-codecs.md +++ b/docs/json-codecs.md @@ -86,7 +86,7 @@ Shipped: M24 / PR-I.1a (Scala reference) → PR-I.1b (Java/Kotlin/KMP) → PR-I. ## Any fields > **Note:** the field-level `AnyMeta` envelope below is distinct from the -> top-level `BaboonTypeMeta` envelope (the `{$mv,$d,$v,$t,$uv,$c}` block +> top-level `BaboonTypeMeta` envelope (the `{$mv,$d,$v,$t,$uv,$rv,$c}` block > that wraps every value emitted through `BaboonCodecsFacade.encodeToJson`). > The top-level envelope is specified in > [`docs/spec/codec-envelope.md`](spec/codec-envelope.md). diff --git a/docs/spec/codec-envelope.md b/docs/spec/codec-envelope.md index 4956f1989..0579c66c1 100644 --- a/docs/spec/codec-envelope.md +++ b/docs/spec/codec-envelope.md @@ -27,13 +27,23 @@ Field summary: | `metaVersion` | u8 | Format version of the envelope itself. See § 3. | | `domainIdentifier` | string | Dotted namespace of the domain, e.g. `"my.ok"`. | | `domainVersion` | string | Semver of the encoded value's domain, e.g. `"1.0.0"`.| -| `domainVersionMinCompat` | string | Oldest domain version that decodes the same payload structurally. Equals `domainVersion` when the type is fresh in this version. | +| `domainVersionMinCompat` | string | Oldest domain version that decodes the same payload structurally (byte-identical bound). Equals `domainVersion` when the type is fresh in this version. | +| `domainVersionReadableMin` | string | **JSON only.** Oldest domain version whose JSON codec can decode the payload under the `json-additive` contract (tolerant key lookup; fields unknown to that version are dropped). Always `<= domainVersionMinCompat`. See `docs/forward-compat.md`. | | `typeIdentifier` | string | Type id within the domain, e.g. `"my.ok/:#Holder"`. | `domainVersionMinCompat` may be elided in either encoding when it equals `domainVersion` (the common case for newly-introduced types). Readers must default a missing `domainVersionMinCompat` to `domainVersion`. +`domainVersionReadableMin` is carried by the JSON envelope only (`$rv`) and is +elided when it equals the effective `domainVersionMinCompat`; readers default +it to `domainVersionMinCompat`. Invariant: `$rv <= $uv <= $v`. The binary v1 +envelope has no slot for it; readers of binary envelopes resolve losslessly +via `domainVersionMinCompat` alone. Whether a JSON reader honours `$rv` is a +reader-side policy (`ForwardReadPolicy`: `Tolerant`, the default, decodes +newer payloads with the `$rv` version's codec and drops unknown fields; +`Lossless` ignores `$rv`). Re-encoding intermediaries must use `Lossless`. + ## 2. Wire formats ### 2.1 Binary (UEBA) @@ -88,12 +98,13 @@ key order. The encoder emits them in declaration order for determinism: "$v": "1.0.0", // domainVersion "$t": "my.ok/:#Holder", // typeIdentifier "$uv": "1.0.0", // domainVersionMinCompat — OMITTED when equal to $v + "$rv": "1.0.0", // domainVersionReadableMin — OMITTED when equal to the effective $uv "$c": { ... } // content (the encoded value's JSON form) } ``` -The `$c` key carries the actual encoded payload. The other five keys are the -envelope. Implementations distinguish envelope keys (`$d`/`$v`/`$t`/`$uv`/ +The `$c` key carries the actual encoded payload. The other six keys are the +envelope. Implementations distinguish envelope keys (`$d`/`$v`/`$t`/`$uv`/`$rv`/ `$mv`) from payload by name; the dollar-prefix is reserved for the envelope and the field-level `AnyMeta` (`$ak`/`$ad`/`$av`/`$at`/`$av`-content), and must not be used by user-defined field names. diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala new file mode 100644 index 000000000..fc44379b4 --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala @@ -0,0 +1,102 @@ +// NOTE: This test references generated symbols (fwde2e.fwd.*, fwde2e.fwd.v1_0_0.*) +// which are copied into this stub only by `mdl :build :test-gen-regular-adt` +// (rsync + codegen into target/test-regular/sc-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the JSON envelope's `$rv` (readable-min) bound +// (docs/forward-compat.md, "Envelope integration"): a facade that registers ONLY +// the old 1.0.0 domain version decodes envelopes produced by the 2.0.0 facade +// exactly where `$rv` says it can, under the Tolerant policy — and refuses under +// Lossless or when no `$rv` was published. +package runtime + +import baboon.runtime.shared._ +import io.circe.Json +import org.scalatest.funsuite.AnyFunSuite + +@scala.annotation.nowarn("cat=deprecation") +class ForwardCompatEnvelopeSpec extends AnyFunSuite { + + // a reader deployed before 2.0.0 existed: knows fwde2e.fwd 1.0.0 only + private def oldReader(policy: ForwardReadPolicy): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register( + BaboonDomainVersion("fwde2e.fwd", "1.0.0"), + fwde2e.fwd.v1_0_0.BaboonCodecsJson, + fwde2e.fwd.v1_0_0.BaboonCodecsUeba, + fwde2e.fwd.v1_0_0.BaboonMetadata, + ) + f.forwardReadPolicy = policy + f + } + + private val writer: BaboonCodecsFacade = fwde2e.fwd.DomainFwde2eFwdFacade + + private def field(json: Json, key: String): Option[String] = json.hcursor.downField(key).as[String].toOption + + test("writer publishes $rv only where the json-additive bound is older than the identical bound") { + val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + assert(field(appended, "$v").contains("2.0.0")) + assert(field(appended, "$uv").isEmpty, "identical-min equals $v and is elided") + assert(field(appended, "$rv").contains("1.0.0")) + + val inserted = writer.encodeToJson(fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get + assert(field(inserted, "$rv").contains("1.0.0")) + + // unchanged type: byte-identical back to 1.0.0, so $uv carries the bound and $rv is elided + val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + assert(field(stable, "$uv").contains("1.0.0")) + assert(field(stable, "$rv").isEmpty) + + // grown enum: not forward-readable at all, no bounds published + val enumHost = writer.encodeToJson(fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + assert(field(enumHost, "$uv").isEmpty) + assert(field(enumHost, "$rv").isEmpty) + + // generated metadata backing the envelope: identical bound == sameIn head + assert(fwde2e.fwd.FwdAppendVar.baboonMinReaderVersions("identical") == fwde2e.fwd.FwdAppendVar.baboonSameInVersions.head) + assert(fwde2e.fwd.FwdAppendVar.baboonMinReaderVersions("json-additive") == "1.0.0") + } + + test("Tolerant old reader decodes newer json-additive payloads with its own codec, dropping unknown fields") { + val reader = oldReader(ForwardReadPolicy.Tolerant) + val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + val decoded = reader.decodeFromJson(appended).toTry.get + assert(decoded.contains(fwde2e.fwd.v1_0_0.FwdAppendVar(42, "hello"))) + + val inserted = writer.encodeToJson(fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get + assert(reader.decodeFromJson(inserted).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdMidInsert(7, "zed"))) + + // byte-identical types keep working as before ($uv path) + val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + assert(reader.decodeFromJson(stable).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdStable("same"))) + } + + test("Lossless old reader refuses newer payloads unless byte-identical") { + val reader = oldReader(ForwardReadPolicy.Lossless) + val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + // the facade reports "no codec resolves" as Right(None) (Option contract), not Left + assert(reader.decodeFromJson(appended).toTry.get.isEmpty, "Lossless must not honour $rv") + + val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + assert(reader.decodeFromJson(stable).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdStable("same"))) + } + + test("no published bound: the old reader refuses under either policy") { + val enumHost = writer.encodeToJson(fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromJson(enumHost).toTry.get.isEmpty) + assert(oldReader(ForwardReadPolicy.Lossless).decodeFromJson(enumHost).toTry.get.isEmpty) + } + + test("envelope round-trips $rv through readMeta") { + val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(1, "b", Some("t"))).toTry.get + val meta = BaboonTypeMeta.readMeta(appended).get + assert(meta.domainVersion == "2.0.0") + assert(meta.domainVersionMinCompat == "2.0.0") + assert(meta.domainVersionReadableMin == "1.0.0") + assert(meta.versionReadableMin.contains(BaboonDomainVersion("fwde2e.fwd", "1.0.0"))) + assert(meta.versionMinCompat.isEmpty) + // and the five-field constructor keeps readable-min = minCompat (pre-$rv envelopes) + val legacy = BaboonTypeMeta(1.toByte, "fwde2e.fwd", "2.0.0", "1.5.0", "T") + assert(legacy.domainVersionReadableMin == "1.5.0") + } +} diff --git a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts index aa33ff1bf..cdc28242b 100644 --- a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts +++ b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts @@ -387,6 +387,7 @@ class StubGenerated implements BaboonGenerated { public baboonDomainIdentifier(): string { return "dom"; } public baboonSameInVersions(): string[] { return ["1.0.0"]; } public baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } + public baboonMinReaderVersions(): { readonly [tier: string]: string } { return { "identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0" }; } public baboonTypeIdentifier(): string { return "T"; } } @@ -451,6 +452,7 @@ class StubAdtBranchGenerated implements BaboonGenerated { public baboonDomainIdentifier(): string { return "dom"; } public baboonSameInVersions(): string[] { return ["1.0.0"]; } public baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } + public baboonMinReaderVersions(): { readonly [tier: string]: string } { return { "identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0" }; } public baboonTypeIdentifier(): string { return "BranchT"; } } diff --git a/test/ts-stub/src/runtime-tests/BaboonExt.test.ts b/test/ts-stub/src/runtime-tests/BaboonExt.test.ts index 99b3027cc..7fd7f5ca2 100644 --- a/test/ts-stub/src/runtime-tests/BaboonExt.test.ts +++ b/test/ts-stub/src/runtime-tests/BaboonExt.test.ts @@ -25,6 +25,7 @@ class FakeInner implements BaboonGenerated { baboonDomainIdentifier(): string { return "my.ok"; } baboonSameInVersions(): string[] { return ["1.0.0"]; } baboonForwardReadable(): { readonly [version: string]: string } { return { "1.0.0": "identical" }; } + baboonMinReaderVersions(): { readonly [tier: string]: string } { return { "identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0" }; } baboonTypeIdentifier(): string { return "my.ok/:#Inner"; } } @@ -34,6 +35,7 @@ class FakeEvolved implements BaboonGenerated { // Stable since 1.0.0 despite domain now being at 3.0.0. baboonSameInVersions(): string[] { return ["1.0.0", "2.0.0", "3.0.0"]; } baboonForwardReadable(): { readonly [version: string]: string } { return { "3.0.0": "identical" }; } + baboonMinReaderVersions(): { readonly [tier: string]: string } { return { "identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0" }; } baboonTypeIdentifier(): string { return "my.ok/:#Evolved"; } } @@ -42,6 +44,7 @@ class FakeEmptySameIn implements BaboonGenerated { baboonDomainIdentifier(): string { return "my.ok"; } baboonSameInVersions(): string[] { return []; } baboonForwardReadable(): { readonly [version: string]: string } { return {}; } + baboonMinReaderVersions(): { readonly [tier: string]: string } { return {}; } baboonTypeIdentifier(): string { return "my.ok/:#Empty"; } } diff --git a/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts b/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts new file mode 100644 index 000000000..dccc1857f --- /dev/null +++ b/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts @@ -0,0 +1,149 @@ +// NOTE: This test references generated symbols (src/generated/fwde2e/**) which are +// copied into this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen +// into target/test-regular/ts-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the JSON envelope's `$rv` (readable-min) bound +// (docs/forward-compat.md, "Envelope integration"): a facade that registers ONLY the +// old 1.0.0 domain version decodes envelopes produced by the 2.0.0 facade exactly where +// `$rv` says it can, under the Tolerant policy — and refuses under Lossless or when no +// `$rv` was published. +import { describe, expect, test } from 'vitest'; +import { + AbstractBaboonConversions, + AbstractBaboonJsonCodecs, + AbstractBaboonUebaCodecs, + BaboonBinCodec, + BaboonDomainVersion, + BaboonEither, + BaboonJsonCodec, + BaboonMeta, + BaboonTypeMeta, + Lazy, +} from '../generated/BaboonSharedRuntime'; +import { BaboonCodecsFacade, ForwardReadPolicy } from '../generated/BaboonCodecsFacade'; +import { DomainFwde2eFwdFacade } from '../generated/fwde2e/fwd/DomainFwde2eFwdFacade'; + +import { FwdAppendVar, FwdEnumHost, FwdMidInsert, FwdStable } from '../generated/fwde2e/fwd/index'; +import { FwdEnumGrows } from '../generated/fwde2e/fwd/FwdEnumGrows'; + +import { FwdAppendVar as FwdAppendVarV1, FwdAppendVar_JsonCodec as FwdAppendVarV1_JsonCodec, FwdAppendVar_UEBACodec as FwdAppendVarV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdAppendVar'; +import { FwdMidInsert as FwdMidInsertV1, FwdMidInsert_JsonCodec as FwdMidInsertV1_JsonCodec, FwdMidInsert_UEBACodec as FwdMidInsertV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdMidInsert'; +import { FwdStable as FwdStableV1, FwdStable_JsonCodec as FwdStableV1_JsonCodec, FwdStable_UEBACodec as FwdStableV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdStable'; +import { FwdEnumHost as FwdEnumHostV1, FwdEnumHost_JsonCodec as FwdEnumHostV1_JsonCodec, FwdEnumHost_UEBACodec as FwdEnumHostV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdEnumHost'; + +const DOMAIN = 'fwde2e.fwd'; +const V1 = '1.0.0'; + +// The generated per-version codec registries are module-private, so the old reader is +// assembled from the exported per-type 1.0.0 codecs (mirrors AnyRoundTrip.test.ts). +class V1JsonCodecs extends AbstractBaboonJsonCodecs { + constructor() { + super(); + this.register(FwdAppendVarV1.BaboonTypeIdentifier, new Lazy>(() => FwdAppendVarV1_JsonCodec.instance as unknown as BaboonJsonCodec)); + this.register(FwdMidInsertV1.BaboonTypeIdentifier, new Lazy>(() => FwdMidInsertV1_JsonCodec.instance as unknown as BaboonJsonCodec)); + this.register(FwdStableV1.BaboonTypeIdentifier, new Lazy>(() => FwdStableV1_JsonCodec.instance as unknown as BaboonJsonCodec)); + this.register(FwdEnumHostV1.BaboonTypeIdentifier, new Lazy>(() => FwdEnumHostV1_JsonCodec.instance as unknown as BaboonJsonCodec)); + } +} + +class V1UebaCodecs extends AbstractBaboonUebaCodecs { + constructor() { + super(); + this.register(FwdAppendVarV1.BaboonTypeIdentifier, new Lazy>(() => FwdAppendVarV1_UEBACodec.instance as unknown as BaboonBinCodec)); + this.register(FwdMidInsertV1.BaboonTypeIdentifier, new Lazy>(() => FwdMidInsertV1_UEBACodec.instance as unknown as BaboonBinCodec)); + this.register(FwdStableV1.BaboonTypeIdentifier, new Lazy>(() => FwdStableV1_UEBACodec.instance as unknown as BaboonBinCodec)); + this.register(FwdEnumHostV1.BaboonTypeIdentifier, new Lazy>(() => FwdEnumHostV1_UEBACodec.instance as unknown as BaboonBinCodec)); + } +} + +class V1Conversions implements AbstractBaboonConversions { + public versionsFrom(): string[] { return []; } + public versionTo(): string { return V1; } +} + +class V1Meta implements BaboonMeta { + public sameInVersions(_typeId: string): string[] { return [V1]; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { [V1]: 'identical' }; } +} + +// a reader deployed before 2.0.0 existed: knows fwde2e.fwd 1.0.0 only +function oldReader(policy: ForwardReadPolicy): BaboonCodecsFacade { + const f = new BaboonCodecsFacade(); + f.register(new BaboonDomainVersion(DOMAIN, V1), () => new V1JsonCodecs(), () => new V1UebaCodecs(), () => new V1Conversions(), () => new V1Meta()); + f.forwardReadPolicy = policy; + return f; +} + +const writer = new DomainFwde2eFwdFacade(); + +function unwrap(e: BaboonEither): R { + if (e.tag !== 'Right') throw new Error(`expected Right, got Left: ${String((e as { value: unknown }).value)}`); + return e.value; +} + +function envelope(value: FwdAppendVar | FwdMidInsert | FwdStable | FwdEnumHost): Record { + return unwrap(writer.encodeToJson(value)) as Record; +} + +describe('JSON envelope $rv (fwde2e.fwd)', () => { + test('writer publishes $rv only where the json-additive bound is older than the identical bound', () => { + const appended = envelope(new FwdAppendVar(42, 'hello', 'appended')); + expect(appended['$v']).toBe('2.0.0'); + expect(appended['$uv']).toBeUndefined(); + expect(appended['$rv']).toBe('1.0.0'); + + expect(envelope(new FwdMidInsert(7, 99, 'zed'))['$rv']).toBe('1.0.0'); + + const stable = envelope(new FwdStable('same')); + expect(stable['$uv']).toBe('1.0.0'); + expect(stable['$rv']).toBeUndefined(); + + const enumHost = envelope(new FwdEnumHost(FwdEnumGrows.C)); + expect(enumHost['$uv']).toBeUndefined(); + expect(enumHost['$rv']).toBeUndefined(); + + expect(FwdAppendVar.BaboonMinReaderVersions['identical']).toBe(FwdAppendVar.BaboonSameInVersions[0]); + expect(FwdAppendVar.BaboonMinReaderVersions['json-additive']).toBe('1.0.0'); + }); + + test('Tolerant old reader decodes newer json-additive payloads with its own codec, dropping unknown fields', () => { + const reader = oldReader(ForwardReadPolicy.Tolerant); + + const appended = unwrap(reader.decodeFromJson(envelope(new FwdAppendVar(42, 'hello', 'appended')))) as FwdAppendVarV1; + expect(appended).toBeInstanceOf(FwdAppendVarV1); + expect(appended.a).toBe(42); + expect(appended.b).toBe('hello'); + + const inserted = unwrap(reader.decodeFromJson(envelope(new FwdMidInsert(7, 99, 'zed')))) as FwdMidInsertV1; + expect(inserted.a).toBe(7); + expect(inserted.z).toBe('zed'); + + const stable = unwrap(reader.decodeFromJson(envelope(new FwdStable('same')))) as FwdStableV1; + expect(stable.s).toBe('same'); + }); + + test('Lossless old reader refuses newer payloads unless byte-identical', () => { + const reader = oldReader(ForwardReadPolicy.Lossless); + expect(reader.decodeFromJson(envelope(new FwdAppendVar(42, 'hello', 'appended'))).tag).toBe('Left'); + const stable = unwrap(reader.decodeFromJson(envelope(new FwdStable('same')))) as FwdStableV1; + expect(stable.s).toBe('same'); + }); + + test('no published bound: the old reader refuses under either policy', () => { + const enumHost = envelope(new FwdEnumHost(FwdEnumGrows.C)); + expect(oldReader(ForwardReadPolicy.Tolerant).decodeFromJson(enumHost).tag).toBe('Left'); + expect(oldReader(ForwardReadPolicy.Lossless).decodeFromJson(enumHost).tag).toBe('Left'); + }); + + test('envelope round-trips $rv through readMetaJson', () => { + const meta = BaboonTypeMeta.readMetaJson(envelope(new FwdAppendVar(1, 'b', 't')))!; + expect(meta.domainVersion).toBe('2.0.0'); + expect(meta.domainVersionMinCompat).toBe('2.0.0'); + expect(meta.domainVersionReadableMin).toBe('1.0.0'); + expect(meta.versionReadableMin()?.domainVersion).toBe('1.0.0'); + expect(meta.versionMinCompat()).toBeUndefined(); + // five-argument construction keeps readable-min = minCompat (pre-$rv envelopes) + const legacy = new BaboonTypeMeta(1, DOMAIN, '2.0.0', '1.5.0', 'T'); + expect(legacy.domainVersionReadableMin).toBe('1.5.0'); + }); +}); From e7bba6c7d98881ebb2a2abb40074cbc85e5ea930 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 14 Sep 2026 14:19:09 +0100 Subject: [PATCH 06/48] feat(envelope): $rv readable-min bound + ForwardReadPolicy across all 9 backends Every runtime now carries BaboonTypeMeta.domainVersionReadableMin (five-field construction keeps readable-min = minCompat), writes/reads the JSON envelope key $rv (elided when equal to the effective $uv), exposes the generated per-type baboonMinReaderVersions (tier -> oldest reader) that feeds it, and resolves JSON codecs under a facade-level ForwardReadPolicy (Tolerant default: honour $rv for payloads newer than any registered version; Lossless: $uv only). UEBA resolution is unchanged (v1 envelope has no slot for the bound). Codegen: baboonMinReaderVersions emitted by all 9 DomainTreeTools / the Rust facade generator. Dart test fakes gain the new provider getter. Co-Authored-By: Claude Fable 5.1 --- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 31 ++++-- .../baboon-runtime/cs/BaboonRuntimeShared.cs | 7 ++ .../baboon-runtime/cs/BaboonTypeMeta.cs | 42 +++++++- .../dart/baboon_codecs_facade.dart | 23 +++- .../baboon-runtime/dart/baboon_runtime.dart | 44 +++++++- .../java/BaboonCodecsFacade.java | 31 ++++-- .../baboon-runtime/java/BaboonTypeMeta.java | 48 ++++++++- .../kotlin-kmp/BaboonCodecsFacade.kt | 29 ++++- .../kotlin-kmp/BaboonRuntimeShared.kt | 27 +++++ .../kotlin/BaboonCodecsFacade.kt | 29 ++++- .../kotlin/BaboonRuntimeShared.kt | 27 +++++ .../python/baboon_codecs_facade.py | 30 +++++- .../python/baboon_runtime_shared.py | 46 +++++++- .../rust/baboon_codecs_facade.rs | 102 +++++++++++++++--- .../swift/BaboonCodecsFacade.swift | 22 +++- .../baboon-runtime/swift/baboon_runtime.swift | 37 ++++++- .../translator/csharp/CSDomainTreeTools.scala | 6 ++ .../translator/dart/DtDefnTranslator.scala | 2 + .../translator/dart/DtDomainTreeTools.scala | 13 ++- .../translator/java/JvDomainTreeTools.scala | 10 +- .../translator/kotlin/KtDomainTreeTools.scala | 10 +- .../translator/python/PyDomainTreeTools.scala | 3 + .../translator/rust/RsBaboonTranslator.scala | 9 ++ .../translator/swift/SwDefnTranslator.scala | 3 + .../translator/swift/SwDomainTreeTools.scala | 12 ++- .../test/runtime/any_meta_codec_test.dart | 6 ++ .../dt-stub/test/runtime/baboon_ext_test.dart | 2 + 27 files changed, 585 insertions(+), 66 deletions(-) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index 079cf9640..6e1f280eb 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -23,10 +23,26 @@ namespace Baboon.Runtime.Shared { + /// + /// How a reader treats JSON payloads written by a NEWER domain version than it registers. + /// Lossless: decode only when the envelope's $uv (byte-identical bound) reaches a registered + /// version — the pre-$rv behavior. Tolerant: additionally honor $rv (json-additive + /// bound): decode with that version's codec, silently dropping fields this reader does not know. + /// Re-encoding intermediaries must use Lossless or they truncate data for downstream consumers. + /// + public enum ForwardReadPolicy + { + Lossless, + Tolerant, + } + public class BaboonCodecsFacade { private const string CONTENT_JSON_KEY = "$c"; + /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + public ForwardReadPolicy ForwardReadPolicy { get; set; } = ForwardReadPolicy.Tolerant; + private readonly ConcurrentDictionary> _versionsCodecsJson = new(); private readonly ConcurrentDictionary> _versionsCodecsBin = new(); private readonly ConcurrentDictionary> _versionsConversions = new(); @@ -966,7 +982,7 @@ private static bool IsAdtConversion(IBaboonGenerated instance, IConversion conve private Either> GetBinCodec(BaboonTypeMeta typeMeta, bool exact) { - var codec = GetCodec(_versionsCodecsBin, typeMeta, exact); + var codec = GetCodec(_versionsCodecsBin, typeMeta, exact, tolerant: false); if (codec.IsLeft) { return Either.Left>( @@ -986,7 +1002,7 @@ private Either> GetJsonCodec(BaboonTypeMeta typeMeta, bool exact) { - var codec = GetCodec(_versionsCodecsJson, typeMeta, exact); + var codec = GetCodec(_versionsCodecsJson, typeMeta, exact, tolerant: ForwardReadPolicy == ForwardReadPolicy.Tolerant); if (codec.IsLeft) { return Either.Left>( @@ -1007,7 +1023,8 @@ private Either private Either GetCodec( ConcurrentDictionary> versionsCodecs, BaboonTypeMeta typeMeta, - bool exact + bool exact, + bool tolerant ) where TCodecs : AbstractBaboonCodecs { @@ -1021,10 +1038,12 @@ bool exact BaboonDomainVersion modelVersion; var lookupVersion = typeMeta.VersionRef; - var minCompat = typeMeta.VersionMinCompat; - if (minCompat is not null && lookupVersion.Version > maxVersion.Version) + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + var lowerBound = tolerant ? typeMeta.VersionReadableMin : typeMeta.VersionMinCompat; + if (lowerBound is not null && lookupVersion.Version > maxVersion.Version) { - modelVersion = minCompat; + modelVersion = lowerBound; } else { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs index 73029a57b..8357240b6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonRuntimeShared.cs @@ -60,6 +60,13 @@ public interface IBaboonGenerated /// the cursor after decoding. /// public IReadOnlyDictionary BaboonForwardReadable(); + + /// + /// Writer-side inverse of : guarantee tier -> oldest domain + /// version whose codec can decode THIS version's encoding of this type. The "identical" bound + /// equals BaboonSameInVersions()[0]; the "json-additive" bound is published as $rv. + /// + public IReadOnlyDictionary BaboonMinReaderVersions(); public string BaboonTypeIdentifier(); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs index 45717d2da..7289df605 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs @@ -69,11 +69,35 @@ public sealed record BaboonTypeMeta( string DomainIdentifier, string DomainVersion, string DomainVersionMinCompat, - string TypeIdentifier + string TypeIdentifier, + // Oldest domain version whose JSON codec can decode the payload under the json-additive + // contract (tolerant key lookup; fields unknown to that version are dropped). Never newer + // than DomainVersionMinCompat. Published as `$rv` when it differs from the (effective) + // minCompat; the binary v1 envelope does not carry it. + string DomainVersionReadableMin ) { + /// Five-field form: readable-min defaults to minCompat (no forward-read beyond byte-identity). + public BaboonTypeMeta(byte MetaVersion, string DomainIdentifier, string DomainVersion, string DomainVersionMinCompat, string TypeIdentifier) + : this(MetaVersion, DomainIdentifier, DomainVersion, DomainVersionMinCompat, TypeIdentifier, DomainVersionMinCompat) + { + } + + /// Tier key of the JSON envelope's readable-min bound in BaboonMinReaderVersions(). + public const string JSON_READABLE_TIER = "json-additive"; + public BaboonDomainVersion VersionRef => new BaboonDomainVersion(DomainIdentifier, DomainVersion); + public BaboonDomainVersion? VersionReadableMin + { + get + { + if (string.IsNullOrEmpty(DomainVersionReadableMin)) return VersionMinCompat; + if (DomainVersionReadableMin == DomainVersion) return null; + return new BaboonDomainVersion(DomainIdentifier, DomainVersionReadableMin); + } + } + public BaboonDomainVersion? VersionMinCompat { get @@ -115,13 +139,18 @@ public static BaboonTypeMeta From(IBaboonGenerated value, Type? declaredType) // is always non-empty. Index directly so a violation throws IndexOutOfRangeException // rather than silently masquerading as a same-version meta. var minCompat = value.BaboonSameInVersions()[0]; + if (!value.BaboonMinReaderVersions().TryGetValue(JSON_READABLE_TIER, out var readableMin)) + { + throw new InvalidOperationException($"BaboonMinReaderVersions() lacks '{JSON_READABLE_TIER}' for type {value.BaboonTypeIdentifier()}"); + } return new BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, value.BaboonDomainIdentifier(), value.BaboonDomainVersion(), minCompat, - typeIdentifier + typeIdentifier, + readableMin ); } @@ -139,6 +168,7 @@ public static class BaboonTypeMetaCodec public const string DOMAIN_IDENTIFIER_KEY = "$d"; public const string DOMAIN_VERSION_KEY = "$v"; public const string DOMAIN_VERSION_MIN_COMPAT_KEY = "$uv"; + public const string DOMAIN_VERSION_READABLE_KEY = "$rv"; public const string TYPE_IDENTIFIER_KEY = "$t"; public static void WriteBin(BaboonTypeMeta meta, BinaryWriter writer) @@ -173,6 +203,11 @@ public static JToken WriteJson(BaboonTypeMeta meta) { obj[DOMAIN_VERSION_MIN_COMPAT_KEY] = meta.DomainVersionMinCompat; } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (!string.IsNullOrEmpty(meta.DomainVersionReadableMin) && meta.DomainVersionReadableMin != meta.DomainVersionMinCompat) + { + obj[DOMAIN_VERSION_READABLE_KEY] = meta.DomainVersionReadableMin; + } return obj; } @@ -232,8 +267,9 @@ public static JToken WriteJson(BaboonTypeMeta meta) if (d is null || v is null || t is null) return null; var uv = obj[DOMAIN_VERSION_MIN_COMPAT_KEY]?.Value() ?? v; + var rv = obj[DOMAIN_VERSION_READABLE_KEY]?.Value() ?? uv; - return new BaboonTypeMeta(META_VERSION, d, v, uv, t); + return new BaboonTypeMeta(META_VERSION, d, v, uv, t, rv); } } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart index 4a80c17a2..1239eaf9b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart @@ -29,7 +29,17 @@ import 'baboon_runtime.dart'; const String _CONTENT_JSON_KEY = r'$c'; +/// How a reader treats JSON payloads written by a NEWER domain version than it registers. +/// lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered +/// version — the pre-`$rv` behavior. tolerant: additionally honor `$rv` (json-additive bound): +/// decode with that version's codec, silently dropping fields this reader does not know. +/// Re-encoding intermediaries must use lossless or they truncate data for downstream consumers. +enum ForwardReadPolicy { lossless, tolerant } + class BaboonCodecsFacade extends BaboonCodecsFacadeBase { + /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.tolerant; + final Map> _versionsCodecsJson = {}; final Map> _versionsCodecsBin = {}; final Map> _versionsConversions = {}; @@ -565,15 +575,16 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { // ----- private dispatch ------------------------------------------------------------------- BaboonEither _getBinCodec(BaboonTypeMeta typeMeta, {required bool exact}) => - _getCodec(_versionsCodecsBin, typeMeta, exact); + _getCodec(_versionsCodecsBin, typeMeta, exact, false); BaboonEither _getJsonCodec(BaboonTypeMeta typeMeta, {required bool exact}) => - _getCodec(_versionsCodecsJson, typeMeta, exact); + _getCodec(_versionsCodecsJson, typeMeta, exact, forwardReadPolicy == ForwardReadPolicy.tolerant); BaboonEither _getCodec( Map> versionsCodecs, BaboonTypeMeta typeMeta, bool exact, + bool tolerant, ) { final versions = _domainVersions[typeMeta.domainIdentifier]; if (versions == null || versions.isEmpty) { @@ -584,9 +595,11 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { final maxVersion = versions.last; final lookupVersion = typeMeta.versionRef(); - final minCompat = typeMeta.versionMinCompat(); - final modelVersion = (minCompat != null && lookupVersion.version.compareTo(maxVersion.version) > 0) - ? minCompat + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + final lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + final modelVersion = (lowerBound != null && lookupVersion.version.compareTo(maxVersion.version) > 0) + ? lowerBound : lookupVersion; final modelV = modelVersion.version; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index 166d46ec3..22f8ab4dd 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -1282,17 +1282,34 @@ class BaboonTypeMeta { final String domainVersion; final String domainVersionMinCompat; final String typeIdentifier; + /// Oldest domain version whose JSON codec can decode the payload under the json-additive + /// contract (tolerant key lookup; fields unknown to that version are dropped). Always + /// <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + /// minCompat; the binary v1 envelope does not carry it. Empty means "= minCompat". + final String domainVersionReadableMin; + + /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. + static const String jsonReadableTier = 'json-additive'; const BaboonTypeMeta( this.metaVersion, this.domainIdentifier, this.domainVersion, this.domainVersionMinCompat, - this.typeIdentifier, - ); + this.typeIdentifier, [ + this.domainVersionReadableMin = '', + ]); BaboonDomainVersion versionRef() => BaboonDomainVersion(domainIdentifier, domainVersion); + String get effectiveReadableMin => domainVersionReadableMin.isEmpty ? domainVersionMinCompat : domainVersionReadableMin; + + BaboonDomainVersion? versionReadableMin() { + if (domainVersionReadableMin.isEmpty) return versionMinCompat(); + if (domainVersionReadableMin == domainVersion) return null; + return BaboonDomainVersion(domainIdentifier, domainVersionReadableMin); + } + BaboonDomainVersion? versionMinCompat() { if (domainVersionMinCompat.isEmpty) return null; if (domainVersionMinCompat == domainVersion) return null; @@ -1330,7 +1347,9 @@ class BaboonTypeMeta { if (d is! String || v is! String || t is! String) return null; final uv = json[r'$uv']; final minCompat = (uv is String) ? uv : v; - return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t); + final rv = json[r'$rv']; + final readableMin = (rv is String) ? rv : minCompat; + return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t, readableMin); } static BaboonTypeMeta? readMetaBin(BaboonBinReader reader) => @@ -1352,12 +1371,19 @@ class BaboonTypeMeta { 'BaboonTypeMeta.from: empty baboonSameInVersions for type [${meta.baboonDomainIdentifier}.$typeId]', ); } + final readableMin = meta.baboonMinReaderVersions[jsonReadableTier]; + if (readableMin == null) { + throw BaboonException( + 'BaboonTypeMeta.from: baboonMinReaderVersions lacks "$jsonReadableTier" for type [${meta.baboonDomainIdentifier}.$typeId]', + ); + } return BaboonTypeMeta( BaboonTypeMetaCodec.metaVersion, meta.baboonDomainIdentifier, meta.baboonDomainVersion, sameIn.first, typeId, + readableMin, ); } @@ -1369,10 +1395,11 @@ class BaboonTypeMeta { domainIdentifier == other.domainIdentifier && domainVersion == other.domainVersion && domainVersionMinCompat == other.domainVersionMinCompat && + effectiveReadableMin == other.effectiveReadableMin && typeIdentifier == other.typeIdentifier; @override - int get hashCode => Object.hash(metaVersion, domainIdentifier, domainVersion, domainVersionMinCompat, typeIdentifier); + int get hashCode => Object.hash(metaVersion, domainIdentifier, domainVersion, domainVersionMinCompat, effectiveReadableMin, typeIdentifier); @override String toString() => 'BaboonTypeMeta($domainIdentifier.$typeIdentifier@$domainVersion)'; @@ -1421,6 +1448,10 @@ class BaboonTypeMetaCodec { if (meta.domainVersion != meta.domainVersionMinCompat) { obj[r'$uv'] = meta.domainVersionMinCompat; } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (meta.domainVersionReadableMin.isNotEmpty && meta.domainVersionReadableMin != meta.domainVersionMinCompat) { + obj[r'$rv'] = meta.domainVersionReadableMin; + } return obj; } } @@ -1441,6 +1472,11 @@ abstract class BaboonMetaProvider { /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards /// the cursor after decoding. Map get baboonForwardReadable; + + /// Writer-side inverse of [baboonForwardReadable]: guarantee tier -> oldest domain version + /// whose codec can decode THIS version's encoding of this type. The "identical" bound equals + /// `baboonSameInVersions[0]`; the "json-additive" bound is published as `$rv`. + Map get baboonMinReaderVersions; } /// Implemented by generated ADT branches. Mirrors Kotlin's `BaboonAdtMemberMeta` for the diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java index b92a134d9..a60ed11b5 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java @@ -29,6 +29,22 @@ * static fallbacks per PR-06-D01. */ public class BaboonCodecsFacade { + /** + * How a reader treats JSON payloads written by a NEWER domain version than it registers. + * LOSSLESS: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered + * version — the pre-`$rv` behavior. TOLERANT: additionally honor `$rv` (json-additive bound): + * decode with that version's codec, silently dropping fields this reader does not know. + * Re-encoding intermediaries must use LOSSLESS or they truncate data for downstream consumers. + */ + public enum ForwardReadPolicy { LOSSLESS, TOLERANT } + + /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + private volatile ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.TOLERANT; + + public ForwardReadPolicy getForwardReadPolicy() { return forwardReadPolicy; } + + public void setForwardReadPolicy(ForwardReadPolicy policy) { this.forwardReadPolicy = policy; } + private static final String CONTENT_JSON_KEY = "$c"; private static final ObjectMapper JSON_PARSER = new ObjectMapper(); @@ -559,17 +575,18 @@ public Baboon // ----- private dispatch --------------------------------------------------------------------- private BaboonEither getBinCodec(BaboonTypeMeta typeMeta, boolean exact) { - return getCodec(versionsCodecsBin, typeMeta, exact); + return getCodec(versionsCodecsBin, typeMeta, exact, false); } private BaboonEither getJsonCodec(BaboonTypeMeta typeMeta, boolean exact) { - return getCodec(versionsCodecsJson, typeMeta, exact); + return getCodec(versionsCodecsJson, typeMeta, exact, forwardReadPolicy == ForwardReadPolicy.TOLERANT); } private BaboonEither getCodec( Map> versionsCodecs, BaboonTypeMeta typeMeta, - boolean exact + boolean exact, + boolean tolerant ) { List versions = domainVersions.get(typeMeta.domainIdentifier()); if (versions == null || versions.isEmpty()) { @@ -581,9 +598,11 @@ private BaboonEither 0) - ? minCompat + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + BaboonDomainVersion lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + BaboonDomainVersion modelVersion = (lowerBound != null && lookupVersion.version().compareTo(maxVersion.version()) > 0) + ? lowerBound : lookupVersion; BaboonVersion modelV = modelVersion.version(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java index 559c84d4d..248b7d32a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java @@ -5,18 +5,40 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; +import java.util.Map; public record BaboonTypeMeta( byte metaVersion, String domainIdentifier, String domainVersion, String domainVersionMinCompat, - String typeIdentifier + String typeIdentifier, + /** + * Oldest domain version whose JSON codec can decode the payload under the json-additive + * contract (tolerant key lookup; fields unknown to that version are dropped). Always + * <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + * minCompat; the binary v1 envelope does not carry it. + */ + String domainVersionReadableMin ) { + /** Five-field form: readable-min defaults to minCompat (no forward-read beyond byte-identity). */ + public BaboonTypeMeta(byte metaVersion, String domainIdentifier, String domainVersion, String domainVersionMinCompat, String typeIdentifier) { + this(metaVersion, domainIdentifier, domainVersion, domainVersionMinCompat, typeIdentifier, domainVersionMinCompat); + } + + /** Tier key of the JSON envelope's readable-min bound in the generated `baboonMinReaderVersions`. */ + public static final String JSON_READABLE_TIER = "json-additive"; + public BaboonDomainVersion versionRef() { return new BaboonDomainVersion(domainIdentifier, domainVersion); } + public BaboonDomainVersion versionReadableMin() { + if (domainVersionReadableMin == null || domainVersionReadableMin.isEmpty()) return versionMinCompat(); + if (domainVersionReadableMin.equals(domainVersion)) return null; + return new BaboonDomainVersion(domainIdentifier, domainVersionReadableMin); + } + public BaboonDomainVersion versionMinCompat() { if (domainVersionMinCompat == null || domainVersionMinCompat.isEmpty()) return null; if (domainVersionMinCompat.equals(domainVersion)) return null; @@ -64,12 +86,24 @@ public static BaboonTypeMeta from(BaboonGenerated value, Class declaredType) } String minCompat = sameIn.get(0); + Map minReaders; + try { + minReaders = (Map) actual.getField("baboonMinReaderVersions").get(null); + } catch (ReflectiveOperationException e) { + throw new BaboonException("Type " + actual.getName() + " is missing static field 'baboonMinReaderVersions'", e); + } + String readableMin = minReaders.get(JSON_READABLE_TIER); + if (readableMin == null) { + throw new BaboonException("Type " + actual.getName() + ": baboonMinReaderVersions lacks '" + JSON_READABLE_TIER + "'"); + } + return new BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, domainIdentifier, domainVersion, minCompat, - typeIdentifier + typeIdentifier, + readableMin ); } @@ -103,6 +137,7 @@ private BaboonTypeMetaCodec() {} public static final String DOMAIN_IDENTIFIER_KEY = "$d"; public static final String DOMAIN_VERSION_KEY = "$v"; public static final String DOMAIN_VERSION_MIN_COMPAT_KEY = "$uv"; + public static final String DOMAIN_VERSION_READABLE_KEY = "$rv"; public static final String TYPE_IDENTIFIER_KEY = "$t"; public static void writeBin(BaboonTypeMeta meta, LEDataOutputStream writer) throws Exception { @@ -129,6 +164,11 @@ public static JsonNode writeJson(BaboonTypeMeta meta) { if (!meta.domainVersion.equals(meta.domainVersionMinCompat)) { obj.put(DOMAIN_VERSION_MIN_COMPAT_KEY, meta.domainVersionMinCompat); } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (meta.domainVersionReadableMin != null && !meta.domainVersionReadableMin.isEmpty() + && !meta.domainVersionReadableMin.equals(meta.domainVersionMinCompat)) { + obj.put(DOMAIN_VERSION_READABLE_KEY, meta.domainVersionReadableMin); + } return obj; } @@ -185,8 +225,10 @@ public static BaboonTypeMeta readMeta(JsonNode json) { JsonNode uvNode = obj.get(DOMAIN_VERSION_MIN_COMPAT_KEY); String uv = uvNode != null && uvNode.isTextual() ? uvNode.asText() : v.asText(); + JsonNode rvNode = obj.get(DOMAIN_VERSION_READABLE_KEY); + String rv = rvNode != null && rvNode.isTextual() ? rvNode.asText() : uv; - return new BaboonTypeMeta(META_VERSION, d.asText(), v.asText(), uv, t.asText()); + return new BaboonTypeMeta(META_VERSION, d.asText(), v.asText(), uv, t.asText(), rv); } } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt index 2e79a5282..1479f146e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt @@ -9,7 +9,18 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject // @baboon:json-end +/** + * How a reader treats JSON payloads written by a NEWER domain version than it registers. + * Lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered + * version — the pre-`$rv` behavior. Tolerant: additionally honor `$rv` (json-additive bound): + * decode with that version's codec, silently dropping fields this reader does not know. + * Re-encoding intermediaries must use Lossless or they truncate data for downstream consumers. + */ +enum class ForwardReadPolicy { Lossless, Tolerant } + open class BaboonCodecsFacade { + /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" // @baboon:json-start @@ -175,6 +186,10 @@ open class BaboonCodecsFacade { if (typeMeta.domainVersion != typeMeta.domainVersionMinCompat) { put("${'$'}uv", JsonPrimitive(typeMeta.domainVersionMinCompat)) } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (typeMeta.domainVersionReadableMin.isNotEmpty() && typeMeta.domainVersionReadableMin != typeMeta.domainVersionMinCompat) { + put("${'$'}rv", JsonPrimitive(typeMeta.domainVersionReadableMin)) + } put(CONTENT_JSON_KEY, content) } } @@ -199,22 +214,24 @@ open class BaboonCodecsFacade { val v = json["${'$'}v"]?.toString()?.trim('"') ?: return null val t = json["${'$'}t"]?.toString()?.trim('"') ?: return null val uv = json["${'$'}uv"]?.toString()?.trim('"') ?: v - return BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t) + val rv = json["${'$'}rv"]?.toString()?.trim('"') ?: uv + return BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t, rv) } private fun getJsonCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsJson, typeMeta, exact) + return getCodec(versionsCodecsJson, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) } // @baboon:json-end private fun getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsBin, typeMeta, exact) + return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false) } private fun getCodec( versionsCodecs: HashMap>, typeMeta: BaboonTypeMeta, exact: Boolean, + tolerant: Boolean, ): BaboonCodecData { val versions = domainVersions[typeMeta.domainIdentifier] ?.takeIf { it.isNotEmpty() } @@ -223,9 +240,11 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() val modelVersion = when { - typeMeta.versionMinCompat() != null && typeMeta.version().version > maxVersion.version -> - typeMeta.versionMinCompat()!! + lowerBound != null && typeMeta.version().version > maxVersion.version -> lowerBound else -> typeMeta.version() } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt index 4ea97fc23..b51a56146 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt @@ -15,6 +15,13 @@ interface BaboonGenerated { * the cursor after decoding. */ val baboonForwardReadable: Map + + /** + * Writer-side inverse of [baboonForwardReadable]: guarantee tier -> oldest domain version whose + * codec can decode THIS version's encoding of this type. The "identical" bound equals + * `baboonSameInVersions[0]`; the "json-additive" bound is published as `$rv`. + */ + val baboonMinReaderVersions: Map val baboonTypeIdentifier: String fun domainVersion(): BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -89,6 +96,13 @@ data class BaboonTypeMeta( val domainVersion: String, val domainVersionMinCompat: String, val typeIdentifier: String, + /** + * Oldest domain version whose JSON codec can decode the payload under the json-additive + * contract (tolerant key lookup; fields unknown to that version are dropped). Always + * <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + * minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. + */ + val domainVersionReadableMin: String = domainVersionMinCompat, ) { fun version(): BaboonDomainVersion = BaboonDomainVersion(domainIdentifier, domainVersion) fun versionMinCompat(): BaboonDomainVersion? { @@ -98,6 +112,13 @@ data class BaboonTypeMeta( else -> BaboonDomainVersion(domainIdentifier, domainVersionMinCompat) } } + fun versionReadableMin(): BaboonDomainVersion? { + return when { + domainVersionReadableMin.isEmpty() -> versionMinCompat() + domainVersionReadableMin == domainVersion -> null + else -> BaboonDomainVersion(domainIdentifier, domainVersionReadableMin) + } + } fun writeBin(writer: BaboonBinaryWriter) { BaboonTypeMetaCodec.writeBin(this, writer) @@ -109,15 +130,21 @@ data class BaboonTypeMeta( is BaboonAdtMemberMeta -> value.baboonAdtTypeIdentifier else -> value.baboonTypeIdentifier } + val readableMin = value.baboonMinReaderVersions[JSON_READABLE_TIER] + ?: error("baboonMinReaderVersions lacks '$JSON_READABLE_TIER' for type ${value.baboonTypeIdentifier}") return BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, value.baboonDomainIdentifier, value.baboonDomainVersion, value.baboonSameInVersions.first(), typeIdentifier, + readableMin, ) } + /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ + const val JSON_READABLE_TIER: String = "json-additive" + fun readMeta(reader: BaboonBinaryReader): BaboonTypeMeta? { return BaboonTypeMetaCodec.readMeta(reader) } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt index 96b3abbdc..32fc1a0fa 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt @@ -12,7 +12,18 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.concurrent.ConcurrentHashMap +/** + * How a reader treats JSON payloads written by a NEWER domain version than it registers. + * Lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered + * version — the pre-`$rv` behavior. Tolerant: additionally honor `$rv` (json-additive bound): + * decode with that version's codec, silently dropping fields this reader does not know. + * Re-encoding intermediaries must use Lossless or they truncate data for downstream consumers. + */ +enum class ForwardReadPolicy { Lossless, Tolerant } + open class BaboonCodecsFacade { + /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" // @baboon:json-start @@ -180,6 +191,10 @@ open class BaboonCodecsFacade { if (typeMeta.domainVersion != typeMeta.domainVersionMinCompat) { put("${'$'}uv", JsonPrimitive(typeMeta.domainVersionMinCompat)) } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if (typeMeta.domainVersionReadableMin.isNotEmpty() && typeMeta.domainVersionReadableMin != typeMeta.domainVersionMinCompat) { + put("${'$'}rv", JsonPrimitive(typeMeta.domainVersionReadableMin)) + } put(CONTENT_JSON_KEY, content) } } @@ -204,22 +219,24 @@ open class BaboonCodecsFacade { val v = json["${'$'}v"]?.toString()?.trim('"') ?: return null val t = json["${'$'}t"]?.toString()?.trim('"') ?: return null val uv = json["${'$'}uv"]?.toString()?.trim('"') ?: v - return BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t) + val rv = json["${'$'}rv"]?.toString()?.trim('"') ?: uv + return BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION, d, v, uv, t, rv) } private fun getJsonCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsJson, typeMeta, exact) + return getCodec(versionsCodecsJson, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) } // @baboon:json-end private fun getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsBin, typeMeta, exact) + return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false) } private fun getCodec( versionsCodecs: ConcurrentHashMap>, typeMeta: BaboonTypeMeta, exact: Boolean, + tolerant: Boolean, ): BaboonCodecData { val versions = domainVersions[typeMeta.domainIdentifier] ?.takeIf { it.isNotEmpty() } @@ -228,9 +245,11 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() val modelVersion = when { - typeMeta.versionMinCompat() != null && typeMeta.version().version > maxVersion.version -> - typeMeta.versionMinCompat()!! + lowerBound != null && typeMeta.version().version > maxVersion.version -> lowerBound else -> typeMeta.version() } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt index fb8ce4d47..cc148ee9f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt @@ -15,6 +15,13 @@ interface BaboonGenerated { * the cursor after decoding. */ val baboonForwardReadable: Map + + /** + * Writer-side inverse of [baboonForwardReadable]: guarantee tier -> oldest domain version whose + * codec can decode THIS version's encoding of this type. The "identical" bound equals + * `baboonSameInVersions[0]`; the "json-additive" bound is published as `$rv`. + */ + val baboonMinReaderVersions: Map val baboonTypeIdentifier: String fun domainVersion(): BaboonDomainVersion = BaboonDomainVersion(baboonDomainIdentifier, baboonDomainVersion) @@ -97,6 +104,13 @@ data class BaboonTypeMeta( val domainVersion: String, val domainVersionMinCompat: String, val typeIdentifier: String, + /** + * Oldest domain version whose JSON codec can decode the payload under the json-additive + * contract (tolerant key lookup; fields unknown to that version are dropped). Always + * <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + * minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. + */ + val domainVersionReadableMin: String = domainVersionMinCompat, ) { fun version(): BaboonDomainVersion = BaboonDomainVersion(domainIdentifier, domainVersion) fun versionMinCompat(): BaboonDomainVersion? { @@ -106,6 +120,13 @@ data class BaboonTypeMeta( else -> BaboonDomainVersion(domainIdentifier, domainVersionMinCompat) } } + fun versionReadableMin(): BaboonDomainVersion? { + return when { + domainVersionReadableMin.isEmpty() -> versionMinCompat() + domainVersionReadableMin == domainVersion -> null + else -> BaboonDomainVersion(domainIdentifier, domainVersionReadableMin) + } + } fun writeBin(writer: LEDataOutputStream) { BaboonTypeMetaCodec.writeBin(this, writer) @@ -118,15 +139,21 @@ data class BaboonTypeMeta( (value as BaboonAdtMemberMeta).baboonAdtTypeIdentifier else -> value.baboonTypeIdentifier } + val readableMin = value.baboonMinReaderVersions[JSON_READABLE_TIER] + ?: error("baboonMinReaderVersions lacks '$JSON_READABLE_TIER' for type ${value.baboonTypeIdentifier}") return BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, value.baboonDomainIdentifier, value.baboonDomainVersion, value.baboonSameInVersions.first(), typeIdentifier, + readableMin, ) } + /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ + const val JSON_READABLE_TIER: String = "json-additive" + fun readMeta(reader: LEDataInputStream): BaboonTypeMeta? { return BaboonTypeMetaCodec.readMeta(reader) } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index 49ed14d4b..6b0b94ea6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -1,3 +1,4 @@ +from enum import Enum import json from typing import Dict, List, Type, Tuple @@ -19,10 +20,24 @@ TO = TypeVar("TO", bound=BaboonGeneratedLatest) +class ForwardReadPolicy(Enum): + """How a reader treats JSON payloads written by a NEWER domain version than it registers. + + LOSSLESS: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered + version — the pre-`$rv` behavior. TOLERANT: additionally honor `$rv` (json-additive bound): + decode with that version's codec, silently dropping fields this reader does not know. + Re-encoding intermediaries must use LOSSLESS or they truncate data for downstream consumers. + """ + LOSSLESS = "lossless" + TOLERANT = "tolerant" + + class BaboonCodecsFacade: CONTENT_JSON_KEY = "$c" def __init__(self): + # JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + self.forward_read_policy: ForwardReadPolicy = ForwardReadPolicy.TOLERANT self.versions_codecs_json: Dict[BaboonDomainVersion, Lazy[AbstractBaboonJsonCodecs]] = {} self.versions_codecs_bin: Dict[BaboonDomainVersion, Lazy[AbstractBaboonUebaCodecs]] = {} self.versions_conversions: Dict[BaboonDomainVersion, Lazy[AbstractBaboonConversions]] = {} @@ -254,15 +269,17 @@ def _try_convert(self, value: BaboonGenerated, versions: List[BaboonDomainVersio return from_model def _get_bin_codec(self, type_meta: BaboonTypeMeta, exact: bool) -> BaboonBinCodec: - return self._get_codec(self.versions_codecs_bin, type_meta, exact) + return self._get_codec(self.versions_codecs_bin, type_meta, exact, tolerant=False) def _get_json_codec(self, type_meta: BaboonTypeMeta, exact: bool) -> BaboonJsonCodec: - return self._get_codec(self.versions_codecs_json, type_meta, exact) + return self._get_codec(self.versions_codecs_json, type_meta, exact, + tolerant=self.forward_read_policy == ForwardReadPolicy.TOLERANT) def _get_codec(self, versions_codecs: Dict[BaboonDomainVersion, Lazy], type_meta: BaboonTypeMeta, - exact: bool) -> BaboonCodecData: + exact: bool, + tolerant: bool) -> BaboonCodecData: versions = self.domain_versions.get(type_meta.domain_identifier, []) if not versions: raise BaboonCodecException.CodecNotFound( @@ -274,9 +291,12 @@ def _get_codec(self, min_version = versions[0] max_version = versions[-1] + # the oldest version whose codec may decode this payload: byte-identical bound, or + # (tolerant JSON reads) the json-additive bound when the writer published one + lower_bound = type_meta.version_readable_min if tolerant else type_meta.version_min_compat # it's a model of newer version than we have, we should find min compat version - if type_meta.version_min_compat and model_version.version > max_version.version: - model_version = type_meta.version_min_compat + if lower_bound and model_version.version > max_version.version: + model_version = lower_bound # it's a model of latest version, get last version codec if exact and model_version.version == max_version.version: diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index f615b8e24..f3460bcde 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal from functools import wraps -from typing import TypeVar, Generic, Callable, Optional, Any +from typing import TypeVar, Generic, Callable, Optional, Any, ClassVar from uuid import UUID, uuid4 from pydantic import BaseModel, ConfigDict @@ -52,6 +52,15 @@ def baboon_same_in_versions(self) -> list[str]: def baboon_forward_readable(self) -> dict[str, str]: return {} + # Writer-side inverse of baboon_forward_readable: guarantee tier -> oldest domain + # version whose codec can decode THIS version's encoding of this type. The + # "identical" bound equals baboon_same_in_versions[0]; the "json-additive" bound is + # published as `$rv`. Non-abstract default (= "no bound beyond byte-identity") keeps + # hand-written stubs working; generated classes override it with a ClassVar. + @property + def baboon_min_reader_versions(self) -> dict[str, str]: + return {} + class BaboonAdtMemberMeta(ABC): @property @abstractmethod @@ -520,6 +529,13 @@ class BaboonTypeMeta(BaseModel): domain_version: str domain_version_min_compat: str type_identifier: str + # Oldest domain version whose JSON codec can decode the payload under the json-additive + # contract (tolerant key lookup; fields unknown to that version are dropped). Always + # <= domain_version_min_compat. Published as `$rv` when it differs from the (effective) + # minCompat; the binary v1 envelope does not carry it. Empty means "= min_compat". + domain_version_readable_min: str = "" + + JSON_READABLE_TIER: ClassVar[str] = "json-additive" model_config = ConfigDict( frozen=True, @@ -537,6 +553,18 @@ def version_min_compat(self) -> Optional[BaboonDomainVersion]: return None return BaboonDomainVersion(self.domain_identifier, self.domain_version_min_compat) + @property + def effective_readable_min(self) -> str: + return self.domain_version_readable_min or self.domain_version_min_compat + + @property + def version_readable_min(self) -> Optional[BaboonDomainVersion]: + if not self.domain_version_readable_min: + return self.version_min_compat + if self.domain_version_readable_min == self.domain_version: + return None + return BaboonDomainVersion(self.domain_identifier, self.domain_version_readable_min) + @staticmethod def from_instance(value: BaboonGenerated) -> 'BaboonTypeMeta': """Codecs discovery with ADTs check to ensure that ADTs is encoded with a codec type desired by the user. @@ -551,12 +579,17 @@ def from_instance(value: BaboonGenerated) -> 'BaboonTypeMeta': else: type_identifier = value.baboon_type_identifier + min_compat = value.baboon_same_in_versions[0] + # baboon_min_reader_versions has a non-abstract default (see BaboonGenerated); a missing + # json-additive bound means "no forward-read beyond byte-identity", i.e. = min_compat + readable_min = value.baboon_min_reader_versions.get(BaboonTypeMeta.JSON_READABLE_TIER, min_compat) return BaboonTypeMeta( meta_version=BaboonTypeMetaCodec.META_VERSION, domain_identifier=value.baboon_domain_identifier, domain_version=value.baboon_domain_version, - domain_version_min_compat=value.baboon_same_in_versions[0], + domain_version_min_compat=min_compat, type_identifier=type_identifier, + domain_version_readable_min=readable_min, ) def write_bin(self, writer: LEDataOutputStream) -> None: @@ -582,6 +615,7 @@ class BaboonTypeMetaCodec: DOMAIN_IDENTIFIER_KEY = "$d" DOMAIN_VERSION_KEY = "$v" DOMAIN_VERSION_MIN_COMPAT_KEY = "$uv" + DOMAIN_VERSION_READABLE_KEY = "$rv" TYPE_IDENTIFIER_KEY = "$t" @staticmethod @@ -618,6 +652,9 @@ def write_json(meta: BaboonTypeMeta) -> dict[str, Any]: if meta.domain_version != meta.domain_version_min_compat: json_obj[BaboonTypeMetaCodec.DOMAIN_VERSION_MIN_COMPAT_KEY] = meta.domain_version_min_compat + # `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if meta.domain_version_readable_min and meta.domain_version_readable_min != meta.domain_version_min_compat: + json_obj[BaboonTypeMetaCodec.DOMAIN_VERSION_READABLE_KEY] = meta.domain_version_readable_min return json_obj @@ -697,6 +734,10 @@ def _read_meta_v1_json(json_obj: dict[str, Any]) -> Optional[BaboonTypeMeta]: BaboonTypeMetaCodec.DOMAIN_VERSION_MIN_COMPAT_KEY, domain_version ) + domain_version_readable_min = json_obj.get( + BaboonTypeMetaCodec.DOMAIN_VERSION_READABLE_KEY, + domain_version_min_compat + ) return BaboonTypeMeta( meta_version=BaboonTypeMetaCodec.META_VERSION_1, @@ -704,6 +745,7 @@ def _read_meta_v1_json(json_obj: dict[str, Any]) -> Optional[BaboonTypeMeta]: domain_version=domain_version, domain_version_min_compat=domain_version_min_compat, type_identifier=type_identifier, + domain_version_readable_min=domain_version_readable_min, ) except Exception: return None diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index d1e807336..20be55405 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -33,6 +33,13 @@ pub trait BaboonGeneratedDyn: std::any::Any + Send + Sync { fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { Vec::new() } + /// Writer-side inverse of `baboon_forward_readable_dyn`: `(guarantee tier, oldest domain + /// version whose codec can decode THIS version's encoding of this type)`. The "identical" + /// bound equals `baboon_same_in_versions_dyn()[0]`; the "json-additive" bound is published + /// as `$rv`. Default is empty (= no bound beyond byte-identity); generated impls override. + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { + Vec::new() + } fn as_any(&self) -> &dyn std::any::Any; /// Consume the box and recover an `Any` for downcasting via `Box::downcast`. Mirrors /// the C# `if (current is TTo result)` pattern used in `Convert`. @@ -219,11 +226,18 @@ pub struct BaboonTypeMeta { pub domain_version: String, pub domain_version_min_compat: String, pub type_identifier: String, + /// Oldest domain version whose JSON codec can decode the payload under the json-additive + /// contract (tolerant key lookup; fields unknown to that version are dropped). Always + /// <= domain_version_min_compat. Published as `$rv` when it differs from the (effective) + /// minCompat; the binary v1 envelope does not carry it. `new` defaults it to minCompat. + pub domain_version_readable_min: String, } impl BaboonTypeMeta { pub const META_VERSION_1: u8 = 1; pub const META_VERSION: u8 = Self::META_VERSION_1; + /// Tier key of the JSON envelope's readable-min bound in `baboon_min_reader_versions_dyn`. + pub const JSON_READABLE_TIER: &'static str = "json-additive"; pub fn new, V: Into, MC: Into, T: Into>( meta_version: u8, @@ -232,15 +246,33 @@ impl BaboonTypeMeta { domain_version_min_compat: MC, type_identifier: T, ) -> Self { + let domain_version_min_compat: String = domain_version_min_compat.into(); BaboonTypeMeta { meta_version, domain_identifier: domain_identifier.into(), domain_version: domain_version.into(), - domain_version_min_compat: domain_version_min_compat.into(), + domain_version_readable_min: domain_version_min_compat.clone(), + domain_version_min_compat, type_identifier: type_identifier.into(), } } + pub fn with_readable_min>(mut self, readable_min: R) -> Self { + self.domain_version_readable_min = readable_min.into(); + self + } + + pub fn version_readable_min(&self) -> Option { + if self.domain_version_readable_min.is_empty() { + return self.version_min_compat(); + } + if self.domain_version_readable_min == self.domain_version { + None + } else { + Some(BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version_readable_min)) + } + } + pub fn version_ref(&self) -> BaboonDomainVersion { BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version) } @@ -271,6 +303,7 @@ pub mod baboon_type_meta_codec { pub const DOMAIN_IDENTIFIER_KEY: &str = "$d"; pub const DOMAIN_VERSION_KEY: &str = "$v"; pub const DOMAIN_VERSION_MIN_COMPAT_KEY: &str = "$uv"; + pub const DOMAIN_VERSION_READABLE_KEY: &str = "$rv"; pub const TYPE_IDENTIFIER_KEY: &str = "$t"; /// Wire format: `[meta-version:u8][domain:string][version:string][has-min-compat:u8][min-compat?:string][type-id:string]`. @@ -378,6 +411,15 @@ pub mod baboon_type_meta_codec { serde_json::Value::String(meta.domain_version_min_compat.clone()), ); } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if !meta.domain_version_readable_min.is_empty() + && meta.domain_version_readable_min != meta.domain_version_min_compat + { + envelope.insert( + DOMAIN_VERSION_READABLE_KEY.to_string(), + serde_json::Value::String(meta.domain_version_readable_min.clone()), + ); + } envelope } @@ -420,13 +462,13 @@ pub mod baboon_type_meta_codec { .get(DOMAIN_VERSION_MIN_COMPAT_KEY) .and_then(|v| v.as_str()) .unwrap_or(v); - Ok(Some(BaboonTypeMeta::new( - BaboonTypeMeta::META_VERSION, - d, - v, - uv, - t, - ))) + let rv = obj + .get(DOMAIN_VERSION_READABLE_KEY) + .and_then(|v| v.as_str()) + .unwrap_or(uv); + Ok(Some( + BaboonTypeMeta::new(BaboonTypeMeta::META_VERSION, d, v, uv, t).with_readable_min(rv), + )) } } @@ -544,7 +586,20 @@ impl AbstractBaboonUebaCodecsImpl { // --- BaboonCodecsFacade --- +/// How a reader treats JSON payloads written by a NEWER domain version than it registers. +/// `Lossless`: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered +/// version — the pre-`$rv` behavior. `Tolerant`: additionally honor `$rv` (json-additive bound): +/// decode with that version's codec, silently dropping fields this reader does not know. +/// Re-encoding intermediaries must use `Lossless` or they truncate data for downstream consumers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardReadPolicy { + Lossless, + Tolerant, +} + pub struct BaboonCodecsFacade { + /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + forward_read_policy: Mutex, versions_codecs_json: Mutex>>>>, versions_codecs_bin: Mutex>>>>, versions_meta: Mutex>>>>, @@ -574,6 +629,7 @@ impl Default for BaboonCodecsFacade { impl BaboonCodecsFacade { pub fn new() -> Self { BaboonCodecsFacade { + forward_read_policy: Mutex::new(ForwardReadPolicy::Tolerant), versions_codecs_json: Mutex::new(HashMap::new()), versions_codecs_bin: Mutex::new(HashMap::new()), versions_meta: Mutex::new(HashMap::new()), @@ -582,6 +638,14 @@ impl BaboonCodecsFacade { } } + pub fn forward_read_policy(&self) -> ForwardReadPolicy { + *self.forward_read_policy.lock().expect("forward_read_policy mutex poisoned") + } + + pub fn set_forward_read_policy(&self, policy: ForwardReadPolicy) { + *self.forward_read_policy.lock().expect("forward_read_policy mutex poisoned") = policy; + } + pub fn latest(&self, domain: &str) -> Result { let versions = self.domain_versions.lock().expect("domain_versions mutex poisoned"); match versions.get(domain) { @@ -855,7 +919,7 @@ impl BaboonCodecsFacade { type_meta: &BaboonTypeMeta, exact: bool, ) -> Result, BaboonCodecError> { - let resolved = self.resolve_version(type_meta, exact)?; + let resolved = self.resolve_version(type_meta, exact, false)?; let table = { let codecs = self.versions_codecs_bin.lock().expect("mutex poisoned"); codecs @@ -882,7 +946,8 @@ impl BaboonCodecsFacade { type_meta: &BaboonTypeMeta, exact: bool, ) -> Result, BaboonCodecError> { - let resolved = self.resolve_version(type_meta, exact)?; + let tolerant = self.forward_read_policy() == ForwardReadPolicy::Tolerant; + let resolved = self.resolve_version(type_meta, exact, tolerant)?; let table = { let codecs = self.versions_codecs_json.lock().expect("mutex poisoned"); codecs @@ -911,6 +976,7 @@ impl BaboonCodecsFacade { &self, type_meta: &BaboonTypeMeta, exact: bool, + tolerant: bool, ) -> Result { let versions = { let map = self.domain_versions.lock().expect("mutex poisoned"); @@ -931,8 +997,11 @@ impl BaboonCodecsFacade { let max_v = max_version.version()?; let min_v = min_version.version()?; - let model_version = match type_meta.version_min_compat() { - Some(min_compat) if lookup_v > max_v => min_compat, + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + let lower_bound = if tolerant { type_meta.version_readable_min() } else { type_meta.version_min_compat() }; + let model_version = match lower_bound { + Some(bound) if lookup_v > max_v => bound, _ => lookup_version, }; let model_v = model_version.version()?; @@ -1031,6 +1100,14 @@ impl BaboonCodecsFacade { value.baboon_type_identifier_dyn() ); let min_compat = same_in[0].clone(); + // `baboon_min_reader_versions_dyn` has a trait default (empty); a missing json-additive + // bound means "no forward-read beyond byte-identity", i.e. = min_compat + let readable_min = value + .baboon_min_reader_versions_dyn() + .into_iter() + .find(|(tier, _)| tier == BaboonTypeMeta::JSON_READABLE_TIER) + .map(|(_, v)| v) + .unwrap_or_else(|| min_compat.clone()); BaboonTypeMeta::new( BaboonTypeMeta::META_VERSION, value.baboon_domain_identifier_dyn(), @@ -1038,6 +1115,7 @@ impl BaboonCodecsFacade { min_compat, type_identifier, ) + .with_readable_min(readable_min) } pub fn encode_to_bin_with_override( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift index c407f65f8..2c5535acf 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift @@ -32,7 +32,20 @@ private let CONTENT_JSON_KEY = "$c" // MFACADE-PR-6: `open` so generated `DomainFacade` subclasses (in user packages outside // the BaboonRuntime module) can inherit. Swift's `public class` is closed by default; // cross-module subclassing requires `open`. +/// How a reader treats JSON payloads written by a NEWER domain version than it registers. +/// lossless: decode only when the envelope's `$uv` (byte-identical bound) reaches a registered +/// version — the pre-`$rv` behavior. tolerant: additionally honor `$rv` (json-additive bound): +/// decode with that version's codec, silently dropping fields this reader does not know. +/// Re-encoding intermediaries must use lossless or they truncate data for downstream consumers. +public enum ForwardReadPolicy { + case lossless + case tolerant +} + open class BaboonCodecsFacade: BaboonCodecsFacadeBase { + /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + public var forwardReadPolicy: ForwardReadPolicy = .tolerant + private var versionsCodecsJson: [BaboonDomainVersion: BaboonLazy] = [:] private var versionsCodecsBin: [BaboonDomainVersion: BaboonLazy] = [:] private var versionsConversions: [BaboonDomainVersion: BaboonLazy] = [:] @@ -602,14 +615,14 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { // ----- private dispatch ------------------------------------------------------------------- private func getBinCodec(_ typeMeta: BaboonTypeMeta, exact: Bool) -> Result { - return getCodec(typeMeta, exact, { (k: BaboonDomainVersion) -> AnyObject? in + return getCodec(typeMeta, exact, false, { (k: BaboonDomainVersion) -> AnyObject? in guard let lazy = self.versionsCodecsBin[k] else { return nil } return lazy.value }) } private func getJsonCodec(_ typeMeta: BaboonTypeMeta, exact: Bool) -> Result { - return getCodec(typeMeta, exact, { (k: BaboonDomainVersion) -> AnyObject? in + return getCodec(typeMeta, exact, forwardReadPolicy == .tolerant, { (k: BaboonDomainVersion) -> AnyObject? in guard let lazy = self.versionsCodecsJson[k] else { return nil } return lazy.value }) @@ -618,6 +631,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { private func getCodec( _ typeMeta: BaboonTypeMeta, _ exact: Bool, + _ tolerant: Bool, _ codecsLookup: (BaboonDomainVersion) -> AnyObject? ) -> Result { guard let versions = domainVersions[typeMeta.domainIdentifier], !versions.isEmpty else { @@ -628,7 +642,9 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { let maxVersion = versions.last! let lookupVersion = typeMeta.versionRef() - let minCompat = typeMeta.versionMinCompat() + // the oldest version whose codec may decode this payload: byte-identical bound, or + // (tolerant JSON reads) the json-additive bound when the writer published one + let minCompat = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat() let lookupV: BaboonVersion let maxV: BaboonVersion diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 2daff9102..9def8ceb5 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -1273,12 +1273,18 @@ public protocol BaboonMetaProvider { /// The prefix-* tiers hold only for top-level framed UEBA reads where the caller discards /// the cursor after decoding. var baboonForwardReadable: [String: String] { get } + + /// Writer-side inverse of `baboonForwardReadable`: guarantee tier -> oldest domain version + /// whose codec can decode THIS version's encoding of this type. The "identical" bound equals + /// `baboonSameInVersions[0]`; the "json-additive" bound is published as `$rv`. + var baboonMinReaderVersions: [String: String] { get } } // Default keeps hand-written conforming stubs source-compatible; generated types // override it with the real per-type table. public extension BaboonMetaProvider { var baboonForwardReadable: [String: String] { [:] } + var baboonMinReaderVersions: [String: String] { [:] } } // Implemented by generated ADT branches. Mirrors Kotlin/Dart `BaboonAdtMember` for the @@ -1363,25 +1369,41 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { public let domainVersion: String public let domainVersionMinCompat: String public let typeIdentifier: String + /// Oldest domain version whose JSON codec can decode the payload under the json-additive + /// contract (tolerant key lookup; fields unknown to that version are dropped). Always + /// <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + /// minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. + public let domainVersionReadableMin: String + + /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. + public static let jsonReadableTier = "json-additive" public init( _ metaVersion: Int, _ domainIdentifier: String, _ domainVersion: String, _ domainVersionMinCompat: String, - _ typeIdentifier: String + _ typeIdentifier: String, + _ domainVersionReadableMin: String? = nil ) { self.metaVersion = metaVersion self.domainIdentifier = domainIdentifier self.domainVersion = domainVersion self.domainVersionMinCompat = domainVersionMinCompat self.typeIdentifier = typeIdentifier + self.domainVersionReadableMin = domainVersionReadableMin ?? domainVersionMinCompat } public func versionRef() -> BaboonDomainVersion { return BaboonDomainVersion(domainIdentifier, domainVersion) } + public func versionReadableMin() -> BaboonDomainVersion? { + if domainVersionReadableMin.isEmpty { return versionMinCompat() } + if domainVersionReadableMin == domainVersion { return nil } + return BaboonDomainVersion(domainIdentifier, domainVersionReadableMin) + } + public func versionMinCompat() -> BaboonDomainVersion? { if domainVersionMinCompat.isEmpty { return nil } if domainVersionMinCompat == domainVersion { return nil } @@ -1426,7 +1448,8 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { guard let v = obj["$v"] as? String else { return nil } guard let t = obj["$t"] as? String else { return nil } let minCompat = (obj["$uv"] as? String) ?? v - return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t) + let readableMin = (obj["$rv"] as? String) ?? minCompat + return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t, readableMin) } public static func readMetaBin(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { @@ -1456,12 +1479,16 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { "BaboonTypeMeta.from: empty baboonSameInVersions for type [\(meta.baboonDomainIdentifier).\(typeId)]" ) } + // baboonMinReaderVersions has a protocol-extension default (see BaboonMetaProvider); a + // missing json-additive bound means "no forward-read beyond byte-identity", i.e. = minCompat + let readableMin = meta.baboonMinReaderVersions[BaboonTypeMeta.jsonReadableTier] ?? sameIn[0] return BaboonTypeMeta( BaboonTypeMetaCodec.metaVersion, meta.baboonDomainIdentifier, meta.baboonDomainVersion, sameIn[0], - typeId + typeId, + readableMin ) } @@ -1513,6 +1540,10 @@ public enum BaboonTypeMetaCodec { if meta.domainVersion != meta.domainVersionMinCompat { obj["$uv"] = meta.domainVersionMinCompat } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if !meta.domainVersionReadableMin.isEmpty && meta.domainVersionReadableMin != meta.domainVersionMinCompat { + obj["$rv"] = meta.domainVersionReadableMin + } return obj } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala index 6125b4f8c..4c17224a6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala @@ -68,6 +68,9 @@ object CSDomainTreeTools { val forwardEntries = forward.readable.toList .map { case (v, tier) => s"""{ "${v.v.toString}", "${tier.wireName}" }""" } .mkString(", ") + val minReaderEntries = evo.minReaders(version, defn.id).toList.sortBy(_._1.weight) + .map { case (tier, v) => s"""{ "${tier.wireName}", "${v.v.toString}" }""" } + .mkString(", ") List( q"""public${propFix}static readonly $csIReadOnlyList<$csString> BaboonSameInVersionsValue = new $csList<$csString> { ${unmodifiedSince.sameIn .map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")} }; @@ -76,6 +79,9 @@ object CSDomainTreeTools { q"""public${propFix}static readonly $csIReadOnlyDictionary<$csString, $csString> BaboonForwardReadableValue = new $csDictionary<$csString, $csString> { $forwardEntries }; |public$methodFix$csIReadOnlyDictionary<$csString, $csString> BaboonForwardReadable() => BaboonForwardReadableValue; |""".stripMargin, + q"""public${propFix}static readonly $csIReadOnlyDictionary<$csString, $csString> BaboonMinReaderVersionsValue = new $csDictionary<$csString, $csString> { $minReaderEntries }; + |public$methodFix$csIReadOnlyDictionary<$csString, $csString> BaboonMinReaderVersions() => BaboonMinReaderVersionsValue; + |""".stripMargin, ) } else { List.empty diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala index 2d3c79f4d..d37a8e1b0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala @@ -365,6 +365,7 @@ object DtDefnTranslator { "baboonTypeIdentifier", "baboonSameInVersions", "baboonForwardReadable", + "baboonMinReaderVersions", "baboonAdtTypeIdentifier", ) val instanceGetters = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.valueGetter) @@ -478,6 +479,7 @@ object DtDefnTranslator { "baboonTypeIdentifier", "baboonSameInVersions", "baboonForwardReadable", + "baboonMinReaderVersions", ) val instanceGetters = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.valueGetter) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala index 5274a7407..d98f18dfb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala @@ -113,7 +113,18 @@ object DtDomainTreeTools { q"Map", isCodecData = false, ) - List(sameInVersion, forwardReadable) + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + case (tier, v) => s"'${tier.wireName}': '${v.v.toString}'" + } + val minReaders = MetaField( + q"static const Map baboonMinReaderVersionsConst", + q"{${minReaderEntries.mkString(", ")}}", + q"$ref.baboonMinReaderVersionsConst", + "baboonMinReaderVersions", + q"Map", + isCodecData = false, + ) + List(sameInVersion, forwardReadable, minReaders) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala index 7a913a03d..94b7778ae 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala @@ -90,7 +90,15 @@ object JvDomainTreeTools { q"$jvMap.ofEntries(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - List(sameInVersion, forwardReadable) + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + case (tier, v) => s"""java.util.Map.entry("${tier.wireName}", "${v.v.toString}")""" + } + val minReaders = MetaField( + q"public static final $jvMap<$jvString, $jvString> baboonMinReaderVersions", + q"$jvMap.ofEntries(${minReaderEntries.mkString(", ")})", + q"$ref.baboonMinReaderVersions", + ) + List(sameInVersion, forwardReadable, minReaders) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala index 967a690be..bce5d0482 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala @@ -93,7 +93,15 @@ object KtDomainTreeTools { q"mapOf(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - List(sameInVersion, forwardReadable) + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + case (tier, v) => s""""${tier.wireName}" to "${v.v.toString}"""" + } + val minReaders = MetaField( + q"val baboonMinReaderVersions: $ktMap<$ktString, $ktString>", + q"mapOf(${minReaderEntries.mkString(", ")})", + q"$ref.baboonMinReaderVersions", + ) + List(sameInVersion, forwardReadable, minReaders) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala index 2c8e5e554..9eb3ed0a8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala @@ -46,9 +46,12 @@ object PyDomainTreeTools { val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id) val forward = evolution.typesForwardReadable(domain.version)(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight) + .map { case (tier, v) => s""""${tier.wireName}": "${v.v.toString}"""" }.mkString(", ") List( q"""baboon_same_in_versions: $pyClassVar[$pyList[$pyStr]] = [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]""".stripMargin, q"""baboon_forward_readable: $pyClassVar[$pyDict[$pyStr, $pyStr]] = {$forwardEntries}""".stripMargin, + q"""baboon_min_reader_versions: $pyClassVar[$pyDict[$pyStr, $pyStr]] = {$minReaderEntries}""".stripMargin, ) } else Nil diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index 7aecb0330..b122a624b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -225,6 +225,14 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( case (v, tier) => s"""("${v.v.toString}".to_string(), "${tier.wireName}".to_string())""" }.mkString(", ") } + val minReadersByTypeId = lineage.evolution.typesForwardReadable(domain.version).keys.map { + tid => (tid.toString, lineage.evolution.minReaders(domain.version, tid)) + }.toMap + def minReaderPairs(typeId: String): String = { + minReadersByTypeId.get(typeId).toList.flatMap(_.toList.sortBy(_._1.weight)).map { + case (tier, v) => s"""("${tier.wireName}".to_string(), "${v.v.toString}".to_string())""" + }.mkString(", ") + } for ((fullPath, dynBase, typeId, _) <- types) { val binCodec = s"${dynBase}V${verSuffix}BinCodec" @@ -236,6 +244,7 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( sb.append(s""" fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" }\n""") sb.append(s""" fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["$versionStr".to_string()] }\n""") sb.append(s""" fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] }\n""") + sb.append(s""" fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![${minReaderPairs(typeId)}] }\n""") sb.append( " fn as_any(&self) -> &dyn std::any::Any { self }\n") sb.append( " fn into_any(self: Box) -> Box { self }\n") sb.append( "}\n") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala index 55ce7792a..41e9a635f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala @@ -463,6 +463,7 @@ object SwDefnTranslator { "baboonTypeIdentifier", "baboonSameInVersions", "baboonForwardReadable", + "baboonMinReaderVersions", "baboonAdtTypeIdentifier", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) @@ -845,6 +846,7 @@ object SwDefnTranslator { "baboonTypeIdentifier", "baboonSameInVersions", "baboonForwardReadable", + "baboonMinReaderVersions", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) @@ -913,6 +915,7 @@ object SwDefnTranslator { "baboonTypeIdentifier", "baboonSameInVersions", "baboonForwardReadable", + "baboonMinReaderVersions", ) val instanceForwarders = mainMeta.filter(m => providerFieldNames.contains(m.name)).map(_.instanceForwarder) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala index cded928de..c0de4106d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala @@ -104,7 +104,17 @@ object SwDomainTreeTools { "baboonForwardReadable", "[String: String]", ) - List(sameInVersion, forwardReadable) + val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + case (tier, v) => s""""${tier.wireName}": "${v.v.toString}"""" + } + val minReaders = MetaField( + q"public static let baboonMinReaderVersions: [String: String]", + q"[${minReaderEntries.mkString(", ")}]", + q"$ref.baboonMinReaderVersions", + "baboonMinReaderVersions", + "[String: String]", + ) + List(sameInVersion, forwardReadable, minReaders) } } } diff --git a/test/dt-stub/test/runtime/any_meta_codec_test.dart b/test/dt-stub/test/runtime/any_meta_codec_test.dart index bdc13f87d..e899063fe 100644 --- a/test/dt-stub/test/runtime/any_meta_codec_test.dart +++ b/test/dt-stub/test/runtime/any_meta_codec_test.dart @@ -38,6 +38,8 @@ class _StubGenerated implements BaboonGenerated, BaboonMetaProvider { List get baboonSameInVersions => ['1.0.0']; @override Map get baboonForwardReadable => const {'1.0.0': 'identical'}; + @override + Map get baboonMinReaderVersions => const {'identical': '1.0.0', 'json-additive': '1.0.0'}; } class _StubAdtBranchGenerated implements BaboonGenerated, BaboonMetaProvider, BaboonAdtMember { @@ -53,6 +55,8 @@ class _StubAdtBranchGenerated implements BaboonGenerated, BaboonMetaProvider, Ba List get baboonSameInVersions => ['1.0.0']; @override Map get baboonForwardReadable => const {'1.0.0': 'identical'}; + @override + Map get baboonMinReaderVersions => const {'identical': '1.0.0', 'json-additive': '1.0.0'}; } class _StubBinCodec extends BaboonBinCodecBase implements BaboonCodecData { @@ -791,6 +795,8 @@ class _StubGeneratedLatest implements BaboonGeneratedLatest, BaboonMetaProvider List get baboonSameInVersions => ['1.0.0']; @override Map get baboonForwardReadable => const {'1.0.0': 'identical'}; + @override + Map get baboonMinReaderVersions => const {'identical': '1.0.0', 'json-additive': '1.0.0'}; } class _StubLatestBinCodec extends BaboonBinCodecBase implements BaboonCodecData { diff --git a/test/dt-stub/test/runtime/baboon_ext_test.dart b/test/dt-stub/test/runtime/baboon_ext_test.dart index d430f5d07..59f4f8100 100644 --- a/test/dt-stub/test/runtime/baboon_ext_test.dart +++ b/test/dt-stub/test/runtime/baboon_ext_test.dart @@ -22,6 +22,8 @@ class _StubMetaProvider implements BaboonMetaProvider { final List baboonSameInVersions; @override Map get baboonForwardReadable => const {}; + @override + Map get baboonMinReaderVersions => const {}; const _StubMetaProvider({ required this.baboonDomainVersion, From 8e8f1b643362d3b2dbe3a63d60703b1e5e737a28 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 14 Sep 2026 14:19:32 +0100 Subject: [PATCH 07/48] docs(forward-compat): list envelope e2e specs Co-Authored-By: Claude Fable 5.1 --- docs/forward-compat.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index cb40bda9f..5114d3e3e 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -137,6 +137,11 @@ Two consequences of scheme 2: `test/ts-stub/.../ForwardCompat.test.ts` — real cross-version decodes over the shared `fwd-e2e-ok` model: old codec reads new compact-UEBA prefix and new JSON; desync and grown-enum negative controls. +- `test/sc-stub/.../ForwardCompatEnvelopeSpec.scala` and + `test/ts-stub/.../ForwardCompatEnvelope.test.ts` — envelope-level proof of + `$rv`: a facade registering only 1.0.0 decodes 2.0.0 envelopes under + `Tolerant` exactly where `$rv` allows, refuses under `Lossless`, and refuses + when no bound was published; `$rv` elision and `readMeta` round-trip. ## Out of scope (recorded) From 58309a158226782f32de1c4cde1ef6ba571292b7 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 14 Sep 2026 19:59:19 +0100 Subject: [PATCH 08/48] fix(envelope): reject unknown hasMinCompat flag bytes; real TS/Rust facade meta tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine binary envelope readers treated any hasMinCompat value other than 1 as "elided", so an illegal flag byte (e.g. 0x02) was silently misparsed — the min-compat string was consumed as the type identifier. codec-envelope.md §2.1 mandates rejection; readers now return None/null/undefined/Ok(None) for any value other than 0x00/0x01. Reproduced fail-first in the Scala and TypeScript stub suites (BaboonTypeMetaCodecSpec, TypeMetaFlagByte.test.ts), verified in Python directly. The TypeScript and Rust generated facade metadata registries emitted degenerate own-version tables (sameInVersions = [own], forward = {own: identical}); they now carry the real per-type sameIn / forward-readable tables like the other backends (unknown type ids resolve to empty rather than fabricated data). Co-Authored-By: Claude Fable 5.1 --- .../baboon-runtime/cs/BaboonTypeMeta.cs | 2 ++ .../baboon-runtime/dart/baboon_runtime.dart | 4 ++- .../baboon-runtime/java/BaboonTypeMeta.java | 2 ++ .../kotlin-kmp/BaboonRuntimeShared.kt | 7 +++-- .../kotlin/BaboonRuntimeShared.kt | 7 +++-- .../python/baboon_runtime_shared.py | 3 ++ .../rust/baboon_codecs_facade.rs | 4 +++ .../scala/BaboonRuntimeShared.scala | 5 ++- .../baboon-runtime/swift/baboon_runtime.swift | 4 ++- .../typescript/BaboonSharedRuntime.ts | 2 ++ .../translator/rust/RsBaboonTranslator.scala | 11 +++++-- .../typescript/TsBaboonTranslator.scala | 23 ++++++++++++-- .../runtime/BaboonTypeMetaCodecSpec.scala | 31 +++++++++++++++++++ .../runtime-tests/TypeMetaFlagByte.test.ts | 28 +++++++++++++++++ 14 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 test/ts-stub/src/runtime-tests/TypeMetaFlagByte.test.ts diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs index 7289df605..d27913276 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs @@ -219,6 +219,8 @@ public static JToken WriteJson(BaboonTypeMeta meta) var domainIdentifier = reader.ReadString(); var domainVersion = reader.ReadString(); var hasMinCompat = reader.ReadByte(); + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return null; var domainVersionMinCompat = hasMinCompat == 1 ? reader.ReadString() : domainVersion; var typeIdentifier = reader.ReadString(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index 22f8ab4dd..6e527ffc8 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -1427,10 +1427,12 @@ class BaboonTypeMetaCodec { return null; } - static BaboonTypeMeta _readMetaV1(BaboonBinReader reader) { + static BaboonTypeMeta? _readMetaV1(BaboonBinReader reader) { final d = reader.readString(); final dv = reader.readString(); final hasMinCompat = reader.readU8(); + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return null; final mc = hasMinCompat == 1 ? reader.readString() : dv; final t = reader.readString(); return BaboonTypeMeta(metaVersion, d, dv, mc, t); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java index 248b7d32a..557cfe7f7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java @@ -179,6 +179,8 @@ public static BaboonTypeMeta readMeta(LEDataInputStream reader) throws Exception String domainIdentifier = BaboonBinTools.readString(reader); String domainVersion = BaboonBinTools.readString(reader); byte hasMinCompat = reader.readByte(); + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return null; String domainVersionMinCompat = hasMinCompat == 1 ? BaboonBinTools.readString(reader) : domainVersion; String typeIdentifier = BaboonBinTools.readString(reader); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt index b51a56146..87ec2cdf6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt @@ -176,10 +176,13 @@ object BaboonTypeMetaCodec { return null } - private fun readMetaV1(reader: BaboonBinaryReader): BaboonTypeMeta { + private fun readMetaV1(reader: BaboonBinaryReader): BaboonTypeMeta? { val domainIdentifier = BaboonBinTools.readString(reader) val domainVersion = BaboonBinTools.readString(reader) - val domainVersionMinCompat = if (reader.readByte().toInt() == 1) BaboonBinTools.readString(reader) else domainVersion + val hasMinCompat = reader.readByte().toInt() + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return null + val domainVersionMinCompat = if (hasMinCompat == 1) BaboonBinTools.readString(reader) else domainVersion val typeIdentifier = BaboonBinTools.readString(reader) return BaboonTypeMeta( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt index cc148ee9f..5ac6f54be 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt @@ -185,10 +185,13 @@ object BaboonTypeMetaCodec { return null } - private fun readMetaV1(reader: LEDataInputStream): BaboonTypeMeta { + private fun readMetaV1(reader: LEDataInputStream): BaboonTypeMeta? { val domainIdentifier = BaboonBinTools.readString(reader) val domainVersion = BaboonBinTools.readString(reader) - val domainVersionMinCompat = if (reader.readByte().toInt() == 1) BaboonBinTools.readString(reader) else domainVersion + val hasMinCompat = reader.readByte().toInt() + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return null + val domainVersionMinCompat = if (hasMinCompat == 1) BaboonBinTools.readString(reader) else domainVersion val typeIdentifier = BaboonBinTools.readString(reader) return BaboonTypeMeta( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index f3460bcde..615726b01 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -703,6 +703,9 @@ def _read_meta_v1_bin(reader: LEDataInputStream) -> Optional[BaboonTypeMeta]: domain_version = reader.read_string() has_min_compat = reader.read_byte() + # codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if has_min_compat not in (0, 1): + return None if has_min_compat == 1: domain_version_min_compat = reader.read_string() else: diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index 20be55405..563a34b2d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -352,6 +352,10 @@ pub mod baboon_type_meta_codec { e, ) })?; + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if has_min_compat != 0 && has_min_compat != 1 { + return Ok(None); + } let domain_version_min_compat = if has_min_compat == 1 { bin_tools::read_string(reader).map_err(|e| { BaboonCodecError::decoder_failure_from_box( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala index d69bdb3d4..738efd925 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala @@ -511,7 +511,10 @@ package baboon.runtime.shared { private def readMetaV1(reader: LEDataInputStream): Option[BaboonTypeMeta] = { val domainIdentifier = BaboonBinTools.readString(reader) val domainVersion = BaboonBinTools.readString(reader) - val domainVersionMinCompat = if (reader.readByte() == 1) BaboonBinTools.readString(reader) else domainVersion + val hasMinCompat = reader.readByte() + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat != 0 && hasMinCompat != 1) return None + val domainVersionMinCompat = if (hasMinCompat == 1) BaboonBinTools.readString(reader) else domainVersion val typeIdentifier = BaboonBinTools.readString(reader) Some( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 9def8ceb5..8182b8ff3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -1519,10 +1519,12 @@ public enum BaboonTypeMetaCodec { return nil } - private static func readMetaV1(_ reader: BaboonBinReader) throws -> BaboonTypeMeta { + private static func readMetaV1(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { let d = try reader.readString() let dv = try reader.readString() let hasMinCompat = reader.readU8() + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if hasMinCompat != 0 && hasMinCompat != 1 { return nil } let mc = hasMinCompat == 1 ? try reader.readString() : dv let t = try reader.readString() return BaboonTypeMeta(metaVersion, d, dv, mc, t) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index a04657464..4866c32f9 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -1191,6 +1191,8 @@ export class BaboonTypeMetaCodec { const domainIdentifier = BinTools.readString(reader); const domainVersion = BinTools.readString(reader); const hasMinCompat = BinTools.readByte(reader); + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if (hasMinCompat !== 0 && hasMinCompat !== 1) return undefined; const domainVersionMinCompat = hasMinCompat === 1 ? BinTools.readString(reader) : domainVersion; const typeIdentifier = BinTools.readString(reader); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index b122a624b..ff74a3d0c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -276,10 +276,17 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // Meta struct val metaName = s"Domain${pascalDomainId}V${verSuffix}Meta" + val sameInByTypeId = lineage.evolution.typesUnchangedSince(domain.version).map { case (tid, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) } sb.append(s"struct $metaName;\n") sb.append(s"impl BaboonAnyMeta for $metaName {\n") - sb.append( " fn same_in_versions(&self, _type_id: &str) -> Vec {\n") - sb.append(s""" vec!["$versionStr".to_string()]\n""") + sb.append( " fn same_in_versions(&self, type_id: &str) -> Vec {\n") + sb.append( " match type_id {\n") + for ((_, _, typeId, _) <- types) { + val vs = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") + sb.append(s""" "$typeId" => vec![$vs],\n""") + } + sb.append( " _ => Vec::new(),\n") + sb.append( " }\n") sb.append( " }\n") sb.append( " fn forward_readable_versions(&self, type_id: &str) -> Vec<(String, String)> {\n") sb.append( " match type_id {\n") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index 8b195a6f9..8647303af 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -294,10 +294,27 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( sb.append(s""" public versionTo(): string { return '$versionStr'; }\n""") sb.append( "}\n\n") - // Meta class + // Meta class: the real per-type sameIn / forward-readable tables for this version + // (user types only; unknown type ids resolve to empty, never to fabricated own-version data). + val sameInEntries = lineage.evolution.typesUnchangedSince(domain.version).toList.collect { + case (tid: TypeId.User, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) + }.sortBy(_._1) + val forwardEntries = lineage.evolution.typesForwardReadable(domain.version).toList.collect { + case (tid: TypeId.User, fr) => (tid.toString, fr.readable.toList.map { case (v, tier) => (v.v.toString, tier.wireName) }) + }.sortBy(_._1) sb.append(s"class ${verClassName}Meta implements BaboonMeta {\n") - sb.append(s""" public sameInVersions(_typeId: string): string[] { return ['$versionStr']; }\n""") - sb.append(s""" public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { '$versionStr': 'identical' }; }\n""") + sb.append( " private static readonly SAME_IN: { readonly [typeId: string]: readonly string[] } = {\n") + for ((tid, vs) <- sameInEntries) { + sb.append(s""" "$tid": [${vs.map(v => s"'$v'").mkString(", ")}],\n""") + } + sb.append( " };\n") + sb.append( " private static readonly FORWARD: { readonly [typeId: string]: { readonly [version: string]: string } } = {\n") + for ((tid, pairs) <- forwardEntries) { + sb.append(s""" "$tid": { ${pairs.map { case (v, t) => s"'$v': '$t'" }.mkString(", ")} },\n""") + } + sb.append( " };\n") + sb.append(s""" public sameInVersions(typeId: string): string[] { return [...(${verClassName}Meta.SAME_IN[typeId] ?? [])]; }\n""") + sb.append(s""" public forwardReadableVersions(typeId: string): { readonly [version: string]: string } { return ${verClassName}Meta.FORWARD[typeId] ?? {}; }\n""") sb.append( "}\n\n") } diff --git a/test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala b/test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala index a54fa7599..e9c2fb53d 100644 --- a/test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala @@ -9,8 +9,39 @@ import baboon.runtime.shared._ import io.circe.Json import org.scalatest.funsuite.AnyFunSuite +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} + class BaboonTypeMetaCodecSpec extends AnyFunSuite { + // Hand-assembled v1 binary envelope head with an arbitrary hasMinCompat flag byte. + private def binEnvelope(flag: Int, minCompat: Option[String]): Array[Byte] = { + val baos = new ByteArrayOutputStream() + val dos = new LEDataOutputStream(baos) + dos.write(BaboonTypeMetaCodec.META_VERSION.toInt) + BaboonBinTools.writeString(dos, "com.example.dom") + BaboonBinTools.writeString(dos, "2.0.0") + dos.write(flag) + minCompat.foreach(v => BaboonBinTools.writeString(dos, v)) + BaboonBinTools.writeString(dos, "MyType") + baos.toByteArray + } + + private def readBin(bytes: Array[Byte]): Option[BaboonTypeMeta] = + BaboonTypeMeta.readMeta(new LEDataInputStream(new ByteArrayInputStream(bytes))) + + // codec-envelope.md §2.1: flag 0x00 = minCompat elided, 0x01 = minCompat follows, + // "other flag values are illegal; readers reject them". + test("binary readMeta honours flag 0 (elided) and 1 (present)") { + assert(readBin(binEnvelope(0, None)).map(_.domainVersionMinCompat).contains("2.0.0")) + assert(readBin(binEnvelope(1, Some("1.0.0"))).map(_.domainVersionMinCompat).contains("1.0.0")) + } + + test("binary readMeta rejects an unknown hasMinCompat flag byte instead of misparsing it as elided") { + // a lenient reader would treat 0x02 as "absent" and then read the min-compat string as the type id + assert(readBin(binEnvelope(2, Some("1.0.0"))).isEmpty, "flag 0x02 must be rejected (None)") + assert(readBin(binEnvelope(0xFF, None)).isEmpty, "flag 0xFF must be rejected (None)") + } + private def buildMeta(): BaboonTypeMeta = BaboonTypeMeta( BaboonTypeMetaCodec.META_VERSION, diff --git a/test/ts-stub/src/runtime-tests/TypeMetaFlagByte.test.ts b/test/ts-stub/src/runtime-tests/TypeMetaFlagByte.test.ts new file mode 100644 index 000000000..3aa2f605b --- /dev/null +++ b/test/ts-stub/src/runtime-tests/TypeMetaFlagByte.test.ts @@ -0,0 +1,28 @@ +// codec-envelope.md §2.1: the binary envelope's hasMinCompat flag is 0x00 (elided) or 0x01 +// (min-compat string follows); "other flag values are illegal; readers reject them". A lenient +// reader would treat 0x02 as "absent" and then misparse the min-compat string as the type id. +import { describe, expect, test } from 'vitest'; +import { BaboonBinReader, BaboonBinWriter, BaboonTypeMeta, BaboonTypeMetaCodec, BinTools } from '../generated/BaboonSharedRuntime'; + +function binEnvelope(flag: number, minCompat: string | undefined): Uint8Array { + const w = new BaboonBinWriter(); + BinTools.writeByte(w, BaboonTypeMetaCodec.META_VERSION); + BinTools.writeString(w, 'com.example.dom'); + BinTools.writeString(w, '2.0.0'); + BinTools.writeByte(w, flag); + if (minCompat !== undefined) BinTools.writeString(w, minCompat); + BinTools.writeString(w, 'MyType'); + return w.toBytes(); +} + +describe('binary envelope hasMinCompat flag byte', () => { + test('flag 0 (elided) and 1 (present) are honoured', () => { + expect(BaboonTypeMeta.readMeta(new BaboonBinReader(binEnvelope(0, undefined)))?.domainVersionMinCompat).toBe('2.0.0'); + expect(BaboonTypeMeta.readMeta(new BaboonBinReader(binEnvelope(1, '1.0.0')))?.domainVersionMinCompat).toBe('1.0.0'); + }); + + test('unknown flag values are rejected instead of misparsed', () => { + expect(BaboonTypeMeta.readMeta(new BaboonBinReader(binEnvelope(2, '1.0.0')))).toBeUndefined(); + expect(BaboonTypeMeta.readMeta(new BaboonBinReader(binEnvelope(0xff, undefined)))).toBeUndefined(); + }); +}); From 8d0c5f4e610c03cf9234811b079b8fa8e6420f3e Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 14 Sep 2026 21:28:47 +0100 Subject: [PATCH 09/48] refactor(codegen): build TS/Rust domain facades and GraphQL SDL with TextTree The TypeScript and Rust domain-facade generators and the GraphQL SDL translator assembled generated code with StringBuilder appends and shipped it through TextTree.verbatim / OutputFile(String). Every other emitter in the compiler builds a TextTree; these three now do too (q-interpolated templates, joinN/joinNN, shift/trim), with per-version, registration and type-definition pieces as composable subtrees instead of imperative append sequences. GraphQL doc comments and the BaboonAny scalar description are interpolated as verbatim nodes: plain String interpolation is escape-processed at render time and stripMargin would strip `|` margins from user text. Generated output is unchanged: schema.graphql files are byte-identical for the whole test model directory, and the 36 TS + 36 Rust facades differ only in the trailing newline, which now matches every other TextTree-rendered file. Co-Authored-By: Claude Fable 5.1 --- .../graphql/GqlBaboonTranslator.scala | 138 ++++---- .../translator/rust/RsBaboonTranslator.scala | 309 ++++++++++-------- .../typescript/TsBaboonTranslator.scala | 238 +++++++------- 3 files changed, 356 insertions(+), 329 deletions(-) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala index ffc79589f..56954b758 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala @@ -9,6 +9,8 @@ import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* import izumi.fundamentals.collections.nonempty.NEList import io.septimalmind.baboon.parser.model.issues.TranslationIssue +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* /** Translates Baboon domain models to GraphQL Schema Definition Language (SDL). * @@ -61,6 +63,8 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( target: GqlTarget, typeTranslator: GqlTypeTranslator, ) extends BaboonAbstractTranslator[F] { + /** SDL carries no typed references, so the tree has no value type. */ + private type GqlTree = TextTree[Nothing] override def translate(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { for { @@ -116,9 +120,9 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( } }.sorted.distinct - val sb = new StringBuilder - sb.append(s"# Generated by Baboon compiler\n") - sb.append(s"# Domain: $pkg, Version: $ver\n\n") + val header: GqlTree = + q"""# Generated by Baboon compiler + |# Domain: ${pkg.toString}, Version: ${ver.toString}""".stripMargin // Emit custom scalars for builtin overflows val builtinCustomScalars = Set( @@ -134,68 +138,62 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( val usedScalars = collectUsedCustomScalars(members, foreignResolutions) val scalarsToEmit = (builtinCustomScalars.intersect(usedScalars).toList ++ foreignScalars).sorted - scalarsToEmit.foreach { - s => - scalarDescription(s) match { - case Some(desc) => - sb.append(desc) - sb.append(s"scalar $s\n") - case None => - sb.append(s"scalar $s\n") - } + val scalars: List[GqlTree] = scalarsToEmit.map { + scalar => + val description = TextTree.verbatim[Nothing](scalarDescription(scalar).getOrElse("")) + q"${description}scalar $scalar" } - if (scalarsToEmit.nonEmpty) sb.append("\n") // Emit map entry types (deduplicated by generated name since different TypeRefs can map to the same GraphQL type) - val mapEntries = allMapTypes.toList.map { + val mapEntries: List[GqlTree] = allMapTypes.toList.map { case (keyRef, valRef) => (typeTranslator.mapEntryTypeName(keyRef, valRef), typeTranslator.fieldTypeStr(keyRef), typeTranslator.fieldTypeStr(valRef)) - }.distinctBy(_._1).sortBy(_._1) - - mapEntries.foreach { + }.distinctBy(_._1).sortBy(_._1).map { case (name, keyType, valType) => - sb.append(s"type $name {\n") - sb.append(s" key: $keyType\n") - sb.append(s" value: $valType\n") - sb.append(s"}\n\n") + q"""type $name { + | key: $keyType + | value: $valType + |}""".stripMargin } // Emit type definitions (skip ADT-owned types — they are emitted by their parent ADT) - members.foreach { + val typedefs: List[GqlTree] = members.flatMap { m => m.defn match { - case _: Typedef.NonDataTypedef => // skip services, contracts - case _: Typedef.Foreign => // skip — handled as scalars or resolved via runtimeMapping - case _ if m.ownedByAdt => // skip — emitted by parent ADT - case defn => emitTypedef(sb, defn, m, domain, foreignResolutions) + case _: Typedef.NonDataTypedef => None // skip services, contracts + case _: Typedef.Foreign => None // skip — handled as scalars or resolved via runtimeMapping + case _ if m.ownedByAdt => None // skip — emitted by parent ADT + case defn => renderTypedef(defn, m, domain, foreignResolutions) } } + val scalarBlock: List[GqlTree] = if (scalars.isEmpty) Nil else List(scalars.joinN()) + val schema: GqlTree = (List(header) ++ scalarBlock ++ mapEntries ++ typedefs).joinNN() + val versionStr = ver.toString.replace(".", "_") val pkgStr = pkg.path.toList.map(typeTranslator.sanitize).mkString("_") val filename = s"$pkgStr/v$versionStr/schema.graphql" - List(filename -> OutputFile(sb.toString, CompilerProduct.Definition)) + List(filename -> OutputFile(schema.render + "\n\n", CompilerProduct.Definition)) } - private def emitTypedef( - sb: StringBuilder, + private def renderTypedef( defn: Typedef.User, member: DomainMember.User, domain: Domain, foreignResolutions: Map[TypeId.User, Option[TypeRef]], - ): Unit = { + ): Option[GqlTree] = { defn match { case dto: Typedef.Dto => - emitDto(sb, dto, member.docs, foreignResolutions) + Some(renderDto(dto, member.docs, foreignResolutions)) case e: Typedef.Enum => - emitEnum(sb, e, member.docs) + Some(renderEnum(e, member.docs)) case adt: Typedef.Adt => - emitAdt(sb, adt, member.docs, domain, foreignResolutions) + Some(renderAdt(adt, member.docs, domain, foreignResolutions)) - case _ => // skip + case _ => None } } @@ -203,42 +201,43 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( typeTranslator.fieldTypeStr(typeTranslator.resolveTypeRef(ref, foreignResolutions)) } - private def emitDto(sb: StringBuilder, dto: Typedef.Dto, docs: Docs, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): Unit = { + /** Doc comments are user text: interpolated verbatim so backslashes survive and `|` margins are never stripped. */ + private def description(docs: Docs, indent: String): GqlTree = + TextTree.verbatim[Nothing](typeTranslator.renderGqlDescription(docs, indent)) + + private def renderDto(dto: Typedef.Dto, docs: Docs, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): GqlTree = { val name = typeTranslator.typeName(dto.id) - sb.append(typeTranslator.renderGqlDescription(docs, "")) - if (dto.fields.isEmpty) { - sb.append(s"type $name {\n") - sb.append(s" _empty: Boolean\n") - sb.append(s"}\n\n") - } else { - sb.append(s"type $name {\n") - dto.fields.foreach { - f => - sb.append(typeTranslator.renderGqlDescription(f.docs, " ")) - sb.append(s" ${typeTranslator.sanitizeName(f.name.name)}: ${resolveFieldType(f.tpe, foreignResolutions)}\n") - } - sb.append(s"}\n\n") - } + // GraphQL forbids empty object types: emit a placeholder field + val fields: List[GqlTree] = + if (dto.fields.isEmpty) List(q" _empty: Boolean") + else + dto.fields.map { + f => + q"${description(f.docs, " ")} ${typeTranslator.sanitizeName(f.name.name)}: ${resolveFieldType(f.tpe, foreignResolutions)}" + } + val body: GqlTree = + q"""type $name { + |${fields.joinN()} + |}""".stripMargin + q"${description(docs, "")}$body" } - private def emitEnum(sb: StringBuilder, e: Typedef.Enum, docs: Docs): Unit = { - val name = typeTranslator.typeName(e.id) - sb.append(typeTranslator.renderGqlDescription(docs, "")) - sb.append(s"enum $name {\n") - e.members.toList.foreach { - m => - sb.append(s" ${typeTranslator.sanitizeEnumValue(m.name)}\n") - } - sb.append(s"}\n\n") + private def renderEnum(e: Typedef.Enum, docs: Docs): GqlTree = { + val name = typeTranslator.typeName(e.id) + val values = e.members.toList.map(m => q" ${typeTranslator.sanitizeEnumValue(m.name)}") + val body: GqlTree = + q"""enum $name { + |${values.joinN()} + |}""".stripMargin + q"${description(docs, "")}$body" } - private def emitAdt( - sb: StringBuilder, + private def renderAdt( adt: Typedef.Adt, docs: Docs, domain: Domain, foreignResolutions: Map[TypeId.User, Option[TypeRef]], - ): Unit = { + ): GqlTree = { import Typedef.Adt.AdtSyntax val name = typeTranslator.typeName(adt.id) @@ -246,22 +245,21 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( val dataMembers = adt.dataMembers(domain) // Emit each branch as a type (Baboon grammar only allows DTOs as ADT branches) - val branchNames = dataMembers.map { + val branches: List[GqlTree] = dataMembers.toList.flatMap { memberId => - val branchName = typeTranslator.typeName(memberId) - domain.defs.meta.nodes.get(memberId).foreach { + domain.defs.meta.nodes.get(memberId).toList.flatMap { case u: DomainMember.User => u.defn match { - case dto: Typedef.Dto => emitDto(sb, dto, u.docs, foreignResolutions) - case _ => // skip non-DTO members (shouldn't happen per grammar) + case dto: Typedef.Dto => List(renderDto(dto, u.docs, foreignResolutions)) + case _ => Nil // skip non-DTO members (shouldn't happen per grammar) } - case _ => // skip + case _ => Nil } - branchName } + val branchNames = dataMembers.map(memberId => typeTranslator.typeName(memberId)) + val union: GqlTree = q"${description(docs, "")}union $name = ${branchNames.mkString(" | ")}" - sb.append(typeTranslator.renderGqlDescription(docs, "")) - sb.append(s"union $name = ${branchNames.mkString(" | ")}\n\n") + (branches :+ union).joinNN() } private def collectUsedCustomScalars( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index ff74a3d0c..3a5fd0b9f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -36,7 +36,7 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // generateLibRs). Without this, `baboon_mcp_server.rs` and the // per-service `*_mcp_server.rs` files are absent from `lib.rs` and the // enclosing `mod.rs`, causing `cargo build` to ignore them. - mcpSrcs <- if (target.language.generateMcpServer) mcpHook.generateMcpServer(family) else F.pure(Sources(Map.empty)) + mcpSrcs <- if (target.language.generateMcpServer) mcpHook.generateMcpServer(family) else F.pure(Sources(Map.empty)) // Convert MCP OutputFile entries to RsDefnTranslator.Output so they // flow through generateModFiles/generateLibRs. They are verbatim @@ -151,9 +151,9 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( if (!(target.language.generateUebaCodecs && target.language.generateUebaCodecsByDefault)) return List.empty if (!(target.language.generateJsonCodecs && target.language.generateJsonCodecsByDefault)) return List.empty - val evo = lineage.evolution - val latestVersion = evo.latest - val latestDomain = lineage.versions(latestVersion) + val evo = lineage.evolution + val latestVersion = evo.latest + val latestDomain = lineage.versions(latestVersion) val latestBasename = rsFiles.basename(latestDomain, evo) // Pascal-case domain name: "my.ok" → "MyOk" @@ -169,8 +169,8 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // top-level vs `ns clash`) get distinct names (`Clash` vs `ClashClash`) so the // generated `${dynBaseName}V${ver}Dyn` symbol does not collide. def collectTypes(domain: Domain): List[(String, String, String, String)] = { - val versionStr = domain.version.v.toString - val isLatest = domain.version == evo.latest + val versionStr = domain.version.v.toString + val isLatest = domain.version == evo.latest val versionTail = if (isLatest) Nil else List("v" + versionStr.replace('.', '_')) val domainParts = domain.id.path.map(_.toLowerCase) ++ versionTail domain.defs.meta.nodes.toList.flatMap { @@ -198,16 +198,24 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // Ordered versions (ascending by semver) val orderedVersions = lineage.versions.toSeq.sortBy(_._1).map { case (_, domain) => domain } - val sb = new StringBuilder - sb.append("use crate::baboon_codecs_facade::{\n") - sb.append(" AbstractBaboonJsonCodecsImpl, AbstractBaboonUebaCodecsImpl,\n") - sb.append(" BaboonAnyBinCodec, BaboonAnyJsonCodec, BaboonAnyMeta,\n") - sb.append(" BaboonCodecsFacade, BaboonDomainVersion, BaboonGeneratedDyn,\n") - sb.append("};\n") - sb.append("use crate::baboon_runtime::{BaboonBinDecode, BaboonBinEncode, BaboonCodecContext};\n") - sb.append("use std::io::{Read, Write};\n") - sb.append("use std::sync::Arc;\n") - sb.append("\n") + val header: TextTree[RsValue] = + q"""use crate::baboon_codecs_facade::{ + | AbstractBaboonJsonCodecsImpl, AbstractBaboonUebaCodecsImpl, + | BaboonAnyBinCodec, BaboonAnyJsonCodec, BaboonAnyMeta, + | BaboonCodecsFacade, BaboonDomainVersion, BaboonGeneratedDyn, + |}; + |use crate::baboon_runtime::{BaboonBinDecode, BaboonBinEncode, BaboonCodecContext}; + |use std::io::{Read, Write}; + |use std::sync::Arc;""".stripMargin + + def factoryNames(domain: Domain): (String, String, String) = { + val verSuffix = domain.version.v.toString.replace('.', '_') + ( + s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_json_codecs", + s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_bin_codecs", + s"Domain${pascalDomainId}V${verSuffix}Meta", + ) + } // Per-version: impl BaboonGeneratedDyn directly on the user type, plus per-type codec // structs, codec factory functions, meta. Implementing the trait on the user type @@ -215,146 +223,157 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // to `facade.encode_to_bin(&ctx, ...)` — closing MFACADE-PR-D's deferred round-trip // exposure note. Older versions live in their own `v` module so each rust // type carries its own version metadata via its impl; no symbol clash. - for (domain <- orderedVersions) { - val versionStr = domain.version.v.toString - val verSuffix = versionStr.replace('.', '_') - val types = collectTypes(domain) - val fwdByTypeId = lineage.evolution.typesForwardReadable(domain.version).map { case (tid, fr) => (tid.toString, fr) } - def fwdPairs(typeId: String): String = { - fwdByTypeId.get(typeId).toList.flatMap(_.readable.toList).map { - case (v, tier) => s"""("${v.v.toString}".to_string(), "${tier.wireName}".to_string())""" - }.mkString(", ") - } - val minReadersByTypeId = lineage.evolution.typesForwardReadable(domain.version).keys.map { - tid => (tid.toString, lineage.evolution.minReaders(domain.version, tid)) - }.toMap - def minReaderPairs(typeId: String): String = { - minReadersByTypeId.get(typeId).toList.flatMap(_.toList.sortBy(_._1.weight)).map { - case (tier, v) => s"""("${tier.wireName}".to_string(), "${v.v.toString}".to_string())""" - }.mkString(", ") - } + val perVersion: List[TextTree[RsValue]] = orderedVersions.toList.map { + domain => + val versionStr = domain.version.v.toString + val verSuffix = versionStr.replace('.', '_') + val types = collectTypes(domain) + val (jsonFn, binFn, metaName) = factoryNames(domain) + + val fwdByTypeId = lineage.evolution.typesForwardReadable(domain.version).map { case (tid, fr) => (tid.toString, fr) } + def fwdPairs(typeId: String): String = { + fwdByTypeId + .get(typeId).toList.flatMap(_.readable.toList).map { + case (v, tier) => s"""("${v.v.toString}".to_string(), "${tier.wireName}".to_string())""" + }.mkString(", ") + } + val minReadersByTypeId = lineage.evolution + .typesForwardReadable(domain.version).keys.map { + tid => (tid.toString, lineage.evolution.minReaders(domain.version, tid)) + }.toMap + def minReaderPairs(typeId: String): String = { + minReadersByTypeId + .get(typeId).toList.flatMap(_.toList.sortBy(_._1.weight)).map { + case (tier, v) => s"""("${tier.wireName}".to_string(), "${v.v.toString}".to_string())""" + }.mkString(", ") + } + val sameInByTypeId = lineage.evolution.typesUnchangedSince(domain.version).map { + case (tid, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) + } - for ((fullPath, dynBase, typeId, _) <- types) { - val binCodec = s"${dynBase}V${verSuffix}BinCodec" - val jsonCodec = s"${dynBase}V${verSuffix}JsonCodec" - - sb.append(s"impl BaboonGeneratedDyn for $fullPath {\n") - sb.append(s""" fn baboon_domain_version_dyn(&self) -> &str { "$versionStr" }\n""") - sb.append(s""" fn baboon_domain_identifier_dyn(&self) -> &str { "$domainIdStr" }\n""") - sb.append(s""" fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" }\n""") - sb.append(s""" fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["$versionStr".to_string()] }\n""") - sb.append(s""" fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] }\n""") - sb.append(s""" fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![${minReaderPairs(typeId)}] }\n""") - sb.append( " fn as_any(&self) -> &dyn std::any::Any { self }\n") - sb.append( " fn into_any(self: Box) -> Box { self }\n") - sb.append( "}\n") - sb.append(s"struct $binCodec;\n") - sb.append(s"impl BaboonAnyBinCodec for $binCodec {\n") - sb.append(s""" fn type_identifier(&self) -> &str { "$typeId" }\n""") - sb.append( " fn encode_dyn(&self, ctx: &BaboonCodecContext, writer: &mut dyn Write, value: &dyn BaboonGeneratedDyn) -> Result<(), crate::any_opaque::BaboonCodecError> {\n") - sb.append(s" let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure(\"${binCodec}.encode: wrong type\"))?;\n") - sb.append( " v.encode_ueba(ctx, writer).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!(\"{}\", e)))\n") - sb.append( " }\n") - sb.append( " fn decode_dyn(&self, ctx: &BaboonCodecContext, reader: &mut dyn Read) -> Result, crate::any_opaque::BaboonCodecError> {\n") - sb.append(s" let v = <$fullPath as BaboonBinDecode>::decode_ueba(ctx, reader).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!(\"{}\", e)))?;\n") - sb.append( " Ok(Box::new(v))\n") - sb.append( " }\n") - sb.append( "}\n") - sb.append(s"struct $jsonCodec;\n") - sb.append(s"impl BaboonAnyJsonCodec for $jsonCodec {\n") - sb.append(s""" fn type_identifier(&self) -> &str { "$typeId" }\n""") - sb.append( " fn encode_json_dyn(&self, _ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn) -> Result {\n") - sb.append(s" let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure(\"${jsonCodec}.encode: wrong type\"))?;\n") - sb.append( " serde_json::to_value(v).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!(\"{}\", e)))\n") - sb.append( " }\n") - sb.append( " fn decode_json_dyn(&self, _ctx: &BaboonCodecContext, wire: &serde_json::Value) -> Result, crate::any_opaque::BaboonCodecError> {\n") - sb.append(s" let v: $fullPath = serde_json::from_value(wire.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!(\"{}\", e)))?;\n") - sb.append( " Ok(Box::new(v))\n") - sb.append( " }\n") - sb.append( "}\n") - } + val perType: List[TextTree[RsValue]] = types.map { + case (fullPath, dynBase, typeId, _) => + val binCodec = s"${dynBase}V${verSuffix}BinCodec" + val jsonCodec = s"${dynBase}V${verSuffix}JsonCodec" + q"""impl BaboonGeneratedDyn for $fullPath { + | fn baboon_domain_version_dyn(&self) -> &str { "$versionStr" } + | fn baboon_domain_identifier_dyn(&self) -> &str { "$domainIdStr" } + | fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" } + | fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["$versionStr".to_string()] } + | fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] } + | fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![${minReaderPairs(typeId)}] } + | fn as_any(&self) -> &dyn std::any::Any { self } + | fn into_any(self: Box) -> Box { self } + |} + |struct $binCodec; + |impl BaboonAnyBinCodec for $binCodec { + | fn type_identifier(&self) -> &str { "$typeId" } + | fn encode_dyn(&self, ctx: &BaboonCodecContext, writer: &mut dyn Write, value: &dyn BaboonGeneratedDyn) -> Result<(), crate::any_opaque::BaboonCodecError> { + | let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure("$binCodec.encode: wrong type"))?; + | v.encode_ueba(ctx, writer).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e))) + | } + | fn decode_dyn(&self, ctx: &BaboonCodecContext, reader: &mut dyn Read) -> Result, crate::any_opaque::BaboonCodecError> { + | let v = <$fullPath as BaboonBinDecode>::decode_ueba(ctx, reader).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))?; + | Ok(Box::new(v)) + | } + |} + |struct $jsonCodec; + |impl BaboonAnyJsonCodec for $jsonCodec { + | fn type_identifier(&self) -> &str { "$typeId" } + | fn encode_json_dyn(&self, _ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn) -> Result { + | let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure("$jsonCodec.encode: wrong type"))?; + | serde_json::to_value(v).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e))) + | } + | fn decode_json_dyn(&self, _ctx: &BaboonCodecContext, wire: &serde_json::Value) -> Result, crate::any_opaque::BaboonCodecError> { + | let v: $fullPath = serde_json::from_value(wire.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))?; + | Ok(Box::new(v)) + | } + |}""".stripMargin + } - // Meta struct - val metaName = s"Domain${pascalDomainId}V${verSuffix}Meta" - val sameInByTypeId = lineage.evolution.typesUnchangedSince(domain.version).map { case (tid, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) } - sb.append(s"struct $metaName;\n") - sb.append(s"impl BaboonAnyMeta for $metaName {\n") - sb.append( " fn same_in_versions(&self, type_id: &str) -> Vec {\n") - sb.append( " match type_id {\n") - for ((_, _, typeId, _) <- types) { - val vs = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") - sb.append(s""" "$typeId" => vec![$vs],\n""") - } - sb.append( " _ => Vec::new(),\n") - sb.append( " }\n") - sb.append( " }\n") - sb.append( " fn forward_readable_versions(&self, type_id: &str) -> Vec<(String, String)> {\n") - sb.append( " match type_id {\n") - for ((_, _, typeId, _) <- types) { - sb.append(s""" "$typeId" => vec![${fwdPairs(typeId)}],\n""") - } - sb.append( " _ => Vec::new(),\n") - sb.append( " }\n") - sb.append( " }\n") - sb.append( "}\n") - - // JSON codecs factory - val jsonFn = s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_json_codecs" - sb.append(s"fn $jsonFn() -> Arc {\n") - sb.append( " let mut t = AbstractBaboonJsonCodecsImpl::new();\n") - for ((_, dynBase, typeId, _) <- types) { - val jsonCodec = s"${dynBase}V${verSuffix}JsonCodec" - sb.append(s""" t.register("$typeId", || Arc::new($jsonCodec) as Arc);\n""") - } - sb.append( " Arc::new(t)\n") - sb.append( "}\n") - - // Bin codecs factory - val binFn = s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_bin_codecs" - sb.append(s"fn $binFn() -> Arc {\n") - sb.append( " let mut t = AbstractBaboonUebaCodecsImpl::new();\n") - for ((_, dynBase, typeId, _) <- types) { - val binCodec = s"${dynBase}V${verSuffix}BinCodec" - sb.append(s""" t.register("$typeId", || Arc::new($binCodec) as Arc);\n""") - } - sb.append( " Arc::new(t)\n") - sb.append( "}\n") + // Meta struct: the real per-type sameIn / forward-readable tables for this version + val sameInArms = types.map { + case (_, _, typeId, _) => + val vs = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") + q""""$typeId" => vec![$vs],""" + } + val forwardArms = types.map { + case (_, _, typeId, _) => q""""$typeId" => vec![${fwdPairs(typeId)}],""" + } + val meta: TextTree[RsValue] = + q"""struct $metaName; + |impl BaboonAnyMeta for $metaName { + | fn same_in_versions(&self, type_id: &str) -> Vec { + | match type_id { + | ${sameInArms.joinN().shift(12).trim} + | _ => Vec::new(), + | } + | } + | fn forward_readable_versions(&self, type_id: &str) -> Vec<(String, String)> { + | match type_id { + | ${forwardArms.joinN().shift(12).trim} + | _ => Vec::new(), + | } + | } + |}""".stripMargin + + // Codec factories + val jsonRegistrations = types.map { + case (_, dynBase, typeId, _) => q"""t.register("$typeId", || Arc::new(${dynBase}V${verSuffix}JsonCodec) as Arc);""" + } + val binRegistrations = types.map { + case (_, dynBase, typeId, _) => q"""t.register("$typeId", || Arc::new(${dynBase}V${verSuffix}BinCodec) as Arc);""" + } + val factories: TextTree[RsValue] = + q"""fn $jsonFn() -> Arc { + | let mut t = AbstractBaboonJsonCodecsImpl::new(); + | ${jsonRegistrations.joinN().shift(4).trim} + | Arc::new(t) + |} + |fn $binFn() -> Arc { + | let mut t = AbstractBaboonUebaCodecsImpl::new(); + | ${binRegistrations.joinN().shift(4).trim} + | Arc::new(t) + |}""".stripMargin + + (perType ++ List(meta, factories)).joinN() } // Facade struct - sb.append(s"\npub struct $structName {\n") - sb.append( " pub facade: BaboonCodecsFacade,\n") - sb.append( "}\n\n") - sb.append(s"impl $structName {\n") - sb.append( " pub fn new() -> Self {\n") - sb.append( " let facade = BaboonCodecsFacade::new();\n") - for (domain <- orderedVersions) { - val versionStr = domain.version.v.toString - val verSuffix = versionStr.replace('.', '_') - val jsonFn = s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_json_codecs" - val binFn = s"make_${domainIdStr.replace('.', '_')}_v${verSuffix}_bin_codecs" - val metaName = s"Domain${pascalDomainId}V${verSuffix}Meta" - sb.append( " facade.register_with_meta(\n") - sb.append(s""" BaboonDomainVersion::new("$domainIdStr", "$versionStr"),\n""") - sb.append(s" $jsonFn,\n") - sb.append(s" $binFn,\n") - sb.append(s" || Arc::new($metaName) as Arc,\n") - sb.append( " );\n") + val registrations: List[TextTree[RsValue]] = orderedVersions.toList.map { + domain => + val (jsonFn, binFn, metaName) = factoryNames(domain) + q"""facade.register_with_meta( + | BaboonDomainVersion::new("$domainIdStr", "${domain.version.v.toString}"), + | $jsonFn, + | $binFn, + | || Arc::new($metaName) as Arc, + |);""".stripMargin } - sb.append(s" $structName { facade }\n") - sb.append( " }\n") - sb.append( "}\n\n") - sb.append(s"impl Default for $structName {\n") - sb.append(s" fn default() -> Self { Self::new() }\n") - sb.append( "}\n") + val facadeStruct: TextTree[RsValue] = + q"""pub struct $structName { + | pub facade: BaboonCodecsFacade, + |} + | + |impl $structName { + | pub fn new() -> Self { + | let facade = BaboonCodecsFacade::new(); + | ${registrations.joinN().shift(8).trim} + | $structName { facade } + | } + |} + | + |impl Default for $structName { + | fn default() -> Self { Self::new() } + |}""".stripMargin - val content = sb.toString() - val crate = trans.toRsCrate(latestDomain.id, latestVersion, evo) + val tree: TextTree[RsValue] = List(header, perVersion.joinN(), facadeStruct).joinNN() + val crate = trans.toRsCrate(latestDomain.id, latestVersion, evo) List( RsDefnTranslator.Output( s"$latestBasename/domain_${pascalDomainId.toLowerCase}_facade.rs", - TextTree.verbatim(content), + tree, crate, CompilerProduct.Runtime, doNotModify = true, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index 8647303af..e53023c19 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -160,9 +160,9 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // where neither codec is active (foreign-bound types) are dropped — they // emit no codec class so there is nothing for the facade to register. def collectTypes(domain: Domain): List[(String, String, String, String, Boolean, Boolean)] = { - val versionStr = domain.version.v.toString - val isLatest = domain.version == evo.latest - val verSuffix = versionStr.replace('.', '_') + val versionStr = domain.version.v.toString + val isLatest = domain.version == evo.latest + val verSuffix = versionStr.replace('.', '_') // Relative path prefix from facade file's dir (= latestBasename) to type's dir val relPrefix = if (isLatest) "." else s"./v$verSuffix" val collected = domain.defs.meta.nodes.toList.flatMap { @@ -222,130 +222,138 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val orderedVersions = lineage.versions.toSeq.sortBy(_._1).map { case (_, domain) => domain } val sfx = target.language.importSuffix - val sb = new StringBuilder // Relative path back to the generated/ root (where BaboonSharedRuntime lives). The facade // file lives at `/.ts`, so depth = segments in `latestBasename`. val rootRel = "../" * latestBasename.split('/').length - // Imports - sb.append(s"import { AbstractBaboonJsonCodecs, AbstractBaboonUebaCodecs, BaboonBinCodec, BaboonJsonCodec, BaboonDomainVersion, BaboonMeta, Lazy } from '${rootRel}BaboonSharedRuntime$sfx';\n") - sb.append(s"import type { AbstractBaboonConversions } from '${rootRel}BaboonSharedRuntime$sfx';\n") - sb.append(s"import { BaboonCodecsFacade } from '${rootRel}BaboonCodecsFacade$sfx';\n") + val runtimeImports: TextTree[TsValue] = + q"""import { AbstractBaboonJsonCodecs, AbstractBaboonUebaCodecs, BaboonBinCodec, BaboonJsonCodec, BaboonDomainVersion, BaboonMeta, Lazy } from '${rootRel}BaboonSharedRuntime$sfx'; + |import type { AbstractBaboonConversions } from '${rootRel}BaboonSharedRuntime$sfx'; + |import { BaboonCodecsFacade } from '${rootRel}BaboonCodecsFacade$sfx';""".stripMargin // Imports: emit a single line per type with only the codec flavours // actually generated for that type. A type may have only JSON, only // UEBA, or both; the type itself (class/enum/adt) is always imported // since identity registration goes through the codec class. - for (domain <- orderedVersions) { - val types = collectTypes(domain) - - for ((importPath, typeAlias, typeName, _, hasJson, hasUeba) <- types) { - val pieces = List( - Some((typeName, typeAlias)), - if (hasJson) Some((s"${typeName}_JsonCodec", s"${typeAlias}_JsonCodec")) else None, - if (hasUeba) Some((s"${typeName}_UEBACodec", s"${typeAlias}_UEBACodec")) else None, - ).flatten - val rendered = pieces.map { - case (src, alias) => if (src == alias) src else s"$src as $alias" - }.mkString(", ") - sb.append(s"import { $rendered } from '$importPath$sfx';\n") - } + val typeImports: List[TextTree[TsValue]] = orderedVersions.toList.flatMap { + domain => + collectTypes(domain).map { + case (importPath, typeAlias, typeName, _, hasJson, hasUeba) => + val pieces = List( + Some((typeName, typeAlias)), + if (hasJson) Some((s"${typeName}_JsonCodec", s"${typeAlias}_JsonCodec")) else None, + if (hasUeba) Some((s"${typeName}_UEBACodec", s"${typeAlias}_UEBACodec")) else None, + ).flatten + val rendered = pieces.map { + case (src, alias) => if (src == alias) src else s"$src as $alias" + }.mkString(", ") + q"import { $rendered } from '$importPath$sfx';" + } } - sb.append("\n") + def versionClassName(domain: Domain): String = s"Domain${pascalDomainId}V${domain.version.v.toString.replace('.', '_')}" // Per-version codec/meta/conversions classes. The JSON and UEBA codec // classes each iterate the type list filtered by their respective // activation flag, so a domain that emits only UEBA codecs produces an // empty (but well-formed) JSON codec class — keeps the facade contract // uniform without referencing non-existent symbols. - for (domain <- orderedVersions) { - val versionStr = domain.version.v.toString - val verSuffix = versionStr.replace('.', '_') - val types = collectTypes(domain) - val verClassName = s"Domain${pascalDomainId}V${verSuffix}" - - // JSON codecs class. We reference `${typeAlias}_JsonCodec.BaboonTypeIdentifier` - // (rather than `${typeAlias}.BaboonTypeIdentifier`) because TypeScript `enum`s - // cannot carry static properties — only the generated codec classes do. - // The codec class carries the same identifier string for every type kind. - sb.append(s"class ${verClassName}JsonCodecs extends AbstractBaboonJsonCodecs {\n") - sb.append( " constructor() {\n") - sb.append( " super();\n") - for ((_, typeAlias, _, _, hasJson, _) <- types if hasJson) { - sb.append(s" this.register(${typeAlias}_JsonCodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_JsonCodec.instance as unknown as BaboonJsonCodec));\n") - } - sb.append( " }\n") - sb.append( "}\n\n") - - // UEBA codecs class — same reasoning as above. - sb.append(s"class ${verClassName}UebaCodecs extends AbstractBaboonUebaCodecs {\n") - sb.append( " constructor() {\n") - sb.append( " super();\n") - for ((_, typeAlias, _, _, _, hasUeba) <- types if hasUeba) { - sb.append(s" this.register(${typeAlias}_UEBACodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_UEBACodec.instance as unknown as BaboonBinCodec));\n") - } - sb.append( " }\n") - sb.append( "}\n\n") - - // Conversions class - sb.append(s"class ${verClassName}Conversions implements AbstractBaboonConversions {\n") - sb.append(s" public versionsFrom(): string[] { return []; }\n") - sb.append(s""" public versionTo(): string { return '$versionStr'; }\n""") - sb.append( "}\n\n") - - // Meta class: the real per-type sameIn / forward-readable tables for this version - // (user types only; unknown type ids resolve to empty, never to fabricated own-version data). - val sameInEntries = lineage.evolution.typesUnchangedSince(domain.version).toList.collect { - case (tid: TypeId.User, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) - }.sortBy(_._1) - val forwardEntries = lineage.evolution.typesForwardReadable(domain.version).toList.collect { - case (tid: TypeId.User, fr) => (tid.toString, fr.readable.toList.map { case (v, tier) => (v.v.toString, tier.wireName) }) - }.sortBy(_._1) - sb.append(s"class ${verClassName}Meta implements BaboonMeta {\n") - sb.append( " private static readonly SAME_IN: { readonly [typeId: string]: readonly string[] } = {\n") - for ((tid, vs) <- sameInEntries) { - sb.append(s""" "$tid": [${vs.map(v => s"'$v'").mkString(", ")}],\n""") - } - sb.append( " };\n") - sb.append( " private static readonly FORWARD: { readonly [typeId: string]: { readonly [version: string]: string } } = {\n") - for ((tid, pairs) <- forwardEntries) { - sb.append(s""" "$tid": { ${pairs.map { case (v, t) => s"'$v': '$t'" }.mkString(", ")} },\n""") - } - sb.append( " };\n") - sb.append(s""" public sameInVersions(typeId: string): string[] { return [...(${verClassName}Meta.SAME_IN[typeId] ?? [])]; }\n""") - sb.append(s""" public forwardReadableVersions(typeId: string): { readonly [version: string]: string } { return ${verClassName}Meta.FORWARD[typeId] ?? {}; }\n""") - sb.append( "}\n\n") + // + // We reference `${typeAlias}_JsonCodec.BaboonTypeIdentifier` (rather than + // `${typeAlias}.BaboonTypeIdentifier`) because TypeScript `enum`s cannot carry + // static properties — only the generated codec classes do. The codec class + // carries the same identifier string for every type kind. + val perVersion: List[TextTree[TsValue]] = orderedVersions.toList.map { + domain => + val versionStr = domain.version.v.toString + val types = collectTypes(domain) + val verClassName = versionClassName(domain) + + val jsonRegistrations = types.collect { + case (_, typeAlias, _, _, true, _) => + q"this.register(${typeAlias}_JsonCodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_JsonCodec.instance as unknown as BaboonJsonCodec));" + } + val uebaRegistrations = types.collect { + case (_, typeAlias, _, _, _, true) => + q"this.register(${typeAlias}_UEBACodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_UEBACodec.instance as unknown as BaboonBinCodec));" + } + + // Meta class: the real per-type sameIn / forward-readable tables for this version + // (user types only; unknown type ids resolve to empty, never to fabricated own-version data). + val sameInEntries = lineage.evolution + .typesUnchangedSince(domain.version).toList.collect { + case (tid: TypeId.User, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) + }.sortBy(_._1).map { + case (tid, vs) => q""""$tid": [${vs.map(v => s"'$v'").mkString(", ")}],""" + } + val forwardEntries = lineage.evolution + .typesForwardReadable(domain.version).toList.collect { + case (tid: TypeId.User, fr) => (tid.toString, fr.readable.toList.map { case (v, tier) => (v.v.toString, tier.wireName) }) + }.sortBy(_._1).map { + case (tid, pairs) => q""""$tid": { ${pairs.map { case (v, t) => s"'$v': '$t'" }.mkString(", ")} },""" + } + + q"""class ${verClassName}JsonCodecs extends AbstractBaboonJsonCodecs { + | constructor() { + | super(); + | ${jsonRegistrations.joinN().shift(8).trim} + | } + |} + | + |class ${verClassName}UebaCodecs extends AbstractBaboonUebaCodecs { + | constructor() { + | super(); + | ${uebaRegistrations.joinN().shift(8).trim} + | } + |} + | + |class ${verClassName}Conversions implements AbstractBaboonConversions { + | public versionsFrom(): string[] { return []; } + | public versionTo(): string { return '$versionStr'; } + |} + | + |class ${verClassName}Meta implements BaboonMeta { + | private static readonly SAME_IN: { readonly [typeId: string]: readonly string[] } = { + | ${sameInEntries.joinN().shift(8).trim} + | }; + | private static readonly FORWARD: { readonly [typeId: string]: { readonly [version: string]: string } } = { + | ${forwardEntries.joinN().shift(8).trim} + | }; + | public sameInVersions(typeId: string): string[] { return [...(${verClassName}Meta.SAME_IN[typeId] ?? [])]; } + | public forwardReadableVersions(typeId: string): { readonly [version: string]: string } { return ${verClassName}Meta.FORWARD[typeId] ?? {}; } + |}""".stripMargin } // Facade class - sb.append(s"export class $className extends BaboonCodecsFacade {\n") - sb.append( " constructor() {\n") - sb.append( " super();\n") - for (domain <- orderedVersions) { - val versionStr = domain.version.v.toString - val verSuffix = versionStr.replace('.', '_') - val verClassName = s"Domain${pascalDomainId}V${verSuffix}" - sb.append( " this.register(\n") - sb.append(s""" new BaboonDomainVersion('$domainIdStr', '$versionStr'),\n""") - sb.append(s" () => new ${verClassName}JsonCodecs(),\n") - sb.append(s" () => new ${verClassName}UebaCodecs(),\n") - sb.append(s" () => new ${verClassName}Conversions(),\n") - sb.append(s" () => new ${verClassName}Meta(),\n") - sb.append( " );\n") + val registrations: List[TextTree[TsValue]] = orderedVersions.toList.map { + domain => + val verClassName = versionClassName(domain) + q"""this.register( + | new BaboonDomainVersion('$domainIdStr', '${domain.version.v.toString}'), + | () => new ${verClassName}JsonCodecs(), + | () => new ${verClassName}UebaCodecs(), + | () => new ${verClassName}Conversions(), + | () => new ${verClassName}Meta(), + |);""".stripMargin } - sb.append( " }\n") - sb.append( "}\n") - - val content = sb.toString() - val outputPath = s"$latestBasename/$className.ts" - val moduleParts = tsFileTools.definitionsBasePkg ++ outputPath.stripSuffix(".ts").split('/').toList - val moduleId = TsValue.TsModuleId(moduleParts) + val facadeClass: TextTree[TsValue] = + q"""export class $className extends BaboonCodecsFacade { + | constructor() { + | super(); + | ${registrations.joinN().shift(8).trim} + | } + |}""".stripMargin + + val imports: TextTree[TsValue] = (runtimeImports :: typeImports).joinN() + val tree: TextTree[TsValue] = List(imports, perVersion.joinNN(), facadeClass).joinNN() + val outputPath = s"$latestBasename/$className.ts" + val moduleParts = tsFileTools.definitionsBasePkg ++ outputPath.stripSuffix(".ts").split('/').toList + val moduleId = TsValue.TsModuleId(moduleParts) List( TsDefnTranslator.Output( outputPath, - TextTree.verbatim(content), + tree, moduleId, CompilerProduct.Runtime, doNotModify = true, @@ -602,7 +610,8 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( * interpolated as a plain string (service interfaces, client/wiring classes * and functions) are invisible to it. A textual scan catches those so a * non-empty wiring/client/service file is barrel-able while a genuinely empty - * one (e.g. wiring suppressed by flags) is not (avoids TS2306). */ + * one (e.g. wiring suppressed by flags) is not (avoids TS2306). + */ private def hasExports(output: TsDefnTranslator.Output): Boolean = { if (exportedNames(output).nonEmpty) true else { @@ -694,10 +703,11 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val prefix = s"${info.serviceDirPath}/" val methodDirs = pathsWithExports .filter(_.startsWith(prefix)) - .flatMap { p => - val rest = p.drop(prefix.length) - val slash = rest.indexOf('/') - if (slash >= 0) Some(rest.substring(0, slash)) else None + .flatMap { + p => + val rest = p.drop(prefix.length) + val slash = rest.indexOf('/') + if (slash >= 0) Some(rest.substring(0, slash)) else None } .toList .distinct @@ -718,9 +728,9 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val indexLines: List[TextTree[TsValue]] = (if (methodDirs.nonEmpty) List(TextTree.text[TsValue](s"export * as methods from './methods$sfx';")) else Nil) ++ - (if (hasService) List(TextTree.text[TsValue](s"export * from './service$sfx';")) else Nil) ++ - (if (hasClient) List(TextTree.text[TsValue](s"export * from './client$sfx';")) else Nil) ++ - (if (hasWiring) List(TextTree.text[TsValue](s"export * from './wiring$sfx';")) else Nil) + (if (hasService) List(TextTree.text[TsValue](s"export * from './service$sfx';")) else Nil) ++ + (if (hasClient) List(TextTree.text[TsValue](s"export * from './client$sfx';")) else Nil) ++ + (if (hasWiring) List(TextTree.text[TsValue](s"export * from './wiring$sfx';")) else Nil) val indexBarrel: Option[TsDefnTranslator.Output] = if (indexLines.isEmpty) None @@ -775,16 +785,16 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // empty .ts) must be skipped from the barrel — `export * from './X'` against an empty // file produces TS2306 "File ... is not a module" on strict tsc/deno. val fileExports = sortedFiles.map(f => (f, exportedNames(f))).filter(_._2.nonEmpty) - // D45: a file whose only export is verbatim/string-form (the domain facade is emitted as - // TextTree.verbatim with a plain-text `export class ...Facade extends BaboonCodecsFacade`) + // D45: a file whose only export is string-form (the domain facade is a string-only TextTree + // carrying a plain-text `export class ...Facade extends BaboonCodecsFacade`) // contributes no TsValue.TsType nodes, so exportedNames is empty and it is dropped from // fileExports above. Detect such files via the textual hasExports fallback (the same // predicate generateServiceBarrels uses) and re-export each with a plain `export *`. They // carry no AST names, so they cannot collide with named exports and need no collision // handling — the same treatment collision-free named files already receive. val verbatimExportFiles = sortedFiles.filter(f => exportedNames(f).isEmpty && hasExports(f)) - val nameCount = fileExports.flatMap(_._2.keys).groupBy(identity).view.mapValues(_.size).toMap - val colliding = nameCount.filter(_._2 > 1).keySet + val nameCount = fileExports.flatMap(_._2.keys).groupBy(identity).view.mapValues(_.size).toMap + val colliding = nameCount.filter(_._2 > 1).keySet val reexports = if (colliding.isEmpty) { // No collisions — simple export * for all files with exports From 840795e2e37cc4a2e985600cd27ab51b4f05b7f4 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 00:23:40 +0100 Subject: [PATCH 10/48] feat(envelope): writer-side ForwardWritePolicy for the UEBA bound; readers decode newer payloads with their newest codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 binary envelope has one bound slot, domainVersionMinCompat, and its layout is frozen (a new field, a flag bit or a trailer would all be a format change). Forward reads for UEBA therefore ride on the VALUE of that slot, chosen by the writer through ForwardWritePolicy on BaboonCodecContext, next to the index mode the bound depends on: - Strict (default): the byte-identical bound, exactly as before. - Tolerant: the prefix-read bound from baboonMinReaderVersions for the payload's index mode (prefix-compact for compact, prefix-any-mode for indexed). Equal to the Strict bound when no prefix relationship exists, so such envelopes stay byte-identical. Readers in all nine runtimes now trust the bound: a payload from a version newer than every registered one is decoded with the reader's newest codec as soon as the bound reaches a registered version, instead of with the bound version's codec via the sameIn scan. Readability is monotone along the chain, so the newest codec is correct under both bound semantics and loses the fewest fields. Reproduced fail-first over a new three-version shared fixture (fwd-e2e-chain-ok): a reader registering 1.0.0 and 2.0.0 decoded a 3.0.0 envelope bound at 1.0.0 as ChainAppend(1) and dropped the 2.0.0 field. The cost is documented in codec-envelope.md §2.1.2 and forward-compat.md: a binary reader cannot tell a Tolerant envelope from a byte-identical one, so ForwardReadPolicy.Lossless has no effect on binary reads and re-encoding intermediaries must run at the writer's version or newer. The Rust runtime crossed the JVM 64KB embedded-constant limit with this change; BaboonTypeMeta and its wire codec moved to baboon_type_meta.rs and are re-exported from baboon_codecs_facade.rs so every existing path still resolves (CLAUDE.md records the symptom and the fix). E2E coverage: ForwardCompatBinEnvelopeSpec (Scala stub) and ForwardCompatBinEnvelope.test.ts (TS stub) — Strict unchanged/refused, Tolerant compact decoded by an old reader, json-additive/identical/grown-enum envelopes identical to Strict, indexed context not lowering a var-len append, and the three-version chain decoded with the mid reader's newest codec. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 1 + .../baboon-runtime/cs/BaboonCodecs.cs | 31 +- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 40 ++- .../baboon-runtime/cs/BaboonTypeMeta.cs | 23 ++ .../dart/baboon_codecs_facade.dart | 28 +- .../baboon-runtime/dart/baboon_runtime.dart | 47 +++ .../java/BaboonCodecContext.java | 31 +- .../java/BaboonCodecsFacade.java | 31 +- .../baboon-runtime/java/BaboonTypeMeta.java | 36 +- .../baboon-runtime/kotlin-kmp/BaboonCodecs.kt | 21 ++ .../kotlin-kmp/BaboonCodecsFacade.kt | 26 +- .../kotlin-kmp/BaboonRuntimeShared.kt | 20 ++ .../baboon-runtime/kotlin/BaboonCodecs.kt | 21 ++ .../kotlin/BaboonCodecsFacade.kt | 26 +- .../kotlin/BaboonRuntimeShared.kt | 20 ++ .../baboon-runtime/python/baboon_codecs.py | 26 +- .../python/baboon_codecs_facade.py | 38 ++- .../python/baboon_runtime_shared.py | 3 + .../rust/baboon_codecs_facade.rs | 316 +++--------------- .../baboon-runtime/rust/baboon_runtime.rs | 50 +++ .../baboon-runtime/rust/baboon_type_meta.rs | 268 +++++++++++++++ .../baboon-runtime/scala/BaboonCodecs.scala | 24 ++ .../scala/BaboonCodecsFacade.scala | 30 +- .../scala/BaboonRuntimeShared.scala | 20 ++ .../swift/BaboonCodecsFacade.swift | 38 ++- .../baboon-runtime/swift/baboon_runtime.swift | 51 +++ .../typescript/BaboonCodecsFacade.ts | 30 +- .../typescript/BaboonSharedRuntime.ts | 64 +++- .../translator/rust/RsBaboonTranslator.scala | 7 + .../fwd-e2e-chain-ok/fwd-chain-1.baboon | 16 + .../fwd-e2e-chain-ok/fwd-chain-2.baboon | 12 + .../fwd-e2e-chain-ok/fwd-chain-3.baboon | 13 + docs/forward-compat.md | 44 ++- docs/spec/codec-envelope.md | 46 ++- .../ForwardCompatBinEnvelopeSpec.scala | 130 +++++++ .../ForwardCompatBinEnvelope.test.ts | 173 ++++++++++ 36 files changed, 1412 insertions(+), 389 deletions(-) create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-1.baboon create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-2.baboon create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-3.baboon create mode 100644 test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala create mode 100644 test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 84c2a0670..f6d82fd79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ mdl :test-service-acceptance # service-flavour RPC wiring round-trips - **Local Kotlin OOM workaround:** `mdl :test` runs language test actions in parallel by default. On laptops with <16 GB RAM the Kotlin compiler daemon OOMs under the parallel matrix (documented in `docs/logs/20260428-2350-m16-closeout-log.md`). Run `mdl --seq :build :test` to force serial execution. Slower (~20 min) but completes on memory-constrained machines. CI uses default parallelism. - **sbt-git (jgit) cannot build inside a linked git worktree** (`NoWorkTreeException: Bare Repository`). When running `mdl :build` or `sbt` from a `git worktree add`-created directory (whose `.git` is a file, not a directory), sbt-git throws this error. Work around by cloning the repo to a real directory (e.g., `git clone /tmp/baboon-ci-clone`) and running the pipeline from there. - **Swift 64KB-constant limit (D7/T23):** `baboon_runtime.swift` was split into `baboon_runtime.swift` (pure domain runtime) and `baboon_service_wiring.swift` (service-wiring runtime) to stay within the JVM 64KB bytecode-constant limit hit when the Swift runtime is embedded via `PortableResource.embedSources`. After modifying `baboon-compiler/src/main/resources/baboon-runtime/`, run `sbt clean` before `sbt compile` (the macro caches content per build). +- **Rust 64KB-constant limit (forward-compat envelope work, 2026-09):** `baboon_codecs_facade.rs` crossed the same JVM string-constant limit, so `BaboonTypeMeta` and `mod baboon_type_meta_codec` live in `baboon_type_meta.rs` and are re-exported from the facade (all `crate::baboon_codecs_facade::BaboonTypeMeta` paths still resolve). The compile error to recognise is `UTF8 string too large ... bad String constant of length N` while emitting `BaboonRuntimeResources$`; the fix is always to split the runtime file and add an `Output` entry for the new file in the translator's shared-runtime list. - **Swift `@BaboonIndirect` recursive-DTO boxing (D8/T23):** Swift value types (`struct`) cannot have stored properties of their own type (infinite size). Baboon detects recursive DTO fields and wraps them in `@BaboonIndirect` (a property-wrapper that boxes the value behind a `Box` class). Any new test model that includes recursive DTOs in Swift must compile with this boxing in place. - **Scala MCP requires Either-mode serviceResult (D24/T69):** the Scala MCP dispatch runtime (`AbstractBaboonMcpServer.handle` in `baboon-runtime/scala/BaboonMcpRuntime.scala`) is synchronous and Either-shaped — `handle` matches `Right`/`Left` on the wiring's `invokeJson` result to drive MCP Channel-A/B. It is NOT generic over the configurable `serviceResult` container that `ScServiceWiringTranslator` honors. Combining a non-Either serviceResult (HKT `F[_,_]`, a custom result type, or `--service-result-no-errors=true`) with `--scala-generate-mcp-server=true` is rejected up front with `TranslationIssue.ScalaMcpRequiresEither` (an actionable compiler error) rather than emitting a delegate whose type mismatches the wiring container. The Scala MCP lane (`test-gen-scala-mcp`) therefore stays on `--service-result-type=Either`. Making Scala MCP container-generic is the async-axis (D24) rework, out of scope here. - **MCP stub model directory isolation (D9/T24):** The MCP stub model lives at `baboon-compiler/src/test/resources/mcp-stub-ok/` — intentionally OUTSIDE the shared `baboon-compiler/src/test/resources/baboon/` model-dir. All `test-gen-*-wiring` and `test-gen-{regular,wrapped}-adt` actions use `--model-dir ./baboon-compiler/src/test/resources/baboon/` and therefore scan every subdirectory. Placing the MCP stub inside the shared dir caused cross-namespace fixture-reference failures in 8 wiring/test lanes (D9). The dedicated `test-gen--mcp` actions point at `--model-dir ./baboon-compiler/src/test/resources/mcp-stub-ok/`. A minimal recursive-only `Tree` model (`baboon-compiler/src/test/resources/baboon/recursive-ok/recursive.baboon`) preserves recursive-DTO coverage in the shared matrix without the cross-namespace hazard. diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs index b2de37487..3a0d1de03 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs @@ -32,28 +32,51 @@ public interface IBaboonCodecData public string BaboonTypeIdentifier(); } + /// + /// Which lower bound the WRITER publishes as the UEBA envelope's DomainVersionMinCompat + /// (the v1 binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope + /// integration (UEBA)"). Strict: the byte-identical bound (BaboonSameInVersions()[0]) — the + /// default. Tolerant: the prefix-read bound for the chosen index mode (prefix-compact for + /// compact payloads, prefix-any-mode for indexed ones); readers older than the writer then + /// decode the payload with their newest codec, dropping the appended fields they do not know. A + /// reader cannot distinguish such an envelope from a byte-identical one, so re-encoding + /// intermediaries must run at the writer's version or newer. + /// + public enum ForwardWritePolicy + { + Strict, + Tolerant, + } + public class BaboonCodecContext { // Accept null facade for the bare singletons (Compact/Indexed). The runtime helper // `WithFacade(...)` is the single intended construction path for ctxes that thread // a facade through generated codec calls — see PR 3.1's facade plumbing for the // `any`-feature cross-format conversion (Q6 option (a) in the design plan). - private BaboonCodecContext(bool useIndexes, BaboonCodecsFacade? facade) + private BaboonCodecContext(bool useIndexes, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade? facade) { UseIndices = useIndexes; + ForwardWritePolicy = forwardWritePolicy; Facade = facade; } public bool UseIndices { get; } + public ForwardWritePolicy ForwardWritePolicy { get; } + public BaboonCodecsFacade? Facade { get; } - public static BaboonCodecContext Indexed { get; } = new(true, null); - public static BaboonCodecContext Compact { get; } = new(false, null); + public static BaboonCodecContext Indexed { get; } = new(true, ForwardWritePolicy.Strict, null); + public static BaboonCodecContext Compact { get; } = new(false, ForwardWritePolicy.Strict, null); public static BaboonCodecContext Default { get; } = Compact; public static BaboonCodecContext WithFacade(bool useIndices, BaboonCodecsFacade facade) => - new(useIndices, facade); + new(useIndices, ForwardWritePolicy.Strict, facade); + + /// Fully specified context: index mode, writer-side forward policy and optional facade. + public static BaboonCodecContext Custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade? facade) => + new(useIndices, forwardWritePolicy, facade); } public interface IBaboonCodec : IBaboonCodecData diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index 6e1f280eb..044cbd0cf 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -40,7 +40,10 @@ public class BaboonCodecsFacade { private const string CONTENT_JSON_KEY = "$c"; - /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + /// + /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, DomainVersionMinCompat, + /// whose meaning is fixed by the WRITER's ; binary reads always trust it. + /// public ForwardReadPolicy ForwardReadPolicy { get; set; } = ForwardReadPolicy.Tolerant; private readonly ConcurrentDictionary> _versionsCodecsJson = new(); @@ -308,7 +311,7 @@ public Either EncodeToBin(BaboonCodecContext ctx, public Either EncodeToBin(BaboonCodecContext ctx, BinaryWriter writer, T value, BaboonTypeMeta? typeMetaOverride) where T : IBaboonGenerated { - var typeMeta = BaboonTypeMeta.From(value, typeof(T)); + var typeMeta = BaboonTypeMeta.ForBin(value, typeof(T), ctx); var codecResult = GetBinCodec(typeMeta, exact: true); if (codecResult.IsLeft) { @@ -1036,24 +1039,29 @@ bool tolerant var minVersion = versions[0]; var maxVersion = versions[^1]; - BaboonDomainVersion modelVersion; - var lookupVersion = typeMeta.VersionRef; - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - var lowerBound = tolerant ? typeMeta.VersionReadableMin : typeMeta.VersionMinCompat; - if (lowerBound is not null && lookupVersion.Version > maxVersion.Version) - { - modelVersion = lowerBound; - } - else - { - modelVersion = lookupVersion; - } - + var modelVersion = typeMeta.VersionRef; var modelV = modelVersion.Version; var maxV = maxVersion.Version; var minV = minVersion.Version; + if (!exact && modelV > maxV) + { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + var lowerBound = tolerant ? typeMeta.VersionReadableMin : typeMeta.VersionMinCompat; + if (lowerBound is not null && lowerBound.Version <= maxV) + { + return GetCodecExact(versionsCodecs, maxVersion, typeMeta.TypeIdentifier); + } + return Either.Left(new BaboonCodecException.CodecNotFound( + $"Unsupported domain version '{modelVersion}'." + )); + } + // exact=true: caller knows the model version and wants its codec — never substitute. // exact=false at the latest registered version (PR-07-D02): single-version-domain case; // would otherwise fall through every range arm because the strictly-less-than bound on diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs index d27913276..c73945d88 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs @@ -85,6 +85,9 @@ public BaboonTypeMeta(byte MetaVersion, string DomainIdentifier, string DomainVe /// Tier key of the JSON envelope's readable-min bound in BaboonMinReaderVersions(). public const string JSON_READABLE_TIER = "json-additive"; + /// Tier keys of the UEBA prefix bounds in BaboonMinReaderVersions(), per index mode. + public const string UEBA_PREFIX_COMPACT_TIER = "prefix-compact"; + public const string UEBA_PREFIX_ANY_MODE_TIER = "prefix-any-mode"; public BaboonDomainVersion VersionRef => new BaboonDomainVersion(DomainIdentifier, DomainVersion); @@ -154,6 +157,26 @@ public static BaboonTypeMeta From(IBaboonGenerated value, Type? declaredType) ); } + /// + /// Envelope for a UEBA payload written under : + /// with DomainVersionMinCompat lowered to the prefix bound of the context's index mode when the + /// writer policy is . + /// + public static BaboonTypeMeta ForBin(IBaboonGenerated value, Type? declaredType, BaboonCodecContext ctx) + { + var meta = From(value, declaredType); + if (ctx.ForwardWritePolicy == ForwardWritePolicy.Strict) + { + return meta; + } + var tier = ctx.UseIndices ? UEBA_PREFIX_ANY_MODE_TIER : UEBA_PREFIX_COMPACT_TIER; + if (!value.BaboonMinReaderVersions().TryGetValue(tier, out var bound)) + { + throw new InvalidOperationException($"BaboonMinReaderVersions() lacks '{tier}' for type {value.BaboonTypeIdentifier()}"); + } + return meta with { DomainVersionMinCompat = bound }; + } + public static BaboonTypeMeta? ReadMeta(BinaryReader reader) => BaboonTypeMetaCodec.ReadMeta(reader); public static BaboonTypeMeta? ReadMeta(JToken json) => BaboonTypeMetaCodec.ReadMeta(json); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart index 1239eaf9b..cccb7ea75 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart @@ -37,7 +37,8 @@ const String _CONTENT_JSON_KEY = r'$c'; enum ForwardReadPolicy { lossless, tolerant } class BaboonCodecsFacade extends BaboonCodecsFacadeBase { - /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + /// whose meaning is fixed by the WRITER's [ForwardWritePolicy]; binary reads always trust it. ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.tolerant; final Map> _versionsCodecsJson = {}; @@ -236,7 +237,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { }) { final BaboonTypeMeta typeMeta; try { - typeMeta = BaboonTypeMeta.from(value, useAdtIdentifier: useAdtIdentifier); + typeMeta = BaboonTypeMeta.forBin(value, ctx, useAdtIdentifier: useAdtIdentifier); } catch (e) { return BaboonLeft(BaboonEncoderFailure('Cannot derive type meta from value: $e', e)); } @@ -594,18 +595,25 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { final minVersion = versions.first; final maxVersion = versions.last; - final lookupVersion = typeMeta.versionRef(); - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - final lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); - final modelVersion = (lowerBound != null && lookupVersion.version.compareTo(maxVersion.version) > 0) - ? lowerBound - : lookupVersion; - + final modelVersion = typeMeta.versionRef(); final modelV = modelVersion.version; final maxV = maxVersion.version; final minV = minVersion.version; + if (!exact && modelV.compareTo(maxV) > 0) { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + final lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + if (lowerBound != null && lowerBound.version.compareTo(maxV) <= 0) { + return _getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier); + } + return BaboonLeft(BaboonCodecNotFound("Unsupported domain version '$modelVersion'.")); + } + if (exact && modelV.compareTo(maxV) == 0) { return _getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index 6e527ffc8..3deb5b74b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -33,10 +33,21 @@ abstract class BaboonCodecsFacadeBase { /// (UEBA <-> JSON) can resolve codecs by `(domain, version, typeid)` from an `AnyMeta` envelope. /// `null` for the bare [defaultCtx]/[indexed]/[compact] singletons; [withFacade] is the single /// intended construction path for ctxes that thread a facade. Mirrors Scala/C#/Java/Kotlin/TS. +/// Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 +/// binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration +/// (UEBA)"). [strict]: the byte-identical bound (`baboonSameInVersions.first`) — the default. +/// [tolerant]: the prefix-read bound for the chosen index mode (`prefix-compact` for compact +/// payloads, `prefix-any-mode` for indexed ones); readers older than the writer then decode the +/// payload with their newest codec, dropping the appended fields they do not know. A reader cannot +/// distinguish such an envelope from a byte-identical one, so re-encoding intermediaries must run +/// at the writer's version or newer. +enum ForwardWritePolicy { strict, tolerant } + abstract class BaboonCodecContext { const BaboonCodecContext(); bool get useIndices; + ForwardWritePolicy get forwardWritePolicy => ForwardWritePolicy.strict; BaboonCodecsFacadeBase? get facade => null; static const BaboonCodecContext defaultCtx = _BaboonCodecContextCompact(); @@ -45,6 +56,23 @@ abstract class BaboonCodecContext { static BaboonCodecContext withFacade(bool useIndices, BaboonCodecsFacadeBase facade) => _BaboonCodecContextWithFacade(useIndices, facade); + + /// Fully specified context: index mode, writer-side forward policy and optional facade. + static BaboonCodecContext custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacadeBase? facade) => + _BaboonCodecContextCustom(useIndices, forwardWritePolicy, facade); +} + +class _BaboonCodecContextCustom extends BaboonCodecContext { + final bool _useIndices; + final ForwardWritePolicy _forwardWritePolicy; + final BaboonCodecsFacadeBase? _facade; + const _BaboonCodecContextCustom(this._useIndices, this._forwardWritePolicy, this._facade); + @override + bool get useIndices => _useIndices; + @override + ForwardWritePolicy get forwardWritePolicy => _forwardWritePolicy; + @override + BaboonCodecsFacadeBase? get facade => _facade; } class _BaboonCodecContextCompact extends BaboonCodecContext { @@ -1290,6 +1318,9 @@ class BaboonTypeMeta { /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. static const String jsonReadableTier = 'json-additive'; + /// Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. + static const String uebaPrefixCompactTier = 'prefix-compact'; + static const String uebaPrefixAnyModeTier = 'prefix-any-mode'; const BaboonTypeMeta( this.metaVersion, @@ -1387,6 +1418,22 @@ class BaboonTypeMeta { ); } + /// Envelope for a UEBA payload written under [ctx]: [from] with `domainVersionMinCompat` lowered + /// to the prefix bound of the context's index mode when the writer policy is + /// [ForwardWritePolicy.tolerant]. + static BaboonTypeMeta forBin(BaboonGenerated value, BaboonCodecContext ctx, {bool useAdtIdentifier = false}) { + final meta = from(value, useAdtIdentifier: useAdtIdentifier); + if (ctx.forwardWritePolicy == ForwardWritePolicy.strict) return meta; + final tier = ctx.useIndices ? uebaPrefixAnyModeTier : uebaPrefixCompactTier; + final bound = (value as BaboonMetaProvider).baboonMinReaderVersions[tier]; + if (bound == null) { + throw BaboonException( + 'BaboonTypeMeta.forBin: baboonMinReaderVersions lacks "$tier" for type [${meta.domainIdentifier}.${meta.typeIdentifier}]', + ); + } + return BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin); + } + @override bool operator ==(Object other) => identical(this, other) || diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java index 5c51c7ec4..83d09dd13 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java @@ -14,11 +14,25 @@ * compatibility (`BaboonCodecContext.Compact` etc. still resolves the same way). */ public final class BaboonCodecContext { + /** + * Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 + * binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration + * (UEBA)"). STRICT: the byte-identical bound (`baboonSameInVersions.get(0)`) — the default. + * TOLERANT: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + * payloads, `prefix-any-mode` for indexed ones); readers older than the writer then decode the + * payload with their newest codec, dropping the appended fields they do not know. A reader + * cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries + * must run at the writer's version or newer. + */ + public enum ForwardWritePolicy { STRICT, TOLERANT } + private final boolean useIndices; + private final ForwardWritePolicy forwardWritePolicy; private final BaboonCodecsFacade facade; - private BaboonCodecContext(boolean useIndices, BaboonCodecsFacade facade) { + private BaboonCodecContext(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade facade) { this.useIndices = useIndices; + this.forwardWritePolicy = forwardWritePolicy; this.facade = facade; } @@ -26,15 +40,24 @@ public boolean useIndices() { return useIndices; } + public ForwardWritePolicy forwardWritePolicy() { + return forwardWritePolicy; + } + public BaboonCodecsFacade facade() { return facade; } - public static final BaboonCodecContext Indexed = new BaboonCodecContext(true, null); - public static final BaboonCodecContext Compact = new BaboonCodecContext(false, null); + public static final BaboonCodecContext Indexed = new BaboonCodecContext(true, ForwardWritePolicy.STRICT, null); + public static final BaboonCodecContext Compact = new BaboonCodecContext(false, ForwardWritePolicy.STRICT, null); public static final BaboonCodecContext Default = Compact; public static BaboonCodecContext withFacade(boolean useIndices, BaboonCodecsFacade facade) { - return new BaboonCodecContext(useIndices, facade); + return new BaboonCodecContext(useIndices, ForwardWritePolicy.STRICT, facade); + } + + /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + public static BaboonCodecContext custom(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade facade) { + return new BaboonCodecContext(useIndices, forwardWritePolicy, facade); } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java index a60ed11b5..b0ea3c7fe 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java @@ -38,7 +38,10 @@ public class BaboonCodecsFacade { */ public enum ForwardReadPolicy { LOSSLESS, TOLERANT } - /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + /** + * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + * whose meaning is fixed by the WRITER's `BaboonCodecContext.ForwardWritePolicy`; binary reads always trust it. + */ private volatile ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.TOLERANT; public ForwardReadPolicy getForwardReadPolicy() { return forwardReadPolicy; } @@ -230,7 +233,7 @@ public BaboonEither encodeToBin(BaboonCodecContext public BaboonEither encodeToBin( BaboonCodecContext ctx, BaboonGenerated value, BaboonTypeMeta typeMetaOverride ) { - BaboonTypeMeta typeMeta = BaboonTypeMeta.from(value, value.getClass()); + BaboonTypeMeta typeMeta = BaboonTypeMeta.forBin(value, value.getClass(), ctx); BaboonEither codecResult = getBinCodec(typeMeta, true); if (codecResult instanceof BaboonEither.Left l) { return BaboonEither.left(l.value()); @@ -597,18 +600,26 @@ private BaboonEither 0) - ? lowerBound - : lookupVersion; - + BaboonDomainVersion modelVersion = typeMeta.versionRef(); BaboonVersion modelV = modelVersion.version(); BaboonVersion maxV = maxVersion.version(); BaboonVersion minV = minVersion.version(); + if (!exact && modelV.compareTo(maxV) > 0) { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + BaboonDomainVersion lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + if (lowerBound != null && lowerBound.version().compareTo(maxV) <= 0) { + return getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier()); + } + return BaboonEither.left(new BaboonCodecException.CodecNotFound( + "Unsupported domain version '" + modelVersion + "'.")); + } + if (exact && modelV.compareTo(maxV) == 0) { return getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier()); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java index 557cfe7f7..3c44f795c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java @@ -28,6 +28,9 @@ public BaboonTypeMeta(byte metaVersion, String domainIdentifier, String domainVe /** Tier key of the JSON envelope's readable-min bound in the generated `baboonMinReaderVersions`. */ public static final String JSON_READABLE_TIER = "json-additive"; + /** Tier keys of the UEBA prefix bounds in the generated `baboonMinReaderVersions`, per index mode. */ + public static final String UEBA_PREFIX_COMPACT_TIER = "prefix-compact"; + public static final String UEBA_PREFIX_ANY_MODE_TIER = "prefix-any-mode"; public BaboonDomainVersion versionRef() { return new BaboonDomainVersion(domainIdentifier, domainVersion); @@ -86,12 +89,7 @@ public static BaboonTypeMeta from(BaboonGenerated value, Class declaredType) } String minCompat = sameIn.get(0); - Map minReaders; - try { - minReaders = (Map) actual.getField("baboonMinReaderVersions").get(null); - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + actual.getName() + " is missing static field 'baboonMinReaderVersions'", e); - } + Map minReaders = readMinReaderVersions(actual); String readableMin = minReaders.get(JSON_READABLE_TIER); if (readableMin == null) { throw new BaboonException("Type " + actual.getName() + ": baboonMinReaderVersions lacks '" + JSON_READABLE_TIER + "'"); @@ -107,6 +105,32 @@ public static BaboonTypeMeta from(BaboonGenerated value, Class declaredType) ); } + /** + * Envelope for a UEBA payload written under `ctx`: {@link #from} with `domainVersionMinCompat` + * lowered to the prefix bound of the context's index mode when the writer policy is TOLERANT. + */ + public static BaboonTypeMeta forBin(BaboonGenerated value, Class declaredType, BaboonCodecContext ctx) { + BaboonTypeMeta meta = from(value, declaredType); + if (ctx.forwardWritePolicy() == BaboonCodecContext.ForwardWritePolicy.STRICT) { + return meta; + } + String tier = ctx.useIndices() ? UEBA_PREFIX_ANY_MODE_TIER : UEBA_PREFIX_COMPACT_TIER; + String bound = readMinReaderVersions(value.getClass()).get(tier); + if (bound == null) { + throw new BaboonException("Type " + value.getClass().getName() + ": baboonMinReaderVersions lacks '" + tier + "'"); + } + return new BaboonTypeMeta(meta.metaVersion(), meta.domainIdentifier(), meta.domainVersion(), bound, meta.typeIdentifier(), meta.domainVersionReadableMin()); + } + + @SuppressWarnings("unchecked") + private static Map readMinReaderVersions(Class actual) { + try { + return (Map) actual.getField("baboonMinReaderVersions").get(null); + } catch (ReflectiveOperationException e) { + throw new BaboonException("Type " + actual.getName() + " is missing static field 'baboonMinReaderVersions'", e); + } + } + private static String readStaticString(Class klass, String name) { try { Object v = klass.getField(name).get(null); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt index b00ddaaf3..5b93735a0 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt @@ -11,8 +11,21 @@ interface BaboonCodecData { val baboonTypeIdentifier: String } +/** + * Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 + * binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration (UEBA)"). + * - Strict: the byte-identical bound (`baboonSameInVersions.first()`) — the default. + * - Tolerant: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + * payloads, `prefix-any-mode` for indexed ones). Readers older than the writer then decode the + * payload with their newest codec, dropping the appended fields they do not know. A reader + * cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries + * must run at the writer's version or newer. + */ +enum class ForwardWritePolicy { Strict, Tolerant } + interface BaboonCodecContext { val useIndices: Boolean + val forwardWritePolicy: ForwardWritePolicy get() = ForwardWritePolicy.Strict /** Optional facade reference, threaded through generated codec calls so the `any`-feature * cross-format conversion (UEBA ↔ JSON) can resolve codecs by `(domain, version, typeid)` @@ -29,6 +42,14 @@ interface BaboonCodecContext { override val useIndices: Boolean = useIndices override val facade: BaboonCodecsFacade = baboonFacade } + + /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + fun custom(indices: Boolean, policy: ForwardWritePolicy, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = + object : BaboonCodecContext { + override val useIndices: Boolean = indices + override val forwardWritePolicy: ForwardWritePolicy = policy + override val facade: BaboonCodecsFacade? = baboonFacade + } } object Indexed : BaboonCodecContext { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt index 1479f146e..2ca6c38db 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt @@ -19,7 +19,10 @@ import kotlinx.serialization.json.jsonObject enum class ForwardReadPolicy { Lossless, Tolerant } open class BaboonCodecsFacade { - /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + /** + * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" @@ -147,7 +150,7 @@ open class BaboonCodecsFacade { value: T, ): ByteArray { val writer = BaboonBinaryWriter() - val typeMeta = BaboonTypeMeta.from(value) + val typeMeta = BaboonTypeMeta.forBin(value, ctx) val codec = getBinCodec(typeMeta, exact = true) as BaboonBinCodec typeMeta.writeBin(writer) codec.encode(ctx, writer, value) @@ -240,12 +243,19 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() - val modelVersion = when { - lowerBound != null && typeMeta.version().version > maxVersion.version -> lowerBound - else -> typeMeta.version() + val modelVersion = typeMeta.version() + if (!exact && modelVersion.version > maxVersion.version) { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() + if (lowerBound != null && lowerBound.version <= maxVersion.version) { + return getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier) + } + throw BaboonCodecException.CodecNotFound("Unsupported domain version '$modelVersion'.") } return when { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt index 87ec2cdf6..df14d2152 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt @@ -144,6 +144,26 @@ data class BaboonTypeMeta( /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ const val JSON_READABLE_TIER: String = "json-additive" + /** Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. */ + const val UEBA_PREFIX_COMPACT_TIER: String = "prefix-compact" + const val UEBA_PREFIX_ANY_MODE_TIER: String = "prefix-any-mode" + + /** + * Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` + * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + */ + fun forBin(value: BaboonGenerated, ctx: BaboonCodecContext): BaboonTypeMeta { + val meta = from(value) + return when (ctx.forwardWritePolicy) { + ForwardWritePolicy.Strict -> meta + ForwardWritePolicy.Tolerant -> { + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + val bound = value.baboonMinReaderVersions[tier] + ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") + meta.copy(domainVersionMinCompat = bound) + } + } + } fun readMeta(reader: BaboonBinaryReader): BaboonTypeMeta? { return BaboonTypeMetaCodec.readMeta(reader) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt index e4002399b..62e43d5d8 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt @@ -13,8 +13,21 @@ interface BaboonCodecData { val baboonTypeIdentifier: String } +/** + * Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 + * binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration (UEBA)"). + * - Strict: the byte-identical bound (`baboonSameInVersions.first()`) — the default. + * - Tolerant: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + * payloads, `prefix-any-mode` for indexed ones). Readers older than the writer then decode the + * payload with their newest codec, dropping the appended fields they do not know. A reader + * cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries + * must run at the writer's version or newer. + */ +enum class ForwardWritePolicy { Strict, Tolerant } + interface BaboonCodecContext { val useIndices: Boolean + val forwardWritePolicy: ForwardWritePolicy get() = ForwardWritePolicy.Strict /** * Optional facade reference, threaded through generated codec calls so the `any`-feature @@ -33,6 +46,14 @@ interface BaboonCodecContext { override val useIndices: Boolean = useIndices override val facade: BaboonCodecsFacade = baboonFacade } + + /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + fun custom(indices: Boolean, policy: ForwardWritePolicy, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = + object : BaboonCodecContext { + override val useIndices: Boolean = indices + override val forwardWritePolicy: ForwardWritePolicy = policy + override val facade: BaboonCodecsFacade? = baboonFacade + } } object Indexed : BaboonCodecContext { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt index 32fc1a0fa..b0f507ab7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt @@ -22,7 +22,10 @@ import java.util.concurrent.ConcurrentHashMap enum class ForwardReadPolicy { Lossless, Tolerant } open class BaboonCodecsFacade { - /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + /** + * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" @@ -151,7 +154,7 @@ open class BaboonCodecsFacade { ): ByteArray { val byteStream = ByteArrayOutputStream() val ledStream = LEDataOutputStream(byteStream) - val typeMeta = BaboonTypeMeta.from(value) + val typeMeta = BaboonTypeMeta.forBin(value, ctx) val codec = getBinCodec(typeMeta, exact = true) as BaboonBinCodec typeMeta.writeBin(ledStream) codec.encode(ctx, ledStream, value) @@ -245,12 +248,19 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() - val modelVersion = when { - lowerBound != null && typeMeta.version().version > maxVersion.version -> lowerBound - else -> typeMeta.version() + val modelVersion = typeMeta.version() + if (!exact && modelVersion.version > maxVersion.version) { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() + if (lowerBound != null && lowerBound.version <= maxVersion.version) { + return getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier) + } + throw BaboonCodecException.CodecNotFound("Unsupported domain version '$modelVersion'.") } return when { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt index 5ac6f54be..2140e9f5b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt @@ -153,6 +153,26 @@ data class BaboonTypeMeta( /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ const val JSON_READABLE_TIER: String = "json-additive" + /** Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. */ + const val UEBA_PREFIX_COMPACT_TIER: String = "prefix-compact" + const val UEBA_PREFIX_ANY_MODE_TIER: String = "prefix-any-mode" + + /** + * Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` + * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + */ + fun forBin(value: BaboonGenerated, ctx: BaboonCodecContext): BaboonTypeMeta { + val meta = from(value) + return when (ctx.forwardWritePolicy) { + ForwardWritePolicy.Strict -> meta + ForwardWritePolicy.Tolerant -> { + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + val bound = value.baboonMinReaderVersions[tier] + ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") + meta.copy(domainVersionMinCompat = bound) + } + } + } fun readMeta(reader: LEDataInputStream): BaboonTypeMeta? { return BaboonTypeMetaCodec.readMeta(reader) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py index c91ea46cc..2ac25072e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod +from enum import Enum from typing import Any, Optional, TypeVar, Generic from pydantic import BaseModel @@ -137,6 +138,22 @@ def encode(self, ctx: 'BaboonCodecContext', writer: 'LEDataOutputStream', instan f"is deprecated, encoder was not generated" ) +class ForwardWritePolicy(Enum): + """Which lower bound the WRITER publishes as the UEBA envelope's `domain_version_min_compat` + (the v1 binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope + integration (UEBA)"). + + - STRICT: the byte-identical bound (`baboon_same_in_versions[0]`) -- the default. + - TOLERANT: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + payloads, `prefix-any-mode` for indexed ones). Readers older than the writer then decode the + payload with their newest codec, dropping the appended fields they do not know. A reader + cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries + must run at the writer's version or newer. + """ + STRICT = "strict" + TOLERANT = "tolerant" + + class BaboonCodecContext: # `Indexed`/`Compact`/`Default` are stable class-attribute singletons assigned after the # class body. Generator-emitted code may use `ctx is BaboonCodecContext.Indexed`-style @@ -147,8 +164,10 @@ class BaboonCodecContext: Compact: 'BaboonCodecContext' Default: 'BaboonCodecContext' - def __init__(self, use_indices: bool, facade: Optional[Any] = None): + def __init__(self, use_indices: bool, facade: Optional[Any] = None, + forward_write_policy: ForwardWritePolicy = ForwardWritePolicy.STRICT): self.use_indices = use_indices + self.forward_write_policy = forward_write_policy # `facade` is threaded through generated codec calls so the `any`-feature cross-format # conversion (UEBA <-> JSON) can resolve codecs by `(domain, version, typeid)` from an # `AnyMeta` envelope. `None` for the bare `Compact`/`Indexed` singletons; `with_facade` @@ -172,6 +191,11 @@ def default(cls) -> 'BaboonCodecContext': def with_facade(cls, use_indices: bool, facade) -> 'BaboonCodecContext': return cls(use_indices, facade) + @classmethod + def custom(cls, use_indices: bool, forward_write_policy: ForwardWritePolicy, facade) -> 'BaboonCodecContext': + """Fully specified context: index mode, writer-side forward policy and optional facade.""" + return cls(use_indices, facade, forward_write_policy) + # Stable singletons — `is`-equality preserved across all uses (PR 10.1). BaboonCodecContext.Indexed = BaboonCodecContext(True) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index 6b0b94ea6..184ece587 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -36,7 +36,8 @@ class BaboonCodecsFacade: CONTENT_JSON_KEY = "$c" def __init__(self): - # JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + # JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domain_version_min_compat`, + # whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. self.forward_read_policy: ForwardReadPolicy = ForwardReadPolicy.TOLERANT self.versions_codecs_json: Dict[BaboonDomainVersion, Lazy[AbstractBaboonJsonCodecs]] = {} self.versions_codecs_bin: Dict[BaboonDomainVersion, Lazy[AbstractBaboonUebaCodecs]] = {} @@ -118,7 +119,7 @@ def _encode_to_bin_stream(self, writer: LEDataOutputStream, value: TI, type_meta_override: Optional[BaboonTypeMeta] = None): - type_meta = BaboonTypeMeta.from_instance(value) + type_meta = self._bin_type_meta(value, ctx) try: codec = self._get_bin_codec(type_meta, exact=True) meta = type_meta_override or type_meta @@ -132,6 +133,20 @@ def _encode_to_bin_stream(self, err ) + @staticmethod + def _bin_type_meta(value: BaboonGenerated, ctx: BaboonCodecContext) -> BaboonTypeMeta: + """Envelope for a UEBA payload written under `ctx`: `from_instance(value)` with + `domain_version_min_compat` lowered to the prefix bound of the context's index mode when + the writer policy is TOLERANT.""" + meta = BaboonTypeMeta.from_instance(value) + if ctx.forward_write_policy == ForwardWritePolicy.STRICT: + return meta + tier = BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER if ctx.use_indices else BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER + # baboon_min_reader_versions has a non-abstract default (see BaboonGenerated); a missing + # prefix bound means "no forward-read beyond byte-identity", i.e. keep min_compat + bound = value.baboon_min_reader_versions.get(tier, meta.domain_version_min_compat) + return meta.model_copy(update={"domain_version_min_compat": bound}) + def decode_from_bin(self, reader: LEDataInputStream) -> BaboonGenerated: type_meta = BaboonTypeMeta.read_meta(reader) if not type_meta: @@ -291,12 +306,19 @@ def _get_codec(self, min_version = versions[0] max_version = versions[-1] - # the oldest version whose codec may decode this payload: byte-identical bound, or - # (tolerant JSON reads) the json-additive bound when the writer published one - lower_bound = type_meta.version_readable_min if tolerant else type_meta.version_min_compat - # it's a model of newer version than we have, we should find min compat version - if lower_bound and model_version.version > max_version.version: - model_version = lower_bound + if (not exact) and model_version.version > max_version.version: + # a payload from a NEWER version than we register. The oldest version whose codec may + # decode it is the bound the writer published (byte-identical or, under its Tolerant + # policy, prefix-readable), or -- for tolerant JSON reads -- the json-additive bound. + # Forward-readability is monotone along the version chain, so once the bound reaches a + # registered version our newest codec reads the payload (losing at most the fields + # appended after our version). + lower_bound = type_meta.version_readable_min if tolerant else type_meta.version_min_compat + if lower_bound and lower_bound.version <= max_version.version: + return self._get_codec_exact(versions_codecs, max_version, type_meta.type_identifier) + raise BaboonCodecException.CodecNotFound( + f"Unsupported domain version '{model_version}'." + ) # it's a model of latest version, get last version codec if exact and model_version.version == max_version.version: diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index 615726b01..70c18fc48 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -536,6 +536,9 @@ class BaboonTypeMeta(BaseModel): domain_version_readable_min: str = "" JSON_READABLE_TIER: ClassVar[str] = "json-additive" + # Tier keys of the UEBA prefix bounds in `baboon_min_reader_versions`, per index mode. + UEBA_PREFIX_COMPACT_TIER: ClassVar[str] = "prefix-compact" + UEBA_PREFIX_ANY_MODE_TIER: ClassVar[str] = "prefix-any-mode" model_config = ConfigDict( frozen=True, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index 563a34b2d..1c925feeb 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -1,5 +1,5 @@ use crate::any_opaque::{AnyMeta, AnyOpaque, BaboonCodecError}; -use crate::baboon_runtime::BaboonCodecContext; +use crate::baboon_runtime::{BaboonCodecContext, ForwardWritePolicy}; use std::any::TypeId; use std::collections::HashMap; use std::io::{Cursor, Read}; @@ -217,264 +217,12 @@ impl std::fmt::Display for BaboonVersion { } } -// --- BaboonTypeMeta (synthetic; used for facade lookup) --- - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BaboonTypeMeta { - pub meta_version: u8, - pub domain_identifier: String, - pub domain_version: String, - pub domain_version_min_compat: String, - pub type_identifier: String, - /// Oldest domain version whose JSON codec can decode the payload under the json-additive - /// contract (tolerant key lookup; fields unknown to that version are dropped). Always - /// <= domain_version_min_compat. Published as `$rv` when it differs from the (effective) - /// minCompat; the binary v1 envelope does not carry it. `new` defaults it to minCompat. - pub domain_version_readable_min: String, -} - -impl BaboonTypeMeta { - pub const META_VERSION_1: u8 = 1; - pub const META_VERSION: u8 = Self::META_VERSION_1; - /// Tier key of the JSON envelope's readable-min bound in `baboon_min_reader_versions_dyn`. - pub const JSON_READABLE_TIER: &'static str = "json-additive"; - - pub fn new, V: Into, MC: Into, T: Into>( - meta_version: u8, - domain_identifier: D, - domain_version: V, - domain_version_min_compat: MC, - type_identifier: T, - ) -> Self { - let domain_version_min_compat: String = domain_version_min_compat.into(); - BaboonTypeMeta { - meta_version, - domain_identifier: domain_identifier.into(), - domain_version: domain_version.into(), - domain_version_readable_min: domain_version_min_compat.clone(), - domain_version_min_compat, - type_identifier: type_identifier.into(), - } - } - - pub fn with_readable_min>(mut self, readable_min: R) -> Self { - self.domain_version_readable_min = readable_min.into(); - self - } - - pub fn version_readable_min(&self) -> Option { - if self.domain_version_readable_min.is_empty() { - return self.version_min_compat(); - } - if self.domain_version_readable_min == self.domain_version { - None - } else { - Some(BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version_readable_min)) - } - } - - pub fn version_ref(&self) -> BaboonDomainVersion { - BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version) - } - - pub fn version_min_compat(&self) -> Option { - if self.domain_version_min_compat.is_empty() || self.domain_version_min_compat == self.domain_version { - None - } else { - Some(BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version_min_compat)) - } - } -} - -// --- BaboonTypeMeta wire codec --- +// --- BaboonTypeMeta --- // -// Mirrors C#'s static `BaboonTypeMetaCodec` (BaboonTypeMeta.cs:126-214) and Scala's -// `BaboonTypeMetaCodec` (BaboonRuntimeShared.scala:156-214). Used by the facade encode/decode -// entry points to write/read the meta prefix that precedes the payload in both binary and -// JSON envelopes. Lives here (alongside `BaboonTypeMeta`) rather than in `any_opaque.rs` -// because it's part of the facade-level wire surface, not the AnyMeta protocol. - -pub mod baboon_type_meta_codec { - use super::{BaboonTypeMeta, BaboonCodecError}; - use crate::baboon_runtime::bin_tools; - use std::io::{Read, Write}; - - pub const META_VERSION_KEY: &str = "$mv"; - pub const DOMAIN_IDENTIFIER_KEY: &str = "$d"; - pub const DOMAIN_VERSION_KEY: &str = "$v"; - pub const DOMAIN_VERSION_MIN_COMPAT_KEY: &str = "$uv"; - pub const DOMAIN_VERSION_READABLE_KEY: &str = "$rv"; - pub const TYPE_IDENTIFIER_KEY: &str = "$t"; - - /// Wire format: `[meta-version:u8][domain:string][version:string][has-min-compat:u8][min-compat?:string][type-id:string]`. - /// Mirrors C# BaboonTypeMetaCodec.WriteBin / Scala writeBin exactly. - pub fn write_bin(meta: &BaboonTypeMeta, writer: &mut dyn Write) -> std::io::Result<()> { - bin_tools::write_byte(writer, BaboonTypeMeta::META_VERSION)?; - bin_tools::write_string(writer, &meta.domain_identifier)?; - bin_tools::write_string(writer, &meta.domain_version)?; - if meta.domain_version == meta.domain_version_min_compat { - bin_tools::write_byte(writer, 0)?; - } else { - bin_tools::write_byte(writer, 1)?; - bin_tools::write_string(writer, &meta.domain_version_min_compat)?; - } - bin_tools::write_string(writer, &meta.type_identifier) - } - - /// Reads the wire-format prefix. Returns `Ok(None)` when the leading meta-version byte - /// does not match `META_VERSION_1` (forward-compat: future meta versions). Mirrors C# - /// `ReadMeta(BinaryReader)` (BaboonTypeMeta.cs:169) returning nullable. - pub fn read_bin(reader: &mut R) -> Result, BaboonCodecError> { - let meta_version = bin_tools::read_byte(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read meta-version byte", - e, - ) - })?; - if meta_version != BaboonTypeMeta::META_VERSION_1 { - return Ok(None); - } - let domain_identifier = bin_tools::read_string(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read domain identifier", - e, - ) - })?; - let domain_version = bin_tools::read_string(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read domain version", - e, - ) - })?; - let has_min_compat = bin_tools::read_byte(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read has-min-compat byte", - e, - ) - })?; - // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected - if has_min_compat != 0 && has_min_compat != 1 { - return Ok(None); - } - let domain_version_min_compat = if has_min_compat == 1 { - bin_tools::read_string(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read min-compat", - e, - ) - })? - } else { - domain_version.clone() - }; - let type_identifier = bin_tools::read_string(reader).map_err(|e| { - BaboonCodecError::decoder_failure_from_box( - "BaboonTypeMetaCodec.read_bin: failed to read type identifier", - e, - ) - })?; - Ok(Some(BaboonTypeMeta::new( - BaboonTypeMeta::META_VERSION, - domain_identifier, - domain_version, - domain_version_min_compat, - type_identifier, - ))) - } - - /// JSON envelope writer. Mirrors C# `BaboonTypeMetaCodec.WriteJson` (BaboonTypeMeta.cs:156) - /// and Scala `writeMeta(json)` — returns the `{$mv,$d,$v,$t,$uv?}` object without `$c`. - /// Callers (e.g. `BaboonCodecsFacade::encode_to_json`) are expected to append the content - /// payload under the `$c` key. Symmetric to `read_meta_json` so the rust runtime exposes - /// the same shape of public envelope API as the peer backends; closes - /// `[MFACADE-PR-3-D13]`. - pub fn write_meta_json(meta: &BaboonTypeMeta) -> serde_json::Map { - let mut envelope = serde_json::Map::new(); - // MFACADE-PR-3: always emit `$mv` as a JSON number so envelopes are - // self-identifying without out-of-band knowledge (proposal §10.6 (a)). - envelope.insert( - META_VERSION_KEY.to_string(), - serde_json::Value::Number(serde_json::Number::from(BaboonTypeMeta::META_VERSION)), - ); - envelope.insert( - DOMAIN_IDENTIFIER_KEY.to_string(), - serde_json::Value::String(meta.domain_identifier.clone()), - ); - envelope.insert( - DOMAIN_VERSION_KEY.to_string(), - serde_json::Value::String(meta.domain_version.clone()), - ); - envelope.insert( - TYPE_IDENTIFIER_KEY.to_string(), - serde_json::Value::String(meta.type_identifier.clone()), - ); - if meta.domain_version != meta.domain_version_min_compat - && !meta.domain_version_min_compat.is_empty() - { - envelope.insert( - DOMAIN_VERSION_MIN_COMPAT_KEY.to_string(), - serde_json::Value::String(meta.domain_version_min_compat.clone()), - ); - } - // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes - if !meta.domain_version_readable_min.is_empty() - && meta.domain_version_readable_min != meta.domain_version_min_compat - { - envelope.insert( - DOMAIN_VERSION_READABLE_KEY.to_string(), - serde_json::Value::String(meta.domain_version_readable_min.clone()), - ); - } - envelope - } - - /// JSON envelope reader. Mirrors C# `ReadMeta(JToken)` (BaboonTypeMeta.cs:189) and Scala - /// `readMeta(json)` (BaboonRuntimeShared.scala:197). Returns `Ok(None)` when the input is - /// not an object, when `$mv` is present but not "1", or when any of `$d`/`$v`/`$t` is missing. - pub fn read_meta_json( - json: &serde_json::Value, - ) -> Result, BaboonCodecError> { - let obj = match json.as_object() { - Some(o) => o, - None => return Ok(None), - }; - if let Some(mv) = obj.get(META_VERSION_KEY) { - // MFACADE-PR-3: accept $mv as either a JSON number or a string (back-compat - // with M28-vintage fixtures); both must equal META_VERSION_1. - let mv_byte: Option = match mv { - serde_json::Value::Number(n) => n.as_u64().and_then(|x| u8::try_from(x).ok()), - serde_json::Value::String(s) => s.parse::().ok(), - _ => None, - }; - match mv_byte { - Some(v) if v == BaboonTypeMeta::META_VERSION_1 => {} - _ => return Ok(None), - } - } - let d = match obj.get(DOMAIN_IDENTIFIER_KEY).and_then(|v| v.as_str()) { - Some(s) => s, - None => return Ok(None), - }; - let v = match obj.get(DOMAIN_VERSION_KEY).and_then(|v| v.as_str()) { - Some(s) => s, - None => return Ok(None), - }; - let t = match obj.get(TYPE_IDENTIFIER_KEY).and_then(|v| v.as_str()) { - Some(s) => s, - None => return Ok(None), - }; - let uv = obj - .get(DOMAIN_VERSION_MIN_COMPAT_KEY) - .and_then(|v| v.as_str()) - .unwrap_or(v); - let rv = obj - .get(DOMAIN_VERSION_READABLE_KEY) - .and_then(|v| v.as_str()) - .unwrap_or(uv); - Ok(Some( - BaboonTypeMeta::new(BaboonTypeMeta::META_VERSION, d, v, uv, t).with_readable_min(rv), - )) - } -} +// `BaboonTypeMeta` and its wire codec live in `baboon_type_meta.rs` (this file is embedded as a +// single JVM string constant and must stay under 64KB); they are re-exported here so the +// `crate::baboon_codecs_facade::{BaboonTypeMeta, baboon_type_meta_codec}` paths keep resolving. +pub use crate::baboon_type_meta::{baboon_type_meta_codec, BaboonTypeMeta}; // --- Lazy primitive --- // @@ -602,7 +350,8 @@ pub enum ForwardReadPolicy { } pub struct BaboonCodecsFacade { - /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domain_version_min_compat`, + /// whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. forward_read_policy: Mutex, versions_codecs_json: Mutex>>>>, versions_codecs_bin: Mutex>>>>, @@ -1001,14 +750,26 @@ impl BaboonCodecsFacade { let max_v = max_version.version()?; let min_v = min_version.version()?; - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - let lower_bound = if tolerant { type_meta.version_readable_min() } else { type_meta.version_min_compat() }; - let model_version = match lower_bound { - Some(bound) if lookup_v > max_v => bound, - _ => lookup_version, - }; - let model_v = model_version.version()?; + if !exact && lookup_v > max_v { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + let lower_bound = if tolerant { type_meta.version_readable_min() } else { type_meta.version_min_compat() }; + if let Some(bound) = lower_bound { + if bound.version()? <= max_v { + return Ok(max_version.clone()); + } + } + return Err(BaboonCodecError::codec_not_found(format!( + "Unsupported domain version '{}'.", + lookup_version + ))); + } + let model_version = lookup_version; + let model_v = lookup_v; if exact && model_v == max_v { return Ok(model_version); @@ -1085,6 +846,25 @@ impl BaboonCodecsFacade { /// /// Codegen invariant (mirrors Scala BaboonRuntimeShared.scala:138 / C# BaboonTypeMeta.cs:110): /// `baboon_same_in_versions_dyn()` is non-empty. Index `[0]` here panics on violation. + /// Envelope for a UEBA payload written under `ctx`: `type_meta_from` with + /// `domain_version_min_compat` lowered to the prefix bound of the context's index mode when the + /// writer policy is `Tolerant`. A missing prefix bound (trait default is empty) means "no + /// forward-read beyond byte-identity", i.e. min_compat is kept. + fn bin_type_meta(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool, ctx: &BaboonCodecContext) -> BaboonTypeMeta { + let mut meta = self.type_meta_from(value, is_adt_trait); + if ctx.forward_write_policy() == ForwardWritePolicy::Tolerant { + let tier = if ctx.use_indices() { + BaboonTypeMeta::UEBA_PREFIX_ANY_MODE_TIER + } else { + BaboonTypeMeta::UEBA_PREFIX_COMPACT_TIER + }; + if let Some((_, bound)) = value.baboon_min_reader_versions_dyn().into_iter().find(|(t, _)| t == tier) { + meta.domain_version_min_compat = bound; + } + } + meta + } + fn type_meta_from(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool) -> BaboonTypeMeta { let type_identifier = if is_adt_trait { match value.as_adt_member_meta_dyn() { @@ -1142,7 +922,7 @@ impl BaboonCodecsFacade { type_meta_override: Option<&BaboonTypeMeta>, is_adt_trait: bool, ) -> Result, BaboonCodecError> { - let type_meta = self.type_meta_from(value, is_adt_trait); + let type_meta = self.bin_type_meta(value, is_adt_trait, ctx); let codec = self.get_bin_codec(&type_meta, true)?; let effective = type_meta_override.unwrap_or(&type_meta); let mut buf = Vec::new(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs index 78bfc8206..645b18381 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs @@ -10,6 +10,20 @@ use std::sync::Arc; // derived for this variant — the facade has no value-equality semantics; instead the rest of // the code matches exhaustively. +/// Which lower bound the WRITER publishes as the UEBA envelope's `domain_version_min_compat` (the +/// v1 binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration +/// (UEBA)"). `Strict`: the byte-identical bound (`baboon_same_in_versions_dyn()[0]`) — the +/// default. `Tolerant`: the prefix-read bound for the chosen index mode (`prefix-compact` for +/// compact payloads, `prefix-any-mode` for indexed ones); readers older than the writer then decode +/// the payload with their newest codec, dropping the appended fields they do not know. A reader +/// cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries +/// must run at the writer's version or newer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ForwardWritePolicy { + Strict, + Tolerant, +} + #[derive(Clone, Debug)] pub enum BaboonCodecContext { Default, @@ -19,6 +33,12 @@ pub enum BaboonCodecContext { use_indices: bool, facade: Arc, }, + /// Fully specified context: index mode, writer-side forward policy and optional facade. + Custom { + use_indices: bool, + forward_write_policy: ForwardWritePolicy, + facade: Option>, + }, } impl PartialEq for BaboonCodecContext { @@ -31,6 +51,18 @@ impl PartialEq for BaboonCodecContext { BaboonCodecContext::WithFacade { use_indices: a, facade: fa }, BaboonCodecContext::WithFacade { use_indices: b, facade: fb }, ) => a == b && Arc::ptr_eq(fa, fb), + ( + BaboonCodecContext::Custom { use_indices: a, forward_write_policy: pa, facade: fa }, + BaboonCodecContext::Custom { use_indices: b, forward_write_policy: pb, facade: fb }, + ) => { + a == b + && pa == pb + && match (fa, fb) { + (None, None) => true, + (Some(x), Some(y)) => Arc::ptr_eq(x, y), + _ => false, + } + } _ => false, } } @@ -47,16 +79,26 @@ impl BaboonCodecContext { match self { BaboonCodecContext::Indexed => true, BaboonCodecContext::WithFacade { use_indices, .. } => *use_indices, + BaboonCodecContext::Custom { use_indices, .. } => *use_indices, _ => false, } } + /// Writer-side UEBA envelope bound policy; `Strict` for every context but `Custom`. + pub fn forward_write_policy(&self) -> ForwardWritePolicy { + match self { + BaboonCodecContext::Custom { forward_write_policy, .. } => *forward_write_policy, + _ => ForwardWritePolicy::Strict, + } + } + /// Returns the embedded `BaboonCodecsFacade` reference for `WithFacade`-constructed contexts; /// `None` for the bare `Default`/`Indexed`/`Compact` singletons. Codec-generator-emitted code /// (PR 4.2+) uses this when an `any` field requires cross-format conversion. pub fn facade(&self) -> Option<&Arc> { match self { BaboonCodecContext::WithFacade { facade, .. } => Some(facade), + BaboonCodecContext::Custom { facade, .. } => facade.as_ref(), _ => None, } } @@ -67,6 +109,14 @@ impl BaboonCodecContext { ) -> Self { BaboonCodecContext::WithFacade { use_indices, facade } } + + pub fn custom( + use_indices: bool, + forward_write_policy: ForwardWritePolicy, + facade: Option>, + ) -> Self { + BaboonCodecContext::Custom { use_indices, forward_write_policy, facade } + } } // --- Index support --- diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs new file mode 100644 index 000000000..f1259cdb1 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs @@ -0,0 +1,268 @@ +// BaboonTypeMeta — the top-level codec envelope (docs/spec/codec-envelope.md) and its wire codec. +// Split out of `baboon_codecs_facade.rs`, which re-exports both symbols, to keep each embedded +// runtime file under the JVM's 64KB string-constant limit. + +use crate::any_opaque::BaboonCodecError; +use crate::baboon_codecs_facade::BaboonDomainVersion; + +// --- BaboonTypeMeta (synthetic; used for facade lookup) --- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BaboonTypeMeta { + pub meta_version: u8, + pub domain_identifier: String, + pub domain_version: String, + pub domain_version_min_compat: String, + pub type_identifier: String, + /// Oldest domain version whose JSON codec can decode the payload under the json-additive + /// contract (tolerant key lookup; fields unknown to that version are dropped). Always + /// <= domain_version_min_compat. Published as `$rv` when it differs from the (effective) + /// minCompat; the binary v1 envelope does not carry it. `new` defaults it to minCompat. + pub domain_version_readable_min: String, +} + +impl BaboonTypeMeta { + pub const META_VERSION_1: u8 = 1; + pub const META_VERSION: u8 = Self::META_VERSION_1; + /// Tier key of the JSON envelope's readable-min bound in `baboon_min_reader_versions_dyn`. + pub const JSON_READABLE_TIER: &'static str = "json-additive"; + /// Tier keys of the UEBA prefix bounds in `baboon_min_reader_versions_dyn`, per index mode. + pub const UEBA_PREFIX_COMPACT_TIER: &'static str = "prefix-compact"; + pub const UEBA_PREFIX_ANY_MODE_TIER: &'static str = "prefix-any-mode"; + + pub fn new, V: Into, MC: Into, T: Into>( + meta_version: u8, + domain_identifier: D, + domain_version: V, + domain_version_min_compat: MC, + type_identifier: T, + ) -> Self { + let domain_version_min_compat: String = domain_version_min_compat.into(); + BaboonTypeMeta { + meta_version, + domain_identifier: domain_identifier.into(), + domain_version: domain_version.into(), + domain_version_readable_min: domain_version_min_compat.clone(), + domain_version_min_compat, + type_identifier: type_identifier.into(), + } + } + + pub fn with_readable_min>(mut self, readable_min: R) -> Self { + self.domain_version_readable_min = readable_min.into(); + self + } + + pub fn version_readable_min(&self) -> Option { + if self.domain_version_readable_min.is_empty() { + return self.version_min_compat(); + } + if self.domain_version_readable_min == self.domain_version { + None + } else { + Some(BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version_readable_min)) + } + } + + pub fn version_ref(&self) -> BaboonDomainVersion { + BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version) + } + + pub fn version_min_compat(&self) -> Option { + if self.domain_version_min_compat.is_empty() || self.domain_version_min_compat == self.domain_version { + None + } else { + Some(BaboonDomainVersion::new(&self.domain_identifier, &self.domain_version_min_compat)) + } + } +} + +// --- BaboonTypeMeta wire codec --- +// +// Mirrors C#'s static `BaboonTypeMetaCodec` (BaboonTypeMeta.cs:126-214) and Scala's +// `BaboonTypeMetaCodec` (BaboonRuntimeShared.scala:156-214). Used by the facade encode/decode +// entry points to write/read the meta prefix that precedes the payload in both binary and +// JSON envelopes. Lives here (alongside `BaboonTypeMeta`) rather than in `any_opaque.rs` +// because it's part of the facade-level wire surface, not the AnyMeta protocol. + +pub mod baboon_type_meta_codec { + use super::{BaboonTypeMeta, BaboonCodecError}; + use crate::baboon_runtime::bin_tools; + use std::io::{Read, Write}; + + pub const META_VERSION_KEY: &str = "$mv"; + pub const DOMAIN_IDENTIFIER_KEY: &str = "$d"; + pub const DOMAIN_VERSION_KEY: &str = "$v"; + pub const DOMAIN_VERSION_MIN_COMPAT_KEY: &str = "$uv"; + pub const DOMAIN_VERSION_READABLE_KEY: &str = "$rv"; + pub const TYPE_IDENTIFIER_KEY: &str = "$t"; + + /// Wire format: `[meta-version:u8][domain:string][version:string][has-min-compat:u8][min-compat?:string][type-id:string]`. + /// Mirrors C# BaboonTypeMetaCodec.WriteBin / Scala writeBin exactly. + pub fn write_bin(meta: &BaboonTypeMeta, writer: &mut dyn Write) -> std::io::Result<()> { + bin_tools::write_byte(writer, BaboonTypeMeta::META_VERSION)?; + bin_tools::write_string(writer, &meta.domain_identifier)?; + bin_tools::write_string(writer, &meta.domain_version)?; + if meta.domain_version == meta.domain_version_min_compat { + bin_tools::write_byte(writer, 0)?; + } else { + bin_tools::write_byte(writer, 1)?; + bin_tools::write_string(writer, &meta.domain_version_min_compat)?; + } + bin_tools::write_string(writer, &meta.type_identifier) + } + + /// Reads the wire-format prefix. Returns `Ok(None)` when the leading meta-version byte + /// does not match `META_VERSION_1` (forward-compat: future meta versions). Mirrors C# + /// `ReadMeta(BinaryReader)` (BaboonTypeMeta.cs:169) returning nullable. + pub fn read_bin(reader: &mut R) -> Result, BaboonCodecError> { + let meta_version = bin_tools::read_byte(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read meta-version byte", + e, + ) + })?; + if meta_version != BaboonTypeMeta::META_VERSION_1 { + return Ok(None); + } + let domain_identifier = bin_tools::read_string(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read domain identifier", + e, + ) + })?; + let domain_version = bin_tools::read_string(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read domain version", + e, + ) + })?; + let has_min_compat = bin_tools::read_byte(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read has-min-compat byte", + e, + ) + })?; + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if has_min_compat != 0 && has_min_compat != 1 { + return Ok(None); + } + let domain_version_min_compat = if has_min_compat == 1 { + bin_tools::read_string(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read min-compat", + e, + ) + })? + } else { + domain_version.clone() + }; + let type_identifier = bin_tools::read_string(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box( + "BaboonTypeMetaCodec.read_bin: failed to read type identifier", + e, + ) + })?; + Ok(Some(BaboonTypeMeta::new( + BaboonTypeMeta::META_VERSION, + domain_identifier, + domain_version, + domain_version_min_compat, + type_identifier, + ))) + } + + /// JSON envelope writer. Mirrors C# `BaboonTypeMetaCodec.WriteJson` (BaboonTypeMeta.cs:156) + /// and Scala `writeMeta(json)` — returns the `{$mv,$d,$v,$t,$uv?}` object without `$c`. + /// Callers (e.g. `BaboonCodecsFacade::encode_to_json`) are expected to append the content + /// payload under the `$c` key. Symmetric to `read_meta_json` so the rust runtime exposes + /// the same shape of public envelope API as the peer backends; closes + /// `[MFACADE-PR-3-D13]`. + pub fn write_meta_json(meta: &BaboonTypeMeta) -> serde_json::Map { + let mut envelope = serde_json::Map::new(); + // MFACADE-PR-3: always emit `$mv` as a JSON number so envelopes are + // self-identifying without out-of-band knowledge (proposal §10.6 (a)). + envelope.insert( + META_VERSION_KEY.to_string(), + serde_json::Value::Number(serde_json::Number::from(BaboonTypeMeta::META_VERSION)), + ); + envelope.insert( + DOMAIN_IDENTIFIER_KEY.to_string(), + serde_json::Value::String(meta.domain_identifier.clone()), + ); + envelope.insert( + DOMAIN_VERSION_KEY.to_string(), + serde_json::Value::String(meta.domain_version.clone()), + ); + envelope.insert( + TYPE_IDENTIFIER_KEY.to_string(), + serde_json::Value::String(meta.type_identifier.clone()), + ); + if meta.domain_version != meta.domain_version_min_compat + && !meta.domain_version_min_compat.is_empty() + { + envelope.insert( + DOMAIN_VERSION_MIN_COMPAT_KEY.to_string(), + serde_json::Value::String(meta.domain_version_min_compat.clone()), + ); + } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if !meta.domain_version_readable_min.is_empty() + && meta.domain_version_readable_min != meta.domain_version_min_compat + { + envelope.insert( + DOMAIN_VERSION_READABLE_KEY.to_string(), + serde_json::Value::String(meta.domain_version_readable_min.clone()), + ); + } + envelope + } + + /// JSON envelope reader. Mirrors C# `ReadMeta(JToken)` (BaboonTypeMeta.cs:189) and Scala + /// `readMeta(json)` (BaboonRuntimeShared.scala:197). Returns `Ok(None)` when the input is + /// not an object, when `$mv` is present but not "1", or when any of `$d`/`$v`/`$t` is missing. + pub fn read_meta_json( + json: &serde_json::Value, + ) -> Result, BaboonCodecError> { + let obj = match json.as_object() { + Some(o) => o, + None => return Ok(None), + }; + if let Some(mv) = obj.get(META_VERSION_KEY) { + // MFACADE-PR-3: accept $mv as either a JSON number or a string (back-compat + // with M28-vintage fixtures); both must equal META_VERSION_1. + let mv_byte: Option = match mv { + serde_json::Value::Number(n) => n.as_u64().and_then(|x| u8::try_from(x).ok()), + serde_json::Value::String(s) => s.parse::().ok(), + _ => None, + }; + match mv_byte { + Some(v) if v == BaboonTypeMeta::META_VERSION_1 => {} + _ => return Ok(None), + } + } + let d = match obj.get(DOMAIN_IDENTIFIER_KEY).and_then(|v| v.as_str()) { + Some(s) => s, + None => return Ok(None), + }; + let v = match obj.get(DOMAIN_VERSION_KEY).and_then(|v| v.as_str()) { + Some(s) => s, + None => return Ok(None), + }; + let t = match obj.get(TYPE_IDENTIFIER_KEY).and_then(|v| v.as_str()) { + Some(s) => s, + None => return Ok(None), + }; + let uv = obj + .get(DOMAIN_VERSION_MIN_COMPAT_KEY) + .and_then(|v| v.as_str()) + .unwrap_or(v); + let rv = obj + .get(DOMAIN_VERSION_READABLE_KEY) + .and_then(|v| v.as_str()) + .unwrap_or(uv); + Ok(Some( + BaboonTypeMeta::new(BaboonTypeMeta::META_VERSION, d, v, uv, t).with_readable_min(rv), + )) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala index 0fc572480..7e6477781 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala @@ -16,8 +16,25 @@ package baboon.runtime.shared { def baboonTypeIdentifier: String } + /** Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` + * (the v1 binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope + * integration (UEBA)"). + * - Strict: the byte-identical bound (`baboonSameInVersions.head`) — the default. + * - Tolerant: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + * payloads, `prefix-any-mode` for indexed ones). Readers older than the writer then decode + * the payload with their newest codec, dropping the appended fields they do not know. + * A reader cannot distinguish such an envelope from a byte-identical one, so re-encoding + * intermediaries must run at the writer's version or newer. + */ + sealed trait ForwardWritePolicy + object ForwardWritePolicy { + case object Strict extends ForwardWritePolicy + case object Tolerant extends ForwardWritePolicy + } + trait BaboonCodecContext { def useIndices: Boolean + def forwardWritePolicy: ForwardWritePolicy = ForwardWritePolicy.Strict // Optional facade reference, supplied only when an `any`-bearing codec needs to cross-convert // between UEBA and JSON branches (`AnyOpaqueJson` → UEBA, `AnyOpaqueUeba` → JSON). Defaults to // `None` so existing call sites stay compatible; users who need cross-convert pass @@ -38,6 +55,13 @@ package baboon.runtime.shared { final case class WithFacade(useIndices: Boolean, baboonFacade: BaboonCodecsFacade) extends BaboonCodecContext { override def facade: Option[BaboonCodecsFacade] = Some(baboonFacade) } + + /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + final case class Custom( + useIndices: Boolean, + override val forwardWritePolicy: ForwardWritePolicy, + override val facade: Option[BaboonCodecsFacade], + ) extends BaboonCodecContext } trait BaboonCodec[T] extends BaboonCodecData diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala index 008c7ec27..3416e0beb 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala @@ -27,7 +27,9 @@ package baboon.runtime.shared { private val CONTENT_JSON_KEY = "$c" - /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + /** JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val versionsCodecsJson: TrieMap[BaboonDomainVersion, Lazy[AbstractBaboonJsonCodecs]] = TrieMap.empty @@ -168,7 +170,7 @@ package baboon.runtime.shared { value: T, typeMetaOverride: Option[BaboonTypeMeta], ): BaboonValue[Unit] = { - val typeMeta = BaboonTypeMeta.from(value) + val typeMeta = BaboonTypeMeta.forBin(value, ctx) (for { codec <- getBinCodec(typeMeta, exact = true).toTry _ <- Try(typeMetaOverride.getOrElse(typeMeta).writeBin(writer)) @@ -530,17 +532,23 @@ package baboon.runtime.shared { minVersion = versions.head maxVersion = versions.last - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - lowerBound = if (tolerant) typeMeta.versionReadableMin else typeMeta.versionMinCompat - // extract domain model version and latest version - modelVersion = lowerBound match { - // it's a model of newer version than we have, we should find min compat version - case Some(v) if typeMeta.version.version > maxVersion.version => v - case _ => typeMeta.version - } + // the oldest version whose codec may decode this payload: the bound the writer published + // (byte-identical or, under its Tolerant policy, prefix-readable), or — for tolerant JSON + // reads — the json-additive bound + lowerBound = if (tolerant) typeMeta.versionReadableMin else typeMeta.versionMinCompat + modelVersion = typeMeta.version codec <- modelVersion match { + // a payload from a NEWER version than we register: forward-readability is monotone along + // the version chain, so once the bound reaches a registered version our newest codec reads + // it (losing at most the fields appended after our version) + case v if !exact && v.version > maxVersion.version => + lowerBound match { + case Some(bound) if bound.version <= maxVersion.version => + getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier) + case _ => + Left(BaboonCodecException.CodecNotFound(s"Unsupported domain version '$modelVersion'.")) + } // it's a model of latest version, get last version codec case v if exact && v.version == maxVersion.version => getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala index 738efd925..b7d4938a2 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala @@ -390,6 +390,9 @@ package baboon.runtime.shared { object BaboonTypeMeta { /** Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. */ final val JSON_READABLE_TIER: String = "json-additive" + /** Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. */ + final val UEBA_PREFIX_COMPACT_TIER: String = "prefix-compact" + final val UEBA_PREFIX_ANY_MODE_TIER: String = "prefix-any-mode" /** Five-field form: readable-min defaults to minCompat (no forward-read beyond byte-identity). */ def apply( @@ -429,6 +432,23 @@ package baboon.runtime.shared { ) } + /** Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` + * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + */ + def forBin[T <: BaboonGenerated: ClassTag](value: T, ctx: BaboonCodecContext): BaboonTypeMeta = { + val meta = from(value) + ctx.forwardWritePolicy match { + case ForwardWritePolicy.Strict => meta + case ForwardWritePolicy.Tolerant => + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + val bound = value.baboonMinReaderVersions.getOrElse( + tier, + throw BaboonCodecException.EncoderFailure(s"baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}"), + ) + meta.copy(domainVersionMinCompat = bound) + } + } + private def typeIsTrait[T](implicit ct: ClassTag[T]): Boolean = { ct.runtimeClass.isInterface } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift index 2c5535acf..892bc802f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift @@ -43,7 +43,8 @@ public enum ForwardReadPolicy { } open class BaboonCodecsFacade: BaboonCodecsFacadeBase { - /// JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. + /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + /// whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. public var forwardReadPolicy: ForwardReadPolicy = .tolerant private var versionsCodecsJson: [BaboonDomainVersion: BaboonLazy] = [:] @@ -261,7 +262,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { ) -> Result { let typeMeta: BaboonTypeMeta do { - typeMeta = try BaboonTypeMeta.from(value, useAdtIdentifier: useAdtIdentifier) + typeMeta = try BaboonTypeMeta.forBin(value, ctx, useAdtIdentifier: useAdtIdentifier) } catch { return .failure(.encoderFailure("Cannot derive type meta from value: \(error)", error)) } @@ -642,9 +643,6 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { let maxVersion = versions.last! let lookupVersion = typeMeta.versionRef() - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - let minCompat = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat() let lookupV: BaboonVersion let maxV: BaboonVersion @@ -659,15 +657,27 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { return .failure(.codecNotFound("Invalid version: \(error)")) } - let modelVersion: BaboonDomainVersion - if let mc = minCompat, lookupV > maxV { - modelVersion = mc - } else { - modelVersion = lookupVersion - } - let modelV: BaboonVersion - do { modelV = try modelVersion.version() } catch { - return .failure(.codecNotFound("Invalid version: \(error)")) + let modelVersion = lookupVersion + let modelV = lookupV + + if !exact && modelV > maxV { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + let lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat() + if let bound = lowerBound { + let boundV: BaboonVersion + do { boundV = try bound.version() } catch { + return .failure(.codecNotFound("Invalid version: \(error)")) + } + if boundV <= maxV { + return getCodecExact(maxVersion, typeMeta.typeIdentifier, codecsLookup) + } + } + return .failure(.codecNotFound("Unsupported domain version '\(modelVersion)'.")) } if exact && modelV == maxV { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 8182b8ff3..a829c442a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -109,10 +109,24 @@ open class BaboonCodecsFacadeBase { // PR 9.1: promoted from Swift enum to class so subclassing can carry an optional facade // reference. Existing static accessors (`.defaultCtx`, `.compact`, `.indexed`, `.default`) // preserved; `useIndices` preserved as a property; new `facade` getter defaults to `nil`. +// Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 +// binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration +// (UEBA)"). `.strict`: the byte-identical bound (`baboonSameInVersions[0]`) — the default. +// `.tolerant`: the prefix-read bound for the chosen index mode (`prefix-compact` for compact +// payloads, `prefix-any-mode` for indexed ones); readers older than the writer then decode the +// payload with their newest codec, dropping the appended fields they do not know. A reader cannot +// distinguish such an envelope from a byte-identical one, so re-encoding intermediaries must run +// at the writer's version or newer. +public enum ForwardWritePolicy { + case strict + case tolerant +} + open class BaboonCodecContext { public init() {} open var useIndices: Bool { return false } + open var forwardWritePolicy: ForwardWritePolicy { return .strict } open var facade: BaboonCodecsFacadeBase? { return nil } public static let defaultCtx: BaboonCodecContext = BaboonCodecContextCompact() @@ -123,6 +137,26 @@ open class BaboonCodecContext { public static func withFacade(_ useIndices: Bool, _ facade: BaboonCodecsFacadeBase) -> BaboonCodecContext { return BaboonCodecContextWithFacade(useIndices: useIndices, facade: facade) } + + // Fully specified context: index mode, writer-side forward policy and optional facade. + public static func custom(_ useIndices: Bool, _ forwardWritePolicy: ForwardWritePolicy, _ facade: BaboonCodecsFacadeBase?) -> BaboonCodecContext { + return BaboonCodecContextCustom(useIndices: useIndices, forwardWritePolicy: forwardWritePolicy, facade: facade) + } +} + +public final class BaboonCodecContextCustom: BaboonCodecContext { + private let _useIndices: Bool + private let _forwardWritePolicy: ForwardWritePolicy + private let _facade: BaboonCodecsFacadeBase? + public init(useIndices: Bool, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacadeBase?) { + self._useIndices = useIndices + self._forwardWritePolicy = forwardWritePolicy + self._facade = facade + super.init() + } + public override var useIndices: Bool { return _useIndices } + public override var forwardWritePolicy: ForwardWritePolicy { return _forwardWritePolicy } + public override var facade: BaboonCodecsFacadeBase? { return _facade } } public final class BaboonCodecContextCompact: BaboonCodecContext { @@ -1377,6 +1411,9 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. public static let jsonReadableTier = "json-additive" + /// Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. + public static let uebaPrefixCompactTier = "prefix-compact" + public static let uebaPrefixAnyModeTier = "prefix-any-mode" public init( _ metaVersion: Int, @@ -1492,6 +1529,20 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { ) } + // Envelope for a UEBA payload written under `ctx`: `from` with `domainVersionMinCompat` lowered + // to the prefix bound of the context's index mode when the writer policy is `.tolerant`. A + // missing prefix bound (protocol-extension default) means "no forward-read beyond + // byte-identity", i.e. minCompat is kept. + public static func forBin(_ value: Any, _ ctx: BaboonCodecContext, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { + let meta = try from(value, useAdtIdentifier: useAdtIdentifier) + guard ctx.forwardWritePolicy == .tolerant, let provider = value as? BaboonMetaProvider else { + return meta + } + let tier = ctx.useIndices ? BaboonTypeMeta.uebaPrefixAnyModeTier : BaboonTypeMeta.uebaPrefixCompactTier + let bound = provider.baboonMinReaderVersions[tier] ?? meta.domainVersionMinCompat + return BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin) + } + public var description: String { return "BaboonTypeMeta(\(domainIdentifier).\(typeIdentifier)@\(domainVersion))" } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts index d1ead3a09..3278853fb 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts @@ -72,7 +72,10 @@ export enum ForwardReadPolicy { } export class BaboonCodecsFacade { - /** JSON forward-read policy; UEBA envelopes (v1) carry no readable-min bound and always resolve losslessly. */ + /** + * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, + * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + */ public forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant; private readonly versionsCodecsJson: Map> = new Map(); @@ -200,7 +203,7 @@ export class BaboonCodecsFacade { typeMetaOverride?: BaboonTypeMeta, useAdtIdentifier: boolean = false, ): BaboonEither { - const typeMeta = BaboonTypeMeta.from(value, useAdtIdentifier); + const typeMeta = BaboonTypeMeta.forBin(value, ctx, useAdtIdentifier); const codecResult = this.getBinCodec(typeMeta, true); if (codecResult.tag === "Left") return codecResult; const codec = codecResult.value as BaboonBinCodec; @@ -602,18 +605,25 @@ export class BaboonCodecsFacade { const minVersion = versions[0]!; const maxVersion = versions[versions.length - 1]!; - const lookupVersion = typeMeta.versionRef(); - // the oldest version whose codec may decode this payload: byte-identical bound, or - // (tolerant JSON reads) the json-additive bound when the writer published one - const lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); - const modelVersion = (lowerBound !== undefined && lookupVersion.version().compareTo(maxVersion.version()) > 0) - ? lowerBound - : lookupVersion; - + const modelVersion = typeMeta.versionRef(); const modelV = modelVersion.version(); const maxV = maxVersion.version(); const minV = minVersion.version(); + if (modelV.compareTo(maxV) > 0) { + // a payload from a NEWER version than we register. The oldest version whose codec may + // decode it is the bound the writer published (byte-identical or, under its Tolerant + // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. + // Forward-readability is monotone along the version chain, so once the bound reaches a + // registered version our newest codec reads the payload (losing at most the fields + // appended after our version). + const lowerBound = tolerant ? typeMeta.versionReadableMin() : typeMeta.versionMinCompat(); + if (lowerBound !== undefined && lowerBound.version().compareTo(maxV) <= 0) { + return BaboonCodecsFacade.getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier); + } + return leftCodecException(new BaboonCodecNotFound(`Unsupported domain version '${modelVersion}'.`)); + } + if (exact && modelV.compareTo(maxV) === 0) { return BaboonCodecsFacade.getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 4866c32f9..342b34d07 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -17,12 +17,30 @@ import type { BaboonCodecsFacade } from "./BaboonCodecsFacade"; * * Mirrors PR 6.1 (Java) plumbing — Q6 option (a) in the design plan. */ +/** + * Which lower bound the WRITER publishes as the UEBA envelope's `domainVersionMinCompat` (the v1 + * binary envelope has a single bound slot; see docs/forward-compat.md, "Envelope integration + * (UEBA)"). + * - Strict: the byte-identical bound (`baboonSameInVersions()[0]`) — the default. + * - Tolerant: the prefix-read bound for the chosen index mode (`prefix-compact` for compact + * payloads, `prefix-any-mode` for indexed ones). Readers older than the writer then decode the + * payload with their newest codec, dropping the appended fields they do not know. A reader + * cannot distinguish such an envelope from a byte-identical one, so re-encoding intermediaries + * must run at the writer's version or newer. + */ +export enum ForwardWritePolicy { + Strict = "strict", + Tolerant = "tolerant", +} + export class BaboonCodecContext { private readonly _useIndices: boolean; + private readonly _forwardWritePolicy: ForwardWritePolicy; private readonly _facade: BaboonCodecsFacade | undefined; - private constructor(useIndices: boolean, facade: BaboonCodecsFacade | undefined) { + private constructor(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacade | undefined) { this._useIndices = useIndices; + this._forwardWritePolicy = forwardWritePolicy; this._facade = facade; } @@ -30,16 +48,25 @@ export class BaboonCodecContext { return this._useIndices; } + public get forwardWritePolicy(): ForwardWritePolicy { + return this._forwardWritePolicy; + } + public get facade(): BaboonCodecsFacade | undefined { return this._facade; } - public static readonly Indexed: BaboonCodecContext = new BaboonCodecContext(true, undefined); - public static readonly Compact: BaboonCodecContext = new BaboonCodecContext(false, undefined); + public static readonly Indexed: BaboonCodecContext = new BaboonCodecContext(true, ForwardWritePolicy.Strict, undefined); + public static readonly Compact: BaboonCodecContext = new BaboonCodecContext(false, ForwardWritePolicy.Strict, undefined); public static readonly Default: BaboonCodecContext = BaboonCodecContext.Compact; public static withFacade(useIndices: boolean, facade: BaboonCodecsFacade): BaboonCodecContext { - return new BaboonCodecContext(useIndices, facade); + return new BaboonCodecContext(useIndices, ForwardWritePolicy.Strict, facade); + } + + /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + public static custom(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacade | undefined): BaboonCodecContext { + return new BaboonCodecContext(useIndices, forwardWritePolicy, facade); } } @@ -1058,6 +1085,9 @@ export class BaboonTypeMeta { } public static readonly JSON_READABLE_TIER = "json-additive"; + /** Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions()`, per index mode. */ + public static readonly UEBA_PREFIX_COMPACT_TIER = "prefix-compact"; + public static readonly UEBA_PREFIX_ANY_MODE_TIER = "prefix-any-mode"; public versionRef(): BaboonDomainVersion { return new BaboonDomainVersion(this.domainIdentifier, this.domainVersion); @@ -1134,6 +1164,32 @@ export class BaboonTypeMeta { ); } + /** + * Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` + * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + */ + public static forBin(value: BaboonGenerated, ctx: BaboonCodecContext, useAdtIdentifier: boolean = false): BaboonTypeMeta { + const meta = BaboonTypeMeta.from(value, useAdtIdentifier); + if (ctx.forwardWritePolicy === ForwardWritePolicy.Strict) { + return meta; + } + const tier = ctx.useIndices ? BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER : BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER; + const bound = value.baboonMinReaderVersions()[tier]; + if (bound === undefined) { + throw new BaboonException( + `baboonMinReaderVersions() lacks '${tier}' for type ${value.baboonTypeIdentifier()}`, + ); + } + return new BaboonTypeMeta( + meta.metaVersion, + meta.domainIdentifier, + meta.domainVersion, + bound, + meta.typeIdentifier, + meta.domainVersionReadableMin, + ); + } + private static isAdtMember(value: BaboonGenerated): value is BaboonAdtMemberMeta { // PR-25.8 / PR-22-D02: typeguard checks for a method (function-typed property), matching // codegen — `TsDomainTreeTools.adtMeta` emits `baboonAdtTypeIdentifier()` as an instance diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index 3a5fd0b9f..d8b86f2bb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -503,6 +503,13 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( CompilerProduct.Runtime, doNotModify = true, ), + RsDefnTranslator.Output( + "baboon_type_meta.rs", + TextTree.text(BaboonRuntimeResources.read("baboon-runtime/rust/baboon_type_meta.rs")), + RsValue.RsCrateId(NEList("crate")), + CompilerProduct.Runtime, + doNotModify = true, + ), RsDefnTranslator.Output( "baboon_codecs_facade.rs", TextTree.text(BaboonRuntimeResources.read("baboon-runtime/rust/baboon_codecs_facade.rs")), diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-1.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-1.baboon new file mode 100644 index 000000000..9b029bd1b --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-1.baboon @@ -0,0 +1,16 @@ +// Forward-compatibility end-to-end fixture: a THREE-version prefix chain +// (docs/forward-compat.md, "Envelope integration (UEBA)"). +// +// Every step appends a variable-length `opt` field, so each step is +// PREFIX_COMPACT and auto-derivable (stub projects carry no manual +// conversions). A reader that registers 1.0.0 and 2.0.0 must decode a +// 3.0.0 envelope published with the 1.0.0 prefix bound using its NEWEST +// (2.0.0) codec — not the bound version's. + +model fwde2e.chain + +version "1.0.0" + +root data ChainAppend { + a: i32 +} diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-2.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-2.baboon new file mode 100644 index 000000000..d2fbae534 --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-2.baboon @@ -0,0 +1,12 @@ +model fwde2e.chain + +version "2.0.0" + +import "1.0.0" { * } without { + ChainAppend +} + +root data ChainAppend { + a: i32 + b: opt[str] +} diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-3.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-3.baboon new file mode 100644 index 000000000..72a1a1497 --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-chain-ok/fwd-chain-3.baboon @@ -0,0 +1,13 @@ +model fwde2e.chain + +version "3.0.0" + +import "2.0.0" { * } without { + ChainAppend +} + +root data ChainAppend { + a: i32 + b: opt[str] + c: opt[str] +} diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 5114d3e3e..b4db34be6 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -98,8 +98,38 @@ Readers choose via `ForwardReadPolicy`: **Re-encoding intermediaries must use this**, otherwise they truncate data for downstream consumers that do understand the newer version. -The binary v1 envelope carries no readable-min bound; UEBA resolution is always -lossless. Spec: `docs/spec/codec-envelope.md`. +Spec: `docs/spec/codec-envelope.md`. + +## Envelope integration (UEBA) + +The binary v1 envelope has a single bound slot, `domainVersionMinCompat`, and +its layout cannot grow without a `metaVersion` bump. Forward reads therefore +ride on the *value* of that slot, chosen by the WRITER through +`ForwardWritePolicy` on `BaboonCodecContext` (alongside the index mode, which +the bound depends on): + +- `Strict` (default): the byte-identical bound — exactly what was written + before the policy existed. +- `Tolerant`: the prefix-read bound from `baboonMinReaderVersions` for the + payload's index mode: `prefix-compact` for compact payloads, `prefix-any-mode` + for indexed ones. When no prefix relationship exists the bound equals the + Strict one, so the envelope is byte-identical. + +Readers (all nine runtimes) trust the bound: a payload from a version newer +than every registered one is decoded with the reader's **newest** codec as soon +as the bound is at or below it — not with the bound version's codec. +Readability is monotone along the chain (a suffix of a prefix chain is a prefix +chain), so the newest codec is always correct and loses the fewest fields. This +rule is the same for JSON `$uv`/`$rv` resolution. + +What the policy costs, and why it is opt-in on the writer: a binary reader +cannot distinguish a Tolerant envelope from a byte-identical one. +`ForwardReadPolicy.Lossless` has no effect on binary reads, and a re-encoding +intermediary older than the writer silently truncates the value. The prefix +client contract is satisfied structurally by the byte-array decode entry points +(the payload is the last element of the envelope; nothing asserts full +consumption); stream-based callers must discard the stream after a forward +decode. ## Relationship to sameIn @@ -142,6 +172,16 @@ Two consequences of scheme 2: `$rv`: a facade registering only 1.0.0 decodes 2.0.0 envelopes under `Tolerant` exactly where `$rv` allows, refuses under `Lossless`, and refuses when no bound was published; `$rv` elision and `readMeta` round-trip. +- `test/sc-stub/.../ForwardCompatBinEnvelopeSpec.scala` and + `test/ts-stub/.../ForwardCompatBinEnvelope.test.ts` — the UEBA writer policy: + Strict envelopes are unchanged and refused by an old reader; Tolerant compact + envelopes carry the `prefix-compact` bound and are decoded by the old reader + with its own codec; json-additive-only, byte-identical and grown-enum types + yield envelopes identical to Strict; an indexed context does not lower the + bound for a variable-length appended field; and over the three-version + `fwd-e2e-chain-ok` model a reader registering 1.0.0 and 2.0.0 decodes a 3.0.0 + envelope bound at 1.0.0 with its 2.0.0 codec (fail-first: the previous + reader picked the bound version's codec and dropped the 2.0.0 field). ## Out of scope (recorded) diff --git a/docs/spec/codec-envelope.md b/docs/spec/codec-envelope.md index 0579c66c1..b77bfc970 100644 --- a/docs/spec/codec-envelope.md +++ b/docs/spec/codec-envelope.md @@ -27,7 +27,7 @@ Field summary: | `metaVersion` | u8 | Format version of the envelope itself. See § 3. | | `domainIdentifier` | string | Dotted namespace of the domain, e.g. `"my.ok"`. | | `domainVersion` | string | Semver of the encoded value's domain, e.g. `"1.0.0"`.| -| `domainVersionMinCompat` | string | Oldest domain version that decodes the same payload structurally (byte-identical bound). Equals `domainVersion` when the type is fresh in this version. | +| `domainVersionMinCompat` | string | Oldest domain version whose codec decodes this payload. Under the writer's default `ForwardWritePolicy.Strict` this is the byte-identical bound (equals `domainVersion` when the type's layout is fresh in this version). Under `Tolerant` — binary only — it is the prefix-read bound for the payload's index mode; see § 2.1.2. | | `domainVersionReadableMin` | string | **JSON only.** Oldest domain version whose JSON codec can decode the payload under the `json-additive` contract (tolerant key lookup; fields unknown to that version are dropped). Always `<= domainVersionMinCompat`. See `docs/forward-compat.md`. | | `typeIdentifier` | string | Type id within the domain, e.g. `"my.ok/:#Holder"`. | @@ -38,8 +38,9 @@ default a missing `domainVersionMinCompat` to `domainVersion`. `domainVersionReadableMin` is carried by the JSON envelope only (`$rv`) and is elided when it equals the effective `domainVersionMinCompat`; readers default it to `domainVersionMinCompat`. Invariant: `$rv <= $uv <= $v`. The binary v1 -envelope has no slot for it; readers of binary envelopes resolve losslessly -via `domainVersionMinCompat` alone. Whether a JSON reader honours `$rv` is a +envelope has no slot for it: the only bound a binary reader sees is +`domainVersionMinCompat`, whose meaning is chosen by the WRITER +(`ForwardWritePolicy`, § 2.1.2). Whether a JSON reader honours `$rv` is a reader-side policy (`ForwardReadPolicy`: `Tolerant`, the default, decodes newer payloads with the `$rv` version's codec and drops unknown fields; `Lossless` ignores `$rv`). Re-encoding intermediaries must use `Lossless`. @@ -86,6 +87,40 @@ fail the harness against the unmodified C# (and other) peers. Total: 33 bytes. +#### 2.1.2 What `domainVersionMinCompat` promises — the writer's `ForwardWritePolicy` + +The layout above is fixed; only the *value* written into the bound slot is +policy-dependent. The policy lives on `BaboonCodecContext` (next to the index +mode, because the bound depends on it): + +- `Strict` (default): the byte-identical bound, `baboonSameInVersions.head`. + Every codec at or above it produces and consumes exactly these bytes. + Identical to all envelopes written before the policy existed. +- `Tolerant`: the prefix-read bound from `baboonMinReaderVersions` for the + payload's index mode — `prefix-compact` for compact contexts, + `prefix-any-mode` for indexed ones (`docs/forward-compat.md`, "Tiers"). + Codecs at or above it read the payload as a prefix: the fields appended by + later versions are left unread. Equal to the Strict bound whenever no + prefix relationship exists, so such envelopes are byte-identical to Strict + ones. + +Readers cannot tell which policy produced an envelope. They trust the bound: +when `domainVersion` is newer than every registered version and the bound is +at or below the newest registered version, they decode with that newest +codec (forward-readability is monotone along the version chain, so this is +the highest-fidelity correct choice); otherwise the payload is unreadable. +Consequences the deploying organisation owns: + +- The prefix contract (`docs/forward-compat.md`, "The prefix-* client + contract") is satisfied structurally by the byte-array decode entry points, + because the payload is the last element of the envelope and readers do not + assert full consumption. Callers of the stream-based entry points must not + continue reading the stream after a forward decode. +- A `Tolerant` envelope decoded by an older reader is silently truncated to + the reader's field set. `ForwardReadPolicy.Lossless` cannot detect this for + binary; re-encoding intermediaries must run at the writer's version or + newer. + ### 2.2 JSON The envelope is a JSON object with the keys below. A reader does not assume @@ -211,7 +246,8 @@ The encoder always writes the four required fields (`metaVersion`, `domainIdentifier`, `domainVersion`, `typeIdentifier`); it elides `domainVersionMinCompat` only when it equals `domainVersion` (the elision is byte-canonical, not stylistic — readers MUST handle both -forms). +forms). The value it writes there is selected by the context's +`ForwardWritePolicy` (§ 2.1.2); the elision rule applies after selection. ## 7. Implementation pointers @@ -219,7 +255,7 @@ Per-backend runtime sources (where the envelope codec lives): - C#: `baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs` - Scala: `baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala` -- Rust: `baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs` (`mod baboon_type_meta_codec`) +- Rust: `baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs` (`mod baboon_type_meta_codec`; re-exported from `baboon_codecs_facade.rs`) - Java: `baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java` - Kotlin: `baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt` - Kotlin-KMP: `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt` diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala new file mode 100644 index 000000000..eec01e9d3 --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala @@ -0,0 +1,130 @@ +// NOTE: This test references generated symbols (fwde2e.fwd.*, fwde2e.chain.*) which are +// copied into this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen into +// target/test-regular/sc-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the UEBA envelope's writer-side forward policy +// (docs/forward-compat.md, "Envelope integration (UEBA)"): the v1 binary envelope has a +// single bound slot, `domainVersionMinCompat`. Under the default Strict policy it carries +// the byte-identical bound (unchanged behaviour); under Tolerant it carries the prefix bound +// for the chosen index mode, and a reader deployed before the writer's version decodes the +// payload with its newest codec. +package runtime + +import baboon.runtime.shared._ +import org.scalatest.funsuite.AnyFunSuite + +import java.io.ByteArrayInputStream + +class ForwardCompatBinEnvelopeSpec extends AnyFunSuite { + + private val tolerantCompact: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Tolerant, None) + private val tolerantIndexed: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = true, ForwardWritePolicy.Tolerant, None) + + private def minCompatOf(bytes: Array[Byte]): String = + BaboonTypeMeta.readMeta(new LEDataInputStream(new ByteArrayInputStream(bytes))).get.domainVersionMinCompat + + // a reader deployed before fwde2e.fwd 2.0.0 existed: knows 1.0.0 only + private def oldFwdReader(): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register( + BaboonDomainVersion("fwde2e.fwd", "1.0.0"), + fwde2e.fwd.v1_0_0.BaboonCodecsJson, + fwde2e.fwd.v1_0_0.BaboonCodecsUeba, + fwde2e.fwd.v1_0_0.BaboonMetadata, + ) + f + } + + private val fwdWriter: BaboonCodecsFacade = fwde2e.fwd.DomainFwde2eFwdFacade + + test("Strict (default) writer publishes the byte-identical bound: envelopes are unchanged and the old reader refuses") { + assert(BaboonCodecContext.Compact.forwardWritePolicy == ForwardWritePolicy.Strict) + assert(BaboonCodecContext.Indexed.forwardWritePolicy == ForwardWritePolicy.Strict) + + val appended = fwdWriter.encodeToBin(BaboonCodecContext.Compact, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + assert(minCompatOf(appended) == "2.0.0", "fresh field layout in 2.0.0: bound elided, equals the domain version") + assert(oldFwdReader().decodeFromBin(appended).isLeft) + + val stable = fwdWriter.encodeToBin(BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("same")).toTry.get + assert(minCompatOf(stable) == "1.0.0") + assert(oldFwdReader().decodeFromBin(stable).toTry.get == fwde2e.fwd.v1_0_0.FwdStable("same")) + } + + test("Tolerant writer publishes the compact prefix bound and the old reader decodes with its own codec") { + val appended = fwdWriter.encodeToBin(tolerantCompact, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + assert(minCompatOf(appended) == "1.0.0") + assert(oldFwdReader().decodeFromBin(appended).toTry.get == fwde2e.fwd.v1_0_0.FwdAppendVar(42, "hello")) + + // json-additive only (mid-position insert): no UEBA prefix bound — the envelope is + // byte-identical to the Strict one and the old reader still refuses + val inserted = fwde2e.fwd.FwdMidInsert(7, Some(99), "zed") + val insertedBytes = fwdWriter.encodeToBin(tolerantCompact, inserted).toTry.get + assert(insertedBytes.sameElements(fwdWriter.encodeToBin(BaboonCodecContext.Compact, inserted).toTry.get)) + assert(oldFwdReader().decodeFromBin(insertedBytes).isLeft) + + // byte-identical type: the bound is 1.0.0 under both policies + val stable = fwde2e.fwd.FwdStable("same") + assert(fwdWriter.encodeToBin(tolerantCompact, stable).toTry.get.sameElements(fwdWriter.encodeToBin(BaboonCodecContext.Compact, stable).toTry.get)) + + // grown enum: not forward-readable at all — unchanged envelope, refused + val enumHost = fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C) + val enumHostBytes = fwdWriter.encodeToBin(tolerantCompact, enumHost).toTry.get + assert(enumHostBytes.sameElements(fwdWriter.encodeToBin(BaboonCodecContext.Compact, enumHost).toTry.get)) + assert(oldFwdReader().decodeFromBin(enumHostBytes).isLeft) + } + + test("indexed payload: a variable-length appended field only earns prefix-compact, so no bound is lowered") { + val appended = fwdWriter.encodeToBin(tolerantIndexed, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + assert(minCompatOf(appended) == "2.0.0") + assert(oldFwdReader().decodeFromBin(appended).isLeft) + // the writer's metadata distinguishes the two modes + assert(fwde2e.fwd.FwdAppendVar.baboonMinReaderVersions(BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER) == "1.0.0") + assert(fwde2e.fwd.FwdAppendVar.baboonMinReaderVersions(BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER) == "2.0.0") + } + + test("three-version chain: the Tolerant bound is the chain minimum and each older reader decodes with its newest codec") { + val value = fwde2e.chain.ChainAppend(1, Some("b"), Some("c")) + val bytes = chainWriter.encodeToBin(tolerantCompact, value).toTry.get + assert(minCompatOf(bytes) == "1.0.0") + assert(midReader().decodeFromBin(bytes).toTry.get == fwde2e.chain.v2_0_0.ChainAppend(1, Some("b"))) + + val v1Only = new BaboonCodecsFacade {} + v1Only.register( + BaboonDomainVersion("fwde2e.chain", "1.0.0"), + fwde2e.chain.v1_0_0.BaboonCodecsJson, + fwde2e.chain.v1_0_0.BaboonCodecsUeba, + fwde2e.chain.v1_0_0.BaboonMetadata, + ) + assert(v1Only.decodeFromBin(bytes).toTry.get == fwde2e.chain.v1_0_0.ChainAppend(1)) + } + + // a reader deployed before fwde2e.chain 3.0.0 existed: knows 1.0.0 and 2.0.0 + private def midReader(): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register( + BaboonDomainVersion("fwde2e.chain", "1.0.0"), + fwde2e.chain.v1_0_0.BaboonCodecsJson, + fwde2e.chain.v1_0_0.BaboonCodecsUeba, + fwde2e.chain.v1_0_0.BaboonMetadata, + ) + f.register( + BaboonDomainVersion("fwde2e.chain", "2.0.0"), + fwde2e.chain.v2_0_0.BaboonCodecsJson, + fwde2e.chain.v2_0_0.BaboonCodecsUeba, + fwde2e.chain.v2_0_0.BaboonMetadata, + ) + f + } + + private val chainWriter: BaboonCodecsFacade = fwde2e.chain.DomainFwde2eChainFacade + + test("reader decodes a newer prefix-readable payload with its newest codec, not the bound version's") { + val value = fwde2e.chain.ChainAppend(1, Some("b"), Some("c")) + // a 3.0.0 envelope whose minCompat names 1.0.0 — what the Tolerant writer policy publishes + val meta = BaboonTypeMeta.from(value).copy(domainVersionMinCompat = "1.0.0") + val bytes = chainWriter.encodeToBin(BaboonCodecContext.Compact, value, Some(meta)).toTry.get + val decoded = midReader().decodeFromBin(bytes).toTry.get + // 2.0.0 is prefix-readable too (readability is monotone along the chain), so `b` must survive + assert(decoded == fwde2e.chain.v2_0_0.ChainAppend(1, Some("b"))) + } +} diff --git a/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts b/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts new file mode 100644 index 000000000..0f2110245 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts @@ -0,0 +1,173 @@ +// NOTE: This test references generated symbols (src/generated/fwde2e/**) which are +// copied into this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen +// into target/test-regular/ts-stub/). Run from the codegen'd copy. +// +// End-to-end proof of the UEBA envelope's writer-side forward policy +// (docs/forward-compat.md, "Envelope integration (UEBA)"): the v1 binary envelope has a +// single bound slot, `domainVersionMinCompat`. Under the default Strict policy it carries the +// byte-identical bound (unchanged behaviour); under Tolerant it carries the prefix bound for +// the chosen index mode, and a reader deployed before the writer's version decodes the payload +// with its newest codec. +import { describe, expect, test } from 'vitest'; +import { + AbstractBaboonConversions, + AbstractBaboonJsonCodecs, + AbstractBaboonUebaCodecs, + BaboonBinCodec, + BaboonBinReader, + BaboonCodecContext, + BaboonDomainVersion, + BaboonEither, + BaboonGenerated, + BaboonJsonCodec, + BaboonMeta, + BaboonTypeMeta, + ForwardWritePolicy, + Lazy, +} from '../generated/BaboonSharedRuntime'; +import { BaboonCodecsFacade } from '../generated/BaboonCodecsFacade'; +import { DomainFwde2eFwdFacade } from '../generated/fwde2e/fwd/DomainFwde2eFwdFacade'; +import { DomainFwde2eChainFacade } from '../generated/fwde2e/chain/DomainFwde2eChainFacade'; + +import { FwdAppendVar, FwdEnumHost, FwdMidInsert, FwdStable } from '../generated/fwde2e/fwd/index'; +import { FwdEnumGrows } from '../generated/fwde2e/fwd/FwdEnumGrows'; +import { FwdAppendVar as FwdAppendVarV1, FwdAppendVar_JsonCodec as FwdAppendVarV1_JsonCodec, FwdAppendVar_UEBACodec as FwdAppendVarV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdAppendVar'; +import { FwdMidInsert as FwdMidInsertV1, FwdMidInsert_JsonCodec as FwdMidInsertV1_JsonCodec, FwdMidInsert_UEBACodec as FwdMidInsertV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdMidInsert'; +import { FwdStable as FwdStableV1, FwdStable_JsonCodec as FwdStableV1_JsonCodec, FwdStable_UEBACodec as FwdStableV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdStable'; +import { FwdEnumHost as FwdEnumHostV1, FwdEnumHost_JsonCodec as FwdEnumHostV1_JsonCodec, FwdEnumHost_UEBACodec as FwdEnumHostV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdEnumHost'; + +import { ChainAppend } from '../generated/fwde2e/chain/ChainAppend'; +import { ChainAppend as ChainAppendV1, ChainAppend_JsonCodec as ChainAppendV1_JsonCodec, ChainAppend_UEBACodec as ChainAppendV1_UEBACodec } from '../generated/fwde2e/chain/v1_0_0/ChainAppend'; +import { ChainAppend as ChainAppendV2, ChainAppend_JsonCodec as ChainAppendV2_JsonCodec, ChainAppend_UEBACodec as ChainAppendV2_UEBACodec } from '../generated/fwde2e/chain/v2_0_0/ChainAppend'; + +type Registration = { typeId: string; json: BaboonJsonCodec; ueba: BaboonBinCodec }; + +// The generated per-version codec registries are module-private, so old readers are assembled +// from the exported per-type codecs (mirrors ForwardCompatEnvelope.test.ts). +function versionModules(domain: string, version: string, regs: Registration[]) { + class JsonCodecs extends AbstractBaboonJsonCodecs { + constructor() { + super(); + for (const r of regs) this.register(r.typeId, new Lazy>(() => r.json)); + } + } + class UebaCodecs extends AbstractBaboonUebaCodecs { + constructor() { + super(); + for (const r of regs) this.register(r.typeId, new Lazy>(() => r.ueba)); + } + } + class Conversions implements AbstractBaboonConversions { + public versionsFrom(): string[] { return []; } + public versionTo(): string { return version; } + } + class Meta implements BaboonMeta { + public sameInVersions(_typeId: string): string[] { return [version]; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { [version]: 'identical' }; } + } + return (f: BaboonCodecsFacade) => + f.register(new BaboonDomainVersion(domain, version), () => new JsonCodecs(), () => new UebaCodecs(), () => new Conversions(), () => new Meta()); +} + +const fwdV1 = versionModules('fwde2e.fwd', '1.0.0', [ + { typeId: FwdAppendVarV1.BaboonTypeIdentifier, json: FwdAppendVarV1_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: FwdAppendVarV1_UEBACodec.instance as unknown as BaboonBinCodec }, + { typeId: FwdMidInsertV1.BaboonTypeIdentifier, json: FwdMidInsertV1_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: FwdMidInsertV1_UEBACodec.instance as unknown as BaboonBinCodec }, + { typeId: FwdStableV1.BaboonTypeIdentifier, json: FwdStableV1_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: FwdStableV1_UEBACodec.instance as unknown as BaboonBinCodec }, + { typeId: FwdEnumHostV1.BaboonTypeIdentifier, json: FwdEnumHostV1_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: FwdEnumHostV1_UEBACodec.instance as unknown as BaboonBinCodec }, +]); +const chainV1 = versionModules('fwde2e.chain', '1.0.0', [ + { typeId: ChainAppendV1.BaboonTypeIdentifier, json: ChainAppendV1_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: ChainAppendV1_UEBACodec.instance as unknown as BaboonBinCodec }, +]); +const chainV2 = versionModules('fwde2e.chain', '2.0.0', [ + { typeId: ChainAppendV2.BaboonTypeIdentifier, json: ChainAppendV2_JsonCodec.instance as unknown as BaboonJsonCodec, ueba: ChainAppendV2_UEBACodec.instance as unknown as BaboonBinCodec }, +]); + +// a reader deployed before fwde2e.fwd 2.0.0 existed: knows 1.0.0 only +function oldFwdReader(): BaboonCodecsFacade { const f = new BaboonCodecsFacade(); fwdV1(f); return f; } +// a reader deployed before fwde2e.chain 3.0.0 existed: knows 1.0.0 and 2.0.0 +function midChainReader(): BaboonCodecsFacade { const f = new BaboonCodecsFacade(); chainV1(f); chainV2(f); return f; } +function v1ChainReader(): BaboonCodecsFacade { const f = new BaboonCodecsFacade(); chainV1(f); return f; } + +const fwdWriter = new DomainFwde2eFwdFacade(); +const chainWriter = new DomainFwde2eChainFacade(); + +const tolerantCompact = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, undefined); +const tolerantIndexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Tolerant, undefined); + +function unwrap(e: BaboonEither): R { + if (e.tag !== 'Right') throw new Error(`expected Right, got Left: ${String((e as { value: unknown }).value)}`); + return e.value; +} + +function bin(writer: BaboonCodecsFacade, ctx: BaboonCodecContext, value: BaboonGenerated): Uint8Array { + return unwrap(writer.encodeToBin(ctx, value)); +} + +function minCompatOf(bytes: Uint8Array): string { + return BaboonTypeMeta.readMeta(new BaboonBinReader(bytes))!.domainVersionMinCompat; +} + +describe('UEBA envelope writer-side forward policy (fwde2e.fwd, fwde2e.chain)', () => { + test('Strict (default) writer publishes the byte-identical bound: envelopes are unchanged and the old reader refuses', () => { + expect(BaboonCodecContext.Compact.forwardWritePolicy).toBe(ForwardWritePolicy.Strict); + expect(BaboonCodecContext.Indexed.forwardWritePolicy).toBe(ForwardWritePolicy.Strict); + + const appended = bin(fwdWriter, BaboonCodecContext.Compact, new FwdAppendVar(42, 'hello', 'appended')); + expect(minCompatOf(appended)).toBe('2.0.0'); + expect(oldFwdReader().decodeFromBin(appended).tag).toBe('Left'); + + const stable = bin(fwdWriter, BaboonCodecContext.Compact, new FwdStable('same')); + expect(minCompatOf(stable)).toBe('1.0.0'); + expect((unwrap(oldFwdReader().decodeFromBin(stable)) as FwdStableV1).s).toBe('same'); + }); + + test('Tolerant writer publishes the compact prefix bound and the old reader decodes with its own codec', () => { + const appended = bin(fwdWriter, tolerantCompact, new FwdAppendVar(42, 'hello', 'appended')); + expect(minCompatOf(appended)).toBe('1.0.0'); + const decoded = unwrap(oldFwdReader().decodeFromBin(appended)) as FwdAppendVarV1; + expect(decoded).toBeInstanceOf(FwdAppendVarV1); + expect(decoded.a).toBe(42); + expect(decoded.b).toBe('hello'); + + // json-additive only (mid-position insert): no UEBA prefix bound — byte-identical to Strict, refused + const inserted = new FwdMidInsert(7, 99, 'zed'); + expect(bin(fwdWriter, tolerantCompact, inserted)).toEqual(bin(fwdWriter, BaboonCodecContext.Compact, inserted)); + expect(oldFwdReader().decodeFromBin(bin(fwdWriter, tolerantCompact, inserted)).tag).toBe('Left'); + + // byte-identical type: bound is 1.0.0 under both policies + const stable = new FwdStable('same'); + expect(bin(fwdWriter, tolerantCompact, stable)).toEqual(bin(fwdWriter, BaboonCodecContext.Compact, stable)); + + // grown enum: not forward-readable at all — unchanged envelope, refused + const enumHost = new FwdEnumHost(FwdEnumGrows.C); + expect(bin(fwdWriter, tolerantCompact, enumHost)).toEqual(bin(fwdWriter, BaboonCodecContext.Compact, enumHost)); + expect(oldFwdReader().decodeFromBin(bin(fwdWriter, tolerantCompact, enumHost)).tag).toBe('Left'); + }); + + test('indexed payload: a variable-length appended field only earns prefix-compact, so no bound is lowered', () => { + const appended = bin(fwdWriter, tolerantIndexed, new FwdAppendVar(42, 'hello', 'appended')); + expect(minCompatOf(appended)).toBe('2.0.0'); + expect(oldFwdReader().decodeFromBin(appended).tag).toBe('Left'); + expect(FwdAppendVar.BaboonMinReaderVersions[BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER]).toBe('1.0.0'); + expect(FwdAppendVar.BaboonMinReaderVersions[BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER]).toBe('2.0.0'); + }); + + test('three-version chain: the Tolerant bound is the chain minimum and each older reader decodes with its newest codec', () => { + const bytes = bin(chainWriter, tolerantCompact, new ChainAppend(1, 'b', 'c')); + expect(minCompatOf(bytes)).toBe('1.0.0'); + + const mid = unwrap(midChainReader().decodeFromBin(bytes)) as ChainAppendV2; + expect(mid).toBeInstanceOf(ChainAppendV2); + expect(mid.a).toBe(1); + expect(mid.b).toBe('b'); + + const oldest = unwrap(v1ChainReader().decodeFromBin(bytes)) as ChainAppendV1; + expect(oldest).toBeInstanceOf(ChainAppendV1); + expect(oldest.a).toBe(1); + + // a Strict 3.0.0 envelope is refused by both + const strict = bin(chainWriter, BaboonCodecContext.Compact, new ChainAppend(1, 'b', 'c')); + expect(minCompatOf(strict)).toBe('3.0.0'); + expect(midChainReader().decodeFromBin(strict).tag).toBe('Left'); + }); +}); From 3f3d1906af77dd7dbe58ba7977274df4e425664c Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 16:15:09 +0100 Subject: [PATCH 11/48] docs(forward-compat): worked wire examples for every envelope flag combination Adds a "Worked examples: what changed on the wire" section: the three knobs (writer ForwardWritePolicy, index mode, reader ForwardReadPolicy) and the reader-rule change; per-type writer bounds for the fwd-e2e fixtures; the real JSON envelopes with both reader policies' outcomes; annotated UEBA bytes for Strict vs Tolerant; the full value x context matrix as seen by a 1.0.0-only reader; the three-version chain before/after the newest-codec rule; and a summary matrix. All envelopes and outcomes were captured from the generated Scala stub. Co-Authored-By: Claude Fable 5.1 --- docs/forward-compat.md | 144 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index b4db34be6..861c2f2a9 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -131,6 +131,150 @@ client contract is satisfied structurally by the byte-array decode entry points consumption); stream-based callers must discard the stream after a forward decode. +## Worked examples: what changed on the wire + +Everything below was produced by the generated Scala stub over the shared +`fwd-e2e-ok` and `fwd-e2e-chain-ok` models; the other eight runtimes emit the +same bytes and keys. + +### The knobs + +| knob | where | values | affects | +|---|---|---|---| +| `ForwardWritePolicy` | writer's `BaboonCodecContext` (next to the index mode) | `Strict` (default), `Tolerant` | the value written into the UEBA `domainVersionMinCompat` slot. No effect on JSON. | +| index mode | writer's `BaboonCodecContext` | compact, indexed | which prefix tier a `Tolerant` writer consults: `prefix-compact` or `prefix-any-mode` | +| `ForwardReadPolicy` | reader's facade | `Tolerant` (default), `Lossless` | whether a JSON reader honours `$rv`. No effect on UEBA — the binary envelope carries one bound and the reader cannot tell how it was chosen. | + +One reader rule changed for both formats: when the envelope's `$v` / +`domainVersion` is newer than every registered version and the applicable +bound is at or below the reader's newest version, the reader decodes with its +**newest** codec. Previously it took the bound version's `sameIn` run and +picked the highest member of that run, i.e. a codec byte-identical to the +bound version's — correct, but dropping every field added between the bound +and the reader's own version. + +### The fixture types and their bounds + +Writer-side bounds (`baboonMinReaderVersions`, emitted per type) for the +2.0.0 domain `fwde2e.fwd` and the 3.0.0 domain `fwde2e.chain`: + +| type | evolution | `identical` | `prefix-any-mode` | `prefix-compact` | `json-additive` | +|---|---|---|---|---|---| +| `FwdAppendVar` | 1.0.0 `{a, b}` → 2.0.0 appends `t: opt[str]` | 2.0.0 | 2.0.0 | **1.0.0** | 1.0.0 | +| `FwdMidInsert` | inserts `m: opt[i32]` between `a` and `z` | 2.0.0 | 2.0.0 | 2.0.0 | **1.0.0** | +| `FwdStable` | unchanged | **1.0.0** | 1.0.0 | 1.0.0 | 1.0.0 | +| `FwdEnumHost` | its enum gained a member | 2.0.0 | 2.0.0 | 2.0.0 | 2.0.0 | +| `ChainAppend` | 1.0.0 `{a}` → 2.0.0 `+b: opt[str]` → 3.0.0 `+c: opt[str]` | 3.0.0 | 3.0.0 | **1.0.0** | 1.0.0 | + +`FwdAppendVar` earns only `prefix-compact` because the appended field is +variable-length: an indexed 2.0.0 blob carries two index entries (for `b` and +`t`) while the 1.0.0 decoder expects one, and the index has no on-wire count. + +### JSON + +The JSON writer has no policy. It always publishes both bounds and elides each +when it carries no information: `$uv` when equal to `$v`, `$rv` when equal to +the effective `$uv`. The reader deployed with only 1.0.0: + +```jsonc +// FwdAppendVar(42, "hi", Some("t")) — identical bound is 2.0.0 ($uv elided), json-additive bound 1.0.0 +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdAppendVar","$rv":"1.0.0","$c":{"a":42,"b":"hi","t":"t"}} +// reader Tolerant → FwdAppendVar(42, "hi") (1.0.0 codec; "t" dropped) +// reader Lossless → refused (no codec) + +// FwdMidInsert(7, Some(99), "z") — same shape: positional layout broke, key lookup did not +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdMidInsert","$rv":"1.0.0","$c":{"a":7,"m":99,"z":"z"}} +// reader Tolerant → FwdMidInsert(7, "z") +// reader Lossless → refused (no codec) + +// FwdStable("s") — byte-identical since 1.0.0: $uv carries it, $rv elided (equal to $uv) +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdStable","$uv":"1.0.0","$c":{"s":"s"}} +// reader Tolerant → FwdStable("s") +// reader Lossless → FwdStable("s") + +// FwdEnumHost(C) — not forward-readable: neither bound published (both equal $v) +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdEnumHost","$c":{"e":"C"}} +// reader Tolerant → refused (no codec) +// reader Lossless → refused (no codec) +``` + +The three-version chain shows the reader-rule change. A reader registering +1.0.0 **and** 2.0.0 receives a 3.0.0 envelope bound at 1.0.0: + +```jsonc +{"$mv":1,"$d":"fwde2e.chain","$v":"3.0.0","$t":"fwde2e.chain/:#ChainAppend","$rv":"1.0.0","$c":{"a":1,"b":"b","c":"c"}} +// 1.0.0+2.0.0 reader, Tolerant → ChainAppend(1, Some("b")) — newest codec (2.0.0); before this change: ChainAppend(1) +// 1.0.0+2.0.0 reader, Lossless → refused (no codec) — $uv is elided, i.e. 3.0.0 +// 1.0.0 reader, Tolerant → ChainAppend(1) +``` + +### UEBA + +Envelope layout (`docs/spec/codec-envelope.md` § 2.1): +`metaVersion | domainId | domainVersion | hasMinCompat | [minCompat] | typeId | payload`. +The policy decides only the `hasMinCompat`/`minCompat` bytes; everything else, +payload included, is identical. `FwdAppendVar(42, "hi", Some("t"))` written by +the 2.0.0 facade in a compact context: + +``` +Strict (default): +01 metaVersion 1 +0A 66 77 64 65 32 65 2E 66 77 64 "fwde2e.fwd" +05 32 2E 30 2E 30 "2.0.0" +00 hasMinCompat = 0 → bound = 2.0.0 (identical bound, elided) +19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 "fwde2e.fwd/:#FwdAppendVar" +00 2A 00 00 00 02 68 69 01 01 74 payload: mode=compact, a=42, b="hi", t=Some("t") + 1.0.0 reader → refused (CodecNotFound) + +Tolerant: +01 +0A 66 77 64 65 32 65 2E 66 77 64 +05 32 2E 30 2E 30 +01 05 31 2E 30 2E 30 hasMinCompat = 1, minCompat = "1.0.0" (prefix-compact bound) +19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 +00 2A 00 00 00 02 68 69 01 01 74 payload unchanged + 1.0.0 reader → FwdAppendVar(42, "hi") (reads a, b; the remaining `01 01 74` is never consumed) +``` + +The same value in an **indexed** context is byte-identical under both +policies: the writer consults the `prefix-any-mode` bound, which is 2.0.0, so +`hasMinCompat` stays 0 and the 1.0.0 reader refuses. This is the case the +index mode exists for — the payload starts `01 | 04 00 00 00 03 00 00 00 | 07 +00 00 00 03 00 00 00 | …`: two index entries where the 1.0.0 decoder would read +one. + +Every combination, as seen by a reader that registers only 1.0.0 +(`ForwardReadPolicy` is irrelevant for binary): + +| value | Strict, compact | Tolerant, compact | Strict, indexed | Tolerant, indexed | +|---|---|---|---|---| +| `FwdAppendVar` | bound 2.0.0 → refused | **bound 1.0.0 → `FwdAppendVar(42,"hi")`** | bound 2.0.0 → refused | bound 2.0.0 → refused | +| `FwdMidInsert` | 2.0.0 → refused | 2.0.0 → refused (json-additive only; bytes identical to Strict) | 2.0.0 → refused | 2.0.0 → refused | +| `FwdStable` | 1.0.0 → decoded | 1.0.0 → decoded (bytes identical to Strict) | 1.0.0 → decoded | 1.0.0 → decoded | +| `FwdEnumHost` | 2.0.0 → refused | 2.0.0 → refused (bytes identical to Strict) | 2.0.0 → refused | 2.0.0 → refused | + +So `Tolerant` changes exactly one envelope in this fixture — the one whose +type has a prefix relationship the payload's mode can honour — and leaves the +rest byte-for-byte as before. + +The chain under `Tolerant`, compact (bound `01 05 31 2E 30 2E 30` = 1.0.0, +`$v` 3.0.0, payload `00 | 01 00 00 00 | 01 01 62 | 01 01 63`): + +| reader registers | decodes as | before this change | +|---|---|---| +| 1.0.0, 2.0.0 | `ChainAppend(1, Some("b"))` — newest codec, `c` unread | `ChainAppend(1)` — the bound version's codec | +| 1.0.0 | `ChainAppend(1)` | `ChainAppend(1)` | +| 1.0.0, 2.0.0 (Strict envelope, bound 3.0.0) | refused | refused | + +### Summary matrix + +| format | writer policy | reader policy | bound the reader acts on | result for an older reader | +|---|---|---|---|---| +| JSON | n/a | `Tolerant` (default) | `$rv`, falling back to `$uv` | decodes whenever the type is json-additive-readable; unknown keys dropped | +| JSON | n/a | `Lossless` | `$uv` only | decodes only byte-identical payloads | +| UEBA | `Strict` (default) | either | `minCompat` = identical bound | decodes only byte-identical payloads — unchanged behaviour | +| UEBA | `Tolerant` | either | `minCompat` = prefix bound for the payload's mode | decodes prefix-readable payloads with its newest codec; trailing appended fields unread; indistinguishable from a byte-identical read | + ## Relationship to sameIn `identical` forward tiers imply membership in the `sameIn` run. Historically From 3e058c800e4f6a88343262f2f1f1f6576a958e4d Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 18:02:56 +0100 Subject: [PATCH 12/48] docs(forward-compat): per-type structure + envelope walk-through in every mode Reworks the worked-examples section around the fixture types themselves: each of FwdAppendVar, FwdMidInsert, FwdStable, FwdEnumHost and the three-version ChainAppend is shown as its .baboon source across versions, its per-tier bounds, the JSON envelope with both reader policies' outcomes, and the UEBA envelope under Strict/Tolerant x compact/indexed with annotated bytes and the older reader's result. All envelopes and outcomes were captured from the generated Scala stub. Co-Authored-By: Claude Fable 5.1 --- docs/forward-compat.md | 261 +++++++++++++++++++++++++++-------------- 1 file changed, 176 insertions(+), 85 deletions(-) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 861c2f2a9..c03e9abc6 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -153,120 +153,211 @@ picked the highest member of that run, i.e. a codec byte-identical to the bound version's — correct, but dropping every field added between the bound and the reader's own version. -### The fixture types and their bounds +### Reading the examples -Writer-side bounds (`baboonMinReaderVersions`, emitted per type) for the -2.0.0 domain `fwde2e.fwd` and the 3.0.0 domain `fwde2e.chain`: +The writer is the 2.0.0 facade of `fwde2e.fwd`; the reader registers only +1.0.0. Every UEBA envelope starts with the same 18 bytes, abbreviated `HDR`: -| type | evolution | `identical` | `prefix-any-mode` | `prefix-compact` | `json-additive` | -|---|---|---|---|---|---| -| `FwdAppendVar` | 1.0.0 `{a, b}` → 2.0.0 appends `t: opt[str]` | 2.0.0 | 2.0.0 | **1.0.0** | 1.0.0 | -| `FwdMidInsert` | inserts `m: opt[i32]` between `a` and `z` | 2.0.0 | 2.0.0 | 2.0.0 | **1.0.0** | -| `FwdStable` | unchanged | **1.0.0** | 1.0.0 | 1.0.0 | 1.0.0 | -| `FwdEnumHost` | its enum gained a member | 2.0.0 | 2.0.0 | 2.0.0 | 2.0.0 | -| `ChainAppend` | 1.0.0 `{a}` → 2.0.0 `+b: opt[str]` → 3.0.0 `+c: opt[str]` | 3.0.0 | 3.0.0 | **1.0.0** | 1.0.0 | +``` +01 metaVersion = 1 +0A 66 77 64 65 32 65 2E 66 77 64 len 10, "fwde2e.fwd" +05 32 2E 30 2E 30 len 5, "2.0.0" +``` + +After `HDR` come the bound bytes — either `00` (elided; bound = 2.0.0) or +`01 05 31 2E 30 2E 30` (`hasMinCompat` = 1, then `"1.0.0"`) — then the +length-prefixed type identifier and the payload. Strings in the payload are +written as `len "text"` for readability; everything else is raw hex. + +### `FwdAppendVar` — a variable-length field appended at the tail + +``` +version "1.0.0" version "2.0.0" +root data FwdAppendVar { root data FwdAppendVar { + a: i32 a: i32 + b: str b: str +} t: opt[str] + } +``` -`FwdAppendVar` earns only `prefix-compact` because the appended field is -variable-length: an indexed 2.0.0 blob carries two index entries (for `b` and -`t`) while the 1.0.0 decoder expects one, and the index has no on-wire count. +Bounds: `identical` 2.0.0 · `prefix-any-mode` 2.0.0 · `prefix-compact` **1.0.0** · +`json-additive` 1.0.0. The append earns only `prefix-compact` because `t` is +variable-length (see the indexed bytes below). -### JSON +Value: `FwdAppendVar(42, "hi", Some("t"))`. -The JSON writer has no policy. It always publishes both bounds and elides each -when it carries no information: `$uv` when equal to `$v`, `$rv` when equal to -the effective `$uv`. The reader deployed with only 1.0.0: +JSON — `$uv` elided (identical bound equals `$v`), `$rv` published: ```jsonc -// FwdAppendVar(42, "hi", Some("t")) — identical bound is 2.0.0 ($uv elided), json-additive bound 1.0.0 {"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdAppendVar","$rv":"1.0.0","$c":{"a":42,"b":"hi","t":"t"}} -// reader Tolerant → FwdAppendVar(42, "hi") (1.0.0 codec; "t" dropped) -// reader Lossless → refused (no codec) +// 1.0.0 reader, Tolerant → FwdAppendVar(42, "hi") ("t" dropped) +// 1.0.0 reader, Lossless → refused (no codec) +``` + +UEBA, four writer modes: + +``` +Strict, compact 1.0.0 reader → refused +HDR | 00 | 19 "fwde2e.fwd/:#FwdAppendVar" + | 00 2A 00 00 00 02 "hi" 01 01 "t" mode=compact, a=42, b, t=Some("t") + +Tolerant, compact 1.0.0 reader → FwdAppendVar(42, "hi") +HDR | 01 05 "1.0.0" | 19 "fwde2e.fwd/:#FwdAppendVar" + | 00 2A 00 00 00 02 "hi" 01 01 "t" payload identical; the 1.0.0 codec stops after b + +Strict, indexed 1.0.0 reader → refused +HDR | 00 | 19 "fwde2e.fwd/:#FwdAppendVar" + | 01 mode=indexed + | 04 00 00 00 03 00 00 00 index entry for b: offset 4, length 3 + | 07 00 00 00 03 00 00 00 index entry for t: offset 7, length 3 + | 2A 00 00 00 02 "hi" 01 01 "t" + +Tolerant, indexed 1.0.0 reader → refused +(byte-identical to Strict, indexed: the prefix-any-mode bound is 2.0.0, so hasMinCompat stays 0) +``` + +The indexed layout is why the tier stops at `prefix-compact`: the index has no +on-wire count, the 1.0.0 decoder expects one entry (for `b`) and would read the +second entry's offset as the start of the fields. + +### `FwdMidInsert` — a field inserted mid-sequence -// FwdMidInsert(7, Some(99), "z") — same shape: positional layout broke, key lookup did not +``` +version "1.0.0" version "2.0.0" +root data FwdMidInsert { root data FwdMidInsert { + a: i32 a: i32 + z: str m: opt[i32] +} z: str + } +``` + +Bounds: `identical` 2.0.0 · `prefix-any-mode` 2.0.0 · `prefix-compact` 2.0.0 · +`json-additive` **1.0.0**. Positional layout broke; key lookup did not. + +Value: `FwdMidInsert(7, Some(99), "z")`. + +```jsonc {"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdMidInsert","$rv":"1.0.0","$c":{"a":7,"m":99,"z":"z"}} -// reader Tolerant → FwdMidInsert(7, "z") -// reader Lossless → refused (no codec) +// 1.0.0 reader, Tolerant → FwdMidInsert(7, "z") +// 1.0.0 reader, Lossless → refused (no codec) +``` -// FwdStable("s") — byte-identical since 1.0.0: $uv carries it, $rv elided (equal to $uv) -{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdStable","$uv":"1.0.0","$c":{"s":"s"}} -// reader Tolerant → FwdStable("s") -// reader Lossless → FwdStable("s") +``` +Strict, compact = Tolerant, compact 1.0.0 reader → refused +HDR | 00 | 19 "fwde2e.fwd/:#FwdMidInsert" + | 00 07 00 00 00 01 63 00 00 00 01 "z" a=7, m=Some(99), z + +Strict, indexed = Tolerant, indexed 1.0.0 reader → refused +HDR | 00 | 19 "fwde2e.fwd/:#FwdMidInsert" + | 01 | 04 00 00 00 05 00 00 00 | 09 00 00 00 02 00 00 00 + | 07 00 00 00 01 63 00 00 00 01 "z" +``` -// FwdEnumHost(C) — not forward-readable: neither bound published (both equal $v) -{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdEnumHost","$c":{"e":"C"}} -// reader Tolerant → refused (no codec) -// reader Lossless → refused (no codec) +No UEBA prefix bound exists, so `Tolerant` writes exactly the Strict bytes. + +### `FwdStable` — unchanged + +``` +version "1.0.0" version "2.0.0" +root data FwdStable { import "1.0.0" { * } without { FwdAppendVar FwdMidInsert FwdEnumGrows } + s: str // FwdStable is inherited from 1.0.0 unchanged +} ``` -The three-version chain shows the reader-rule change. A reader registering -1.0.0 **and** 2.0.0 receives a 3.0.0 envelope bound at 1.0.0: +Bounds: every tier **1.0.0**. + +Value: `FwdStable("s")`. ```jsonc -{"$mv":1,"$d":"fwde2e.chain","$v":"3.0.0","$t":"fwde2e.chain/:#ChainAppend","$rv":"1.0.0","$c":{"a":1,"b":"b","c":"c"}} -// 1.0.0+2.0.0 reader, Tolerant → ChainAppend(1, Some("b")) — newest codec (2.0.0); before this change: ChainAppend(1) -// 1.0.0+2.0.0 reader, Lossless → refused (no codec) — $uv is elided, i.e. 3.0.0 -// 1.0.0 reader, Tolerant → ChainAppend(1) +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdStable","$uv":"1.0.0","$c":{"s":"s"}} +// 1.0.0 reader, Tolerant → FwdStable("s") +// 1.0.0 reader, Lossless → FwdStable("s") ($uv reaches 1.0.0; $rv elided because it equals $uv) +``` + +``` +Strict, compact = Tolerant, compact 1.0.0 reader → FwdStable("s") +HDR | 01 05 "1.0.0" | 16 "fwde2e.fwd/:#FwdStable" + | 00 01 "s" + +Strict, indexed = Tolerant, indexed 1.0.0 reader → FwdStable("s") +HDR | 01 05 "1.0.0" | 16 "fwde2e.fwd/:#FwdStable" + | 01 | 00 00 00 00 02 00 00 00 | 01 "s" ``` -### UEBA +The bound is already 1.0.0 under `Strict`; `Tolerant` has nothing to lower. -Envelope layout (`docs/spec/codec-envelope.md` § 2.1): -`metaVersion | domainId | domainVersion | hasMinCompat | [minCompat] | typeId | payload`. -The policy decides only the `hasMinCompat`/`minCompat` bytes; everything else, -payload included, is identical. `FwdAppendVar(42, "hi", Some("t"))` written by -the 2.0.0 facade in a compact context: +### `FwdEnumHost` — the referenced enum grew ``` -Strict (default): -01 metaVersion 1 -0A 66 77 64 65 32 65 2E 66 77 64 "fwde2e.fwd" -05 32 2E 30 2E 30 "2.0.0" -00 hasMinCompat = 0 → bound = 2.0.0 (identical bound, elided) -19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 "fwde2e.fwd/:#FwdAppendVar" -00 2A 00 00 00 02 68 69 01 01 74 payload: mode=compact, a=42, b="hi", t=Some("t") - 1.0.0 reader → refused (CodecNotFound) - -Tolerant: -01 -0A 66 77 64 65 32 65 2E 66 77 64 -05 32 2E 30 2E 30 -01 05 31 2E 30 2E 30 hasMinCompat = 1, minCompat = "1.0.0" (prefix-compact bound) -19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 -00 2A 00 00 00 02 68 69 01 01 74 payload unchanged - 1.0.0 reader → FwdAppendVar(42, "hi") (reads a, b; the remaining `01 01 74` is never consumed) +version "1.0.0" version "2.0.0" +enum FwdEnumGrows { A B } enum FwdEnumGrows { A B C } // redefined +root data FwdEnumHost { // FwdEnumHost itself is inherited from 1.0.0 unchanged; + e: FwdEnumGrows // it changes because its dependency changed +} ``` -The same value in an **indexed** context is byte-identical under both -policies: the writer consults the `prefix-any-mode` bound, which is 2.0.0, so -`hasMinCompat` stays 0 and the 1.0.0 reader refuses. This is the case the -index mode exists for — the payload starts `01 | 04 00 00 00 03 00 00 00 | 07 -00 00 00 03 00 00 00 | …`: two index entries where the 1.0.0 decoder would read -one. +Bounds: every tier 2.0.0 — not forward-readable (positional `u8` +discriminant; a 1.0.0 decoder has no member 2). -Every combination, as seen by a reader that registers only 1.0.0 -(`ForwardReadPolicy` is irrelevant for binary): +Value: `FwdEnumHost(C)`. -| value | Strict, compact | Tolerant, compact | Strict, indexed | Tolerant, indexed | -|---|---|---|---|---| -| `FwdAppendVar` | bound 2.0.0 → refused | **bound 1.0.0 → `FwdAppendVar(42,"hi")`** | bound 2.0.0 → refused | bound 2.0.0 → refused | -| `FwdMidInsert` | 2.0.0 → refused | 2.0.0 → refused (json-additive only; bytes identical to Strict) | 2.0.0 → refused | 2.0.0 → refused | -| `FwdStable` | 1.0.0 → decoded | 1.0.0 → decoded (bytes identical to Strict) | 1.0.0 → decoded | 1.0.0 → decoded | -| `FwdEnumHost` | 2.0.0 → refused | 2.0.0 → refused (bytes identical to Strict) | 2.0.0 → refused | 2.0.0 → refused | +```jsonc +{"$mv":1,"$d":"fwde2e.fwd","$v":"2.0.0","$t":"fwde2e.fwd/:#FwdEnumHost","$c":{"e":"C"}} +// 1.0.0 reader, Tolerant → refused (no codec) (no $uv, no $rv) +// 1.0.0 reader, Lossless → refused (no codec) +``` + +``` +Strict, compact = Tolerant, compact 1.0.0 reader → refused +HDR | 00 | 18 "fwde2e.fwd/:#FwdEnumHost" + | 00 02 e = member index 2 (C) -So `Tolerant` changes exactly one envelope in this fixture — the one whose -type has a prefix relationship the payload's mode can honour — and leaves the -rest byte-for-byte as before. +Strict, indexed = Tolerant, indexed 1.0.0 reader → refused +HDR | 00 | 18 "fwde2e.fwd/:#FwdEnumHost" + | 01 02 indexed mode byte, no variable-length fields → no index entries +``` + +### `ChainAppend` — three versions, two appends + +``` +version "1.0.0" version "2.0.0" version "3.0.0" +root data ChainAppend { root data ChainAppend { root data ChainAppend { + a: i32 a: i32 a: i32 +} b: opt[str] b: opt[str] + } c: opt[str] + } +``` + +Bounds at 3.0.0: `identical` 3.0.0 · `prefix-any-mode` 3.0.0 · `prefix-compact` +**1.0.0** · `json-additive` 1.0.0 (both steps are `prefix-compact`; the chain +takes the minimum). Writer: the 3.0.0 facade of `fwde2e.chain`. Two readers: +one registering 1.0.0 and 2.0.0, one registering 1.0.0 only. + +Value: `ChainAppend(1, Some("b"), Some("c"))`. -The chain under `Tolerant`, compact (bound `01 05 31 2E 30 2E 30` = 1.0.0, -`$v` 3.0.0, payload `00 | 01 00 00 00 | 01 01 62 | 01 01 63`): +```jsonc +{"$mv":1,"$d":"fwde2e.chain","$v":"3.0.0","$t":"fwde2e.chain/:#ChainAppend","$rv":"1.0.0","$c":{"a":1,"b":"b","c":"c"}} +// 1.0.0+2.0.0 reader, Tolerant → ChainAppend(1, Some("b")) newest codec (2.0.0); before this change: ChainAppend(1) +// 1.0.0+2.0.0 reader, Lossless → refused (no codec) $uv is elided, i.e. 3.0.0 +// 1.0.0 reader, Tolerant → ChainAppend(1) +``` + +``` +Strict, compact 1.0.0+2.0.0 reader → refused +01 | 0C "fwde2e.chain" | 05 "3.0.0" | 00 | 1A "fwde2e.chain/:#ChainAppend" + | 00 01 00 00 00 01 01 "b" 01 01 "c" a=1, b=Some("b"), c=Some("c") + +Tolerant, compact 1.0.0+2.0.0 reader → ChainAppend(1, Some("b")) +01 | 0C "fwde2e.chain" | 05 "3.0.0" | 01 05 "1.0.0" | 1A "fwde2e.chain/:#ChainAppend" + | 00 01 00 00 00 01 01 "b" 01 01 "c" 1.0.0 reader → ChainAppend(1) + before this change, 1.0.0+2.0.0 reader → ChainAppend(1) +``` -| reader registers | decodes as | before this change | -|---|---|---| -| 1.0.0, 2.0.0 | `ChainAppend(1, Some("b"))` — newest codec, `c` unread | `ChainAppend(1)` — the bound version's codec | -| 1.0.0 | `ChainAppend(1)` | `ChainAppend(1)` | -| 1.0.0, 2.0.0 (Strict envelope, bound 3.0.0) | refused | refused | +### Summary -### Summary matrix +In these fixtures `Tolerant` changes exactly two envelopes — the compact +`FwdAppendVar` and the compact `ChainAppend` — and leaves every other +combination byte-for-byte as `Strict` writes it. | format | writer policy | reader policy | bound the reader acts on | result for an older reader | |---|---|---|---|---| From d9c0cdcb9d269f1a1e08c34a30bbfa2cd2df8146 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 19:58:31 +0100 Subject: [PATCH 13/48] feat(envelope): binary envelope metaVersion 2 with both bounds; readers accept v1 and v2, writers default to v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 binary envelope has one bound slot, so a v1 reader cannot tell a byte-identical read from a prefix read. metaVersion 2 is the JSON envelope's field set in binary form: 02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId | payload flags bit 0: minCompat follows (elided when == domainVersion); bit 1: readableMin follows (elided when == effective minCompat); any other bit is rejected. minCompat is always the byte-identical bound and readableMin the prefix bound for the payload's index mode, so ForwardWritePolicy is irrelevant under v2 and the reader's ForwardReadPolicy applies to binary exactly as it does to JSON: Tolerant decodes with the newest codec once readableMin reaches a registered version, Lossless requires minCompat to. The layout is selected per encode through BaboonCodecContext.envelopeVersion (V1 default, V2 opt-in) in all ten runtime directories; the fully specified context constructor gained the parameter. writeBin dispatches on meta.metaVersion and fails fast on anything else; readMeta accepts 1 and 2 and rejects other versions and unknown v2 flag bits. Binary getCodec now passes the reader's policy through; v1 metas carry readableMin == minCompat, so their behaviour is unchanged. Reproduced fail-first in the Scala and TypeScript stubs: a hand-assembled v2 envelope was rejected by readMeta ("v2 envelope must be readable"). New specs BinEnvelopeV2Spec / BinEnvelopeV2.test.ts cover the reader, the byte-identical default, per-type flags and bounds in both index modes, round trips through the writer and old readers under each policy, the three-version chain with Lossless now enforceable for binary, and rejection of unknown flag bits and metaVersions. baboon_runtime.swift crossed the JVM 64KB embedded-constant limit; BaboonVersion/BaboonDomainVersion/BaboonTypeMeta/BaboonTypeMetaCodec moved to baboon_type_meta.swift in the same module (translator emits it; CLAUDE.md records the pattern). Spec: codec-envelope.md §1, §2.1.3 (new), §3 (byte 2 active), §5, §6. forward-compat.md: v2 section, knobs, per-type v2 examples, summary matrix. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- .../baboon-runtime/cs/BaboonCodecs.cs | 30 +- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 7 +- .../baboon-runtime/cs/BaboonTypeMeta.cs | 66 +++- .../dart/baboon_codecs_facade.dart | 7 +- .../baboon-runtime/dart/baboon_runtime.dart | 76 +++- .../java/BaboonCodecContext.java | 29 +- .../java/BaboonCodecsFacade.java | 7 +- .../baboon-runtime/java/BaboonTypeMeta.java | 61 +++- .../baboon-runtime/kotlin-kmp/BaboonCodecs.kt | 15 +- .../kotlin-kmp/BaboonCodecsFacade.kt | 7 +- .../kotlin-kmp/BaboonRuntimeShared.kt | 69 +++- .../baboon-runtime/kotlin/BaboonCodecs.kt | 15 +- .../kotlin/BaboonCodecsFacade.kt | 7 +- .../kotlin/BaboonRuntimeShared.kt | 69 +++- .../baboon-runtime/python/baboon_codecs.py | 23 +- .../python/baboon_codecs_facade.py | 15 +- .../python/baboon_runtime_shared.py | 60 ++++ .../rust/baboon_codecs_facade.rs | 24 +- .../baboon-runtime/rust/baboon_runtime.rs | 30 +- .../baboon-runtime/rust/baboon_type_meta.rs | 78 ++++- .../baboon-runtime/scala/BaboonCodecs.scala | 16 +- .../scala/BaboonCodecsFacade.scala | 8 +- .../scala/BaboonRuntimeShared.scala | 85 ++++- .../swift/BaboonCodecsFacade.swift | 7 +- .../baboon-runtime/swift/baboon_runtime.swift | 296 ++-------------- .../swift/baboon_type_meta.swift | 327 ++++++++++++++++++ .../typescript/BaboonCodecsFacade.ts | 7 +- .../typescript/BaboonSharedRuntime.ts | 121 ++++++- .../translator/swift/SwBaboonTranslator.scala | 1 + docs/forward-compat.md | 66 +++- docs/spec/codec-envelope.md | 81 ++++- .../scala/runtime/BinEnvelopeV2Spec.scala | 150 ++++++++ .../ForwardCompatBinEnvelopeSpec.scala | 4 +- .../src/runtime-tests/BinEnvelopeV2.test.ts | 182 ++++++++++ .../ForwardCompatBinEnvelope.test.ts | 5 +- 36 files changed, 1608 insertions(+), 445 deletions(-) create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift create mode 100644 test/sc-stub/src/test/scala/runtime/BinEnvelopeV2Spec.scala create mode 100644 test/ts-stub/src/runtime-tests/BinEnvelopeV2.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f6d82fd79..7c74b2448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ mdl :test-service-acceptance # service-flavour RPC wiring round-trips - **Local Kotlin OOM workaround:** `mdl :test` runs language test actions in parallel by default. On laptops with <16 GB RAM the Kotlin compiler daemon OOMs under the parallel matrix (documented in `docs/logs/20260428-2350-m16-closeout-log.md`). Run `mdl --seq :build :test` to force serial execution. Slower (~20 min) but completes on memory-constrained machines. CI uses default parallelism. - **sbt-git (jgit) cannot build inside a linked git worktree** (`NoWorkTreeException: Bare Repository`). When running `mdl :build` or `sbt` from a `git worktree add`-created directory (whose `.git` is a file, not a directory), sbt-git throws this error. Work around by cloning the repo to a real directory (e.g., `git clone /tmp/baboon-ci-clone`) and running the pipeline from there. - **Swift 64KB-constant limit (D7/T23):** `baboon_runtime.swift` was split into `baboon_runtime.swift` (pure domain runtime) and `baboon_service_wiring.swift` (service-wiring runtime) to stay within the JVM 64KB bytecode-constant limit hit when the Swift runtime is embedded via `PortableResource.embedSources`. After modifying `baboon-compiler/src/main/resources/baboon-runtime/`, run `sbt clean` before `sbt compile` (the macro caches content per build). -- **Rust 64KB-constant limit (forward-compat envelope work, 2026-09):** `baboon_codecs_facade.rs` crossed the same JVM string-constant limit, so `BaboonTypeMeta` and `mod baboon_type_meta_codec` live in `baboon_type_meta.rs` and are re-exported from the facade (all `crate::baboon_codecs_facade::BaboonTypeMeta` paths still resolve). The compile error to recognise is `UTF8 string too large ... bad String constant of length N` while emitting `BaboonRuntimeResources$`; the fix is always to split the runtime file and add an `Output` entry for the new file in the translator's shared-runtime list. +- **Rust / Swift 64KB-constant limit (forward-compat envelope work, 2026-09):** `baboon_codecs_facade.rs` and then `baboon_runtime.swift` crossed the same JVM string-constant limit. Rust: `BaboonTypeMeta` and `mod baboon_type_meta_codec` live in `baboon_type_meta.rs`, re-exported from the facade so every `crate::baboon_codecs_facade::…` path still resolves. Swift: `BaboonVersion`/`BaboonDomainVersion`/`BaboonTypeMeta`/`BaboonTypeMetaCodec` live in `baboon_type_meta.swift` (same module, no imports needed). The compile error to recognise is `UTF8 string too large ... bad String constant of length N` while emitting `BaboonRuntimeResources$`; the fix is always to split the runtime file and add an output entry for the new file in the translator's shared-runtime list. - **Swift `@BaboonIndirect` recursive-DTO boxing (D8/T23):** Swift value types (`struct`) cannot have stored properties of their own type (infinite size). Baboon detects recursive DTO fields and wraps them in `@BaboonIndirect` (a property-wrapper that boxes the value behind a `Box` class). Any new test model that includes recursive DTOs in Swift must compile with this boxing in place. - **Scala MCP requires Either-mode serviceResult (D24/T69):** the Scala MCP dispatch runtime (`AbstractBaboonMcpServer.handle` in `baboon-runtime/scala/BaboonMcpRuntime.scala`) is synchronous and Either-shaped — `handle` matches `Right`/`Left` on the wiring's `invokeJson` result to drive MCP Channel-A/B. It is NOT generic over the configurable `serviceResult` container that `ScServiceWiringTranslator` honors. Combining a non-Either serviceResult (HKT `F[_,_]`, a custom result type, or `--service-result-no-errors=true`) with `--scala-generate-mcp-server=true` is rejected up front with `TranslationIssue.ScalaMcpRequiresEither` (an actionable compiler error) rather than emitting a delegate whose type mismatches the wiring container. The Scala MCP lane (`test-gen-scala-mcp`) therefore stays on `--service-result-type=Either`. Making Scala MCP container-generic is the async-axis (D24) rework, out of scope here. - **MCP stub model directory isolation (D9/T24):** The MCP stub model lives at `baboon-compiler/src/test/resources/mcp-stub-ok/` — intentionally OUTSIDE the shared `baboon-compiler/src/test/resources/baboon/` model-dir. All `test-gen-*-wiring` and `test-gen-{regular,wrapped}-adt` actions use `--model-dir ./baboon-compiler/src/test/resources/baboon/` and therefore scan every subdirectory. Placing the MCP stub inside the shared dir caused cross-namespace fixture-reference failures in 8 wiring/test lanes (D9). The dedicated `test-gen--mcp` actions point at `--model-dir ./baboon-compiler/src/test/resources/mcp-stub-ok/`. A minimal recursive-only `Tree` model (`baboon-compiler/src/test/resources/baboon/recursive-ok/recursive.baboon`) preserves recursive-DTO coverage in the shared matrix without the cross-namespace hazard. diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs index 3a0d1de03..5acaaf265 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs @@ -48,16 +48,30 @@ public enum ForwardWritePolicy Tolerant, } + /// + /// Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + /// V1 (default): single bound slot (DomainVersionMinCompat), value chosen by . + /// V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for the + /// payload's index mode; the reader's ForwardReadPolicy then applies to binary exactly as it does + /// to JSON. Only readers that know v2 can decode it. + /// + public enum BaboonEnvelopeVersion + { + V1, + V2, + } + public class BaboonCodecContext { // Accept null facade for the bare singletons (Compact/Indexed). The runtime helper // `WithFacade(...)` is the single intended construction path for ctxes that thread // a facade through generated codec calls — see PR 3.1's facade plumbing for the // `any`-feature cross-format conversion (Q6 option (a) in the design plan). - private BaboonCodecContext(bool useIndexes, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade? facade) + private BaboonCodecContext(bool useIndexes, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacade? facade) { UseIndices = useIndexes; ForwardWritePolicy = forwardWritePolicy; + EnvelopeVersion = envelopeVersion; Facade = facade; } @@ -65,18 +79,20 @@ private BaboonCodecContext(bool useIndexes, ForwardWritePolicy forwardWritePolic public ForwardWritePolicy ForwardWritePolicy { get; } + public BaboonEnvelopeVersion EnvelopeVersion { get; } + public BaboonCodecsFacade? Facade { get; } - public static BaboonCodecContext Indexed { get; } = new(true, ForwardWritePolicy.Strict, null); - public static BaboonCodecContext Compact { get; } = new(false, ForwardWritePolicy.Strict, null); + public static BaboonCodecContext Indexed { get; } = new(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, null); + public static BaboonCodecContext Compact { get; } = new(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, null); public static BaboonCodecContext Default { get; } = Compact; public static BaboonCodecContext WithFacade(bool useIndices, BaboonCodecsFacade facade) => - new(useIndices, ForwardWritePolicy.Strict, facade); + new(useIndices, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, facade); - /// Fully specified context: index mode, writer-side forward policy and optional facade. - public static BaboonCodecContext Custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade? facade) => - new(useIndices, forwardWritePolicy, facade); + /// Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. + public static BaboonCodecContext Custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacade? facade) => + new(useIndices, forwardWritePolicy, envelopeVersion, facade); } public interface IBaboonCodec : IBaboonCodecData diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index 044cbd0cf..9ad57b3d7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -41,8 +41,8 @@ public class BaboonCodecsFacade private const string CONTENT_JSON_KEY = "$c"; /// - /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, DomainVersionMinCompat, - /// whose meaning is fixed by the WRITER's ; binary reads always trust it. + /// Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + /// bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. /// public ForwardReadPolicy ForwardReadPolicy { get; set; } = ForwardReadPolicy.Tolerant; @@ -985,7 +985,8 @@ private static bool IsAdtConversion(IBaboonGenerated instance, IConversion conve private Either> GetBinCodec(BaboonTypeMeta typeMeta, bool exact) { - var codec = GetCodec(_versionsCodecsBin, typeMeta, exact, tolerant: false); + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + var codec = GetCodec(_versionsCodecsBin, typeMeta, exact, tolerant: ForwardReadPolicy == ForwardReadPolicy.Tolerant); if (codec.IsLeft) { return Either.Left>( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs index c73945d88..dfba7c26e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs @@ -165,7 +165,7 @@ public static BaboonTypeMeta From(IBaboonGenerated value, Type? declaredType) public static BaboonTypeMeta ForBin(IBaboonGenerated value, Type? declaredType, BaboonCodecContext ctx) { var meta = From(value, declaredType); - if (ctx.ForwardWritePolicy == ForwardWritePolicy.Strict) + if (ctx.EnvelopeVersion == BaboonEnvelopeVersion.V1 && ctx.ForwardWritePolicy == ForwardWritePolicy.Strict) { return meta; } @@ -174,7 +174,10 @@ public static BaboonTypeMeta ForBin(IBaboonGenerated value, Type? declaredType, { throw new InvalidOperationException($"BaboonMinReaderVersions() lacks '{tier}' for type {value.BaboonTypeIdentifier()}"); } - return meta with { DomainVersionMinCompat = bound }; + // V2 carries both bounds (the writer policy is irrelevant); V1 Tolerant puts the prefix bound in its single slot + return ctx.EnvelopeVersion == BaboonEnvelopeVersion.V2 + ? meta with { MetaVersion = BaboonTypeMetaCodec.META_VERSION_2, DomainVersionReadableMin = bound } + : meta with { DomainVersionMinCompat = bound }; } public static BaboonTypeMeta? ReadMeta(BinaryReader reader) => BaboonTypeMetaCodec.ReadMeta(reader); @@ -185,8 +188,15 @@ public static BaboonTypeMeta ForBin(IBaboonGenerated value, Type? declaredType, public static class BaboonTypeMetaCodec { public const byte META_VERSION_1 = 1; + public const byte META_VERSION_2 = 2; + /// Layout written by default (binary) and always (JSON $mv). public const byte META_VERSION = META_VERSION_1; + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + private const byte V2_FLAG_MIN_COMPAT = 0x01; + private const byte V2_FLAG_READABLE_MIN = 0x02; + private const byte V2_FLAGS_MASK = V2_FLAG_MIN_COMPAT | V2_FLAG_READABLE_MIN; + public const string META_VERSION_KEY = "$mv"; public const string DOMAIN_IDENTIFIER_KEY = "$d"; public const string DOMAIN_VERSION_KEY = "$v"; @@ -196,7 +206,34 @@ public static class BaboonTypeMetaCodec public static void WriteBin(BaboonTypeMeta meta, BinaryWriter writer) { - writer.Write(META_VERSION); + switch (meta.MetaVersion) + { + case META_VERSION_1: WriteBinV1(meta, writer); return; + case META_VERSION_2: WriteBinV2(meta, writer); return; + default: throw new InvalidOperationException($"Unsupported binary envelope metaVersion {meta.MetaVersion}"); + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private static void WriteBinV2(BaboonTypeMeta meta, BinaryWriter writer) + { + var minCompat = string.IsNullOrEmpty(meta.DomainVersionMinCompat) ? meta.DomainVersion : meta.DomainVersionMinCompat; + var readableMin = string.IsNullOrEmpty(meta.DomainVersionReadableMin) ? minCompat : meta.DomainVersionReadableMin; + var hasMinCompat = minCompat != meta.DomainVersion; + var hasReadableMin = readableMin != minCompat; + writer.Write(META_VERSION_2); + writer.Write(meta.DomainIdentifier); + writer.Write(meta.DomainVersion); + writer.Write((byte)((hasMinCompat ? V2_FLAG_MIN_COMPAT : 0) | (hasReadableMin ? V2_FLAG_READABLE_MIN : 0))); + if (hasMinCompat) writer.Write(minCompat); + if (hasReadableMin) writer.Write(readableMin); + writer.Write(meta.TypeIdentifier); + } + + private static void WriteBinV1(BaboonTypeMeta meta, BinaryWriter writer) + { + writer.Write(META_VERSION_1); writer.Write(meta.DomainIdentifier); writer.Write(meta.DomainVersion); if (meta.DomainVersion == meta.DomainVersionMinCompat) @@ -237,8 +274,29 @@ public static JToken WriteJson(BaboonTypeMeta meta) public static BaboonTypeMeta? ReadMeta(BinaryReader reader) { var metaVersion = reader.ReadByte(); - if (metaVersion != META_VERSION_1) return null; + switch (metaVersion) + { + case META_VERSION_1: return ReadMetaV1(reader); + case META_VERSION_2: return ReadMetaV2(reader); + default: return null; + } + } + private static BaboonTypeMeta? ReadMetaV2(BinaryReader reader) + { + var domainIdentifier = reader.ReadString(); + var domainVersion = reader.ReadString(); + var flags = reader.ReadByte(); + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags & ~V2_FLAGS_MASK) != 0) return null; + var minCompat = (flags & V2_FLAG_MIN_COMPAT) != 0 ? reader.ReadString() : domainVersion; + var readableMin = (flags & V2_FLAG_READABLE_MIN) != 0 ? reader.ReadString() : minCompat; + var typeIdentifier = reader.ReadString(); + return new BaboonTypeMeta(META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin); + } + + private static BaboonTypeMeta? ReadMetaV1(BinaryReader reader) + { var domainIdentifier = reader.ReadString(); var domainVersion = reader.ReadString(); var hasMinCompat = reader.ReadByte(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart index cccb7ea75..c9576b5c3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart @@ -37,8 +37,8 @@ const String _CONTENT_JSON_KEY = r'$c'; enum ForwardReadPolicy { lossless, tolerant } class BaboonCodecsFacade extends BaboonCodecsFacadeBase { - /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - /// whose meaning is fixed by the WRITER's [ForwardWritePolicy]; binary reads always trust it. + /// Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + /// bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.tolerant; final Map> _versionsCodecsJson = {}; @@ -575,8 +575,9 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { // ----- private dispatch ------------------------------------------------------------------- + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) BaboonEither _getBinCodec(BaboonTypeMeta typeMeta, {required bool exact}) => - _getCodec(_versionsCodecsBin, typeMeta, exact, false); + _getCodec(_versionsCodecsBin, typeMeta, exact, forwardReadPolicy == ForwardReadPolicy.tolerant); BaboonEither _getJsonCodec(BaboonTypeMeta typeMeta, {required bool exact}) => _getCodec(_versionsCodecsJson, typeMeta, exact, forwardReadPolicy == ForwardReadPolicy.tolerant); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index 3deb5b74b..aafa51432 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -43,11 +43,19 @@ abstract class BaboonCodecsFacadeBase { /// at the writer's version or newer. enum ForwardWritePolicy { strict, tolerant } +/// Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). +/// [v1] (default): single bound slot (`domainVersionMinCompat`), value chosen by [ForwardWritePolicy]. +/// [v2]: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for +/// the payload's index mode; the reader's [ForwardReadPolicy] then applies to binary exactly as it does +/// to JSON. Only readers that know v2 can decode it. +enum BaboonEnvelopeVersion { v1, v2 } + abstract class BaboonCodecContext { const BaboonCodecContext(); bool get useIndices; ForwardWritePolicy get forwardWritePolicy => ForwardWritePolicy.strict; + BaboonEnvelopeVersion get envelopeVersion => BaboonEnvelopeVersion.v1; BaboonCodecsFacadeBase? get facade => null; static const BaboonCodecContext defaultCtx = _BaboonCodecContextCompact(); @@ -57,21 +65,24 @@ abstract class BaboonCodecContext { static BaboonCodecContext withFacade(bool useIndices, BaboonCodecsFacadeBase facade) => _BaboonCodecContextWithFacade(useIndices, facade); - /// Fully specified context: index mode, writer-side forward policy and optional facade. - static BaboonCodecContext custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacadeBase? facade) => - _BaboonCodecContextCustom(useIndices, forwardWritePolicy, facade); + /// Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. + static BaboonCodecContext custom(bool useIndices, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacadeBase? facade) => + _BaboonCodecContextCustom(useIndices, forwardWritePolicy, envelopeVersion, facade); } class _BaboonCodecContextCustom extends BaboonCodecContext { final bool _useIndices; final ForwardWritePolicy _forwardWritePolicy; + final BaboonEnvelopeVersion _envelopeVersion; final BaboonCodecsFacadeBase? _facade; - const _BaboonCodecContextCustom(this._useIndices, this._forwardWritePolicy, this._facade); + const _BaboonCodecContextCustom(this._useIndices, this._forwardWritePolicy, this._envelopeVersion, this._facade); @override bool get useIndices => _useIndices; @override ForwardWritePolicy get forwardWritePolicy => _forwardWritePolicy; @override + BaboonEnvelopeVersion get envelopeVersion => _envelopeVersion; + @override BaboonCodecsFacadeBase? get facade => _facade; } @@ -1423,7 +1434,8 @@ class BaboonTypeMeta { /// [ForwardWritePolicy.tolerant]. static BaboonTypeMeta forBin(BaboonGenerated value, BaboonCodecContext ctx, {bool useAdtIdentifier = false}) { final meta = from(value, useAdtIdentifier: useAdtIdentifier); - if (ctx.forwardWritePolicy == ForwardWritePolicy.strict) return meta; + final v2 = ctx.envelopeVersion == BaboonEnvelopeVersion.v2; + if (!v2 && ctx.forwardWritePolicy == ForwardWritePolicy.strict) return meta; final tier = ctx.useIndices ? uebaPrefixAnyModeTier : uebaPrefixCompactTier; final bound = (value as BaboonMetaProvider).baboonMinReaderVersions[tier]; if (bound == null) { @@ -1431,7 +1443,10 @@ class BaboonTypeMeta { 'BaboonTypeMeta.forBin: baboonMinReaderVersions lacks "$tier" for type [${meta.domainIdentifier}.${meta.typeIdentifier}]', ); } - return BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin); + // v2 carries both bounds (the writer policy is irrelevant); v1 tolerant puts the prefix bound in its single slot + return v2 + ? BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion2, meta.domainIdentifier, meta.domainVersion, meta.domainVersionMinCompat, meta.typeIdentifier, bound) + : BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin); } @override @@ -1453,9 +1468,45 @@ class BaboonTypeMeta { } class BaboonTypeMetaCodec { + /// Layout written by default (binary) and always (JSON `$mv`). static const int metaVersion = 1; + static const int metaVersion2 = 2; + + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + static const int _v2FlagMinCompat = 0x01; + static const int _v2FlagReadableMin = 0x02; + static const int _v2FlagsMask = _v2FlagMinCompat | _v2FlagReadableMin; static void writeBin(BaboonTypeMeta meta, BaboonBinWriter writer) { + switch (meta.metaVersion) { + case metaVersion: + _writeBinV1(meta, writer); + return; + case metaVersion2: + _writeBinV2(meta, writer); + return; + default: + throw BaboonException('Unsupported binary envelope metaVersion ${meta.metaVersion}'); + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + static void _writeBinV2(BaboonTypeMeta meta, BaboonBinWriter writer) { + final minCompat = meta.domainVersionMinCompat.isEmpty ? meta.domainVersion : meta.domainVersionMinCompat; + final readableMin = meta.domainVersionReadableMin.isEmpty ? minCompat : meta.domainVersionReadableMin; + final hasMinCompat = minCompat != meta.domainVersion; + final hasReadableMin = readableMin != minCompat; + writer.writeU8(metaVersion2); + writer.writeString(meta.domainIdentifier); + writer.writeString(meta.domainVersion); + writer.writeU8((hasMinCompat ? _v2FlagMinCompat : 0) | (hasReadableMin ? _v2FlagReadableMin : 0)); + if (hasMinCompat) writer.writeString(minCompat); + if (hasReadableMin) writer.writeString(readableMin); + writer.writeString(meta.typeIdentifier); + } + + static void _writeBinV1(BaboonTypeMeta meta, BaboonBinWriter writer) { writer.writeU8(metaVersion); writer.writeString(meta.domainIdentifier); writer.writeString(meta.domainVersion); @@ -1471,9 +1522,22 @@ class BaboonTypeMetaCodec { static BaboonTypeMeta? readMeta(BaboonBinReader reader) { final v = reader.readU8(); if (v == metaVersion) return _readMetaV1(reader); + if (v == metaVersion2) return _readMetaV2(reader); return null; } + static BaboonTypeMeta? _readMetaV2(BaboonBinReader reader) { + final d = reader.readString(); + final dv = reader.readString(); + final flags = reader.readU8(); + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags & ~_v2FlagsMask) != 0) return null; + final mc = (flags & _v2FlagMinCompat) != 0 ? reader.readString() : dv; + final rm = (flags & _v2FlagReadableMin) != 0 ? reader.readString() : mc; + final t = reader.readString(); + return BaboonTypeMeta(metaVersion2, d, dv, mc, t, rm); + } + static BaboonTypeMeta? _readMetaV1(BaboonBinReader reader) { final d = reader.readString(); final dv = reader.readString(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java index 83d09dd13..d2706534d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java @@ -26,13 +26,24 @@ public final class BaboonCodecContext { */ public enum ForwardWritePolicy { STRICT, TOLERANT } + /** + * Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + * V1 (default): single bound slot (`domainVersionMinCompat`), value chosen by {@link ForwardWritePolicy}. + * V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for the + * payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it does to + * JSON. Only readers that know v2 can decode it. + */ + public enum BaboonEnvelopeVersion { V1, V2 } + private final boolean useIndices; private final ForwardWritePolicy forwardWritePolicy; + private final BaboonEnvelopeVersion envelopeVersion; private final BaboonCodecsFacade facade; - private BaboonCodecContext(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade facade) { + private BaboonCodecContext(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacade facade) { this.useIndices = useIndices; this.forwardWritePolicy = forwardWritePolicy; + this.envelopeVersion = envelopeVersion; this.facade = facade; } @@ -44,20 +55,24 @@ public ForwardWritePolicy forwardWritePolicy() { return forwardWritePolicy; } + public BaboonEnvelopeVersion envelopeVersion() { + return envelopeVersion; + } + public BaboonCodecsFacade facade() { return facade; } - public static final BaboonCodecContext Indexed = new BaboonCodecContext(true, ForwardWritePolicy.STRICT, null); - public static final BaboonCodecContext Compact = new BaboonCodecContext(false, ForwardWritePolicy.STRICT, null); + public static final BaboonCodecContext Indexed = new BaboonCodecContext(true, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V1, null); + public static final BaboonCodecContext Compact = new BaboonCodecContext(false, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V1, null); public static final BaboonCodecContext Default = Compact; public static BaboonCodecContext withFacade(boolean useIndices, BaboonCodecsFacade facade) { - return new BaboonCodecContext(useIndices, ForwardWritePolicy.STRICT, facade); + return new BaboonCodecContext(useIndices, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V1, facade); } - /** Fully specified context: index mode, writer-side forward policy and optional facade. */ - public static BaboonCodecContext custom(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonCodecsFacade facade) { - return new BaboonCodecContext(useIndices, forwardWritePolicy, facade); + /** Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. */ + public static BaboonCodecContext custom(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacade facade) { + return new BaboonCodecContext(useIndices, forwardWritePolicy, envelopeVersion, facade); } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java index b0ea3c7fe..4105d05d9 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java @@ -39,8 +39,8 @@ public class BaboonCodecsFacade { public enum ForwardReadPolicy { LOSSLESS, TOLERANT } /** - * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - * whose meaning is fixed by the WRITER's `BaboonCodecContext.ForwardWritePolicy`; binary reads always trust it. + * Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + * bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. */ private volatile ForwardReadPolicy forwardReadPolicy = ForwardReadPolicy.TOLERANT; @@ -578,7 +578,8 @@ public Baboon // ----- private dispatch --------------------------------------------------------------------- private BaboonEither getBinCodec(BaboonTypeMeta typeMeta, boolean exact) { - return getCodec(versionsCodecsBin, typeMeta, exact, false); + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + return getCodec(versionsCodecsBin, typeMeta, exact, forwardReadPolicy == ForwardReadPolicy.TOLERANT); } private BaboonEither getJsonCodec(BaboonTypeMeta typeMeta, boolean exact) { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java index 3c44f795c..46b9a0428 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java @@ -111,7 +111,8 @@ public static BaboonTypeMeta from(BaboonGenerated value, Class declaredType) */ public static BaboonTypeMeta forBin(BaboonGenerated value, Class declaredType, BaboonCodecContext ctx) { BaboonTypeMeta meta = from(value, declaredType); - if (ctx.forwardWritePolicy() == BaboonCodecContext.ForwardWritePolicy.STRICT) { + boolean v2 = ctx.envelopeVersion() == BaboonCodecContext.BaboonEnvelopeVersion.V2; + if (!v2 && ctx.forwardWritePolicy() == BaboonCodecContext.ForwardWritePolicy.STRICT) { return meta; } String tier = ctx.useIndices() ? UEBA_PREFIX_ANY_MODE_TIER : UEBA_PREFIX_COMPACT_TIER; @@ -119,7 +120,10 @@ public static BaboonTypeMeta forBin(BaboonGenerated value, Class declaredType if (bound == null) { throw new BaboonException("Type " + value.getClass().getName() + ": baboonMinReaderVersions lacks '" + tier + "'"); } - return new BaboonTypeMeta(meta.metaVersion(), meta.domainIdentifier(), meta.domainVersion(), bound, meta.typeIdentifier(), meta.domainVersionReadableMin()); + // V2 carries both bounds (the writer policy is irrelevant); V1 TOLERANT puts the prefix bound in its single slot + return v2 + ? new BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION_2, meta.domainIdentifier(), meta.domainVersion(), meta.domainVersionMinCompat(), meta.typeIdentifier(), bound) + : new BaboonTypeMeta(meta.metaVersion(), meta.domainIdentifier(), meta.domainVersion(), bound, meta.typeIdentifier(), meta.domainVersionReadableMin()); } @SuppressWarnings("unchecked") @@ -155,8 +159,15 @@ public static final class BaboonTypeMetaCodec { private BaboonTypeMetaCodec() {} public static final byte META_VERSION_1 = 1; + public static final byte META_VERSION_2 = 2; + /** Layout written by default (binary) and always (JSON `$mv`). */ public static final byte META_VERSION = META_VERSION_1; + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + private static final int V2_FLAG_MIN_COMPAT = 0x01; + private static final int V2_FLAG_READABLE_MIN = 0x02; + private static final int V2_FLAGS_MASK = V2_FLAG_MIN_COMPAT | V2_FLAG_READABLE_MIN; + public static final String META_VERSION_KEY = "$mv"; public static final String DOMAIN_IDENTIFIER_KEY = "$d"; public static final String DOMAIN_VERSION_KEY = "$v"; @@ -165,7 +176,33 @@ private BaboonTypeMetaCodec() {} public static final String TYPE_IDENTIFIER_KEY = "$t"; public static void writeBin(BaboonTypeMeta meta, LEDataOutputStream writer) throws Exception { - writer.writeByte(META_VERSION); + if (meta.metaVersion == META_VERSION_1) { + writeBinV1(meta, writer); + } else if (meta.metaVersion == META_VERSION_2) { + writeBinV2(meta, writer); + } else { + throw new BaboonException("Unsupported binary envelope metaVersion " + meta.metaVersion); + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private static void writeBinV2(BaboonTypeMeta meta, LEDataOutputStream writer) throws Exception { + String minCompat = (meta.domainVersionMinCompat == null || meta.domainVersionMinCompat.isEmpty()) ? meta.domainVersion : meta.domainVersionMinCompat; + String readableMin = (meta.domainVersionReadableMin == null || meta.domainVersionReadableMin.isEmpty()) ? minCompat : meta.domainVersionReadableMin; + boolean hasMinCompat = !minCompat.equals(meta.domainVersion); + boolean hasReadableMin = !readableMin.equals(minCompat); + writer.writeByte(META_VERSION_2); + BaboonBinTools.writeString(writer, meta.domainIdentifier); + BaboonBinTools.writeString(writer, meta.domainVersion); + writer.writeByte((hasMinCompat ? V2_FLAG_MIN_COMPAT : 0) | (hasReadableMin ? V2_FLAG_READABLE_MIN : 0)); + if (hasMinCompat) BaboonBinTools.writeString(writer, minCompat); + if (hasReadableMin) BaboonBinTools.writeString(writer, readableMin); + BaboonBinTools.writeString(writer, meta.typeIdentifier); + } + + private static void writeBinV1(BaboonTypeMeta meta, LEDataOutputStream writer) throws Exception { + writer.writeByte(META_VERSION_1); BaboonBinTools.writeString(writer, meta.domainIdentifier); BaboonBinTools.writeString(writer, meta.domainVersion); if (meta.domainVersion.equals(meta.domainVersionMinCompat)) { @@ -198,8 +235,24 @@ public static JsonNode writeJson(BaboonTypeMeta meta) { public static BaboonTypeMeta readMeta(LEDataInputStream reader) throws Exception { byte metaVersion = reader.readByte(); - if (metaVersion != META_VERSION_1) return null; + if (metaVersion == META_VERSION_1) return readMetaV1(reader); + if (metaVersion == META_VERSION_2) return readMetaV2(reader); + return null; + } + + private static BaboonTypeMeta readMetaV2(LEDataInputStream reader) throws Exception { + String domainIdentifier = BaboonBinTools.readString(reader); + String domainVersion = BaboonBinTools.readString(reader); + int flags = reader.readByte() & 0xFF; + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags & ~V2_FLAGS_MASK) != 0) return null; + String minCompat = (flags & V2_FLAG_MIN_COMPAT) != 0 ? BaboonBinTools.readString(reader) : domainVersion; + String readableMin = (flags & V2_FLAG_READABLE_MIN) != 0 ? BaboonBinTools.readString(reader) : minCompat; + String typeIdentifier = BaboonBinTools.readString(reader); + return new BaboonTypeMeta(META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin); + } + private static BaboonTypeMeta readMetaV1(LEDataInputStream reader) throws Exception { String domainIdentifier = BaboonBinTools.readString(reader); String domainVersion = BaboonBinTools.readString(reader); byte hasMinCompat = reader.readByte(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt index 5b93735a0..0a4143911 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecs.kt @@ -23,9 +23,19 @@ interface BaboonCodecData { */ enum class ForwardWritePolicy { Strict, Tolerant } +/** + * Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + * - V1 (default): single bound slot (`domainVersionMinCompat`), value chosen by `ForwardWritePolicy`. + * - V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for + * the payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it + * does to JSON. Only readers that know v2 can decode it. + */ +enum class BaboonEnvelopeVersion { V1, V2 } + interface BaboonCodecContext { val useIndices: Boolean val forwardWritePolicy: ForwardWritePolicy get() = ForwardWritePolicy.Strict + val envelopeVersion: BaboonEnvelopeVersion get() = BaboonEnvelopeVersion.V1 /** Optional facade reference, threaded through generated codec calls so the `any`-feature * cross-format conversion (UEBA ↔ JSON) can resolve codecs by `(domain, version, typeid)` @@ -43,11 +53,12 @@ interface BaboonCodecContext { override val facade: BaboonCodecsFacade = baboonFacade } - /** Fully specified context: index mode, writer-side forward policy and optional facade. */ - fun custom(indices: Boolean, policy: ForwardWritePolicy, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = + /** Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. */ + fun custom(indices: Boolean, policy: ForwardWritePolicy, envelope: BaboonEnvelopeVersion, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = object : BaboonCodecContext { override val useIndices: Boolean = indices override val forwardWritePolicy: ForwardWritePolicy = policy + override val envelopeVersion: BaboonEnvelopeVersion = envelope override val facade: BaboonCodecsFacade? = baboonFacade } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt index 2ca6c38db..ab7b260c2 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt @@ -20,8 +20,8 @@ enum class ForwardReadPolicy { Lossless, Tolerant } open class BaboonCodecsFacade { /** - * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + * Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + * bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" @@ -227,7 +227,8 @@ open class BaboonCodecsFacade { // @baboon:json-end private fun getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false) + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) } private fun getCodec( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt index df14d2152..5f6c8809d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt @@ -154,13 +154,15 @@ data class BaboonTypeMeta( */ fun forBin(value: BaboonGenerated, ctx: BaboonCodecContext): BaboonTypeMeta { val meta = from(value) - return when (ctx.forwardWritePolicy) { - ForwardWritePolicy.Strict -> meta - ForwardWritePolicy.Tolerant -> { - val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER - val bound = value.baboonMinReaderVersions[tier] - ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") - meta.copy(domainVersionMinCompat = bound) + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + fun prefixBound(): String = value.baboonMinReaderVersions[tier] + ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") + return when (ctx.envelopeVersion) { + // V2 carries both bounds; the writer policy is irrelevant + BaboonEnvelopeVersion.V2 -> meta.copy(metaVersion = BaboonTypeMetaCodec.META_VERSION_2, domainVersionReadableMin = prefixBound()) + BaboonEnvelopeVersion.V1 -> when (ctx.forwardWritePolicy) { + ForwardWritePolicy.Strict -> meta + ForwardWritePolicy.Tolerant -> meta.copy(domainVersionMinCompat = prefixBound()) } } } @@ -172,10 +174,41 @@ data class BaboonTypeMeta( } object BaboonTypeMetaCodec { - private const val META_VERSION_1: Byte = 1 + const val META_VERSION_1: Byte = 1 + const val META_VERSION_2: Byte = 2 + /** Layout written by default (binary) and always (JSON `$mv`). */ const val META_VERSION: Byte = META_VERSION_1 + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + private const val V2_FLAG_MIN_COMPAT: Int = 0x01 + private const val V2_FLAG_READABLE_MIN: Int = 0x02 + private const val V2_FLAGS_MASK: Int = V2_FLAG_MIN_COMPAT or V2_FLAG_READABLE_MIN + fun writeBin(meta: BaboonTypeMeta, writer: BaboonBinaryWriter) { + when (meta.metaVersion) { + META_VERSION_1 -> writeBinV1(meta, writer) + META_VERSION_2 -> writeBinV2(meta, writer) + else -> throw BaboonCodecException.EncoderFailure("Unsupported binary envelope metaVersion ${meta.metaVersion}") + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private fun writeBinV2(meta: BaboonTypeMeta, writer: BaboonBinaryWriter) { + val minCompat = meta.domainVersionMinCompat.ifEmpty { meta.domainVersion } + val readableMin = meta.domainVersionReadableMin.ifEmpty { minCompat } + val hasMinCompat = minCompat != meta.domainVersion + val hasReadableMin = readableMin != minCompat + writer.writeByte(META_VERSION_2.toInt()) + BaboonBinTools.writeString(writer, meta.domainIdentifier) + BaboonBinTools.writeString(writer, meta.domainVersion) + writer.writeByte((if (hasMinCompat) V2_FLAG_MIN_COMPAT else 0) or (if (hasReadableMin) V2_FLAG_READABLE_MIN else 0)) + if (hasMinCompat) BaboonBinTools.writeString(writer, minCompat) + if (hasReadableMin) BaboonBinTools.writeString(writer, readableMin) + BaboonBinTools.writeString(writer, meta.typeIdentifier) + } + + private fun writeBinV1(meta: BaboonTypeMeta, writer: BaboonBinaryWriter) { writer.writeByte(META_VERSION_1.toInt()) BaboonBinTools.writeString(writer, meta.domainIdentifier) BaboonBinTools.writeString(writer, meta.domainVersion) @@ -189,11 +222,23 @@ object BaboonTypeMetaCodec { } fun readMeta(reader: BaboonBinaryReader): BaboonTypeMeta? { - val metaVersion = reader.readByte() - if (metaVersion == META_VERSION_1) { - return readMetaV1(reader) + return when (reader.readByte()) { + META_VERSION_1 -> readMetaV1(reader) + META_VERSION_2 -> readMetaV2(reader) + else -> null } - return null + } + + private fun readMetaV2(reader: BaboonBinaryReader): BaboonTypeMeta? { + val domainIdentifier = BaboonBinTools.readString(reader) + val domainVersion = BaboonBinTools.readString(reader) + val flags = reader.readByte().toInt() + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags and V2_FLAGS_MASK.inv()) != 0) return null + val minCompat = if ((flags and V2_FLAG_MIN_COMPAT) != 0) BaboonBinTools.readString(reader) else domainVersion + val readableMin = if ((flags and V2_FLAG_READABLE_MIN) != 0) BaboonBinTools.readString(reader) else minCompat + val typeIdentifier = BaboonBinTools.readString(reader) + return BaboonTypeMeta(META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin) } private fun readMetaV1(reader: BaboonBinaryReader): BaboonTypeMeta? { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt index 62e43d5d8..34d74e741 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecs.kt @@ -25,9 +25,19 @@ interface BaboonCodecData { */ enum class ForwardWritePolicy { Strict, Tolerant } +/** + * Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + * - V1 (default): single bound slot (`domainVersionMinCompat`), value chosen by `ForwardWritePolicy`. + * - V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for + * the payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it + * does to JSON. Only readers that know v2 can decode it. + */ +enum class BaboonEnvelopeVersion { V1, V2 } + interface BaboonCodecContext { val useIndices: Boolean val forwardWritePolicy: ForwardWritePolicy get() = ForwardWritePolicy.Strict + val envelopeVersion: BaboonEnvelopeVersion get() = BaboonEnvelopeVersion.V1 /** * Optional facade reference, threaded through generated codec calls so the `any`-feature @@ -47,11 +57,12 @@ interface BaboonCodecContext { override val facade: BaboonCodecsFacade = baboonFacade } - /** Fully specified context: index mode, writer-side forward policy and optional facade. */ - fun custom(indices: Boolean, policy: ForwardWritePolicy, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = + /** Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. */ + fun custom(indices: Boolean, policy: ForwardWritePolicy, envelope: BaboonEnvelopeVersion, baboonFacade: BaboonCodecsFacade?): BaboonCodecContext = object : BaboonCodecContext { override val useIndices: Boolean = indices override val forwardWritePolicy: ForwardWritePolicy = policy + override val envelopeVersion: BaboonEnvelopeVersion = envelope override val facade: BaboonCodecsFacade? = baboonFacade } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt index b0f507ab7..e69c63802 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt @@ -23,8 +23,8 @@ enum class ForwardReadPolicy { Lossless, Tolerant } open class BaboonCodecsFacade { /** - * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + * Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + * bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant private val CONTENT_JSON_KEY = "${'$'}c" @@ -232,7 +232,8 @@ open class BaboonCodecsFacade { // @baboon:json-end private fun getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonCodecData { - return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false) + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + return getCodec(versionsCodecsBin, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) } private fun getCodec( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt index 2140e9f5b..d5f54cc9c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt @@ -163,13 +163,15 @@ data class BaboonTypeMeta( */ fun forBin(value: BaboonGenerated, ctx: BaboonCodecContext): BaboonTypeMeta { val meta = from(value) - return when (ctx.forwardWritePolicy) { - ForwardWritePolicy.Strict -> meta - ForwardWritePolicy.Tolerant -> { - val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER - val bound = value.baboonMinReaderVersions[tier] - ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") - meta.copy(domainVersionMinCompat = bound) + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + fun prefixBound(): String = value.baboonMinReaderVersions[tier] + ?: error("baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}") + return when (ctx.envelopeVersion) { + // V2 carries both bounds; the writer policy is irrelevant + BaboonEnvelopeVersion.V2 -> meta.copy(metaVersion = BaboonTypeMetaCodec.META_VERSION_2, domainVersionReadableMin = prefixBound()) + BaboonEnvelopeVersion.V1 -> when (ctx.forwardWritePolicy) { + ForwardWritePolicy.Strict -> meta + ForwardWritePolicy.Tolerant -> meta.copy(domainVersionMinCompat = prefixBound()) } } } @@ -181,10 +183,41 @@ data class BaboonTypeMeta( } object BaboonTypeMetaCodec { - private const val META_VERSION_1: Byte = 1 + const val META_VERSION_1: Byte = 1 + const val META_VERSION_2: Byte = 2 + /** Layout written by default (binary) and always (JSON `$mv`). */ const val META_VERSION: Byte = META_VERSION_1 + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + private const val V2_FLAG_MIN_COMPAT: Int = 0x01 + private const val V2_FLAG_READABLE_MIN: Int = 0x02 + private const val V2_FLAGS_MASK: Int = V2_FLAG_MIN_COMPAT or V2_FLAG_READABLE_MIN + fun writeBin(meta: BaboonTypeMeta, writer: LEDataOutputStream) { + when (meta.metaVersion) { + META_VERSION_1 -> writeBinV1(meta, writer) + META_VERSION_2 -> writeBinV2(meta, writer) + else -> throw BaboonCodecException.EncoderFailure("Unsupported binary envelope metaVersion ${meta.metaVersion}") + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private fun writeBinV2(meta: BaboonTypeMeta, writer: LEDataOutputStream) { + val minCompat = meta.domainVersionMinCompat.ifEmpty { meta.domainVersion } + val readableMin = meta.domainVersionReadableMin.ifEmpty { minCompat } + val hasMinCompat = minCompat != meta.domainVersion + val hasReadableMin = readableMin != minCompat + writer.write(META_VERSION_2.toInt()) + BaboonBinTools.writeString(writer, meta.domainIdentifier) + BaboonBinTools.writeString(writer, meta.domainVersion) + writer.write((if (hasMinCompat) V2_FLAG_MIN_COMPAT else 0) or (if (hasReadableMin) V2_FLAG_READABLE_MIN else 0)) + if (hasMinCompat) BaboonBinTools.writeString(writer, minCompat) + if (hasReadableMin) BaboonBinTools.writeString(writer, readableMin) + BaboonBinTools.writeString(writer, meta.typeIdentifier) + } + + private fun writeBinV1(meta: BaboonTypeMeta, writer: LEDataOutputStream) { writer.write(META_VERSION_1.toInt()) BaboonBinTools.writeString(writer, meta.domainIdentifier) BaboonBinTools.writeString(writer, meta.domainVersion) @@ -198,11 +231,23 @@ object BaboonTypeMetaCodec { } fun readMeta(reader: LEDataInputStream): BaboonTypeMeta? { - val metaVersion = reader.readByte() - if (metaVersion == META_VERSION_1) { - return readMetaV1(reader) + return when (reader.readByte()) { + META_VERSION_1 -> readMetaV1(reader) + META_VERSION_2 -> readMetaV2(reader) + else -> null } - return null + } + + private fun readMetaV2(reader: LEDataInputStream): BaboonTypeMeta? { + val domainIdentifier = BaboonBinTools.readString(reader) + val domainVersion = BaboonBinTools.readString(reader) + val flags = reader.readByte().toInt() + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags and V2_FLAGS_MASK.inv()) != 0) return null + val minCompat = if ((flags and V2_FLAG_MIN_COMPAT) != 0) BaboonBinTools.readString(reader) else domainVersion + val readableMin = if ((flags and V2_FLAG_READABLE_MIN) != 0) BaboonBinTools.readString(reader) else minCompat + val typeIdentifier = BaboonBinTools.readString(reader) + return BaboonTypeMeta(META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin) } private fun readMetaV1(reader: LEDataInputStream): BaboonTypeMeta? { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py index 2ac25072e..e72537d01 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py @@ -154,6 +154,18 @@ class ForwardWritePolicy(Enum): TOLERANT = "tolerant" +class BaboonEnvelopeVersion(Enum): + """Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + + - V1 (default): single bound slot (`domain_version_min_compat`), value chosen by `ForwardWritePolicy`. + - V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for + the payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it + does to JSON. Only readers that know v2 can decode it. + """ + V1 = "v1" + V2 = "v2" + + class BaboonCodecContext: # `Indexed`/`Compact`/`Default` are stable class-attribute singletons assigned after the # class body. Generator-emitted code may use `ctx is BaboonCodecContext.Indexed`-style @@ -165,9 +177,11 @@ class BaboonCodecContext: Default: 'BaboonCodecContext' def __init__(self, use_indices: bool, facade: Optional[Any] = None, - forward_write_policy: ForwardWritePolicy = ForwardWritePolicy.STRICT): + forward_write_policy: ForwardWritePolicy = ForwardWritePolicy.STRICT, + envelope_version: BaboonEnvelopeVersion = BaboonEnvelopeVersion.V1): self.use_indices = use_indices self.forward_write_policy = forward_write_policy + self.envelope_version = envelope_version # `facade` is threaded through generated codec calls so the `any`-feature cross-format # conversion (UEBA <-> JSON) can resolve codecs by `(domain, version, typeid)` from an # `AnyMeta` envelope. `None` for the bare `Compact`/`Indexed` singletons; `with_facade` @@ -192,9 +206,10 @@ def with_facade(cls, use_indices: bool, facade) -> 'BaboonCodecContext': return cls(use_indices, facade) @classmethod - def custom(cls, use_indices: bool, forward_write_policy: ForwardWritePolicy, facade) -> 'BaboonCodecContext': - """Fully specified context: index mode, writer-side forward policy and optional facade.""" - return cls(use_indices, facade, forward_write_policy) + def custom(cls, use_indices: bool, forward_write_policy: ForwardWritePolicy, + envelope_version: BaboonEnvelopeVersion, facade) -> 'BaboonCodecContext': + """Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade.""" + return cls(use_indices, facade, forward_write_policy, envelope_version) # Stable singletons — `is`-equality preserved across all uses (PR 10.1). diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index 184ece587..80f7c8166 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -36,8 +36,8 @@ class BaboonCodecsFacade: CONTENT_JSON_KEY = "$c" def __init__(self): - # JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domain_version_min_compat`, - # whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + # Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + # bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. self.forward_read_policy: ForwardReadPolicy = ForwardReadPolicy.TOLERANT self.versions_codecs_json: Dict[BaboonDomainVersion, Lazy[AbstractBaboonJsonCodecs]] = {} self.versions_codecs_bin: Dict[BaboonDomainVersion, Lazy[AbstractBaboonUebaCodecs]] = {} @@ -139,12 +139,17 @@ def _bin_type_meta(value: BaboonGenerated, ctx: BaboonCodecContext) -> BaboonTyp `domain_version_min_compat` lowered to the prefix bound of the context's index mode when the writer policy is TOLERANT.""" meta = BaboonTypeMeta.from_instance(value) - if ctx.forward_write_policy == ForwardWritePolicy.STRICT: + v2 = ctx.envelope_version == BaboonEnvelopeVersion.V2 + if not v2 and ctx.forward_write_policy == ForwardWritePolicy.STRICT: return meta tier = BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER if ctx.use_indices else BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER # baboon_min_reader_versions has a non-abstract default (see BaboonGenerated); a missing # prefix bound means "no forward-read beyond byte-identity", i.e. keep min_compat bound = value.baboon_min_reader_versions.get(tier, meta.domain_version_min_compat) + if v2: + # V2 carries both bounds; the writer policy is irrelevant + return meta.model_copy(update={"meta_version": BaboonTypeMetaCodec.META_VERSION_2, + "domain_version_readable_min": bound}) return meta.model_copy(update={"domain_version_min_compat": bound}) def decode_from_bin(self, reader: LEDataInputStream) -> BaboonGenerated: @@ -284,7 +289,9 @@ def _try_convert(self, value: BaboonGenerated, versions: List[BaboonDomainVersio return from_model def _get_bin_codec(self, type_meta: BaboonTypeMeta, exact: bool) -> BaboonBinCodec: - return self._get_codec(self.versions_codecs_bin, type_meta, exact, tolerant=False) + # v1 envelopes carry readable_min == min_compat, so the policy only bites on v2 envelopes (and JSON) + return self._get_codec(self.versions_codecs_bin, type_meta, exact, + tolerant=self.forward_read_policy == ForwardReadPolicy.TOLERANT) def _get_json_codec(self, type_meta: BaboonTypeMeta, exact: bool) -> BaboonJsonCodec: return self._get_codec(self.versions_codecs_json, type_meta, exact, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index 70c18fc48..f009c70eb 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -612,8 +612,15 @@ def read_meta_json(value: dict[str, Any]) -> Optional['BaboonTypeMeta']: class BaboonTypeMetaCodec: META_VERSION_1: int = 1 + META_VERSION_2: int = 2 + # Layout written by default (binary) and always (JSON `$mv`). META_VERSION: int = META_VERSION_1 + # v2 flags byte (codec-envelope.md §2.1.3): bit 0 -- min_compat follows; bit 1 -- readable_min follows. + _V2_FLAG_MIN_COMPAT: int = 0x01 + _V2_FLAG_READABLE_MIN: int = 0x02 + _V2_FLAGS_MASK: int = _V2_FLAG_MIN_COMPAT | _V2_FLAG_READABLE_MIN + META_VERSION_KEY = "$mv" DOMAIN_IDENTIFIER_KEY = "$d" DOMAIN_VERSION_KEY = "$v" @@ -623,6 +630,34 @@ class BaboonTypeMetaCodec: @staticmethod def write_bin(meta: BaboonTypeMeta, writer: LEDataOutputStream) -> None: + if meta.meta_version == BaboonTypeMetaCodec.META_VERSION_1: + BaboonTypeMetaCodec._write_bin_v1(meta, writer) + elif meta.meta_version == BaboonTypeMetaCodec.META_VERSION_2: + BaboonTypeMetaCodec._write_bin_v2(meta, writer) + else: + raise BaboonCodecException.EncoderFailure(f"Unsupported binary envelope meta_version {meta.meta_version}") + + @staticmethod + def _write_bin_v2(meta: BaboonTypeMeta, writer: LEDataOutputStream) -> None: + # v2: `02 | domain_id | domain_version | flags | [min_compat] | [readable_min] | type_id`; each bound + # is elided exactly as in JSON (min_compat when == domain_version, readable_min when == effective min_compat) + min_compat = meta.domain_version_min_compat or meta.domain_version + readable_min = meta.domain_version_readable_min or min_compat + has_min_compat = min_compat != meta.domain_version + has_readable_min = readable_min != min_compat + writer.write_byte(BaboonTypeMetaCodec.META_VERSION_2) + writer.write_str(meta.domain_identifier) + writer.write_str(meta.domain_version) + writer.write_byte((BaboonTypeMetaCodec._V2_FLAG_MIN_COMPAT if has_min_compat else 0) + | (BaboonTypeMetaCodec._V2_FLAG_READABLE_MIN if has_readable_min else 0)) + if has_min_compat: + writer.write_str(min_compat) + if has_readable_min: + writer.write_str(readable_min) + writer.write_str(meta.type_identifier) + + @staticmethod + def _write_bin_v1(meta: BaboonTypeMeta, writer: LEDataOutputStream) -> None: # PR-23-D03 fix (PR 10.4): pre-existing bugs. # 1) `writer.write_string(writer, ...)` — there is no `write_string` method on # `LEDataOutputStream`; the correct API is `write_str(s)` (single arg). The @@ -667,10 +702,35 @@ def read_meta_bin(reader: LEDataInputStream) -> Optional[BaboonTypeMeta]: meta_version = reader.read_byte() if meta_version == BaboonTypeMetaCodec.META_VERSION_1: return BaboonTypeMetaCodec._read_meta_v1_bin(reader) + if meta_version == BaboonTypeMetaCodec.META_VERSION_2: + return BaboonTypeMetaCodec._read_meta_v2_bin(reader) return None except Exception: return None + @staticmethod + def _read_meta_v2_bin(reader: LEDataInputStream) -> Optional[BaboonTypeMeta]: + try: + domain_identifier = reader.read_string() + domain_version = reader.read_string() + flags = reader.read_byte() + # unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if flags & ~BaboonTypeMetaCodec._V2_FLAGS_MASK: + return None + min_compat = reader.read_string() if flags & BaboonTypeMetaCodec._V2_FLAG_MIN_COMPAT else domain_version + readable_min = reader.read_string() if flags & BaboonTypeMetaCodec._V2_FLAG_READABLE_MIN else min_compat + type_identifier = reader.read_string() + return BaboonTypeMeta( + meta_version=BaboonTypeMetaCodec.META_VERSION_2, + domain_identifier=domain_identifier, + domain_version=domain_version, + domain_version_min_compat=min_compat, + type_identifier=type_identifier, + domain_version_readable_min=readable_min, + ) + except Exception: + return None + @staticmethod def read_meta_json(json_obj: dict[str, Any]) -> Optional[BaboonTypeMeta]: # MFACADE-PR-3: accept $mv as either a JSON number (int) or a string diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index 1c925feeb..cdd57e693 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -1,5 +1,5 @@ use crate::any_opaque::{AnyMeta, AnyOpaque, BaboonCodecError}; -use crate::baboon_runtime::{BaboonCodecContext, ForwardWritePolicy}; +use crate::baboon_runtime::{BaboonCodecContext, BaboonEnvelopeVersion, ForwardWritePolicy}; use std::any::TypeId; use std::collections::HashMap; use std::io::{Cursor, Read}; @@ -350,8 +350,8 @@ pub enum ForwardReadPolicy { } pub struct BaboonCodecsFacade { - /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domain_version_min_compat`, - /// whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + /// Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + /// bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. forward_read_policy: Mutex, versions_codecs_json: Mutex>>>>, versions_codecs_bin: Mutex>>>>, @@ -672,7 +672,8 @@ impl BaboonCodecsFacade { type_meta: &BaboonTypeMeta, exact: bool, ) -> Result, BaboonCodecError> { - let resolved = self.resolve_version(type_meta, exact, false)?; + // v1 envelopes carry readable_min == min_compat, so the policy only bites on v2 envelopes (and JSON) + let resolved = self.resolve_version(type_meta, exact, self.forward_read_policy() == ForwardReadPolicy::Tolerant)?; let table = { let codecs = self.versions_codecs_bin.lock().expect("mutex poisoned"); codecs @@ -852,13 +853,24 @@ impl BaboonCodecsFacade { /// forward-read beyond byte-identity", i.e. min_compat is kept. fn bin_type_meta(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool, ctx: &BaboonCodecContext) -> BaboonTypeMeta { let mut meta = self.type_meta_from(value, is_adt_trait); - if ctx.forward_write_policy() == ForwardWritePolicy::Tolerant { + let v2 = ctx.envelope_version() == BaboonEnvelopeVersion::V2; + if v2 || ctx.forward_write_policy() == ForwardWritePolicy::Tolerant { let tier = if ctx.use_indices() { BaboonTypeMeta::UEBA_PREFIX_ANY_MODE_TIER } else { BaboonTypeMeta::UEBA_PREFIX_COMPACT_TIER }; - if let Some((_, bound)) = value.baboon_min_reader_versions_dyn().into_iter().find(|(t, _)| t == tier) { + let bound = value + .baboon_min_reader_versions_dyn() + .into_iter() + .find(|(t, _)| t == tier) + .map(|(_, v)| v) + .unwrap_or_else(|| meta.domain_version_min_compat.clone()); + if v2 { + // V2 carries both bounds; the writer policy is irrelevant + meta.meta_version = BaboonTypeMeta::META_VERSION_2; + meta.domain_version_readable_min = bound; + } else { meta.domain_version_min_compat = bound; } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs index 645b18381..f779563fe 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs @@ -24,6 +24,17 @@ pub enum ForwardWritePolicy { Tolerant, } +/// Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). +/// `V1` (default): single bound slot (`domain_version_min_compat`), value chosen by +/// `ForwardWritePolicy`. `V2`: JSON-equivalent layout carrying both the byte-identical bound and the +/// prefix-read bound for the payload's index mode; the reader's `ForwardReadPolicy` then applies to +/// binary exactly as it does to JSON. Only readers that know v2 can decode it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BaboonEnvelopeVersion { + V1, + V2, +} + #[derive(Clone, Debug)] pub enum BaboonCodecContext { Default, @@ -33,10 +44,11 @@ pub enum BaboonCodecContext { use_indices: bool, facade: Arc, }, - /// Fully specified context: index mode, writer-side forward policy and optional facade. + /// Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. Custom { use_indices: bool, forward_write_policy: ForwardWritePolicy, + envelope_version: BaboonEnvelopeVersion, facade: Option>, }, } @@ -52,11 +64,12 @@ impl PartialEq for BaboonCodecContext { BaboonCodecContext::WithFacade { use_indices: b, facade: fb }, ) => a == b && Arc::ptr_eq(fa, fb), ( - BaboonCodecContext::Custom { use_indices: a, forward_write_policy: pa, facade: fa }, - BaboonCodecContext::Custom { use_indices: b, forward_write_policy: pb, facade: fb }, + BaboonCodecContext::Custom { use_indices: a, forward_write_policy: pa, envelope_version: ea, facade: fa }, + BaboonCodecContext::Custom { use_indices: b, forward_write_policy: pb, envelope_version: eb, facade: fb }, ) => { a == b && pa == pb + && ea == eb && match (fa, fb) { (None, None) => true, (Some(x), Some(y)) => Arc::ptr_eq(x, y), @@ -92,6 +105,14 @@ impl BaboonCodecContext { } } + /// Writer-side binary envelope layout; `V1` for every context but `Custom`. + pub fn envelope_version(&self) -> BaboonEnvelopeVersion { + match self { + BaboonCodecContext::Custom { envelope_version, .. } => *envelope_version, + _ => BaboonEnvelopeVersion::V1, + } + } + /// Returns the embedded `BaboonCodecsFacade` reference for `WithFacade`-constructed contexts; /// `None` for the bare `Default`/`Indexed`/`Compact` singletons. Codec-generator-emitted code /// (PR 4.2+) uses this when an `any` field requires cross-format conversion. @@ -113,9 +134,10 @@ impl BaboonCodecContext { pub fn custom( use_indices: bool, forward_write_policy: ForwardWritePolicy, + envelope_version: BaboonEnvelopeVersion, facade: Option>, ) -> Self { - BaboonCodecContext::Custom { use_indices, forward_write_policy, facade } + BaboonCodecContext::Custom { use_indices, forward_write_policy, envelope_version, facade } } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs index f1259cdb1..bc0c16219 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_type_meta.rs @@ -23,6 +23,8 @@ pub struct BaboonTypeMeta { impl BaboonTypeMeta { pub const META_VERSION_1: u8 = 1; + pub const META_VERSION_2: u8 = 2; + /// Layout written by default (binary) and always (JSON `$mv`). pub const META_VERSION: u8 = Self::META_VERSION_1; /// Tier key of the JSON envelope's readable-min bound in `baboon_min_reader_versions_dyn`. pub const JSON_READABLE_TIER: &'static str = "json-additive"; @@ -97,10 +99,49 @@ pub mod baboon_type_meta_codec { pub const DOMAIN_VERSION_READABLE_KEY: &str = "$rv"; pub const TYPE_IDENTIFIER_KEY: &str = "$t"; - /// Wire format: `[meta-version:u8][domain:string][version:string][has-min-compat:u8][min-compat?:string][type-id:string]`. - /// Mirrors C# BaboonTypeMetaCodec.WriteBin / Scala writeBin exactly. + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — min_compat follows; bit 1 — readable_min follows. + const V2_FLAG_MIN_COMPAT: u8 = 0x01; + const V2_FLAG_READABLE_MIN: u8 = 0x02; + const V2_FLAGS_MASK: u8 = V2_FLAG_MIN_COMPAT | V2_FLAG_READABLE_MIN; + + /// Dispatches on `meta.meta_version`: v1 `[01][domain][version][has-min-compat:u8][min-compat?][type-id]`, + /// v2 `[02][domain][version][flags:u8][min-compat?][readable-min?][type-id]`. pub fn write_bin(meta: &BaboonTypeMeta, writer: &mut dyn Write) -> std::io::Result<()> { - bin_tools::write_byte(writer, BaboonTypeMeta::META_VERSION)?; + match meta.meta_version { + BaboonTypeMeta::META_VERSION_1 => write_bin_v1(meta, writer), + BaboonTypeMeta::META_VERSION_2 => write_bin_v2(meta, writer), + other => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Unsupported binary envelope meta_version {}", other), + )), + } + } + + // each bound is elided exactly as in JSON (min_compat when == domain_version, readable_min when == + // effective min_compat) + fn write_bin_v2(meta: &BaboonTypeMeta, writer: &mut dyn Write) -> std::io::Result<()> { + let min_compat = if meta.domain_version_min_compat.is_empty() { &meta.domain_version } else { &meta.domain_version_min_compat }; + let readable_min = if meta.domain_version_readable_min.is_empty() { min_compat } else { &meta.domain_version_readable_min }; + let has_min_compat = min_compat != &meta.domain_version; + let has_readable_min = readable_min != min_compat; + bin_tools::write_byte(writer, BaboonTypeMeta::META_VERSION_2)?; + bin_tools::write_string(writer, &meta.domain_identifier)?; + bin_tools::write_string(writer, &meta.domain_version)?; + bin_tools::write_byte( + writer, + (if has_min_compat { V2_FLAG_MIN_COMPAT } else { 0 }) | (if has_readable_min { V2_FLAG_READABLE_MIN } else { 0 }), + )?; + if has_min_compat { + bin_tools::write_string(writer, min_compat)?; + } + if has_readable_min { + bin_tools::write_string(writer, readable_min)?; + } + bin_tools::write_string(writer, &meta.type_identifier) + } + + fn write_bin_v1(meta: &BaboonTypeMeta, writer: &mut dyn Write) -> std::io::Result<()> { + bin_tools::write_byte(writer, BaboonTypeMeta::META_VERSION_1)?; bin_tools::write_string(writer, &meta.domain_identifier)?; bin_tools::write_string(writer, &meta.domain_version)?; if meta.domain_version == meta.domain_version_min_compat { @@ -122,9 +163,38 @@ pub mod baboon_type_meta_codec { e, ) })?; - if meta_version != BaboonTypeMeta::META_VERSION_1 { + match meta_version { + BaboonTypeMeta::META_VERSION_1 => read_bin_v1(reader), + BaboonTypeMeta::META_VERSION_2 => read_bin_v2(reader), + _ => Ok(None), + } + } + + fn read_bin_v2(reader: &mut R) -> Result, BaboonCodecError> { + fn rs(reader: &mut R, what: &str) -> Result { + bin_tools::read_string(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box(&format!("BaboonTypeMetaCodec.read_bin(v2): failed to read {}", what), e) + }) + } + let domain_identifier = rs(reader, "domain identifier")?; + let domain_version = rs(reader, "domain version")?; + let flags = bin_tools::read_byte(reader).map_err(|e| { + BaboonCodecError::decoder_failure_from_box("BaboonTypeMetaCodec.read_bin(v2): failed to read flags byte", e) + })?; + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if flags & !V2_FLAGS_MASK != 0 { return Ok(None); } + let min_compat = if flags & V2_FLAG_MIN_COMPAT != 0 { rs(reader, "min-compat")? } else { domain_version.clone() }; + let readable_min = if flags & V2_FLAG_READABLE_MIN != 0 { rs(reader, "readable-min")? } else { min_compat.clone() }; + let type_identifier = rs(reader, "type identifier")?; + Ok(Some( + BaboonTypeMeta::new(BaboonTypeMeta::META_VERSION_2, domain_identifier, domain_version, min_compat, type_identifier) + .with_readable_min(readable_min), + )) + } + + fn read_bin_v1(reader: &mut R) -> Result, BaboonCodecError> { let domain_identifier = bin_tools::read_string(reader).map_err(|e| { BaboonCodecError::decoder_failure_from_box( "BaboonTypeMetaCodec.read_bin: failed to read domain identifier", diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala index 7e6477781..b7d17a7f3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala @@ -32,9 +32,22 @@ package baboon.runtime.shared { case object Tolerant extends ForwardWritePolicy } + /** Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + * - V1 (default): single bound slot (`domainVersionMinCompat`), value chosen by [[ForwardWritePolicy]]. + * - V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read + * bound for the payload's index mode; the reader's `ForwardReadPolicy` then applies to + * binary exactly as it does to JSON. Only readers that know v2 can decode it. + */ + sealed trait BaboonEnvelopeVersion + object BaboonEnvelopeVersion { + case object V1 extends BaboonEnvelopeVersion + case object V2 extends BaboonEnvelopeVersion + } + trait BaboonCodecContext { def useIndices: Boolean def forwardWritePolicy: ForwardWritePolicy = ForwardWritePolicy.Strict + def envelopeVersion: BaboonEnvelopeVersion = BaboonEnvelopeVersion.V1 // Optional facade reference, supplied only when an `any`-bearing codec needs to cross-convert // between UEBA and JSON branches (`AnyOpaqueJson` → UEBA, `AnyOpaqueUeba` → JSON). Defaults to // `None` so existing call sites stay compatible; users who need cross-convert pass @@ -56,10 +69,11 @@ package baboon.runtime.shared { override def facade: Option[BaboonCodecsFacade] = Some(baboonFacade) } - /** Fully specified context: index mode, writer-side forward policy and optional facade. */ + /** Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. */ final case class Custom( useIndices: Boolean, override val forwardWritePolicy: ForwardWritePolicy, + override val envelopeVersion: BaboonEnvelopeVersion, override val facade: Option[BaboonCodecsFacade], ) extends BaboonCodecContext } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala index 3416e0beb..3589776a5 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala @@ -27,8 +27,8 @@ package baboon.runtime.shared { private val CONTENT_JSON_KEY = "$c" - /** JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + /** Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + * bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. */ var forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant @@ -510,7 +510,9 @@ package baboon.runtime.shared { } private def getBinCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonValue[BaboonBinCodec[BaboonGenerated]] = { - getCodec(versionsCodecsBin, typeMeta, exact, tolerant = false).map(_.asInstanceOf[BaboonBinCodec[BaboonGenerated]]) + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + getCodec(versionsCodecsBin, typeMeta, exact, tolerant = forwardReadPolicy == ForwardReadPolicy.Tolerant) + .map(_.asInstanceOf[BaboonBinCodec[BaboonGenerated]]) } private def getJsonCodec(typeMeta: BaboonTypeMeta, exact: Boolean): BaboonValue[BaboonJsonCodec[BaboonGenerated]] = { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala index b7d4938a2..319d5b34a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonRuntimeShared.scala @@ -432,20 +432,27 @@ package baboon.runtime.shared { ) } - /** Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` - * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + /** Envelope for a UEBA payload written under `ctx`. + * - V1 layout: `from(value)`, with `domainVersionMinCompat` lowered to the prefix bound of the + * context's index mode when the writer policy is Tolerant (the single slot must carry it). + * - V2 layout: both bounds travel — `domainVersionMinCompat` stays byte-identical and + * `domainVersionReadableMin` carries the prefix bound; the writer policy is irrelevant. */ def forBin[T <: BaboonGenerated: ClassTag](value: T, ctx: BaboonCodecContext): BaboonTypeMeta = { val meta = from(value) - ctx.forwardWritePolicy match { - case ForwardWritePolicy.Strict => meta - case ForwardWritePolicy.Tolerant => - val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER - val bound = value.baboonMinReaderVersions.getOrElse( - tier, - throw BaboonCodecException.EncoderFailure(s"baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}"), - ) - meta.copy(domainVersionMinCompat = bound) + val tier = if (ctx.useIndices) UEBA_PREFIX_ANY_MODE_TIER else UEBA_PREFIX_COMPACT_TIER + def prefixBound: String = value.baboonMinReaderVersions.getOrElse( + tier, + throw BaboonCodecException.EncoderFailure(s"baboonMinReaderVersions lacks '$tier' for type ${value.baboonTypeIdentifier}"), + ) + ctx.envelopeVersion match { + case BaboonEnvelopeVersion.V2 => + meta.copy(metaVersion = BaboonTypeMetaCodec.META_VERSION_2, domainVersionReadableMin = prefixBound) + case BaboonEnvelopeVersion.V1 => + ctx.forwardWritePolicy match { + case ForwardWritePolicy.Strict => meta + case ForwardWritePolicy.Tolerant => meta.copy(domainVersionMinCompat = prefixBound) + } } } @@ -463,8 +470,15 @@ package baboon.runtime.shared { } object BaboonTypeMetaCodec { - private val META_VERSION_1: Byte = 1 - val META_VERSION: Byte = META_VERSION_1 + val META_VERSION_1: Byte = 1 + val META_VERSION_2: Byte = 2 + /** Layout written by default (binary) and always (JSON `$mv`). */ + val META_VERSION: Byte = META_VERSION_1 + + /** v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. */ + private val V2_FLAG_MIN_COMPAT: Int = 0x01 + private val V2_FLAG_READABLE_MIN: Int = 0x02 + private val V2_FLAGS_MASK: Int = V2_FLAG_MIN_COMPAT | V2_FLAG_READABLE_MIN private val META_VERSION_KEY = "$mv" private val DOMAIN_IDENTIFIER_KEY = "$d" @@ -474,6 +488,14 @@ package baboon.runtime.shared { private val TYPE_IDENTIFIER_KEY = "$t" def writeBin(meta: BaboonTypeMeta, writer: LEDataOutputStream): Unit = { + meta.metaVersion match { + case META_VERSION_1 => writeBinV1(meta, writer) + case META_VERSION_2 => writeBinV2(meta, writer) + case other => throw BaboonCodecException.EncoderFailure(s"Unsupported binary envelope metaVersion $other") + } + } + + private def writeBinV1(meta: BaboonTypeMeta, writer: LEDataOutputStream): Unit = { writer.write(META_VERSION_1.toInt) BaboonBinTools.writeString(writer, meta.domainIdentifier) BaboonBinTools.writeString(writer, meta.domainVersion) @@ -486,6 +508,22 @@ package baboon.runtime.shared { BaboonBinTools.writeString(writer, meta.typeIdentifier) } + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private def writeBinV2(meta: BaboonTypeMeta, writer: LEDataOutputStream): Unit = { + val minCompat = if (meta.domainVersionMinCompat.isEmpty) meta.domainVersion else meta.domainVersionMinCompat + val readableMin = if (meta.domainVersionReadableMin.isEmpty) minCompat else meta.domainVersionReadableMin + val hasMinCompat = minCompat != meta.domainVersion + val hasReadableMin = readableMin != minCompat + writer.write(META_VERSION_2.toInt) + BaboonBinTools.writeString(writer, meta.domainIdentifier) + BaboonBinTools.writeString(writer, meta.domainVersion) + writer.write((if (hasMinCompat) V2_FLAG_MIN_COMPAT else 0) | (if (hasReadableMin) V2_FLAG_READABLE_MIN else 0)) + if (hasMinCompat) BaboonBinTools.writeString(writer, minCompat) + if (hasReadableMin) BaboonBinTools.writeString(writer, readableMin) + BaboonBinTools.writeString(writer, meta.typeIdentifier) + } + def writeJson(meta: BaboonTypeMeta): Json = { // MFACADE-PR-3: always emit `$mv` as a JSON number so envelopes are // self-identifying without out-of-band knowledge (proposal §10.6 (a)). @@ -505,10 +543,23 @@ package baboon.runtime.shared { } def readMeta(reader: LEDataInputStream): Option[BaboonTypeMeta] = { - val metaVersion = reader.readByte() - if (metaVersion == META_VERSION_1) { - readMetaV1(reader) - } else None + reader.readByte() match { + case META_VERSION_1 => readMetaV1(reader) + case META_VERSION_2 => readMetaV2(reader) + case _ => None + } + } + + private def readMetaV2(reader: LEDataInputStream): Option[BaboonTypeMeta] = { + val domainIdentifier = BaboonBinTools.readString(reader) + val domainVersion = BaboonBinTools.readString(reader) + val flags = reader.readByte().toInt + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags & ~V2_FLAGS_MASK) != 0) return None + val minCompat = if ((flags & V2_FLAG_MIN_COMPAT) != 0) BaboonBinTools.readString(reader) else domainVersion + val readableMin = if ((flags & V2_FLAG_READABLE_MIN) != 0) BaboonBinTools.readString(reader) else minCompat + val typeIdentifier = BaboonBinTools.readString(reader) + Some(BaboonTypeMeta(META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin)) } def readMeta(json: Json): Option[BaboonTypeMeta] = { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift index 892bc802f..fef4905b0 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift @@ -43,8 +43,8 @@ public enum ForwardReadPolicy { } open class BaboonCodecsFacade: BaboonCodecsFacadeBase { - /// JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - /// whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + /// Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + /// bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. public var forwardReadPolicy: ForwardReadPolicy = .tolerant private var versionsCodecsJson: [BaboonDomainVersion: BaboonLazy] = [:] @@ -616,7 +616,8 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { // ----- private dispatch ------------------------------------------------------------------- private func getBinCodec(_ typeMeta: BaboonTypeMeta, exact: Bool) -> Result { - return getCodec(typeMeta, exact, false, { (k: BaboonDomainVersion) -> AnyObject? in + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + return getCodec(typeMeta, exact, forwardReadPolicy == .tolerant, { (k: BaboonDomainVersion) -> AnyObject? in guard let lazy = self.versionsCodecsBin[k] else { return nil } return lazy.value }) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index a829c442a..125eecdde 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -122,11 +122,22 @@ public enum ForwardWritePolicy { case tolerant } +// Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). +// `.v1` (default): single bound slot (`domainVersionMinCompat`), value chosen by `ForwardWritePolicy`. +// `.v2`: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for +// the payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it does +// to JSON. Only readers that know v2 can decode it. +public enum BaboonEnvelopeVersion { + case v1 + case v2 +} + open class BaboonCodecContext { public init() {} open var useIndices: Bool { return false } open var forwardWritePolicy: ForwardWritePolicy { return .strict } + open var envelopeVersion: BaboonEnvelopeVersion { return .v1 } open var facade: BaboonCodecsFacadeBase? { return nil } public static let defaultCtx: BaboonCodecContext = BaboonCodecContextCompact() @@ -138,24 +149,27 @@ open class BaboonCodecContext { return BaboonCodecContextWithFacade(useIndices: useIndices, facade: facade) } - // Fully specified context: index mode, writer-side forward policy and optional facade. - public static func custom(_ useIndices: Bool, _ forwardWritePolicy: ForwardWritePolicy, _ facade: BaboonCodecsFacadeBase?) -> BaboonCodecContext { - return BaboonCodecContextCustom(useIndices: useIndices, forwardWritePolicy: forwardWritePolicy, facade: facade) + // Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. + public static func custom(_ useIndices: Bool, _ forwardWritePolicy: ForwardWritePolicy, _ envelopeVersion: BaboonEnvelopeVersion, _ facade: BaboonCodecsFacadeBase?) -> BaboonCodecContext { + return BaboonCodecContextCustom(useIndices: useIndices, forwardWritePolicy: forwardWritePolicy, envelopeVersion: envelopeVersion, facade: facade) } } public final class BaboonCodecContextCustom: BaboonCodecContext { private let _useIndices: Bool private let _forwardWritePolicy: ForwardWritePolicy + private let _envelopeVersion: BaboonEnvelopeVersion private let _facade: BaboonCodecsFacadeBase? - public init(useIndices: Bool, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacadeBase?) { + public init(useIndices: Bool, forwardWritePolicy: ForwardWritePolicy, envelopeVersion: BaboonEnvelopeVersion, facade: BaboonCodecsFacadeBase?) { self._useIndices = useIndices self._forwardWritePolicy = forwardWritePolicy + self._envelopeVersion = envelopeVersion self._facade = facade super.init() } public override var useIndices: Bool { return _useIndices } public override var forwardWritePolicy: ForwardWritePolicy { return _forwardWritePolicy } + public override var envelopeVersion: BaboonEnvelopeVersion { return _envelopeVersion } public override var facade: BaboonCodecsFacadeBase? { return _facade } } @@ -1329,277 +1343,9 @@ public protocol BaboonAdtMember { // --- Version / DomainVersion / TypeMeta --- // -// PR-19-D01 lesson: regex literals in template files are read verbatim — but Swift runtime files -// in this project go through `processEscapes` (see PR-20-D01 sister-bug). We use only manual -// numeric parsing, no regex; safe regardless. - -public struct BaboonVersion: Comparable, Hashable, CustomStringConvertible { - public let major: Int - public let minor: Int - public let patch: Int - - public init(major: Int, minor: Int, patch: Int) { - self.major = major - self.minor = minor - self.patch = patch - } - - public static func from(_ version: String) throws -> BaboonVersion { - let chunks = version.split(separator: ".", omittingEmptySubsequences: false) - if chunks.count != 3 { - throw BaboonException("Expected to have version in format x.y.z, got \(version)") - } - guard let major = Int(chunks[0].trimmingCharacters(in: .whitespaces)) else { - throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid major value.") - } - guard let minor = Int(chunks[1].trimmingCharacters(in: .whitespaces)) else { - throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid minor value.") - } - guard let patch = Int(chunks[2].trimmingCharacters(in: .whitespaces)) else { - throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid patch value.") - } - return BaboonVersion(major: major, minor: minor, patch: patch) - } - - public static func < (lhs: BaboonVersion, rhs: BaboonVersion) -> Bool { - if lhs.major != rhs.major { return lhs.major < rhs.major } - if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } - return lhs.patch < rhs.patch - } - - public var description: String { return "\(major).\(minor).\(patch)" } -} - -public struct BaboonException: Error, CustomStringConvertible { - public let message: String - public let cause: Error? - public init(_ message: String, _ cause: Error? = nil) { - self.message = message - self.cause = cause - } - public var description: String { return "BaboonException: \(message)" } -} - -public struct BaboonDomainVersion: Hashable, CustomStringConvertible { - public let domainIdentifier: String - public let domainVersion: String - - public init(_ domainIdentifier: String, _ domainVersion: String) { - self.domainIdentifier = domainIdentifier - self.domainVersion = domainVersion - } - - public func version() throws -> BaboonVersion { - return try BaboonVersion.from(domainVersion) - } - - public var description: String { return "\(domainIdentifier):\(domainVersion)" } -} - -// On-wire type meta envelope. Mirrors Dart/Kotlin `BaboonTypeMeta`. -public struct BaboonTypeMeta: Hashable, CustomStringConvertible { - public let metaVersion: Int - public let domainIdentifier: String - public let domainVersion: String - public let domainVersionMinCompat: String - public let typeIdentifier: String - /// Oldest domain version whose JSON codec can decode the payload under the json-additive - /// contract (tolerant key lookup; fields unknown to that version are dropped). Always - /// <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) - /// minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. - public let domainVersionReadableMin: String - - /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. - public static let jsonReadableTier = "json-additive" - /// Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. - public static let uebaPrefixCompactTier = "prefix-compact" - public static let uebaPrefixAnyModeTier = "prefix-any-mode" - - public init( - _ metaVersion: Int, - _ domainIdentifier: String, - _ domainVersion: String, - _ domainVersionMinCompat: String, - _ typeIdentifier: String, - _ domainVersionReadableMin: String? = nil - ) { - self.metaVersion = metaVersion - self.domainIdentifier = domainIdentifier - self.domainVersion = domainVersion - self.domainVersionMinCompat = domainVersionMinCompat - self.typeIdentifier = typeIdentifier - self.domainVersionReadableMin = domainVersionReadableMin ?? domainVersionMinCompat - } - - public func versionRef() -> BaboonDomainVersion { - return BaboonDomainVersion(domainIdentifier, domainVersion) - } - - public func versionReadableMin() -> BaboonDomainVersion? { - if domainVersionReadableMin.isEmpty { return versionMinCompat() } - if domainVersionReadableMin == domainVersion { return nil } - return BaboonDomainVersion(domainIdentifier, domainVersionReadableMin) - } - - public func versionMinCompat() -> BaboonDomainVersion? { - if domainVersionMinCompat.isEmpty { return nil } - if domainVersionMinCompat == domainVersion { return nil } - return BaboonDomainVersion(domainIdentifier, domainVersionMinCompat) - } - - public func writeBin(_ writer: BaboonBinWriter) { - BaboonTypeMetaCodec.writeBin(self, writer) - } - - public func writeJson() -> [String: Any] { - return BaboonTypeMetaCodec.writeJson(self) - } - - // MFACADE-PR-3: accept `$mv` as either a JSON number or a string (back-compat - // with M28-vintage fixtures); both must equal `metaVersion`. Absent falls through. - public static func readMetaJson(_ json: Any?) -> BaboonTypeMeta? { - guard let obj = json as? [String: Any] else { return nil } - if let mv = obj["$mv"] { - // MFACADE-PR-3-D02: reject Bool explicitly — in Foundation Bool bridges to NSNumber, - // so `true`/`false` would otherwise slip through the NSNumber branch with intValue 1/0. - if mv is Bool { return nil } - var mvInt: Int? = nil - if let n = mv as? Int { - mvInt = n - } else if let n = mv as? NSNumber { - // MFACADE-PR-7-D12: reject Float/Double-typed NSNumber. JSONSerialization - // bridges JSON numbers as NSNumber whose `objCType` reflects the source literal: - // `d` = Double, `f` = Float; integer types ('i'/'l'/'q'/'s'/'c'/etc.) otherwise. - // Even whole-valued doubles like `1.0` are rejected because the source token - // wasn't integer-typed. Decided per-PR-7 to be strict-everywhere about - // numeric-type discrimination where parse-time preservation allows. - let oct = String(cString: n.objCType) - if oct == "d" || oct == "f" { return nil } - mvInt = n.intValue - } else if let s = mv as? String { - mvInt = Int(s) - } - guard let n = mvInt, n == BaboonTypeMetaCodec.metaVersion else { return nil } - } - guard let d = obj["$d"] as? String else { return nil } - guard let v = obj["$v"] as? String else { return nil } - guard let t = obj["$t"] as? String else { return nil } - let minCompat = (obj["$uv"] as? String) ?? v - let readableMin = (obj["$rv"] as? String) ?? minCompat - return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t, readableMin) - } - - public static func readMetaBin(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { - return try BaboonTypeMetaCodec.readMeta(reader) - } - - // Build a meta from a generated value. Optionally use the ADT type identifier when encoding - // through an ADT-typed reference (PR-19-D02). Throws when the value does not conform to - // [BaboonMetaProvider] — generated DTOs gain this conformance via the codegen's - // automatic `: BaboonMetaProvider` clause (MFACADE-PR-E). - public static func from(_ value: Any, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { - guard let meta = value as? BaboonMetaProvider else { - throw BaboonException( - "BaboonTypeMeta.from: value of type \(type(of: value)) does not conform to BaboonMetaProvider." - ) - } - let typeId: String - if useAdtIdentifier, let adt = value as? BaboonAdtMember { - typeId = adt.baboonAdtTypeIdentifier - } else { - typeId = meta.baboonTypeIdentifier - } - let sameIn = meta.baboonSameInVersions - // PR-08-D02 fail-fast: a generator emitting an empty `sameInVersions` is a bug. - if sameIn.isEmpty { - throw BaboonException( - "BaboonTypeMeta.from: empty baboonSameInVersions for type [\(meta.baboonDomainIdentifier).\(typeId)]" - ) - } - // baboonMinReaderVersions has a protocol-extension default (see BaboonMetaProvider); a - // missing json-additive bound means "no forward-read beyond byte-identity", i.e. = minCompat - let readableMin = meta.baboonMinReaderVersions[BaboonTypeMeta.jsonReadableTier] ?? sameIn[0] - return BaboonTypeMeta( - BaboonTypeMetaCodec.metaVersion, - meta.baboonDomainIdentifier, - meta.baboonDomainVersion, - sameIn[0], - typeId, - readableMin - ) - } - - // Envelope for a UEBA payload written under `ctx`: `from` with `domainVersionMinCompat` lowered - // to the prefix bound of the context's index mode when the writer policy is `.tolerant`. A - // missing prefix bound (protocol-extension default) means "no forward-read beyond - // byte-identity", i.e. minCompat is kept. - public static func forBin(_ value: Any, _ ctx: BaboonCodecContext, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { - let meta = try from(value, useAdtIdentifier: useAdtIdentifier) - guard ctx.forwardWritePolicy == .tolerant, let provider = value as? BaboonMetaProvider else { - return meta - } - let tier = ctx.useIndices ? BaboonTypeMeta.uebaPrefixAnyModeTier : BaboonTypeMeta.uebaPrefixCompactTier - let bound = provider.baboonMinReaderVersions[tier] ?? meta.domainVersionMinCompat - return BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin) - } - - public var description: String { - return "BaboonTypeMeta(\(domainIdentifier).\(typeIdentifier)@\(domainVersion))" - } -} - -public enum BaboonTypeMetaCodec { - public static let metaVersion: Int = 1 - - public static func writeBin(_ meta: BaboonTypeMeta, _ writer: BaboonBinWriter) { - writer.writeU8(UInt8(metaVersion)) - writer.writeString(meta.domainIdentifier) - writer.writeString(meta.domainVersion) - if meta.domainVersion == meta.domainVersionMinCompat { - writer.writeU8(0) - } else { - writer.writeU8(1) - writer.writeString(meta.domainVersionMinCompat) - } - writer.writeString(meta.typeIdentifier) - } - - public static func readMeta(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { - let v = Int(reader.readU8()) - if v == metaVersion { return try readMetaV1(reader) } - return nil - } - - private static func readMetaV1(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { - let d = try reader.readString() - let dv = try reader.readString() - let hasMinCompat = reader.readU8() - // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected - if hasMinCompat != 0 && hasMinCompat != 1 { return nil } - let mc = hasMinCompat == 1 ? try reader.readString() : dv - let t = try reader.readString() - return BaboonTypeMeta(metaVersion, d, dv, mc, t) - } - - public static func writeJson(_ meta: BaboonTypeMeta) -> [String: Any] { - // MFACADE-PR-3: always emit `$mv` as a JSON number so envelopes are - // self-identifying without out-of-band knowledge (proposal §10.6 (a)). - var obj: [String: Any] = [ - "$mv": metaVersion, - "$d": meta.domainIdentifier, - "$v": meta.domainVersion, - "$t": meta.typeIdentifier, - ] - if meta.domainVersion != meta.domainVersionMinCompat { - obj["$uv"] = meta.domainVersionMinCompat - } - // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes - if !meta.domainVersionReadableMin.isEmpty && meta.domainVersionReadableMin != meta.domainVersionMinCompat { - obj["$rv"] = meta.domainVersionReadableMin - } - return obj - } -} +// `BaboonVersion`, `BaboonDomainVersion`, `BaboonTypeMeta` and `BaboonTypeMetaCodec` live in +// `baboon_type_meta.swift` (same module; this file is embedded as a single JVM string constant and +// must stay under 64KB). // --- Deep Equality / HashCode for JSON-shaped values --- // diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift new file mode 100644 index 000000000..3ec6f8d8c --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift @@ -0,0 +1,327 @@ +// BaboonVersion / BaboonDomainVersion / BaboonTypeMeta — the top-level codec envelope +// (docs/spec/codec-envelope.md) and its wire codec. Split out of `baboon_runtime.swift` to keep +// each embedded runtime file under the JVM's 64KB string-constant limit; same Swift module. + +import Foundation + +// --- Version / DomainVersion / TypeMeta --- +// +// PR-19-D01 lesson: regex literals in template files are read verbatim — but Swift runtime files +// in this project go through `processEscapes` (see PR-20-D01 sister-bug). We use only manual +// numeric parsing, no regex; safe regardless. + +public struct BaboonVersion: Comparable, Hashable, CustomStringConvertible { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + public static func from(_ version: String) throws -> BaboonVersion { + let chunks = version.split(separator: ".", omittingEmptySubsequences: false) + if chunks.count != 3 { + throw BaboonException("Expected to have version in format x.y.z, got \(version)") + } + guard let major = Int(chunks[0].trimmingCharacters(in: .whitespaces)) else { + throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid major value.") + } + guard let minor = Int(chunks[1].trimmingCharacters(in: .whitespaces)) else { + throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid minor value.") + } + guard let patch = Int(chunks[2].trimmingCharacters(in: .whitespaces)) else { + throw BaboonException("Expected to have version in format x.y.z, got \(version). Invalid patch value.") + } + return BaboonVersion(major: major, minor: minor, patch: patch) + } + + public static func < (lhs: BaboonVersion, rhs: BaboonVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + public var description: String { return "\(major).\(minor).\(patch)" } +} + +public struct BaboonException: Error, CustomStringConvertible { + public let message: String + public let cause: Error? + public init(_ message: String, _ cause: Error? = nil) { + self.message = message + self.cause = cause + } + public var description: String { return "BaboonException: \(message)" } +} + +public struct BaboonDomainVersion: Hashable, CustomStringConvertible { + public let domainIdentifier: String + public let domainVersion: String + + public init(_ domainIdentifier: String, _ domainVersion: String) { + self.domainIdentifier = domainIdentifier + self.domainVersion = domainVersion + } + + public func version() throws -> BaboonVersion { + return try BaboonVersion.from(domainVersion) + } + + public var description: String { return "\(domainIdentifier):\(domainVersion)" } +} + +// On-wire type meta envelope. Mirrors Dart/Kotlin `BaboonTypeMeta`. +public struct BaboonTypeMeta: Hashable, CustomStringConvertible { + public let metaVersion: Int + public let domainIdentifier: String + public let domainVersion: String + public let domainVersionMinCompat: String + public let typeIdentifier: String + /// Oldest domain version whose JSON codec can decode the payload under the json-additive + /// contract (tolerant key lookup; fields unknown to that version are dropped). Always + /// <= domainVersionMinCompat. Published as `$rv` when it differs from the (effective) + /// minCompat; the binary v1 envelope does not carry it. Defaults to minCompat. + public let domainVersionReadableMin: String + + /// Tier key of the JSON envelope's readable-min bound in `baboonMinReaderVersions`. + public static let jsonReadableTier = "json-additive" + /// Tier keys of the UEBA prefix bounds in `baboonMinReaderVersions`, per index mode. + public static let uebaPrefixCompactTier = "prefix-compact" + public static let uebaPrefixAnyModeTier = "prefix-any-mode" + + public init( + _ metaVersion: Int, + _ domainIdentifier: String, + _ domainVersion: String, + _ domainVersionMinCompat: String, + _ typeIdentifier: String, + _ domainVersionReadableMin: String? = nil + ) { + self.metaVersion = metaVersion + self.domainIdentifier = domainIdentifier + self.domainVersion = domainVersion + self.domainVersionMinCompat = domainVersionMinCompat + self.typeIdentifier = typeIdentifier + self.domainVersionReadableMin = domainVersionReadableMin ?? domainVersionMinCompat + } + + public func versionRef() -> BaboonDomainVersion { + return BaboonDomainVersion(domainIdentifier, domainVersion) + } + + public func versionReadableMin() -> BaboonDomainVersion? { + if domainVersionReadableMin.isEmpty { return versionMinCompat() } + if domainVersionReadableMin == domainVersion { return nil } + return BaboonDomainVersion(domainIdentifier, domainVersionReadableMin) + } + + public func versionMinCompat() -> BaboonDomainVersion? { + if domainVersionMinCompat.isEmpty { return nil } + if domainVersionMinCompat == domainVersion { return nil } + return BaboonDomainVersion(domainIdentifier, domainVersionMinCompat) + } + + public func writeBin(_ writer: BaboonBinWriter) { + BaboonTypeMetaCodec.writeBin(self, writer) + } + + public func writeJson() -> [String: Any] { + return BaboonTypeMetaCodec.writeJson(self) + } + + // MFACADE-PR-3: accept `$mv` as either a JSON number or a string (back-compat + // with M28-vintage fixtures); both must equal `metaVersion`. Absent falls through. + public static func readMetaJson(_ json: Any?) -> BaboonTypeMeta? { + guard let obj = json as? [String: Any] else { return nil } + if let mv = obj["$mv"] { + // MFACADE-PR-3-D02: reject Bool explicitly — in Foundation Bool bridges to NSNumber, + // so `true`/`false` would otherwise slip through the NSNumber branch with intValue 1/0. + if mv is Bool { return nil } + var mvInt: Int? = nil + if let n = mv as? Int { + mvInt = n + } else if let n = mv as? NSNumber { + // MFACADE-PR-7-D12: reject Float/Double-typed NSNumber. JSONSerialization + // bridges JSON numbers as NSNumber whose `objCType` reflects the source literal: + // `d` = Double, `f` = Float; integer types ('i'/'l'/'q'/'s'/'c'/etc.) otherwise. + // Even whole-valued doubles like `1.0` are rejected because the source token + // wasn't integer-typed. Decided per-PR-7 to be strict-everywhere about + // numeric-type discrimination where parse-time preservation allows. + let oct = String(cString: n.objCType) + if oct == "d" || oct == "f" { return nil } + mvInt = n.intValue + } else if let s = mv as? String { + mvInt = Int(s) + } + guard let n = mvInt, n == BaboonTypeMetaCodec.metaVersion else { return nil } + } + guard let d = obj["$d"] as? String else { return nil } + guard let v = obj["$v"] as? String else { return nil } + guard let t = obj["$t"] as? String else { return nil } + let minCompat = (obj["$uv"] as? String) ?? v + let readableMin = (obj["$rv"] as? String) ?? minCompat + return BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion, d, v, minCompat, t, readableMin) + } + + public static func readMetaBin(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { + return try BaboonTypeMetaCodec.readMeta(reader) + } + + // Build a meta from a generated value. Optionally use the ADT type identifier when encoding + // through an ADT-typed reference (PR-19-D02). Throws when the value does not conform to + // [BaboonMetaProvider] — generated DTOs gain this conformance via the codegen's + // automatic `: BaboonMetaProvider` clause (MFACADE-PR-E). + public static func from(_ value: Any, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { + guard let meta = value as? BaboonMetaProvider else { + throw BaboonException( + "BaboonTypeMeta.from: value of type \(type(of: value)) does not conform to BaboonMetaProvider." + ) + } + let typeId: String + if useAdtIdentifier, let adt = value as? BaboonAdtMember { + typeId = adt.baboonAdtTypeIdentifier + } else { + typeId = meta.baboonTypeIdentifier + } + let sameIn = meta.baboonSameInVersions + // PR-08-D02 fail-fast: a generator emitting an empty `sameInVersions` is a bug. + if sameIn.isEmpty { + throw BaboonException( + "BaboonTypeMeta.from: empty baboonSameInVersions for type [\(meta.baboonDomainIdentifier).\(typeId)]" + ) + } + // baboonMinReaderVersions has a protocol-extension default (see BaboonMetaProvider); a + // missing json-additive bound means "no forward-read beyond byte-identity", i.e. = minCompat + let readableMin = meta.baboonMinReaderVersions[BaboonTypeMeta.jsonReadableTier] ?? sameIn[0] + return BaboonTypeMeta( + BaboonTypeMetaCodec.metaVersion, + meta.baboonDomainIdentifier, + meta.baboonDomainVersion, + sameIn[0], + typeId, + readableMin + ) + } + + // Envelope for a UEBA payload written under `ctx`: `from` with `domainVersionMinCompat` lowered + // to the prefix bound of the context's index mode when the writer policy is `.tolerant`. A + // missing prefix bound (protocol-extension default) means "no forward-read beyond + // byte-identity", i.e. minCompat is kept. + public static func forBin(_ value: Any, _ ctx: BaboonCodecContext, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { + let meta = try from(value, useAdtIdentifier: useAdtIdentifier) + let v2 = ctx.envelopeVersion == .v2 + guard v2 || ctx.forwardWritePolicy == .tolerant, let provider = value as? BaboonMetaProvider else { + return meta + } + let tier = ctx.useIndices ? BaboonTypeMeta.uebaPrefixAnyModeTier : BaboonTypeMeta.uebaPrefixCompactTier + let bound = provider.baboonMinReaderVersions[tier] ?? meta.domainVersionMinCompat + // v2 carries both bounds (the writer policy is irrelevant); v1 tolerant puts the prefix bound in its single slot + return v2 + ? BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion2, meta.domainIdentifier, meta.domainVersion, meta.domainVersionMinCompat, meta.typeIdentifier, bound) + : BaboonTypeMeta(meta.metaVersion, meta.domainIdentifier, meta.domainVersion, bound, meta.typeIdentifier, meta.domainVersionReadableMin) + } + + public var description: String { + return "BaboonTypeMeta(\(domainIdentifier).\(typeIdentifier)@\(domainVersion))" + } +} + +public enum BaboonTypeMetaCodec { + /// Layout written by default (binary) and always (JSON `$mv`). + public static let metaVersion: Int = 1 + public static let metaVersion2: Int = 2 + + // v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. + private static let v2FlagMinCompat: UInt8 = 0x01 + private static let v2FlagReadableMin: UInt8 = 0x02 + private static let v2FlagsMask: UInt8 = v2FlagMinCompat | v2FlagReadableMin + + public static func writeBin(_ meta: BaboonTypeMeta, _ writer: BaboonBinWriter) { + switch meta.metaVersion { + case metaVersion: writeBinV1(meta, writer) + case metaVersion2: writeBinV2(meta, writer) + default: preconditionFailure("Unsupported binary envelope metaVersion \(meta.metaVersion)") + } + } + + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private static func writeBinV2(_ meta: BaboonTypeMeta, _ writer: BaboonBinWriter) { + let minCompat = meta.domainVersionMinCompat.isEmpty ? meta.domainVersion : meta.domainVersionMinCompat + let readableMin = meta.domainVersionReadableMin.isEmpty ? minCompat : meta.domainVersionReadableMin + let hasMinCompat = minCompat != meta.domainVersion + let hasReadableMin = readableMin != minCompat + writer.writeU8(UInt8(metaVersion2)) + writer.writeString(meta.domainIdentifier) + writer.writeString(meta.domainVersion) + writer.writeU8((hasMinCompat ? v2FlagMinCompat : 0) | (hasReadableMin ? v2FlagReadableMin : 0)) + if hasMinCompat { writer.writeString(minCompat) } + if hasReadableMin { writer.writeString(readableMin) } + writer.writeString(meta.typeIdentifier) + } + + private static func writeBinV1(_ meta: BaboonTypeMeta, _ writer: BaboonBinWriter) { + writer.writeU8(UInt8(metaVersion)) + writer.writeString(meta.domainIdentifier) + writer.writeString(meta.domainVersion) + if meta.domainVersion == meta.domainVersionMinCompat { + writer.writeU8(0) + } else { + writer.writeU8(1) + writer.writeString(meta.domainVersionMinCompat) + } + writer.writeString(meta.typeIdentifier) + } + + public static func readMeta(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { + let v = Int(reader.readU8()) + if v == metaVersion { return try readMetaV1(reader) } + if v == metaVersion2 { return try readMetaV2(reader) } + return nil + } + + private static func readMetaV2(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { + let d = try reader.readString() + let dv = try reader.readString() + let flags = reader.readU8() + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if (flags & ~v2FlagsMask) != 0 { return nil } + let mc = (flags & v2FlagMinCompat) != 0 ? try reader.readString() : dv + let rm = (flags & v2FlagReadableMin) != 0 ? try reader.readString() : mc + let t = try reader.readString() + return BaboonTypeMeta(metaVersion2, d, dv, mc, t, rm) + } + + private static func readMetaV1(_ reader: BaboonBinReader) throws -> BaboonTypeMeta? { + let d = try reader.readString() + let dv = try reader.readString() + let hasMinCompat = reader.readU8() + // codec-envelope.md §2.1: only 0x00 (elided) and 0x01 (present) are legal; anything else is rejected + if hasMinCompat != 0 && hasMinCompat != 1 { return nil } + let mc = hasMinCompat == 1 ? try reader.readString() : dv + let t = try reader.readString() + return BaboonTypeMeta(metaVersion, d, dv, mc, t) + } + + public static func writeJson(_ meta: BaboonTypeMeta) -> [String: Any] { + // MFACADE-PR-3: always emit `$mv` as a JSON number so envelopes are + // self-identifying without out-of-band knowledge (proposal §10.6 (a)). + var obj: [String: Any] = [ + "$mv": metaVersion, + "$d": meta.domainIdentifier, + "$v": meta.domainVersion, + "$t": meta.typeIdentifier, + ] + if meta.domainVersion != meta.domainVersionMinCompat { + obj["$uv"] = meta.domainVersionMinCompat + } + // `$rv` is elided when it equals the effective `$uv`: unchanged types emit no new bytes + if !meta.domainVersionReadableMin.isEmpty && meta.domainVersionReadableMin != meta.domainVersionMinCompat { + obj["$rv"] = meta.domainVersionReadableMin + } + return obj + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts index 3278853fb..dc1b035b7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts @@ -73,8 +73,8 @@ export enum ForwardReadPolicy { export class BaboonCodecsFacade { /** - * JSON forward-read policy. UEBA envelopes (v1) carry a single bound, `domainVersionMinCompat`, - * whose meaning is fixed by the WRITER's `ForwardWritePolicy`; binary reads always trust it. + * Forward-read policy for JSON `$rv` and for binary v2 `readableMin`. Binary v1 envelopes carry one + * bound whose meaning the WRITER fixed via `ForwardWritePolicy`; it is trusted whatever this policy says. */ public forwardReadPolicy: ForwardReadPolicy = ForwardReadPolicy.Tolerant; @@ -584,7 +584,8 @@ export class BaboonCodecsFacade { // ----- private dispatch -------------------------------------------------------------------- private getBinCodec(typeMeta: BaboonTypeMeta, exact: boolean): BaboonEither { - return this.getCodec(this.versionsCodecsBin, typeMeta, exact, false); + // v1 envelopes carry readableMin == minCompat, so the policy only bites on v2 envelopes (and JSON) + return this.getCodec(this.versionsCodecsBin, typeMeta, exact, this.forwardReadPolicy === ForwardReadPolicy.Tolerant); } private getJsonCodec(typeMeta: BaboonTypeMeta, exact: boolean): BaboonEither { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 342b34d07..7f1c8996e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -33,14 +33,28 @@ export enum ForwardWritePolicy { Tolerant = "tolerant", } +/** + * Which top-level binary envelope layout the WRITER emits (docs/spec/codec-envelope.md §2.1). + * - V1 (default): single bound slot (`domainVersionMinCompat`), value chosen by `ForwardWritePolicy`. + * - V2: JSON-equivalent layout carrying both the byte-identical bound and the prefix-read bound for + * the payload's index mode; the reader's `ForwardReadPolicy` then applies to binary exactly as it + * does to JSON. Only readers that know v2 can decode it. + */ +export enum BaboonEnvelopeVersion { + V1 = "v1", + V2 = "v2", +} + export class BaboonCodecContext { private readonly _useIndices: boolean; private readonly _forwardWritePolicy: ForwardWritePolicy; + private readonly _envelopeVersion: BaboonEnvelopeVersion; private readonly _facade: BaboonCodecsFacade | undefined; - private constructor(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacade | undefined) { + private constructor(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, envelopeVersion: BaboonEnvelopeVersion, facade: BaboonCodecsFacade | undefined) { this._useIndices = useIndices; this._forwardWritePolicy = forwardWritePolicy; + this._envelopeVersion = envelopeVersion; this._facade = facade; } @@ -52,21 +66,25 @@ export class BaboonCodecContext { return this._forwardWritePolicy; } + public get envelopeVersion(): BaboonEnvelopeVersion { + return this._envelopeVersion; + } + public get facade(): BaboonCodecsFacade | undefined { return this._facade; } - public static readonly Indexed: BaboonCodecContext = new BaboonCodecContext(true, ForwardWritePolicy.Strict, undefined); - public static readonly Compact: BaboonCodecContext = new BaboonCodecContext(false, ForwardWritePolicy.Strict, undefined); + public static readonly Indexed: BaboonCodecContext = new BaboonCodecContext(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, undefined); + public static readonly Compact: BaboonCodecContext = new BaboonCodecContext(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, undefined); public static readonly Default: BaboonCodecContext = BaboonCodecContext.Compact; public static withFacade(useIndices: boolean, facade: BaboonCodecsFacade): BaboonCodecContext { - return new BaboonCodecContext(useIndices, ForwardWritePolicy.Strict, facade); + return new BaboonCodecContext(useIndices, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, facade); } - /** Fully specified context: index mode, writer-side forward policy and optional facade. */ - public static custom(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, facade: BaboonCodecsFacade | undefined): BaboonCodecContext { - return new BaboonCodecContext(useIndices, forwardWritePolicy, facade); + /** Fully specified context: index mode, writer-side forward policy, envelope layout and optional facade. */ + public static custom(useIndices: boolean, forwardWritePolicy: ForwardWritePolicy, envelopeVersion: BaboonEnvelopeVersion, facade: BaboonCodecsFacade | undefined): BaboonCodecContext { + return new BaboonCodecContext(useIndices, forwardWritePolicy, envelopeVersion, facade); } } @@ -1165,26 +1183,42 @@ export class BaboonTypeMeta { } /** - * Envelope for a UEBA payload written under `ctx`: `from(value)` with `domainVersionMinCompat` - * lowered to the prefix bound of the context's index mode when the writer policy is Tolerant. + * Envelope for a UEBA payload written under `ctx`. + * - V1 layout: `from(value)`, with `domainVersionMinCompat` lowered to the prefix bound of the + * context's index mode when the writer policy is Tolerant (the single slot must carry it). + * - V2 layout: both bounds travel — `domainVersionMinCompat` stays byte-identical and + * `domainVersionReadableMin` carries the prefix bound; the writer policy is irrelevant. */ public static forBin(value: BaboonGenerated, ctx: BaboonCodecContext, useAdtIdentifier: boolean = false): BaboonTypeMeta { const meta = BaboonTypeMeta.from(value, useAdtIdentifier); + const prefixBound = (): string => { + const tier = ctx.useIndices ? BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER : BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER; + const bound = value.baboonMinReaderVersions()[tier]; + if (bound === undefined) { + throw new BaboonException( + `baboonMinReaderVersions() lacks '${tier}' for type ${value.baboonTypeIdentifier()}`, + ); + } + return bound; + }; + if (ctx.envelopeVersion === BaboonEnvelopeVersion.V2) { + return new BaboonTypeMeta( + BaboonTypeMetaCodec.META_VERSION_2, + meta.domainIdentifier, + meta.domainVersion, + meta.domainVersionMinCompat, + meta.typeIdentifier, + prefixBound(), + ); + } if (ctx.forwardWritePolicy === ForwardWritePolicy.Strict) { return meta; } - const tier = ctx.useIndices ? BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER : BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER; - const bound = value.baboonMinReaderVersions()[tier]; - if (bound === undefined) { - throw new BaboonException( - `baboonMinReaderVersions() lacks '${tier}' for type ${value.baboonTypeIdentifier()}`, - ); - } return new BaboonTypeMeta( meta.metaVersion, meta.domainIdentifier, meta.domainVersion, - bound, + prefixBound(), meta.typeIdentifier, meta.domainVersionReadableMin, ); @@ -1218,8 +1252,15 @@ export class BaboonTypeMeta { export class BaboonTypeMetaCodec { public static readonly META_VERSION_1: number = 1; + public static readonly META_VERSION_2: number = 2; + /** Layout written by default (binary) and always (JSON `$mv`). */ public static readonly META_VERSION: number = BaboonTypeMetaCodec.META_VERSION_1; + /** v2 flags byte (codec-envelope.md §2.1.3): bit 0 — minCompat follows; bit 1 — readableMin follows. */ + private static readonly V2_FLAG_MIN_COMPAT = 0x01; + private static readonly V2_FLAG_READABLE_MIN = 0x02; + private static readonly V2_FLAGS_MASK = BaboonTypeMetaCodec.V2_FLAG_MIN_COMPAT | BaboonTypeMetaCodec.V2_FLAG_READABLE_MIN; + public static readonly META_VERSION_KEY = "$mv"; public static readonly DOMAIN_IDENTIFIER_KEY = "$d"; public static readonly DOMAIN_VERSION_KEY = "$v"; @@ -1228,7 +1269,15 @@ export class BaboonTypeMetaCodec { public static readonly TYPE_IDENTIFIER_KEY = "$t"; public static writeBin(meta: BaboonTypeMeta, writer: BaboonBinWriter): void { - BinTools.writeByte(writer, BaboonTypeMetaCodec.META_VERSION & 0xFF); + switch (meta.metaVersion) { + case BaboonTypeMetaCodec.META_VERSION_1: BaboonTypeMetaCodec.writeBinV1(meta, writer); return; + case BaboonTypeMetaCodec.META_VERSION_2: BaboonTypeMetaCodec.writeBinV2(meta, writer); return; + default: throw new BaboonException(`Unsupported binary envelope metaVersion ${meta.metaVersion}`); + } + } + + private static writeBinV1(meta: BaboonTypeMeta, writer: BaboonBinWriter): void { + BinTools.writeByte(writer, BaboonTypeMetaCodec.META_VERSION_1); BinTools.writeString(writer, meta.domainIdentifier); BinTools.writeString(writer, meta.domainVersion); if (meta.domainVersion === meta.domainVersionMinCompat) { @@ -1240,10 +1289,44 @@ export class BaboonTypeMetaCodec { BinTools.writeString(writer, meta.typeIdentifier); } + // v2: `02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId`; each bound is + // elided exactly as in JSON (minCompat when == domainVersion, readableMin when == effective minCompat) + private static writeBinV2(meta: BaboonTypeMeta, writer: BaboonBinWriter): void { + const minCompat = meta.domainVersionMinCompat || meta.domainVersion; + const readableMin = meta.domainVersionReadableMin || minCompat; + const hasMinCompat = minCompat !== meta.domainVersion; + const hasReadableMin = readableMin !== minCompat; + BinTools.writeByte(writer, BaboonTypeMetaCodec.META_VERSION_2); + BinTools.writeString(writer, meta.domainIdentifier); + BinTools.writeString(writer, meta.domainVersion); + BinTools.writeByte(writer, (hasMinCompat ? BaboonTypeMetaCodec.V2_FLAG_MIN_COMPAT : 0) | (hasReadableMin ? BaboonTypeMetaCodec.V2_FLAG_READABLE_MIN : 0)); + if (hasMinCompat) BinTools.writeString(writer, minCompat); + if (hasReadableMin) BinTools.writeString(writer, readableMin); + BinTools.writeString(writer, meta.typeIdentifier); + } + public static readBin(reader: BaboonBinReader): BaboonTypeMeta | undefined { const metaVersion = BinTools.readByte(reader); - if (metaVersion !== BaboonTypeMetaCodec.META_VERSION_1) return undefined; + switch (metaVersion) { + case BaboonTypeMetaCodec.META_VERSION_1: return BaboonTypeMetaCodec.readBinV1(reader); + case BaboonTypeMetaCodec.META_VERSION_2: return BaboonTypeMetaCodec.readBinV2(reader); + default: return undefined; + } + } + + private static readBinV2(reader: BaboonBinReader): BaboonTypeMeta | undefined { + const domainIdentifier = BinTools.readString(reader); + const domainVersion = BinTools.readString(reader); + const flags = BinTools.readByte(reader); + // unknown flag bits are illegal; a lenient reader would misparse the strings that follow + if ((flags & ~BaboonTypeMetaCodec.V2_FLAGS_MASK) !== 0) return undefined; + const minCompat = (flags & BaboonTypeMetaCodec.V2_FLAG_MIN_COMPAT) !== 0 ? BinTools.readString(reader) : domainVersion; + const readableMin = (flags & BaboonTypeMetaCodec.V2_FLAG_READABLE_MIN) !== 0 ? BinTools.readString(reader) : minCompat; + const typeIdentifier = BinTools.readString(reader); + return new BaboonTypeMeta(BaboonTypeMetaCodec.META_VERSION_2, domainIdentifier, domainVersion, minCompat, typeIdentifier, readableMin); + } + private static readBinV1(reader: BaboonBinReader): BaboonTypeMeta | undefined { const domainIdentifier = BinTools.readString(reader); const domainVersion = BinTools.readString(reader); const hasMinCompat = BinTools.readByte(reader); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala index 950c9f8ce..e27c6f4a1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala @@ -394,6 +394,7 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( F.pure( List( rt("BaboonRuntime/baboon_runtime.swift", "baboon-runtime/swift/baboon_runtime.swift"), + rt("BaboonRuntime/baboon_type_meta.swift", "baboon-runtime/swift/baboon_type_meta.swift"), rt("BaboonRuntime/baboon_service_wiring.swift", "baboon-runtime/swift/baboon_service_wiring.swift"), rt("BaboonRuntime/BaboonAnyOpaque.swift", "baboon-runtime/swift/BaboonAnyOpaque.swift"), rt("BaboonRuntime/BaboonCodecsFacade.swift", "baboon-runtime/swift/BaboonCodecsFacade.swift"), diff --git a/docs/forward-compat.md b/docs/forward-compat.md index c03e9abc6..70421a9a6 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -122,15 +122,36 @@ Readability is monotone along the chain (a suffix of a prefix chain is a prefix chain), so the newest codec is always correct and loses the fewest fields. This rule is the same for JSON `$uv`/`$rv` resolution. -What the policy costs, and why it is opt-in on the writer: a binary reader +What the v1 policy costs, and why it is opt-in on the writer: a v1 reader cannot distinguish a Tolerant envelope from a byte-identical one. -`ForwardReadPolicy.Lossless` has no effect on binary reads, and a re-encoding +`ForwardReadPolicy.Lossless` has no effect on v1 reads, and a re-encoding intermediary older than the writer silently truncates the value. The prefix client contract is satisfied structurally by the byte-array decode entry points (the payload is the last element of the envelope; nothing asserts full consumption); stream-based callers must discard the stream after a forward decode. +### Envelope v2 — the JSON-equivalent binary layout + +`BaboonCodecContext.envelopeVersion = V2` (default `V1`) switches the writer to +the `metaVersion = 2` layout (`docs/spec/codec-envelope.md` §2.1.3): +`02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId | +payload`. It carries both bounds with fixed meaning — `minCompat` is the +byte-identical bound, `readableMin` the prefix bound for the payload's index +mode — behind a flags byte (bit 0: minCompat present, bit 1: readableMin +present; elision rules as in JSON). Consequences: + +- `ForwardWritePolicy` is irrelevant under v2; both bounds always travel. +- The reader's `ForwardReadPolicy` applies to binary exactly as to JSON: + `Tolerant` decodes with the newest codec once `readableMin` reaches a + registered version, `Lossless` requires `minCompat` to — so a re-encoding + intermediary can protect itself, which v1 never allowed. +- Every reader in the nine runtimes accepts v1 and v2. A reader built before + v2 existed rejects a v2 envelope as an unknown `metaVersion`, which is why the + default stays v1: switch the writer only once the readers are current. +- Types with no prefix relationship produce a v2 envelope that is the v1 + envelope with `02` in front; the flag byte values coincide bit-for-bit. + ## Worked examples: what changed on the wire Everything below was produced by the generated Scala stub over the shared @@ -141,9 +162,10 @@ same bytes and keys. | knob | where | values | affects | |---|---|---|---| -| `ForwardWritePolicy` | writer's `BaboonCodecContext` (next to the index mode) | `Strict` (default), `Tolerant` | the value written into the UEBA `domainVersionMinCompat` slot. No effect on JSON. | -| index mode | writer's `BaboonCodecContext` | compact, indexed | which prefix tier a `Tolerant` writer consults: `prefix-compact` or `prefix-any-mode` | -| `ForwardReadPolicy` | reader's facade | `Tolerant` (default), `Lossless` | whether a JSON reader honours `$rv`. No effect on UEBA — the binary envelope carries one bound and the reader cannot tell how it was chosen. | +| `envelopeVersion` | writer's `BaboonCodecContext` | `V1` (default), `V2` | which binary layout is written: v1 (one bound slot) or v2 (both bounds, JSON-equivalent). No effect on JSON. | +| `ForwardWritePolicy` | writer's `BaboonCodecContext` (next to the index mode) | `Strict` (default), `Tolerant` | the value written into the v1 `domainVersionMinCompat` slot. No effect on JSON or on v2. | +| index mode | writer's `BaboonCodecContext` | compact, indexed | which prefix tier the writer consults for the prefix bound: `prefix-compact` or `prefix-any-mode` | +| `ForwardReadPolicy` | reader's facade | `Tolerant` (default), `Lossless` | whether a reader honours `readableMin` (JSON `$rv`, binary v2). No effect on binary v1 — that envelope carries one bound and the reader cannot tell how it was chosen. | One reader rule changed for both formats: when the envelope's `$v` / `domainVersion` is newer than every registered version and the applicable @@ -214,6 +236,16 @@ HDR | 00 | 19 "fwde2e.fwd/:#FwdAppendVar" Tolerant, indexed 1.0.0 reader → refused (byte-identical to Strict, indexed: the prefix-any-mode bound is 2.0.0, so hasMinCompat stays 0) + +envelope v2, compact 1.0.0 reader, Tolerant → FwdAppendVar(42, "hi") +02 | 0A "fwde2e.fwd" | 05 "2.0.0" 1.0.0 reader, Lossless → refused + | 02 flags: minCompat elided (= 2.0.0), readableMin present + | 05 "1.0.0" readableMin = prefix-compact bound + | 19 "fwde2e.fwd/:#FwdAppendVar" + | 00 2A 00 00 00 02 "hi" 01 01 "t" + +envelope v2, indexed 1.0.0 reader → refused (either policy) +02 | 0A "fwde2e.fwd" | 05 "2.0.0" | 00 | 19 "fwde2e.fwd/:#FwdAppendVar" | 01 | …index… flags 0: prefix-any-mode bound is 2.0.0 ``` The indexed layout is why the tier stops at `prefix-compact`: the index has no @@ -282,6 +314,10 @@ HDR | 01 05 "1.0.0" | 16 "fwde2e.fwd/:#FwdStable" Strict, indexed = Tolerant, indexed 1.0.0 reader → FwdStable("s") HDR | 01 05 "1.0.0" | 16 "fwde2e.fwd/:#FwdStable" | 01 | 00 00 00 00 02 00 00 00 | 01 "s" + +envelope v2, compact 1.0.0 reader → FwdStable("s") under either policy +02 | 0A "fwde2e.fwd" | 05 "2.0.0" | 01 05 "1.0.0" | 16 "fwde2e.fwd/:#FwdStable" | 00 01 "s" + flags 0b01: minCompat 1.0.0; readableMin equal, elided ``` The bound is already 1.0.0 under `Strict`; `Tolerant` has nothing to lower. @@ -351,6 +387,12 @@ Tolerant, compact 1.0.0+2.0.0 reader → 01 | 0C "fwde2e.chain" | 05 "3.0.0" | 01 05 "1.0.0" | 1A "fwde2e.chain/:#ChainAppend" | 00 01 00 00 00 01 01 "b" 01 01 "c" 1.0.0 reader → ChainAppend(1) before this change, 1.0.0+2.0.0 reader → ChainAppend(1) + +envelope v2, compact 1.0.0+2.0.0 reader, Tolerant → ChainAppend(1, Some("b")) +02 | 0C "fwde2e.chain" | 05 "3.0.0" 1.0.0+2.0.0 reader, Lossless → refused (minCompat 3.0.0) + | 02 | 05 "1.0.0" flags 0b10: minCompat elided (= 3.0.0), readableMin 1.0.0 + | 1A "fwde2e.chain/:#ChainAppend" + | 00 01 00 00 00 01 01 "b" 01 01 "c" ``` ### Summary @@ -363,8 +405,10 @@ combination byte-for-byte as `Strict` writes it. |---|---|---|---|---| | JSON | n/a | `Tolerant` (default) | `$rv`, falling back to `$uv` | decodes whenever the type is json-additive-readable; unknown keys dropped | | JSON | n/a | `Lossless` | `$uv` only | decodes only byte-identical payloads | -| UEBA | `Strict` (default) | either | `minCompat` = identical bound | decodes only byte-identical payloads — unchanged behaviour | -| UEBA | `Tolerant` | either | `minCompat` = prefix bound for the payload's mode | decodes prefix-readable payloads with its newest codec; trailing appended fields unread; indistinguishable from a byte-identical read | +| UEBA v1 (default) | `Strict` (default) | either | `minCompat` = identical bound | decodes only byte-identical payloads — unchanged behaviour | +| UEBA v1 (default) | `Tolerant` | either | `minCompat` = prefix bound for the payload's mode | decodes prefix-readable payloads with its newest codec; trailing appended fields unread; indistinguishable from a byte-identical read | +| UEBA v2 | irrelevant | `Tolerant` (default) | `readableMin` (prefix bound), falling back to `minCompat` | decodes prefix-readable payloads with its newest codec; trailing appended fields unread | +| UEBA v2 | irrelevant | `Lossless` | `minCompat` only | decodes only byte-identical payloads | ## Relationship to sameIn @@ -407,6 +451,14 @@ Two consequences of scheme 2: `$rv`: a facade registering only 1.0.0 decodes 2.0.0 envelopes under `Tolerant` exactly where `$rv` allows, refuses under `Lossless`, and refuses when no bound was published; `$rv` elision and `readMeta` round-trip. +- `test/sc-stub/.../BinEnvelopeV2Spec.scala` and + `test/ts-stub/.../BinEnvelopeV2.test.ts` — envelope v2: a hand-assembled v2 + envelope decodes under `Tolerant` and is refused under `Lossless` (fail-first: + the previous reader rejected metaVersion 2 outright); the default context + still writes byte-identical v1; the v2 writer's flags/bounds per type and + index mode; round trips through the writer and old readers; the + three-version chain with `Lossless` now enforceable; unknown flag bits and + unknown metaVersions rejected. - `test/sc-stub/.../ForwardCompatBinEnvelopeSpec.scala` and `test/ts-stub/.../ForwardCompatBinEnvelope.test.ts` — the UEBA writer policy: Strict envelopes are unchanged and refused by an old reader; Tolerant compact diff --git a/docs/spec/codec-envelope.md b/docs/spec/codec-envelope.md index b77bfc970..9b507114c 100644 --- a/docs/spec/codec-envelope.md +++ b/docs/spec/codec-envelope.md @@ -24,26 +24,30 @@ Field summary: | Field | Type | Purpose | |--------------------------|---------|------------------------------------------------------| -| `metaVersion` | u8 | Format version of the envelope itself. See § 3. | +| `metaVersion` | u8 | Format version of the envelope itself: `1` (single-bound binary layout, the default; always `1` in JSON) or `2` (binary layout carrying both bounds). See § 3. | | `domainIdentifier` | string | Dotted namespace of the domain, e.g. `"my.ok"`. | | `domainVersion` | string | Semver of the encoded value's domain, e.g. `"1.0.0"`.| | `domainVersionMinCompat` | string | Oldest domain version whose codec decodes this payload. Under the writer's default `ForwardWritePolicy.Strict` this is the byte-identical bound (equals `domainVersion` when the type's layout is fresh in this version). Under `Tolerant` — binary only — it is the prefix-read bound for the payload's index mode; see § 2.1.2. | -| `domainVersionReadableMin` | string | **JSON only.** Oldest domain version whose JSON codec can decode the payload under the `json-additive` contract (tolerant key lookup; fields unknown to that version are dropped). Always `<= domainVersionMinCompat`. See `docs/forward-compat.md`. | +| `domainVersionReadableMin` | string | **JSON and binary v2.** Oldest domain version whose codec can decode the payload under the format's forward-read contract: `json-additive` for JSON (tolerant key lookup; unknown fields dropped), the prefix tier of the payload's index mode for binary v2 (`prefix-compact` / `prefix-any-mode`; trailing appended fields unread). Always `<= domainVersionMinCompat`. Absent from binary v1. See `docs/forward-compat.md`. | | `typeIdentifier` | string | Type id within the domain, e.g. `"my.ok/:#Holder"`. | `domainVersionMinCompat` may be elided in either encoding when it equals `domainVersion` (the common case for newly-introduced types). Readers must default a missing `domainVersionMinCompat` to `domainVersion`. -`domainVersionReadableMin` is carried by the JSON envelope only (`$rv`) and is -elided when it equals the effective `domainVersionMinCompat`; readers default -it to `domainVersionMinCompat`. Invariant: `$rv <= $uv <= $v`. The binary v1 -envelope has no slot for it: the only bound a binary reader sees is +`domainVersionReadableMin` is carried by the JSON envelope (`$rv`) and by the +binary v2 envelope (§ 2.1.3), elided when it equals the effective +`domainVersionMinCompat`; readers default it to `domainVersionMinCompat`. +Invariant: `readableMin <= minCompat <= domainVersion`. The binary v1 envelope +has no slot for it: the only bound a v1 reader sees is `domainVersionMinCompat`, whose meaning is chosen by the WRITER -(`ForwardWritePolicy`, § 2.1.2). Whether a JSON reader honours `$rv` is a +(`ForwardWritePolicy`, § 2.1.2). Whether a reader honours `readableMin` is a reader-side policy (`ForwardReadPolicy`: `Tolerant`, the default, decodes -newer payloads with the `$rv` version's codec and drops unknown fields; -`Lossless` ignores `$rv`). Re-encoding intermediaries must use `Lossless`. +newer payloads with the reader's newest codec whenever `readableMin` reaches a +registered version; `Lossless` ignores it and requires `minCompat` to). It +applies to JSON and to binary v2 alike; a v1 envelope carries no separate +`readableMin`, so the policy has no effect on it. Re-encoding intermediaries +must use `Lossless`. ## 2. Wire formats @@ -121,6 +125,41 @@ Consequences the deploying organisation owns: binary; re-encoding intermediaries must run at the writer's version or newer. +#### 2.1.3 Binary v2 — both bounds + +Selected by the writer's `BaboonCodecContext.envelopeVersion = V2` (default +`V1`). The layout is the JSON envelope's field set in binary form: + +``` ++--------+-------------------+---------------+----------+--------------------+--------------------+----------------+ +| 1 byte | length-prefixed | length- | 1 byte | length-prefixed | length-prefixed | length- | +| u8 = 2 | UTF-8 string | prefixed UTF-8| flags | UTF-8 string | UTF-8 string | prefixed UTF-8 | +| metaVer| domainIdentifier | domainVersion | | (only if bit 0) | (only if bit 1) | typeIdentifier | +| | | | | minCompat | readableMin | | ++--------+-------------------+---------------+----------+--------------------+--------------------+----------------+ +``` + +The `flags` byte: +- bit 0 (`0x01`) — `domainVersionMinCompat` follows. Elided when it equals + `domainVersion`. +- bit 1 (`0x02`) — `domainVersionReadableMin` follows. Elided when it equals + the effective `domainVersionMinCompat`. +- any other bit set — illegal; readers reject the envelope. + +Semantics are fixed, not policy-dependent: `minCompat` is always the +byte-identical bound and `readableMin` the prefix bound for the payload's +index mode (`prefix-compact` for compact payloads, `prefix-any-mode` for +indexed ones). `ForwardWritePolicy` is irrelevant under v2 — both bounds +travel — and the reader's `ForwardReadPolicy` decides, exactly as for JSON, +whether `readableMin` may be used: `Tolerant` decodes with the reader's +newest codec once `readableMin` reaches a registered version, `Lossless` +requires `minCompat` to. Envelopes whose type has no prefix relationship +carry `flags` bit 1 clear and are the v1 envelope with `02` in front and the +`hasMinCompat` byte reinterpreted as `flags` (the two agree bit-for-bit). + +Readers accept v1 and v2 (§ 5); a v1-only reader rejects v2 as an unknown +`metaVersion`, so v2 is for deployments whose readers are known to be current. + ### 2.2 JSON The envelope is a JSON object with the keys below. A reader does not assume @@ -154,8 +193,9 @@ idiom; never throw across the version boundary). | Byte | Status | Meaning | |---------|-------------|-----------------------------------------------------------------------------| | 0 | reserved | Sentinel; never emitted. Reserved to remain available for future use. | -| 1 | **active** | Full meta — all four fields described in § 1, layout per § 2. | -| 2..15 | reserved | Free for future allocation if a new top-level layout is introduced. | +| 1 | **active** | Single-bound binary layout (§ 2.1); the JSON envelope's `$mv`. Written by default. | +| 2 | **active** | Two-bound binary layout (§ 2.1.3). Written when the context selects `V2`. | +| 3..15 | reserved | Free for future allocation if a new top-level layout is introduced. | | 16 | retired | Briefly used during M32 development. Never released; reader rejection-only. | | 17..255 | reserved | Free for future allocation. | @@ -219,9 +259,11 @@ across cs/sc/py/rs/ts/jv/dt/sw. See MFACADE-PR-3-D04..D06 in `BaboonTypeMetaCodec.readMeta(reader|jsonValue)` returns the envelope parsed from the bin reader or JSON value: -- **Success** — a fully-populated `BaboonTypeMeta` value with all four - fields present (`domainVersionMinCompat` defaulted to `domainVersion` - when elided). +- **Success** — a fully-populated `BaboonTypeMeta` value with all fields + present (`domainVersionMinCompat` defaulted to `domainVersion` when elided, + `domainVersionReadableMin` defaulted to the effective `domainVersionMinCompat` + when elided or absent from the layout). `metaVersion` records the layout + that was read (`1` or `2`); binary readers accept both. - **Recoverable failure** (return null/None/Right-of-error per language idiom): unrecognised `metaVersion`, malformed envelope (missing required fields, wrong types, bin-side ULEB128 truncation). @@ -237,10 +279,11 @@ caller fall through. ## 6. Encoder contract -`BaboonTypeMetaCodec.writeBin(meta, writer)` and `writeJson(meta)` always -emit a `metaVersion=1` envelope per § 2. Writers do not have a fallback -to byte 16 or any other allocation; new versions get explicit codepaths -when introduced. +`BaboonTypeMetaCodec.writeJson(meta)` always emits `$mv = 1`. +`writeBin(meta, writer)` emits the layout named by `meta.metaVersion` — `1` +(§ 2.1) or `2` (§ 2.1.3) — and fails fast on any other value. The facade's +`encodeToBin` picks the layout from `BaboonCodecContext.envelopeVersion` +(default `V1`). Writers have no fallback to byte 16 or any other allocation. The encoder always writes the four required fields (`metaVersion`, `domainIdentifier`, `domainVersion`, `typeIdentifier`); it elides @@ -262,7 +305,7 @@ Per-backend runtime sources (where the envelope codec lives): - TypeScript: `baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts` - Python: `baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py` - Dart: `baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart` -- Swift: `baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift` +- Swift: `baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift` (same module as `baboon_runtime.swift`) All ten implementations share the field set and layout in § 1–§ 2; divergences are interop bugs. diff --git a/test/sc-stub/src/test/scala/runtime/BinEnvelopeV2Spec.scala b/test/sc-stub/src/test/scala/runtime/BinEnvelopeV2Spec.scala new file mode 100644 index 000000000..549550fe6 --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/BinEnvelopeV2Spec.scala @@ -0,0 +1,150 @@ +// NOTE: This test references generated symbols (fwde2e.fwd.*, fwde2e.chain.*) which are +// copied into this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen into +// target/test-regular/sc-stub/). Run from the codegen'd copy. +// +// Binary envelope metaVersion 2 (docs/spec/codec-envelope.md §2.1.3): the JSON-equivalent +// layout carrying BOTH bounds — `domainVersionMinCompat` (byte-identical) and +// `domainVersionReadableMin` (prefix bound for the payload's index mode) — behind a flags byte. +// Writers stay on v1 unless the context selects v2; readers accept both. +package runtime + +import baboon.runtime.shared._ +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} + +class BinEnvelopeV2Spec extends AnyFunSuite { + + // a reader deployed before fwde2e.fwd 2.0.0 existed: knows 1.0.0 only + private def oldFwdReader(policy: ForwardReadPolicy): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register( + BaboonDomainVersion("fwde2e.fwd", "1.0.0"), + fwde2e.fwd.v1_0_0.BaboonCodecsJson, + fwde2e.fwd.v1_0_0.BaboonCodecsUeba, + fwde2e.fwd.v1_0_0.BaboonMetadata, + ) + f.forwardReadPolicy = policy + f + } + + private def bytesOf(write: LEDataOutputStream => Unit): Array[Byte] = { + val baos = new ByteArrayOutputStream() + val dos = new LEDataOutputStream(baos) + write(dos) + baos.toByteArray + } + + private def readMeta(bytes: Array[Byte]): Option[BaboonTypeMeta] = + BaboonTypeMeta.readMeta(new LEDataInputStream(new ByteArrayInputStream(bytes))) + + // Hand-assembled v2 envelope: 02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId | payload + private def v2Envelope(flags: Int, minCompat: Option[String], readableMin: Option[String], typeId: String, payload: Array[Byte]): Array[Byte] = + bytesOf { dos => + dos.write(2) + BaboonBinTools.writeString(dos, "fwde2e.fwd") + BaboonBinTools.writeString(dos, "2.0.0") + dos.write(flags) + minCompat.foreach(v => BaboonBinTools.writeString(dos, v)) + readableMin.foreach(v => BaboonBinTools.writeString(dos, v)) + BaboonBinTools.writeString(dos, typeId) + dos.write(payload) + } + + private val appended = fwde2e.fwd.FwdAppendVar(42, "hi", Some("t")) + private val appendedPayload = bytesOf(dos => fwde2e.fwd.FwdAppendVar_UEBACodec.instance.encode(BaboonCodecContext.Compact, dos, appended)) + + private val fwdWriter: BaboonCodecsFacade = fwde2e.fwd.DomainFwde2eFwdFacade + private val chainWriter: BaboonCodecsFacade = fwde2e.chain.DomainFwde2eChainFacade + private val v2Compact: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, None) + private val v2Indexed: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, None) + + private def midChainReader(policy: ForwardReadPolicy): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register(BaboonDomainVersion("fwde2e.chain", "1.0.0"), fwde2e.chain.v1_0_0.BaboonCodecsJson, fwde2e.chain.v1_0_0.BaboonCodecsUeba, fwde2e.chain.v1_0_0.BaboonMetadata) + f.register(BaboonDomainVersion("fwde2e.chain", "2.0.0"), fwde2e.chain.v2_0_0.BaboonCodecsJson, fwde2e.chain.v2_0_0.BaboonCodecsUeba, fwde2e.chain.v2_0_0.BaboonMetadata) + f.forwardReadPolicy = policy + f + } + + // the header shared by every fwde2e.fwd 2.0.0 envelope, then the layout-specific bytes + private def fwdHead(metaVersion: Int): Array[Byte] = bytesOf { dos => + dos.write(metaVersion); BaboonBinTools.writeString(dos, "fwde2e.fwd"); BaboonBinTools.writeString(dos, "2.0.0") + } + private def str(s: String): Array[Byte] = bytesOf(dos => BaboonBinTools.writeString(dos, s)) + + test("the default context still writes v1: envelopes are byte-identical to before") { + assert(BaboonCodecContext.Compact.envelopeVersion == BaboonEnvelopeVersion.V1) + assert(BaboonCodecContext.Indexed.envelopeVersion == BaboonEnvelopeVersion.V1) + val env = fwdWriter.encodeToBin(BaboonCodecContext.Compact, appended).toTry.get + assert(env.head == 1.toByte) + assert(env.sameElements(fwdHead(1) ++ Array[Byte](0) ++ str(fwde2e.fwd.FwdAppendVar.baboonTypeIdentifier) ++ appendedPayload)) + } + + test("v2 writer publishes both bounds with JSON elision rules, per type") { + // appended var-len field: identical bound elided (= 2.0.0), prefix-compact bound 1.0.0 -> flags 0b10 + val app = fwdWriter.encodeToBin(v2Compact, appended).toTry.get + assert(app.sameElements(fwdHead(2) ++ Array[Byte](0x02) ++ str("1.0.0") ++ str(fwde2e.fwd.FwdAppendVar.baboonTypeIdentifier) ++ appendedPayload)) + val appMeta = readMeta(app).get + assert(appMeta.metaVersion == 2.toByte && appMeta.domainVersionMinCompat == "2.0.0" && appMeta.domainVersionReadableMin == "1.0.0") + + // indexed: prefix-any-mode bound is 2.0.0 -> nothing to publish, flags 0 + val appIdx = fwdWriter.encodeToBin(v2Indexed, appended).toTry.get + assert(appIdx.startsWith(fwdHead(2) ++ Array[Byte](0x00) ++ str(fwde2e.fwd.FwdAppendVar.baboonTypeIdentifier))) + + // byte-identical since 1.0.0: minCompat 1.0.0, readableMin equal to it and elided -> flags 0b01 + val stable = fwdWriter.encodeToBin(v2Compact, fwde2e.fwd.FwdStable("s")).toTry.get + assert(stable.startsWith(fwdHead(2) ++ Array[Byte](0x01) ++ str("1.0.0") ++ str(fwde2e.fwd.FwdStable.baboonTypeIdentifier))) + + // json-additive only / not forward-readable: no bound at all -> flags 0 + val mid = fwdWriter.encodeToBin(v2Compact, fwde2e.fwd.FwdMidInsert(7, Some(99), "z")).toTry.get + val enum = fwdWriter.encodeToBin(v2Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + assert(mid.startsWith(fwdHead(2) ++ Array[Byte](0x00) ++ str(fwde2e.fwd.FwdMidInsert.baboonTypeIdentifier))) + assert(enum.startsWith(fwdHead(2) ++ Array[Byte](0x00) ++ str(fwde2e.fwd.FwdEnumHost.baboonTypeIdentifier))) + + // under v2 the writer policy is irrelevant: both bounds travel anyway + val v2Tolerant = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V2, None) + assert(fwdWriter.encodeToBin(v2Tolerant, appended).toTry.get.sameElements(app)) + } + + test("v2 round-trips through the writer's own facade and through old readers under each policy") { + val app = fwdWriter.encodeToBin(v2Compact, appended).toTry.get + assert(fwdWriter.decodeFromBin(app).toTry.get == appended) + assert(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(app).toTry.get == fwde2e.fwd.v1_0_0.FwdAppendVar(42, "hi")) + assert(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(app).isLeft) + + val stable = fwdWriter.encodeToBin(v2Compact, fwde2e.fwd.FwdStable("s")).toTry.get + assert(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(stable).toTry.get == fwde2e.fwd.v1_0_0.FwdStable("s")) + assert(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(stable).toTry.get == fwde2e.fwd.v1_0_0.FwdStable("s")) + + val enum = fwdWriter.encodeToBin(v2Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + assert(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(enum).isLeft) + } + + test("three-version chain in v2: Lossless is enforceable for binary, Tolerant decodes with the newest codec") { + val value = fwde2e.chain.ChainAppend(1, Some("b"), Some("c")) + val env = chainWriter.encodeToBin(v2Compact, value).toTry.get + val meta = readMeta(env).get + assert(meta.domainVersionMinCompat == "3.0.0" && meta.domainVersionReadableMin == "1.0.0") + assert(midChainReader(ForwardReadPolicy.Tolerant).decodeFromBin(env).toTry.get == fwde2e.chain.v2_0_0.ChainAppend(1, Some("b"))) + assert(midChainReader(ForwardReadPolicy.Lossless).decodeFromBin(env).isLeft, "v2 carries the identical bound separately, so Lossless can refuse") + } + + test("v2 readMeta rejects unknown flag bits; unknown metaVersions stay rejected") { + assert(readMeta(v2Envelope(0x04, None, None, "T", Array.empty)).isEmpty) + assert(readMeta(v2Envelope(0x03, Some("1.5.0"), Some("1.0.0"), "T", Array.empty)).map(m => (m.domainVersionMinCompat, m.domainVersionReadableMin)).contains(("1.5.0", "1.0.0"))) + assert(readMeta(bytesOf(dos => { dos.write(3); BaboonBinTools.writeString(dos, "d"); BaboonBinTools.writeString(dos, "1.0.0") })).isEmpty) + } + + test("reader accepts a v2 envelope: readable-min lets a Tolerant old reader decode, Lossless refuses") { + // identical bound elided (= 2.0.0), prefix-compact bound 1.0.0 present: flags = 0b10 + val env = v2Envelope(0x02, None, Some("1.0.0"), fwde2e.fwd.FwdAppendVar.baboonTypeIdentifier, appendedPayload) + val meta = readMeta(env).getOrElse(fail("v2 envelope must be readable")) + assert(meta.metaVersion == 2.toByte) + assert(meta.domainVersionMinCompat == "2.0.0") + assert(meta.domainVersionReadableMin == "1.0.0") + + assert(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(env).toTry.get == fwde2e.fwd.v1_0_0.FwdAppendVar(42, "hi")) + assert(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(env).isLeft, "Lossless must not honour the readable-min bound") + } +} diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala index eec01e9d3..8816e8c66 100644 --- a/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatBinEnvelopeSpec.scala @@ -17,8 +17,8 @@ import java.io.ByteArrayInputStream class ForwardCompatBinEnvelopeSpec extends AnyFunSuite { - private val tolerantCompact: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Tolerant, None) - private val tolerantIndexed: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = true, ForwardWritePolicy.Tolerant, None) + private val tolerantCompact: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, None) + private val tolerantIndexed: BaboonCodecContext = BaboonCodecContext.Custom(useIndices = true, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, None) private def minCompatOf(bytes: Array[Byte]): String = BaboonTypeMeta.readMeta(new LEDataInputStream(new ByteArrayInputStream(bytes))).get.domainVersionMinCompat diff --git a/test/ts-stub/src/runtime-tests/BinEnvelopeV2.test.ts b/test/ts-stub/src/runtime-tests/BinEnvelopeV2.test.ts new file mode 100644 index 000000000..37ddb4f48 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/BinEnvelopeV2.test.ts @@ -0,0 +1,182 @@ +// NOTE: This test references generated symbols (src/generated/fwde2e/**) which are copied into +// this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen into +// target/test-regular/ts-stub/). Run from the codegen'd copy. +// +// Binary envelope metaVersion 2 (docs/spec/codec-envelope.md §2.1.3): the JSON-equivalent layout +// carrying BOTH bounds — `domainVersionMinCompat` (byte-identical) and `domainVersionReadableMin` +// (prefix bound for the payload's index mode) — behind a flags byte. Writers stay on v1 unless the +// context selects v2; readers accept both. +import { describe, expect, test } from 'vitest'; +import { + AbstractBaboonConversions, + AbstractBaboonJsonCodecs, + AbstractBaboonUebaCodecs, + BaboonBinCodec, + BaboonBinReader, + BaboonBinWriter, + BaboonCodecContext, + BaboonDomainVersion, + BaboonEither, + BaboonEnvelopeVersion, + BaboonGenerated, + BaboonJsonCodec, + BaboonMeta, + BaboonTypeMeta, + BinTools, + ForwardWritePolicy, + Lazy, +} from '../generated/BaboonSharedRuntime'; +import { BaboonCodecsFacade, ForwardReadPolicy } from '../generated/BaboonCodecsFacade'; +import { DomainFwde2eFwdFacade } from '../generated/fwde2e/fwd/DomainFwde2eFwdFacade'; +import { DomainFwde2eChainFacade } from '../generated/fwde2e/chain/DomainFwde2eChainFacade'; + +import { FwdAppendVar, FwdEnumHost, FwdMidInsert, FwdStable } from '../generated/fwde2e/fwd/index'; +import { FwdAppendVar_UEBACodec } from '../generated/fwde2e/fwd/FwdAppendVar'; +import { FwdEnumGrows } from '../generated/fwde2e/fwd/FwdEnumGrows'; +import { FwdAppendVar as FwdAppendVarV1, FwdAppendVar_JsonCodec as FwdAppendVarV1_JsonCodec, FwdAppendVar_UEBACodec as FwdAppendVarV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdAppendVar'; +import { FwdMidInsert as FwdMidInsertV1, FwdMidInsert_JsonCodec as FwdMidInsertV1_JsonCodec, FwdMidInsert_UEBACodec as FwdMidInsertV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdMidInsert'; +import { FwdStable as FwdStableV1, FwdStable_JsonCodec as FwdStableV1_JsonCodec, FwdStable_UEBACodec as FwdStableV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdStable'; +import { FwdEnumHost as FwdEnumHostV1, FwdEnumHost_JsonCodec as FwdEnumHostV1_JsonCodec, FwdEnumHost_UEBACodec as FwdEnumHostV1_UEBACodec } from '../generated/fwde2e/fwd/v1_0_0/FwdEnumHost'; +import { ChainAppend } from '../generated/fwde2e/chain/ChainAppend'; +import { ChainAppend as ChainAppendV1, ChainAppend_JsonCodec as ChainAppendV1_JsonCodec, ChainAppend_UEBACodec as ChainAppendV1_UEBACodec } from '../generated/fwde2e/chain/v1_0_0/ChainAppend'; +import { ChainAppend as ChainAppendV2, ChainAppend_JsonCodec as ChainAppendV2_JsonCodec, ChainAppend_UEBACodec as ChainAppendV2_UEBACodec } from '../generated/fwde2e/chain/v2_0_0/ChainAppend'; + +type Registration = { typeId: string; json: BaboonJsonCodec; ueba: BaboonBinCodec }; +function versionModules(domain: string, version: string, regs: Registration[]) { + class JsonCodecs extends AbstractBaboonJsonCodecs { + constructor() { super(); for (const r of regs) this.register(r.typeId, new Lazy>(() => r.json)); } + } + class UebaCodecs extends AbstractBaboonUebaCodecs { + constructor() { super(); for (const r of regs) this.register(r.typeId, new Lazy>(() => r.ueba)); } + } + class Conversions implements AbstractBaboonConversions { + public versionsFrom(): string[] { return []; } + public versionTo(): string { return version; } + } + class Meta implements BaboonMeta { + public sameInVersions(_typeId: string): string[] { return [version]; } + public forwardReadableVersions(_typeId: string): { readonly [version: string]: string } { return { [version]: 'identical' }; } + } + return (f: BaboonCodecsFacade) => + f.register(new BaboonDomainVersion(domain, version), () => new JsonCodecs(), () => new UebaCodecs(), () => new Conversions(), () => new Meta()); +} +const reg = (typeId: string, json: unknown, ueba: unknown): Registration => + ({ typeId, json: json as BaboonJsonCodec, ueba: ueba as BaboonBinCodec }); +const fwdV1 = versionModules('fwde2e.fwd', '1.0.0', [ + reg(FwdAppendVarV1.BaboonTypeIdentifier, FwdAppendVarV1_JsonCodec.instance, FwdAppendVarV1_UEBACodec.instance), + reg(FwdMidInsertV1.BaboonTypeIdentifier, FwdMidInsertV1_JsonCodec.instance, FwdMidInsertV1_UEBACodec.instance), + reg(FwdStableV1.BaboonTypeIdentifier, FwdStableV1_JsonCodec.instance, FwdStableV1_UEBACodec.instance), + reg(FwdEnumHostV1.BaboonTypeIdentifier, FwdEnumHostV1_JsonCodec.instance, FwdEnumHostV1_UEBACodec.instance), +]); +const chainV1 = versionModules('fwde2e.chain', '1.0.0', [reg(ChainAppendV1.BaboonTypeIdentifier, ChainAppendV1_JsonCodec.instance, ChainAppendV1_UEBACodec.instance)]); +const chainV2 = versionModules('fwde2e.chain', '2.0.0', [reg(ChainAppendV2.BaboonTypeIdentifier, ChainAppendV2_JsonCodec.instance, ChainAppendV2_UEBACodec.instance)]); + +function oldFwdReader(policy: ForwardReadPolicy): BaboonCodecsFacade { const f = new BaboonCodecsFacade(); fwdV1(f); f.forwardReadPolicy = policy; return f; } +function midChainReader(policy: ForwardReadPolicy): BaboonCodecsFacade { const f = new BaboonCodecsFacade(); chainV1(f); chainV2(f); f.forwardReadPolicy = policy; return f; } + +const fwdWriter = new DomainFwde2eFwdFacade(); +const chainWriter = new DomainFwde2eChainFacade(); +const v2Compact = BaboonCodecContext.custom(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, undefined); +const v2Indexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, undefined); + +function unwrap(e: BaboonEither): R { + if (e.tag !== 'Right') throw new Error(`expected Right, got Left: ${String((e as { value: unknown }).value)}`); + return e.value; +} +function bin(writer: BaboonCodecsFacade, ctx: BaboonCodecContext, value: BaboonGenerated): Uint8Array { return unwrap(writer.encodeToBin(ctx, value)); } +function readMeta(bytes: Uint8Array): BaboonTypeMeta | undefined { return BaboonTypeMeta.readMeta(new BaboonBinReader(bytes)); } +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} +function str(s: string): Uint8Array { const w = new BaboonBinWriter(); BinTools.writeString(w, s); return w.toBytes(); } +function fwdHead(metaVersion: number): Uint8Array { + const w = new BaboonBinWriter(); BinTools.writeByte(w, metaVersion); BinTools.writeString(w, 'fwde2e.fwd'); BinTools.writeString(w, '2.0.0'); return w.toBytes(); +} +const appended = new FwdAppendVar(42, 'hi', 't'); +const appendedPayload = (() => { const w = new BaboonBinWriter(); FwdAppendVar_UEBACodec.instance.encode(BaboonCodecContext.Compact, appended, w); return w.toBytes(); })(); + +// Hand-assembled v2 envelope: 02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId | payload +function v2Envelope(flags: number, minCompat: string | undefined, readableMin: string | undefined, typeId: string, payload: Uint8Array): Uint8Array { + const w = new BaboonBinWriter(); + BinTools.writeByte(w, 2); BinTools.writeString(w, 'fwde2e.fwd'); BinTools.writeString(w, '2.0.0'); BinTools.writeByte(w, flags); + if (minCompat !== undefined) BinTools.writeString(w, minCompat); + if (readableMin !== undefined) BinTools.writeString(w, readableMin); + BinTools.writeString(w, typeId); + return concat(w.toBytes(), payload); +} + +describe('binary envelope metaVersion 2', () => { + test('reader accepts a hand-assembled v2 envelope: readable-min lets a Tolerant old reader decode, Lossless refuses', () => { + const env = v2Envelope(0x02, undefined, '1.0.0', FwdAppendVar.BaboonTypeIdentifier, appendedPayload); + const meta = readMeta(env)!; + expect(meta.metaVersion).toBe(2); + expect(meta.domainVersionMinCompat).toBe('2.0.0'); + expect(meta.domainVersionReadableMin).toBe('1.0.0'); + const decoded = unwrap(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(env)) as FwdAppendVarV1; + expect(decoded).toBeInstanceOf(FwdAppendVarV1); + expect(decoded.a).toBe(42); + expect(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(env).tag).toBe('Left'); + }); + + test('the default context still writes v1: envelopes are byte-identical to before', () => { + expect(BaboonCodecContext.Compact.envelopeVersion).toBe(BaboonEnvelopeVersion.V1); + expect(BaboonCodecContext.Indexed.envelopeVersion).toBe(BaboonEnvelopeVersion.V1); + const env = bin(fwdWriter, BaboonCodecContext.Compact, appended); + expect(env).toEqual(concat(fwdHead(1), Uint8Array.of(0), str(FwdAppendVar.BaboonTypeIdentifier), appendedPayload)); + }); + + test('v2 writer publishes both bounds with JSON elision rules, per type', () => { + const app = bin(fwdWriter, v2Compact, appended); + expect(app).toEqual(concat(fwdHead(2), Uint8Array.of(0x02), str('1.0.0'), str(FwdAppendVar.BaboonTypeIdentifier), appendedPayload)); + const appMeta = readMeta(app)!; + expect([appMeta.metaVersion, appMeta.domainVersionMinCompat, appMeta.domainVersionReadableMin]).toEqual([2, '2.0.0', '1.0.0']); + + const appIdx = bin(fwdWriter, v2Indexed, appended); + const idxHead = concat(fwdHead(2), Uint8Array.of(0x00), str(FwdAppendVar.BaboonTypeIdentifier)); + expect(Array.from(appIdx.slice(0, idxHead.length))).toEqual(Array.from(idxHead)); + + const stable = bin(fwdWriter, v2Compact, new FwdStable('s')); + const stableHead = concat(fwdHead(2), Uint8Array.of(0x01), str('1.0.0'), str(FwdStable.BaboonTypeIdentifier)); + expect(Array.from(stable.slice(0, stableHead.length))).toEqual(Array.from(stableHead)); + + for (const [value, typeId] of [[new FwdMidInsert(7, 99, 'z'), FwdMidInsert.BaboonTypeIdentifier], [new FwdEnumHost(FwdEnumGrows.C), FwdEnumHost.BaboonTypeIdentifier]] as const) { + const env = bin(fwdWriter, v2Compact, value as BaboonGenerated); + const head = concat(fwdHead(2), Uint8Array.of(0x00), str(typeId)); + expect(Array.from(env.slice(0, head.length))).toEqual(Array.from(head)); + } + + const v2Tolerant = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V2, undefined); + expect(bin(fwdWriter, v2Tolerant, appended)).toEqual(app); + }); + + test('v2 round-trips through the writer and through old readers under each policy', () => { + const app = bin(fwdWriter, v2Compact, appended); + expect((unwrap(fwdWriter.decodeFromBin(app)) as FwdAppendVar).t).toBe('t'); + expect((unwrap(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(app)) as FwdAppendVarV1).b).toBe('hi'); + expect(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(app).tag).toBe('Left'); + + const stable = bin(fwdWriter, v2Compact, new FwdStable('s')); + expect((unwrap(oldFwdReader(ForwardReadPolicy.Lossless).decodeFromBin(stable)) as FwdStableV1).s).toBe('s'); + expect(oldFwdReader(ForwardReadPolicy.Tolerant).decodeFromBin(bin(fwdWriter, v2Compact, new FwdEnumHost(FwdEnumGrows.C))).tag).toBe('Left'); + }); + + test('three-version chain in v2: Lossless is enforceable for binary, Tolerant decodes with the newest codec', () => { + const env = bin(chainWriter, v2Compact, new ChainAppend(1, 'b', 'c')); + const meta = readMeta(env)!; + expect([meta.domainVersionMinCompat, meta.domainVersionReadableMin]).toEqual(['3.0.0', '1.0.0']); + const mid = unwrap(midChainReader(ForwardReadPolicy.Tolerant).decodeFromBin(env)) as ChainAppendV2; + expect(mid).toBeInstanceOf(ChainAppendV2); + expect(mid.b).toBe('b'); + expect(midChainReader(ForwardReadPolicy.Lossless).decodeFromBin(env).tag).toBe('Left'); + }); + + test('v2 readMeta rejects unknown flag bits; unknown metaVersions stay rejected', () => { + expect(readMeta(v2Envelope(0x04, undefined, undefined, 'T', new Uint8Array()))).toBeUndefined(); + const both = readMeta(v2Envelope(0x03, '1.5.0', '1.0.0', 'T', new Uint8Array()))!; + expect([both.domainVersionMinCompat, both.domainVersionReadableMin]).toEqual(['1.5.0', '1.0.0']); + const w = new BaboonBinWriter(); BinTools.writeByte(w, 3); BinTools.writeString(w, 'd'); BinTools.writeString(w, '1.0.0'); + expect(readMeta(w.toBytes())).toBeUndefined(); + }); +}); diff --git a/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts b/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts index 0f2110245..cb11a3051 100644 --- a/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts +++ b/test/ts-stub/src/runtime-tests/ForwardCompatBinEnvelope.test.ts @@ -18,6 +18,7 @@ import { BaboonCodecContext, BaboonDomainVersion, BaboonEither, + BaboonEnvelopeVersion, BaboonGenerated, BaboonJsonCodec, BaboonMeta, @@ -91,8 +92,8 @@ function v1ChainReader(): BaboonCodecsFacade { const f = new BaboonCodecsFacade( const fwdWriter = new DomainFwde2eFwdFacade(); const chainWriter = new DomainFwde2eChainFacade(); -const tolerantCompact = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, undefined); -const tolerantIndexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Tolerant, undefined); +const tolerantCompact = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, undefined); +const tolerantIndexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, undefined); function unwrap(e: BaboonEither): R { if (e.tag !== 'Right') throw new Error(`expected Right, got Left: ${String((e as { value: unknown }).value)}`); From 362652962021b24bbcc3d4ef3b528473b8c8bf33 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 20:16:07 +0100 Subject: [PATCH 14/48] docs(forward-compat): v2 envelope dumps for every fixture type Adds the captured envelope-v2 bytes for FwdMidInsert and FwdEnumHost (flags 0: the v1 bytes with 02 in front) so the per-type walk-through covers v2 for all five fixture types, and explains how the v2 blocks relate to the shared v1 header. All v2 examples were re-captured from the generated Scala stub and match the bytes asserted by BinEnvelopeV2Spec. Co-Authored-By: Claude Fable 5.1 --- docs/forward-compat.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 70421a9a6..2f3095d97 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -189,7 +189,11 @@ The writer is the 2.0.0 facade of `fwde2e.fwd`; the reader registers only After `HDR` come the bound bytes — either `00` (elided; bound = 2.0.0) or `01 05 31 2E 30 2E 30` (`hasMinCompat` = 1, then `"1.0.0"`) — then the length-prefixed type identifier and the payload. Strings in the payload are -written as `len "text"` for readability; everything else is raw hex. +written as `len "text"` for readability; everything else is raw hex. The +`envelope v2` blocks are the same values written with +`BaboonCodecContext.envelopeVersion = V2`; they start with `02` instead of +`HDR`'s `01`, and their third field is the flags byte of §2.1.3 rather than +`hasMinCompat`. ### `FwdAppendVar` — a variable-length field appended at the tail @@ -283,9 +287,14 @@ Strict, indexed = Tolerant, indexed 1.0.0 reader → refuse HDR | 00 | 19 "fwde2e.fwd/:#FwdMidInsert" | 01 | 04 00 00 00 05 00 00 00 | 09 00 00 00 02 00 00 00 | 07 00 00 00 01 63 00 00 00 01 "z" + +envelope v2, compact 1.0.0 reader → refused (either policy) +02 | 0A "fwde2e.fwd" | 05 "2.0.0" | 00 | 19 "fwde2e.fwd/:#FwdMidInsert" + | 00 07 00 00 00 01 63 00 00 00 01 "z" flags 0: no bound to publish — the v1 bytes with 02 in front ``` -No UEBA prefix bound exists, so `Tolerant` writes exactly the Strict bytes. +No UEBA prefix bound exists, so `Tolerant` writes exactly the Strict bytes and +v2 carries no bound at all. ### `FwdStable` — unchanged @@ -351,6 +360,10 @@ HDR | 00 | 18 "fwde2e.fwd/:#FwdEnumHost" Strict, indexed = Tolerant, indexed 1.0.0 reader → refused HDR | 00 | 18 "fwde2e.fwd/:#FwdEnumHost" | 01 02 indexed mode byte, no variable-length fields → no index entries + +envelope v2, compact 1.0.0 reader → refused (either policy) +02 | 0A "fwde2e.fwd" | 05 "2.0.0" | 00 | 18 "fwde2e.fwd/:#FwdEnumHost" | 00 02 + flags 0: not forward-readable, nothing to publish ``` ### `ChainAppend` — three versions, two appends From 065140df9e2c0ceaf028e96a294c6d17a3eb4c79 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 21:35:29 +0100 Subject: [PATCH 15/48] test(envelope): cross-language golden bytes in all backends; fix Rust sameIn dyn and Python multi-version facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A byte-for-byte check of the binary envelope across backends (same values, same contexts) found two asymmetries the per-language suites had not: - Rust: the generated `baboon_same_in_versions_dyn` returned `[own version]` for every type while the Meta struct carried the real sameIn table. The envelope's byte-identical bound is the head of that run, so Rust-written envelopes of unchanged types (v1 and v2) elided the bound every other backend publishes — an older reader in any language refused them. The dyn impl now emits the real run (RsBaboonTranslator). - Python: `_register_version` sorted by `v.version.version`, an attribute that does not exist, so no domain facade with more than one version could be constructed (no runtime test built one). Sort by `v.version`. Every backend that had no envelope test — C#, Kotlin, KMP, Java, Python, Dart, Swift, Rust — gains a golden-bytes suite asserting the exact sequences from docs/forward-compat.md (v1 Strict, v1 Tolerant, v2 compact and indexed for FwdAppendVar; FwdStable v1 and v2; FwdEnumHost v2; the three-version chain v2), the v1/Strict default of the built-in contexts, and a v2 round trip through the writer's own facade. Scala and TypeScript already assert the same sequences structurally. Swift: test/sw-stub/Package.swift is hand-written and had no targets for the fwde2e.fwd / fwde2e.chain fixtures, so the Swift lane never compiled or tested them (SwiftPM only warns about unowned Sources/ dirs). Targets, per-module test targets and the RuntimeTests dependencies are added. Co-Authored-By: Claude Fable 5.1 --- .../python/baboon_codecs_facade.py | 3 +- .../translator/rust/RsBaboonTranslator.scala | 6 +- docs/forward-compat.md | 13 ++++ .../BaboonTests/BinEnvelopeGoldenTests.cs | 61 +++++++++++++++ .../runtime/bin_envelope_golden_test.dart | 64 +++++++++++++++ .../java/runtime/BinEnvelopeGoldenTest.java | 78 +++++++++++++++++++ .../kotlin/runtime/BinEnvelopeGoldenTest.kt | 56 +++++++++++++ .../kotlin/runtime/BinEnvelopeGoldenTest.kt | 56 +++++++++++++ .../RuntimeTests/test_bin_envelope_golden.py | 63 +++++++++++++++ .../tests/bin_envelope_golden_tests.rs | 68 ++++++++++++++++ test/sw-stub/Package.swift | 39 +++++++++- .../RuntimeTests/BinEnvelopeGoldenTests.swift | 57 ++++++++++++++ 12 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 test/cs-stub/BaboonTests/BinEnvelopeGoldenTests.cs create mode 100644 test/dt-stub/test/runtime/bin_envelope_golden_test.dart create mode 100644 test/jv-stub/src/test/java/runtime/BinEnvelopeGoldenTest.java create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt create mode 100644 test/kt-stub/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_bin_envelope_golden.py create mode 100644 test/rs-stub/tests/bin_envelope_golden_tests.rs create mode 100644 test/sw-stub/Tests/RuntimeTests/BinEnvelopeGoldenTests.swift diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index 80f7c8166..fa9919aaf 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -406,7 +406,8 @@ def _register_version(self, domain_version: BaboonDomainVersion) -> None: versions = self.domain_versions[domain_id] if domain_version not in versions: versions.append(domain_version) - versions.sort(key=lambda v: v.version.version) + # `BaboonDomainVersion.version` already is the comparable `Version` + versions.sort(key=lambda v: v.version) # ----- `any`-feature cross-format helpers (PR 10.1) ----------------------------------- diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index d8b86f2bb..d071f3529 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -255,11 +255,15 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( case (fullPath, dynBase, typeId, _) => val binCodec = s"${dynBase}V${verSuffix}BinCodec" val jsonCodec = s"${dynBase}V${verSuffix}JsonCodec" + // the real sameIn run: the envelope's byte-identical bound is its head, so a degenerate + // `[own version]` here would make Rust-written envelopes of unchanged types unreadable by + // older readers in every language + val sameInVec = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") q"""impl BaboonGeneratedDyn for $fullPath { | fn baboon_domain_version_dyn(&self) -> &str { "$versionStr" } | fn baboon_domain_identifier_dyn(&self) -> &str { "$domainIdStr" } | fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" } - | fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["$versionStr".to_string()] } + | fn baboon_same_in_versions_dyn(&self) -> Vec { vec![$sameInVec] } | fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] } | fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![${minReaderPairs(typeId)}] } | fn as_any(&self) -> &dyn std::any::Any { self } diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 2f3095d97..a3b73904e 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -472,6 +472,19 @@ Two consequences of scheme 2: index mode; round trips through the writer and old readers; the three-version chain with `Lossless` now enforceable; unknown flag bits and unknown metaVersions rejected. +- Golden envelope bytes in every other backend — `BinEnvelopeGoldenTests.cs`, + `BinEnvelopeGoldenTest.kt` (JVM and KMP stubs), `BinEnvelopeGoldenTest.java`, + `test_bin_envelope_golden.py`, `bin_envelope_golden_test.dart`, + `BinEnvelopeGoldenTests.swift`, `bin_envelope_golden_tests.rs` — assert the + exact byte sequences shown in the worked examples (v1 Strict, v1 Tolerant, v2 + compact and indexed, for `FwdAppendVar`, `FwdStable`, `FwdEnumHost` and the + chain), the v1 default of the built-in contexts, and a v2 round trip through + the writer's own facade. They exist because the first cross-language check + found two asymmetries the per-language suites had not: the Rust per-type + `baboon_same_in_versions_dyn` returned `[own version]` (so Rust-written + envelopes of unchanged types elided the byte-identical bound that every other + backend publishes), and the Python facade could not register a second domain + version at all (`_register_version` sorted by a non-existent attribute). - `test/sc-stub/.../ForwardCompatBinEnvelopeSpec.scala` and `test/ts-stub/.../ForwardCompatBinEnvelope.test.ts` — the UEBA writer policy: Strict envelopes are unchanged and refused by an old reader; Tolerant compact diff --git a/test/cs-stub/BaboonTests/BinEnvelopeGoldenTests.cs b/test/cs-stub/BaboonTests/BinEnvelopeGoldenTests.cs new file mode 100644 index 000000000..18d2117c2 --- /dev/null +++ b/test/cs-stub/BaboonTests/BinEnvelopeGoldenTests.cs @@ -0,0 +1,61 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +using System; +using System.Linq; +using Baboon.Runtime.Shared; +using Fwde2e.Chain; +using Fwde2e.Fwd; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class BinEnvelopeGoldenTests + { + private static string Hx(byte[] b) => string.Join(" ", b.Select(x => x.ToString("X2"))); + private static byte[] Enc(BaboonCodecsFacade f, BaboonCodecContext ctx, IBaboonGenerated v) => f.EncodeToBin(ctx, v).GetRight(); + + private static readonly BaboonCodecContext V1Tolerant = BaboonCodecContext.Custom(false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, null); + private static readonly BaboonCodecContext V2Compact = BaboonCodecContext.Custom(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null); + private static readonly BaboonCodecContext V2Indexed = BaboonCodecContext.Custom(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null); + + [Test] + public void DefaultContextsWriteV1Strict() + { + Assert.That(BaboonCodecContext.Compact.EnvelopeVersion, Is.EqualTo(BaboonEnvelopeVersion.V1)); + Assert.That(BaboonCodecContext.Compact.ForwardWritePolicy, Is.EqualTo(ForwardWritePolicy.Strict)); + Assert.That(BaboonCodecContext.Indexed.EnvelopeVersion, Is.EqualTo(BaboonEnvelopeVersion.V1)); + } + + [Test] + public void EnvelopesMatchTheCrossLanguageGoldenBytes() + { + var fwd = new DomainFwde2eFwdFacade(); + var chain = new DomainFwde2eChainFacade(); + var app = new FwdAppendVar(42, "hi", "t"); + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + Assert.That(Hx(Enc(fwd, BaboonCodecContext.Compact, app)), Is.EqualTo("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74")); + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + Assert.That(Hx(Enc(fwd, V1Tolerant, app)), Is.EqualTo("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74")); + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + Assert.That(Hx(Enc(fwd, V2Compact, app)), Is.EqualTo("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74")); + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + Assert.That(Hx(Enc(fwd, V2Indexed, app)), Is.EqualTo("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74")); + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + Assert.That(Hx(Enc(fwd, BaboonCodecContext.Compact, new FwdStable("s"))), Is.EqualTo("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73")); + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + Assert.That(Hx(Enc(fwd, V2Compact, new FwdStable("s"))), Is.EqualTo("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73")); + // FwdEnumHost, v2 compact: flags 0, no bound + Assert.That(Hx(Enc(fwd, V2Compact, new FwdEnumHost(FwdEnumGrows.C))), Is.EqualTo("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02")); + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + Assert.That(Hx(Enc(chain, V2Compact, new ChainAppend(1, "b", "c"))), Is.EqualTo("02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63")); + } + + [Test] + public void V2EnvelopeRoundTripsThroughItsOwnFacade() + { + var fwd = new DomainFwde2eFwdFacade(); + var app = new FwdAppendVar(42, "hi", "t"); + Assert.That(fwd.DecodeFromBin(Enc(fwd, V2Compact, app)).GetRight(), Is.EqualTo(app)); + } + } +} diff --git a/test/dt-stub/test/runtime/bin_envelope_golden_test.dart b/test/dt-stub/test/runtime/bin_envelope_golden_test.dart new file mode 100644 index 000000000..9755f38fd --- /dev/null +++ b/test/dt-stub/test/runtime/bin_envelope_golden_test.dart @@ -0,0 +1,64 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +import 'dart:typed_data'; + +import 'package:baboon_runtime/baboon_codecs_facade.dart'; +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:test/test.dart'; + +import '../../lib/fwde2e/chain/chain_append.dart'; +import '../../lib/fwde2e/chain/domain_fwde2e_chain_facade.dart'; +import '../../lib/fwde2e/fwd/domain_fwde2e_fwd_facade.dart'; +import '../../lib/fwde2e/fwd/fwd_append_var.dart'; +import '../../lib/fwde2e/fwd/fwd_enum_grows.dart'; +import '../../lib/fwde2e/fwd/fwd_enum_host.dart'; +import '../../lib/fwde2e/fwd/fwd_stable.dart'; + +String hx(Uint8List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + +Uint8List enc(BaboonCodecsFacade f, BaboonCodecContext ctx, BaboonGenerated v) { + final encoded = f.encodeToBin(ctx, v); + expect(encoded, isA>()); + return (encoded as BaboonRight).value as Uint8List; +} + +final v1Tolerant = BaboonCodecContext.custom(false, ForwardWritePolicy.tolerant, BaboonEnvelopeVersion.v1, null); +final v2Compact = BaboonCodecContext.custom(false, ForwardWritePolicy.strict, BaboonEnvelopeVersion.v2, null); +final v2Indexed = BaboonCodecContext.custom(true, ForwardWritePolicy.strict, BaboonEnvelopeVersion.v2, null); + +void main() { + test('default contexts write v1 Strict', () { + expect(BaboonCodecContext.compact.envelopeVersion, BaboonEnvelopeVersion.v1); + expect(BaboonCodecContext.compact.forwardWritePolicy, ForwardWritePolicy.strict); + expect(BaboonCodecContext.indexed.envelopeVersion, BaboonEnvelopeVersion.v1); + }); + + test('envelopes match the cross-language golden bytes', () { + final fwd = DomainFwde2eFwdFacade(); + final chain = DomainFwde2eChainFacade(); + const app = FwdAppendVar(a: 42, b: 'hi', t: 't'); + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + expect(hx(enc(fwd, BaboonCodecContext.compact, app)), '01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74'); + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + expect(hx(enc(fwd, v1Tolerant, app)), '01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74'); + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + expect(hx(enc(fwd, v2Compact, app)), '02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74'); + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + expect(hx(enc(fwd, v2Indexed, app)), '02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74'); + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + expect(hx(enc(fwd, BaboonCodecContext.compact, const FwdStable(s: 's'))), '01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73'); + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + expect(hx(enc(fwd, v2Compact, const FwdStable(s: 's'))), '02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73'); + // FwdEnumHost, v2 compact: flags 0, no bound + expect(hx(enc(fwd, v2Compact, const FwdEnumHost(e: FwdEnumGrows.C))), '02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02'); + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + expect(hx(enc(chain, v2Compact, const ChainAppend(a: 1, b: 'b', c: 'c'))), '02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63'); + }); + + test('v2 envelope round-trips through its own facade', () { + final fwd = DomainFwde2eFwdFacade(); + const app = FwdAppendVar(a: 42, b: 'hi', t: 't'); + final decoded = fwd.decodeFromBinBytes(enc(fwd, v2Compact, app)); + expect(decoded, isA>()); + expect((decoded as BaboonRight).value, equals(app)); + }); +} diff --git a/test/jv-stub/src/test/java/runtime/BinEnvelopeGoldenTest.java b/test/jv-stub/src/test/java/runtime/BinEnvelopeGoldenTest.java new file mode 100644 index 000000000..11f5f9834 --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/BinEnvelopeGoldenTest.java @@ -0,0 +1,78 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +package runtime; + +import baboon.runtime.shared.BaboonCodecContext; +import baboon.runtime.shared.BaboonCodecException; +import baboon.runtime.shared.BaboonCodecsFacade; +import baboon.runtime.shared.BaboonEither; +import baboon.runtime.shared.BaboonGenerated; +import fwde2e.chain.ChainAppend; +import fwde2e.chain.DomainFwde2eChainFacade; +import fwde2e.fwd.DomainFwde2eFwdFacade; +import fwde2e.fwd.FwdAppendVar; +import fwde2e.fwd.FwdEnumGrows; +import fwde2e.fwd.FwdEnumHost; +import fwde2e.fwd.FwdStable; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +class BinEnvelopeGoldenTest { + private static String hx(byte[] b) { + return IntStream.range(0, b.length).mapToObj(i -> String.format("%02X", b[i] & 0xFF)).collect(Collectors.joining(" ")); + } + + private static byte[] enc(BaboonCodecsFacade f, BaboonCodecContext ctx, BaboonGenerated v) { + BaboonEither encoded = f.encodeToBin(ctx, v); + assertInstanceOf(BaboonEither.Right.class, encoded, "encodeToBin must succeed"); + return ((BaboonEither.Right) encoded).value(); + } + + private static final BaboonCodecContext V1_TOLERANT = BaboonCodecContext.custom(false, BaboonCodecContext.ForwardWritePolicy.TOLERANT, BaboonCodecContext.BaboonEnvelopeVersion.V1, null); + private static final BaboonCodecContext V2_COMPACT = BaboonCodecContext.custom(false, BaboonCodecContext.ForwardWritePolicy.STRICT, BaboonCodecContext.BaboonEnvelopeVersion.V2, null); + private static final BaboonCodecContext V2_INDEXED = BaboonCodecContext.custom(true, BaboonCodecContext.ForwardWritePolicy.STRICT, BaboonCodecContext.BaboonEnvelopeVersion.V2, null); + + @Test + void defaultContextsWriteV1Strict() { + assertEquals(BaboonCodecContext.BaboonEnvelopeVersion.V1, BaboonCodecContext.Compact.envelopeVersion()); + assertEquals(BaboonCodecContext.ForwardWritePolicy.STRICT, BaboonCodecContext.Compact.forwardWritePolicy()); + assertEquals(BaboonCodecContext.BaboonEnvelopeVersion.V1, BaboonCodecContext.Indexed.envelopeVersion()); + } + + @Test + void envelopesMatchTheCrossLanguageGoldenBytes() { + DomainFwde2eFwdFacade fwd = new DomainFwde2eFwdFacade(); + DomainFwde2eChainFacade chain = new DomainFwde2eChainFacade(); + FwdAppendVar app = new FwdAppendVar(42, "hi", Optional.of("t")); + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, BaboonCodecContext.Compact, app))); + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, V1_TOLERANT, app))); + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, V2_COMPACT, app))); + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, V2_INDEXED, app))); + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, BaboonCodecContext.Compact, new FwdStable("s")))); + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, V2_COMPACT, new FwdStable("s")))); + // FwdEnumHost, v2 compact: flags 0, no bound + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02", hx(enc(fwd, V2_COMPACT, new FwdEnumHost(FwdEnumGrows.C)))); + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63", hx(enc(chain, V2_COMPACT, new ChainAppend(1, Optional.of("b"), Optional.of("c"))))); + } + + @Test + void v2EnvelopeRoundTripsThroughItsOwnFacade() { + DomainFwde2eFwdFacade fwd = new DomainFwde2eFwdFacade(); + FwdAppendVar app = new FwdAppendVar(42, "hi", Optional.of("t")); + BaboonEither decoded = fwd.decodeFromBin(enc(fwd, V2_COMPACT, app)); + assertInstanceOf(BaboonEither.Right.class, decoded, "decodeFromBin must succeed"); + assertEquals(app, ((BaboonEither.Right) decoded).value()); + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt new file mode 100644 index 000000000..91dbf4f06 --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt @@ -0,0 +1,56 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +package runtime + +import baboon.runtime.shared.BaboonCodecContext +import baboon.runtime.shared.BaboonCodecsFacade +import baboon.runtime.shared.BaboonEnvelopeVersion +import baboon.runtime.shared.BaboonGenerated +import baboon.runtime.shared.ForwardWritePolicy +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BinEnvelopeGoldenTest { + private fun hx(b: ByteArray): String = b.joinToString(" ") { (it.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase() } + private fun enc(f: BaboonCodecsFacade, ctx: BaboonCodecContext, v: T): ByteArray = f.encodeToBin(ctx, v) + + private val v1Tolerant = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, null) + private val v2Compact = BaboonCodecContext.custom(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null) + private val v2Indexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null) + + @Test + fun defaultContextsWriteV1Strict() { + assertEquals(BaboonEnvelopeVersion.V1, BaboonCodecContext.Compact.envelopeVersion) + assertEquals(ForwardWritePolicy.Strict, BaboonCodecContext.Compact.forwardWritePolicy) + assertEquals(BaboonEnvelopeVersion.V1, BaboonCodecContext.Indexed.envelopeVersion) + } + + @Test + fun envelopesMatchTheCrossLanguageGoldenBytes() { + val fwd = fwde2e.fwd.DomainFwde2eFwdFacade() + val chain = fwde2e.chain.DomainFwde2eChainFacade() + val app = fwde2e.fwd.FwdAppendVar(42, "hi", "t") + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, BaboonCodecContext.Compact, app))) + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v1Tolerant, app))) + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v2Compact, app))) + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v2Indexed, app))) + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("s")))) + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, v2Compact, fwde2e.fwd.FwdStable("s")))) + // FwdEnumHost, v2 compact: flags 0, no bound + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02", hx(enc(fwd, v2Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)))) + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63", hx(enc(chain, v2Compact, fwde2e.chain.ChainAppend(1, "b", "c")))) + } + + @Test + fun v2EnvelopeRoundTripsThroughItsOwnFacade() { + val fwd = fwde2e.fwd.DomainFwde2eFwdFacade() + val app = fwde2e.fwd.FwdAppendVar(42, "hi", "t") + assertEquals(app, fwd.decodeFromBin(enc(fwd, v2Compact, app))) + } +} diff --git a/test/kt-stub/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt b/test/kt-stub/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt new file mode 100644 index 000000000..91dbf4f06 --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/BinEnvelopeGoldenTest.kt @@ -0,0 +1,56 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +package runtime + +import baboon.runtime.shared.BaboonCodecContext +import baboon.runtime.shared.BaboonCodecsFacade +import baboon.runtime.shared.BaboonEnvelopeVersion +import baboon.runtime.shared.BaboonGenerated +import baboon.runtime.shared.ForwardWritePolicy +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BinEnvelopeGoldenTest { + private fun hx(b: ByteArray): String = b.joinToString(" ") { (it.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase() } + private fun enc(f: BaboonCodecsFacade, ctx: BaboonCodecContext, v: T): ByteArray = f.encodeToBin(ctx, v) + + private val v1Tolerant = BaboonCodecContext.custom(false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, null) + private val v2Compact = BaboonCodecContext.custom(false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null) + private val v2Indexed = BaboonCodecContext.custom(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, null) + + @Test + fun defaultContextsWriteV1Strict() { + assertEquals(BaboonEnvelopeVersion.V1, BaboonCodecContext.Compact.envelopeVersion) + assertEquals(ForwardWritePolicy.Strict, BaboonCodecContext.Compact.forwardWritePolicy) + assertEquals(BaboonEnvelopeVersion.V1, BaboonCodecContext.Indexed.envelopeVersion) + } + + @Test + fun envelopesMatchTheCrossLanguageGoldenBytes() { + val fwd = fwde2e.fwd.DomainFwde2eFwdFacade() + val chain = fwde2e.chain.DomainFwde2eChainFacade() + val app = fwde2e.fwd.FwdAppendVar(42, "hi", "t") + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, BaboonCodecContext.Compact, app))) + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v1Tolerant, app))) + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v2Compact, app))) + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74", hx(enc(fwd, v2Indexed, app))) + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + assertEquals("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("s")))) + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(enc(fwd, v2Compact, fwde2e.fwd.FwdStable("s")))) + // FwdEnumHost, v2 compact: flags 0, no bound + assertEquals("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02", hx(enc(fwd, v2Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)))) + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + assertEquals("02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63", hx(enc(chain, v2Compact, fwde2e.chain.ChainAppend(1, "b", "c")))) + } + + @Test + fun v2EnvelopeRoundTripsThroughItsOwnFacade() { + val fwd = fwde2e.fwd.DomainFwde2eFwdFacade() + val app = fwde2e.fwd.FwdAppendVar(42, "hi", "t") + assertEquals(app, fwd.decodeFromBin(enc(fwd, v2Compact, app))) + } +} diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_bin_envelope_golden.py b/test/py-stub/BaboonTests/RuntimeTests/test_bin_envelope_golden.py new file mode 100644 index 000000000..071218909 --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_bin_envelope_golden.py @@ -0,0 +1,63 @@ +# Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +import unittest + +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext, BaboonEnvelopeVersion, ForwardWritePolicy +from BaboonDefinitions.Generated.fwde2e.chain.ChainAppend import ChainAppend +from BaboonDefinitions.Generated.fwde2e.chain.domain_facade import DomainFwde2eChainFacade +from BaboonDefinitions.Generated.fwde2e.fwd.FwdAppendVar import FwdAppendVar +from BaboonDefinitions.Generated.fwde2e.fwd.FwdEnumGrows import FwdEnumGrows +from BaboonDefinitions.Generated.fwde2e.fwd.FwdEnumHost import FwdEnumHost +from BaboonDefinitions.Generated.fwde2e.fwd.FwdStable import FwdStable +from BaboonDefinitions.Generated.fwde2e.fwd.domain_facade import DomainFwde2eFwdFacade + + +def hx(b: bytes) -> str: + return " ".join(f"{x:02X}" for x in b) + + +V1_TOLERANT = BaboonCodecContext.custom(False, ForwardWritePolicy.TOLERANT, BaboonEnvelopeVersion.V1, None) +V2_COMPACT = BaboonCodecContext.custom(False, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V2, None) +V2_INDEXED = BaboonCodecContext.custom(True, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V2, None) + + +class TestBinEnvelopeGolden(unittest.TestCase): + def test_default_contexts_write_v1_strict(self): + self.assertEqual(BaboonEnvelopeVersion.V1, BaboonCodecContext.Compact.envelope_version) + self.assertEqual(ForwardWritePolicy.STRICT, BaboonCodecContext.Compact.forward_write_policy) + self.assertEqual(BaboonEnvelopeVersion.V1, BaboonCodecContext.Indexed.envelope_version) + + def test_multi_version_domain_facade_is_constructible(self): + # regression: `_register_version` sorted by `v.version.version` and raised AttributeError + # for every domain with more than one version, so no multi-version facade could be built + DomainFwde2eFwdFacade() + DomainFwde2eChainFacade() + + def test_envelopes_match_the_cross_language_golden_bytes(self): + fwd = DomainFwde2eFwdFacade() + chain = DomainFwde2eChainFacade() + app = FwdAppendVar(a=42, b="hi", t="t") + # FwdAppendVar, v1 Strict (default) compact: identical bound elided + self.assertEqual("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(fwd.encode_to_bin(BaboonCodecContext.Compact, app))) + # FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + self.assertEqual("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(fwd.encode_to_bin(V1_TOLERANT, app))) + # FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + self.assertEqual("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74", hx(fwd.encode_to_bin(V2_COMPACT, app))) + # FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + self.assertEqual("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74", hx(fwd.encode_to_bin(V2_INDEXED, app))) + # FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + self.assertEqual("01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(fwd.encode_to_bin(BaboonCodecContext.Compact, FwdStable(s="s")))) + # FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + self.assertEqual("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73", hx(fwd.encode_to_bin(V2_COMPACT, FwdStable(s="s")))) + # FwdEnumHost, v2 compact: flags 0, no bound + self.assertEqual("02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02", hx(fwd.encode_to_bin(V2_COMPACT, FwdEnumHost(e=FwdEnumGrows.C)))) + # ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + self.assertEqual("02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63", hx(chain.encode_to_bin(V2_COMPACT, ChainAppend(a=1, b="b", c="c")))) + + def test_v2_envelope_round_trips_through_its_own_facade(self): + fwd = DomainFwde2eFwdFacade() + app = FwdAppendVar(a=42, b="hi", t="t") + self.assertEqual(app, fwd.decode_from_bin_bytes(fwd.encode_to_bin(V2_COMPACT, app))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/rs-stub/tests/bin_envelope_golden_tests.rs b/test/rs-stub/tests/bin_envelope_golden_tests.rs new file mode 100644 index 000000000..8dcd1904e --- /dev/null +++ b/test/rs-stub/tests/bin_envelope_golden_tests.rs @@ -0,0 +1,68 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +use baboon_rs_stub::baboon_runtime::{BaboonCodecContext, BaboonEnvelopeVersion, ForwardWritePolicy}; +use baboon_rs_stub::baboon_codecs_facade::{BaboonCodecsFacade, BaboonGeneratedDyn}; +use baboon_rs_stub::fwde2e::chain::chain_append::ChainAppend; +use baboon_rs_stub::fwde2e::chain::domain_fwde2echain_facade::DomainFwde2eChainFacade; +use baboon_rs_stub::fwde2e::fwd::domain_fwde2efwd_facade::DomainFwde2eFwdFacade; +use baboon_rs_stub::fwde2e::fwd::fwd_append_var::FwdAppendVar; +use baboon_rs_stub::fwde2e::fwd::fwd_enum_grows::FwdEnumGrows; +use baboon_rs_stub::fwde2e::fwd::fwd_enum_host::FwdEnumHost; +use baboon_rs_stub::fwde2e::fwd::fwd_stable::FwdStable; + +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02X}", x)).collect::>().join(" ") +} + +fn enc(f: &BaboonCodecsFacade, ctx: &BaboonCodecContext, v: &dyn BaboonGeneratedDyn) -> String { + hx(&f.encode_to_bin(ctx, v).expect("encode_to_bin must succeed")) +} + +fn v1_tolerant() -> BaboonCodecContext { BaboonCodecContext::custom(false, ForwardWritePolicy::Tolerant, BaboonEnvelopeVersion::V1, None) } +fn v2_compact() -> BaboonCodecContext { BaboonCodecContext::custom(false, ForwardWritePolicy::Strict, BaboonEnvelopeVersion::V2, None) } +fn v2_indexed() -> BaboonCodecContext { BaboonCodecContext::custom(true, ForwardWritePolicy::Strict, BaboonEnvelopeVersion::V2, None) } + +#[test] +fn default_contexts_write_v1_strict() { + assert_eq!(BaboonCodecContext::Compact.envelope_version(), BaboonEnvelopeVersion::V1); + assert_eq!(BaboonCodecContext::Compact.forward_write_policy(), ForwardWritePolicy::Strict); + assert_eq!(BaboonCodecContext::Indexed.envelope_version(), BaboonEnvelopeVersion::V1); +} + +#[test] +fn same_in_versions_dyn_is_the_real_run() { + // regression: the generated impl returned `[own version]` for every type, so Rust-written + // envelopes of unchanged types elided the byte-identical bound that every other backend publishes + assert_eq!(FwdStable { s: "s".into() }.baboon_same_in_versions_dyn(), vec!["1.0.0".to_string(), "2.0.0".to_string()]); +} + +#[test] +fn envelopes_match_the_cross_language_golden_bytes() { + let fwd = DomainFwde2eFwdFacade::new(); + let chain = DomainFwde2eChainFacade::new(); + let app = FwdAppendVar { a: 42, b: "hi".into(), t: Some("t".into()) }; + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + assert_eq!(enc(&fwd.facade, &BaboonCodecContext::Compact, &app), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74"); + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + assert_eq!(enc(&fwd.facade, &v1_tolerant(), &app), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74"); + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + assert_eq!(enc(&fwd.facade, &v2_compact(), &app), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74"); + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + assert_eq!(enc(&fwd.facade, &v2_indexed(), &app), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74"); + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + assert_eq!(enc(&fwd.facade, &BaboonCodecContext::Compact, &FwdStable { s: "s".into() }), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73"); + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + assert_eq!(enc(&fwd.facade, &v2_compact(), &FwdStable { s: "s".into() }), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73"); + // FwdEnumHost, v2 compact: flags 0, no bound + assert_eq!(enc(&fwd.facade, &v2_compact(), &FwdEnumHost { e: FwdEnumGrows::C }), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02"); + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + assert_eq!(enc(&chain.facade, &v2_compact(), &ChainAppend { a: 1, b: Some("b".into()), c: Some("c".into()) }), "02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63"); +} + +#[test] +fn v2_envelope_round_trips_through_its_own_facade() { + let fwd = DomainFwde2eFwdFacade::new(); + let app = FwdAppendVar { a: 42, b: "hi".into(), t: Some("t".into()) }; + let bytes = fwd.facade.encode_to_bin(&v2_compact(), &app).expect("encode"); + let decoded = fwd.facade.decode_from_bin(&mut bytes.as_slice()).expect("decode"); + assert_eq!(decoded.as_any().downcast_ref::(), Some(&app)); +} diff --git a/test/sw-stub/Package.swift b/test/sw-stub/Package.swift index 426f74327..164d626b3 100644 --- a/test/sw-stub/Package.swift +++ b/test/sw-stub/Package.swift @@ -60,6 +60,33 @@ let package = Package( path: "Sources/RenameNs" ), + // ----- fwde2e.fwd / fwde2e.chain forward-compat fixtures (docs/forward-compat.md) ------- + .target( + name: "Fwde2eFwd_v1_0_0", + dependencies: ["BaboonRuntime"], + path: "Sources/Fwde2eFwd_v1_0_0" + ), + .target( + name: "Fwde2eFwd", + dependencies: ["BaboonRuntime", "Fwde2eFwd_v1_0_0"], + path: "Sources/Fwde2eFwd" + ), + .target( + name: "Fwde2eChain_v1_0_0", + dependencies: ["BaboonRuntime"], + path: "Sources/Fwde2eChain_v1_0_0" + ), + .target( + name: "Fwde2eChain_v2_0_0", + dependencies: ["BaboonRuntime", "Fwde2eChain_v1_0_0"], + path: "Sources/Fwde2eChain_v2_0_0" + ), + .target( + name: "Fwde2eChain", + dependencies: ["BaboonRuntime", "Fwde2eChain_v1_0_0", "Fwde2eChain_v2_0_0"], + path: "Sources/Fwde2eChain" + ), + // ----- Single-version fixtures ---------------------------------------------------------- .target( name: "MyOk", @@ -190,7 +217,7 @@ let package = Package( // ExtractedContractsTests imports MyOkExtractedContracts (T45). .testTarget( name: "RuntimeTests", - dependencies: ["BaboonRuntime", "MyOk", "IdentifierOk", "MyOkM19Foreign", "TestpkgPkg0", "RecursiveTree", "MyOkExtractedContracts"], + dependencies: ["BaboonRuntime", "MyOk", "IdentifierOk", "MyOkM19Foreign", "TestpkgPkg0", "RecursiveTree", "MyOkExtractedContracts", "Fwde2eFwd", "Fwde2eChain"], path: "Tests/RuntimeTests" ), @@ -290,6 +317,16 @@ let package = Package( dependencies: ["BaboonRuntime", "TestpkgPkg0"], path: "Tests/BaboonTests/TestpkgPkg0" ), + .testTarget( + name: "Fwde2eFwdTests", + dependencies: ["BaboonRuntime", "Fwde2eFwd"], + path: "Tests/BaboonTests/Fwde2eFwd" + ), + .testTarget( + name: "Fwde2eChainTests", + dependencies: ["BaboonRuntime", "Fwde2eChain"], + path: "Tests/BaboonTests/Fwde2eChain" + ), .testTarget( name: "MyOkExtractedContractsTests", dependencies: ["BaboonRuntime", "MyOkExtractedContracts"], diff --git a/test/sw-stub/Tests/RuntimeTests/BinEnvelopeGoldenTests.swift b/test/sw-stub/Tests/RuntimeTests/BinEnvelopeGoldenTests.swift new file mode 100644 index 000000000..fd09fdb33 --- /dev/null +++ b/test/sw-stub/Tests/RuntimeTests/BinEnvelopeGoldenTests.swift @@ -0,0 +1,57 @@ +// Cross-language golden bytes for the top-level binary envelope (docs/spec/codec-envelope.md §2.1, §2.1.2, §2.1.3; docs/forward-compat.md, "Worked examples"). The same values must produce these exact bytes in every backend; the Scala and TypeScript stubs assert the same sequences structurally. +import XCTest +@testable import BaboonRuntime +@testable import Fwde2eChain +@testable import Fwde2eFwd + +final class BinEnvelopeGoldenTests: XCTestCase { + private func hx(_ d: Data) -> String { d.map { String(format: "%02X", $0) }.joined(separator: " ") } + + private func enc(_ f: BaboonCodecsFacade, _ ctx: BaboonCodecContext, _ v: Any) throws -> Data { + switch f.encodeToBin(ctx, v) { + case .success(let bytes): return bytes + case .failure(let e): XCTFail("encodeToBin must succeed: \(e)"); throw e + } + } + + private let v1Tolerant = BaboonCodecContext.custom(false, .tolerant, .v1, nil) + private let v2Compact = BaboonCodecContext.custom(false, .strict, .v2, nil) + private let v2Indexed = BaboonCodecContext.custom(true, .strict, .v2, nil) + + func testDefaultContextsWriteV1Strict() { + XCTAssertEqual(BaboonCodecContext.compact.envelopeVersion, .v1) + XCTAssertEqual(BaboonCodecContext.compact.forwardWritePolicy, .strict) + XCTAssertEqual(BaboonCodecContext.indexed.envelopeVersion, .v1) + } + + func testEnvelopesMatchTheCrossLanguageGoldenBytes() throws { + let fwd = DomainFwde2eFwdFacade() + let chain = DomainFwde2eChainFacade() + let app = FwdAppendVar(a: 42, b: "hi", t: "t") + // FwdAppendVar, v1 Strict (default) compact: identical bound elided + XCTAssertEqual(hx(try enc(fwd, BaboonCodecContext.compact, app)), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74") + // FwdAppendVar, v1 Tolerant compact: prefix-compact bound 1.0.0 in the single slot + XCTAssertEqual(hx(try enc(fwd, v1Tolerant, app)), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74") + // FwdAppendVar, v2 compact: flags 0b10, readableMin 1.0.0 + XCTAssertEqual(hx(try enc(fwd, v2Compact, app)), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 02 05 31 2E 30 2E 30 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 00 2A 00 00 00 02 68 69 01 01 74") + // FwdAppendVar, v2 indexed: flags 0 (prefix-any-mode bound is 2.0.0) + XCTAssertEqual(hx(try enc(fwd, v2Indexed, app)), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 01 04 00 00 00 03 00 00 00 07 00 00 00 03 00 00 00 2A 00 00 00 02 68 69 01 01 74") + // FwdStable, v1 Strict compact: byte-identical since 1.0.0 -> hasMinCompat 1 + XCTAssertEqual(hx(try enc(fwd, BaboonCodecContext.compact, FwdStable(s: "s"))), "01 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73") + // FwdStable, v2 compact: flags 0b01, minCompat 1.0.0, readableMin elided + XCTAssertEqual(hx(try enc(fwd, v2Compact, FwdStable(s: "s"))), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 01 05 31 2E 30 2E 30 16 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 53 74 61 62 6C 65 00 01 73") + // FwdEnumHost, v2 compact: flags 0, no bound + XCTAssertEqual(hx(try enc(fwd, v2Compact, FwdEnumHost(e: FwdEnumGrows.C))), "02 0A 66 77 64 65 32 65 2E 66 77 64 05 32 2E 30 2E 30 00 18 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 45 6E 75 6D 48 6F 73 74 00 02") + // ChainAppend 3.0.0, v2 compact: flags 0b10, readableMin 1.0.0 + XCTAssertEqual(hx(try enc(chain, v2Compact, ChainAppend(a: 1, b: "b", c: "c"))), "02 0C 66 77 64 65 32 65 2E 63 68 61 69 6E 05 33 2E 30 2E 30 02 05 31 2E 30 2E 30 1A 66 77 64 65 32 65 2E 63 68 61 69 6E 2F 3A 23 43 68 61 69 6E 41 70 70 65 6E 64 00 01 00 00 00 01 01 62 01 01 63") + } + + func testV2EnvelopeRoundTripsThroughItsOwnFacade() throws { + let fwd = DomainFwde2eFwdFacade() + let app = FwdAppendVar(a: 42, b: "hi", t: "t") + guard case .success(let value) = fwd.decodeFromBinBytes(try enc(fwd, v2Compact, app)) else { + XCTFail("decodeFromBinBytes must succeed"); return + } + XCTAssertEqual(value as? FwdAppendVar, app) + } +} From b4329f20af0598271d97e1c426e69b1616e4c1c5 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 21:53:30 +0100 Subject: [PATCH 16/48] docs(spec): v2 conformance reference with canonical bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §2.1.4 to codec-envelope.md, the v2 counterpart of the v1 conformance block: annotated canonical bytes for FwdAppendVar (flags bit 1, readableMin present) and FwdStable (flags bit 0, minCompat present), byte counts against the v1 form, and pointers to the golden-bytes suites that pin them in every backend. Co-Authored-By: Claude Fable 5.1 --- docs/spec/codec-envelope.md | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/spec/codec-envelope.md b/docs/spec/codec-envelope.md index 9b507114c..cc5a8a3e4 100644 --- a/docs/spec/codec-envelope.md +++ b/docs/spec/codec-envelope.md @@ -160,6 +160,48 @@ carry `flags` bit 1 clear and are the v1 envelope with `02` in front and the Readers accept v1 and v2 (§ 5); a v1-only reader rejects v2 as an unknown `metaVersion`, so v2 is for deployments whose readers are known to be current. +#### 2.1.4 Conformance reference — canonical v2 bytes + +Pinned in every backend by the golden-bytes suites (`BinEnvelopeGoldenTests.cs`, +`BinEnvelopeGoldenTest.kt` in the Kotlin and KMP stubs, `BinEnvelopeGoldenTest.java`, +`test_bin_envelope_golden.py`, `bin_envelope_golden_test.dart`, +`BinEnvelopeGoldenTests.swift`, `bin_envelope_golden_tests.rs`) and structurally by +`BinEnvelopeV2Spec.scala` / `BinEnvelopeV2.test.ts`. Model: `fwde2e.fwd` 2.0.0 +(`baboon-compiler/src/test/resources/baboon/fwd-e2e-ok/`). + +`FwdAppendVar(a=42, b="hi", t=Some("t"))`, compact context — 1.0.0 `{a, b}`, +2.0.0 appended `t: opt[str]`, so the byte-identical bound is 2.0.0 (elided) and +the `prefix-compact` bound is 1.0.0: + +``` +02 META_VERSION_2 +0A 66 77 64 65 32 65 2E 66 77 64 len(10) + "fwde2e.fwd" +05 32 2E 30 2E 30 len(5) + "2.0.0" +02 flags: bit 1 (readableMin follows) +05 31 2E 30 2E 30 len(5) + "1.0.0" readableMin +19 66 77 64 65 32 65 2E 66 77 64 2F 3A 23 46 77 64 41 70 70 65 6E 64 56 61 72 + len(25) + "fwde2e.fwd/:#FwdAppendVar" +00 payload: mode byte (compact) +2A 00 00 00 a = 42 (i32 LE) +02 68 69 b = "hi" +01 01 74 t = Some("t") +``` + +Total: 62 bytes. The same value in the default v1 context is the 56-byte +sequence `01 | … | 00 | typeId | payload` (bound elided). + +`FwdStable(s="s")`, compact — unchanged since 1.0.0, so the byte-identical +bound is present and `readableMin` (equal to it) is elided: + +``` +02 0A "fwde2e.fwd" 05 "2.0.0" 01 05 "1.0.0" 16 "fwde2e.fwd/:#FwdStable" 00 01 73 + ^^ flags: bit 0 (minCompat follows) +``` + +A backend that drifts from these sequences fails its golden suite; the +cross-language acceptance harness (`mdl :test-acceptance`) additionally +cross-reads envelopes between all backends. + ### 2.2 JSON The envelope is a JSON object with the keys below. A reader does not assume From 62f56af09bd5284c02fdf579e1385cab382fad0e Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Tue, 15 Sep 2026 21:54:44 +0100 Subject: [PATCH 17/48] docs: record the known gaps of the forward-compat / envelope work forward-compat.md gains a "Known gaps" section: no runtime e2e for prefix-any-mode (and why), non-uniform missing-tier handling across runtimes (and why it is unreachable for generated types), and the timestamp kind-byte round-trip flake tracked as #91. CLAUDE.md records that test/sw-stub/Package.swift is hand-written and silently skips fixture domains until their targets are added. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 1 + docs/forward-compat.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7c74b2448..a76ae910b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ mdl :test-service-acceptance # service-flavour RPC wiring round-trips - **sbt-git (jgit) cannot build inside a linked git worktree** (`NoWorkTreeException: Bare Repository`). When running `mdl :build` or `sbt` from a `git worktree add`-created directory (whose `.git` is a file, not a directory), sbt-git throws this error. Work around by cloning the repo to a real directory (e.g., `git clone /tmp/baboon-ci-clone`) and running the pipeline from there. - **Swift 64KB-constant limit (D7/T23):** `baboon_runtime.swift` was split into `baboon_runtime.swift` (pure domain runtime) and `baboon_service_wiring.swift` (service-wiring runtime) to stay within the JVM 64KB bytecode-constant limit hit when the Swift runtime is embedded via `PortableResource.embedSources`. After modifying `baboon-compiler/src/main/resources/baboon-runtime/`, run `sbt clean` before `sbt compile` (the macro caches content per build). - **Rust / Swift 64KB-constant limit (forward-compat envelope work, 2026-09):** `baboon_codecs_facade.rs` and then `baboon_runtime.swift` crossed the same JVM string-constant limit. Rust: `BaboonTypeMeta` and `mod baboon_type_meta_codec` live in `baboon_type_meta.rs`, re-exported from the facade so every `crate::baboon_codecs_facade::…` path still resolves. Swift: `BaboonVersion`/`BaboonDomainVersion`/`BaboonTypeMeta`/`BaboonTypeMetaCodec` live in `baboon_type_meta.swift` (same module, no imports needed). The compile error to recognise is `UTF8 string too large ... bad String constant of length N` while emitting `BaboonRuntimeResources$`; the fix is always to split the runtime file and add an output entry for the new file in the translator's shared-runtime list. +- **Swift `Package.swift` is hand-written:** `test/sw-stub/Package.swift` lists every generated module as an explicit `.target` (one per domain version, older versions depended upon by newer ones), a `.testTarget` per module under `Tests/BaboonTests/`, and the `RuntimeTests` dependencies. SwiftPM only *warns* about `Sources/` folders that belong to no target, so a fixture domain added to the shared model dir compiles and is tested in the other 8 backends but is silently skipped by the Swift lane until its targets are added (this happened to `fwd-e2e-ok` / `fwd-e2e-chain-ok` in 2026-09). Add the targets in the same commit as the fixture. - **Swift `@BaboonIndirect` recursive-DTO boxing (D8/T23):** Swift value types (`struct`) cannot have stored properties of their own type (infinite size). Baboon detects recursive DTO fields and wraps them in `@BaboonIndirect` (a property-wrapper that boxes the value behind a `Box` class). Any new test model that includes recursive DTOs in Swift must compile with this boxing in place. - **Scala MCP requires Either-mode serviceResult (D24/T69):** the Scala MCP dispatch runtime (`AbstractBaboonMcpServer.handle` in `baboon-runtime/scala/BaboonMcpRuntime.scala`) is synchronous and Either-shaped — `handle` matches `Right`/`Left` on the wiring's `invokeJson` result to drive MCP Channel-A/B. It is NOT generic over the configurable `serviceResult` container that `ScServiceWiringTranslator` honors. Combining a non-Either serviceResult (HKT `F[_,_]`, a custom result type, or `--service-result-no-errors=true`) with `--scala-generate-mcp-server=true` is rejected up front with `TranslationIssue.ScalaMcpRequiresEither` (an actionable compiler error) rather than emitting a delegate whose type mismatches the wiring container. The Scala MCP lane (`test-gen-scala-mcp`) therefore stays on `--service-result-type=Either`. Making Scala MCP container-generic is the async-axis (D24) rework, out of scope here. - **MCP stub model directory isolation (D9/T24):** The MCP stub model lives at `baboon-compiler/src/test/resources/mcp-stub-ok/` — intentionally OUTSIDE the shared `baboon-compiler/src/test/resources/baboon/` model-dir. All `test-gen-*-wiring` and `test-gen-{regular,wrapped}-adt` actions use `--model-dir ./baboon-compiler/src/test/resources/baboon/` and therefore scan every subdirectory. Placing the MCP stub inside the shared dir caused cross-namespace fixture-reference failures in 8 wiring/test lanes (D9). The dedicated `test-gen--mcp` actions point at `--model-dir ./baboon-compiler/src/test/resources/mcp-stub-ok/`. A minimal recursive-only `Tree` model (`baboon-compiler/src/test/resources/baboon/recursive-ok/recursive.baboon`) preserves recursive-DTO coverage in the shared matrix without the cross-namespace hazard. diff --git a/docs/forward-compat.md b/docs/forward-compat.md index a3b73904e..18113be13 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -496,6 +496,30 @@ Two consequences of scheme 2: envelope bound at 1.0.0 with its 2.0.0 codec (fail-first: the previous reader picked the bound version's codec and dropped the 2.0.0 field). +## Known gaps + +- **`prefix-any-mode` has no runtime end-to-end test.** The tier requires an + appended *fixed-length* field, and every fixed-length scalar is + non-defaultable, so such a step needs a hand-written conversion — which the + stub projects do not carry. The tier is covered at the compiler level + (`ForwardCompatComparatorTest`, isolated `fwd-compat-ok` fixtures) and the + runtime suites cover the negative side only: an indexed context does not + lower the bound for a variable-length append. +- **Missing-tier handling is not uniform.** When a value's + `baboonMinReaderVersions` lacks the tier the writer needs, Scala, TypeScript, + C#, Kotlin, KMP, Java and Dart fail fast; Python, Swift and Rust fall back to + the byte-identical bound (no lowering), following their pre-existing + leniency for the JSON bound, because those runtimes give + `baboonMinReaderVersions` a default in the base trait. The compiler emits all + four tiers for every generated type, so the difference is unreachable for + generated code and only visible to hand-written `BaboonGenerated` + implementations. +- **Cross-runtime timestamp round trips are not byte-stable.** The `tso`/`tsu` + kind byte is derived from the writer's local time zone independently in each + runtime; unrelated to the envelope but it makes the converter round-trip + test (`RTCodecTest`) intermittently fail on random fixtures. Tracked as + https://github.com/7mind/baboon/issues/91. + ## Out of scope (recorded) UEBA format evolution (on-wire index count + body length + skipping decoder) From 69bbb4c48a519d9a4148f370b76bde19af4bac6a Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Wed, 16 Sep 2026 12:41:16 +0100 Subject: [PATCH 18/48] fix(runtime): uniform fail-fast on a missing min-reader tier; structural fallback in the converter round-trip test Missing-tier handling: Python, Swift and Rust fell back to the byte-identical bound when baboonMinReaderVersions lacked the json-additive or prefix tier, because their base types gave the member a default; the other seven runtimes failed fast. The defaults are removed (abstract property / no protocol-extension default / required trait method), the writers raise an encoder failure, the Rust facade's type-meta construction returns Result instead of a bare value, and the hand-written test fakes in the Swift and Rust stubs now provide the four tiers. Fail-first tests: test_min_reader_tiers_fail_fast.py, MinReaderTiersFailFastTests.swift, min_reader_tiers_fail_fast_tests.rs. Timestamp kind byte (7mind/baboon#91): the C# RpDateTime intentionally carries its DateTimeKind on the wire and the Scala/JVM side is left unchanged, so a tso/tsu is not byte-stable across writers by design. RTCodecTest therefore falls back to a structural, equal-length comparison when the re-encoded bytes differ, which is the property the round trip actually promises. Python wrote 2 (Local) for every non-zero offset; it now writes 0 like the other non-.NET runtimes (test_timestamp_kind_byte.py). docs/ueba-format.md defines the byte. Co-Authored-By: Claude Fable 5.1 --- .../baboon/tests/RTCodecTest.scala | 28 ++++- .../python/baboon_codecs_facade.py | 7 +- .../python/baboon_runtime_shared.py | 20 ++-- .../rust/baboon_codecs_facade.rs | 59 +++++----- .../baboon-runtime/swift/baboon_runtime.swift | 3 +- .../swift/baboon_type_meta.swift | 19 ++-- docs/forward-compat.md | 36 +++--- docs/ueba-format.md | 2 +- .../test_min_reader_tiers_fail_fast.py | 54 +++++++++ .../RuntimeTests/test_timestamp_kind_byte.py | 26 +++++ test/rs-stub/tests/any_meta_codec_tests.rs | 2 + test/rs-stub/tests/any_round_trip_tests.rs | 1 + .../baboon_codecs_facade_latest_tests.rs | 1 + .../tests/baboon_type_meta_codec_tests.rs | 1 + .../tests/min_reader_tiers_fail_fast_tests.rs | 104 ++++++++++++++++++ .../RuntimeTests/AnyMetaCodecTests.swift | 2 + .../Tests/RuntimeTests/BaboonExtTests.swift | 1 + .../MinReaderTiersFailFastTests.swift | 35 ++++++ 18 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_min_reader_tiers_fail_fast.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_timestamp_kind_byte.py create mode 100644 test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs create mode 100644 test/sw-stub/Tests/RuntimeTests/MinReaderTiersFailFastTests.swift diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RTCodecTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RTCodecTest.scala index cd6af4693..0f4bc89a4 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RTCodecTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RTCodecTest.scala @@ -293,12 +293,28 @@ abstract class RTCodecTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] exten ) } - // Compare - _ <- F.fromEither { - if (uebaBytes == reEncodedBytes) { - Right(()) - } else { - Left(new RuntimeException(s"Roundtrip failed for $fileName: byte arrays differ")) + // Compare. Byte-exact is the norm; the one legitimate difference is the `tso`/`tsu` + // kind byte: C# writes its `DateTimeKind` (Local when the offset matches the writer's + // TimeZoneInfo.Local), the converter emulates that with Java's zone rules, and the two + // engines disagree on the local offset of arbitrary instants (7mind/baboon#91). JSON + // cannot carry the kind, so when the bytes differ the round trip must still be + // structurally lossless and of identical length. + _ <- { + if (uebaBytes == reEncodedBytes) F.unit + else { + for { + reDecoded <- codec.decode(fam, Pkg(NEList("testpkg", "pkg0")), Version.parse("3.0.0"), typeId, reEncodedBytes).leftMap { + err => + BaboonIssue.Translation( + TranslationIssue + .TranslationBug()(Issue.IssueContext(SourceFilePosition.unknown, new RuntimeException(s"Re-decode failed for $fileName (typeId=$typeId): $err"))) + ) + } + _ <- F.fromEither { + if (uebaBytes.length == reEncodedBytes.length && reDecoded == jsonContent) Right(()) + else Left(new RuntimeException(s"Roundtrip failed for $fileName: byte arrays differ and the re-decoded value is not equal")) + } + } yield () } } } yield () diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index fa9919aaf..e4fc6998f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -143,9 +143,10 @@ def _bin_type_meta(value: BaboonGenerated, ctx: BaboonCodecContext) -> BaboonTyp if not v2 and ctx.forward_write_policy == ForwardWritePolicy.STRICT: return meta tier = BaboonTypeMeta.UEBA_PREFIX_ANY_MODE_TIER if ctx.use_indices else BaboonTypeMeta.UEBA_PREFIX_COMPACT_TIER - # baboon_min_reader_versions has a non-abstract default (see BaboonGenerated); a missing - # prefix bound means "no forward-read beyond byte-identity", i.e. keep min_compat - bound = value.baboon_min_reader_versions.get(tier, meta.domain_version_min_compat) + bound = value.baboon_min_reader_versions.get(tier) + if bound is None: + raise BaboonCodecException.EncoderFailure( + f"baboon_min_reader_versions lacks '{tier}' for type {value.baboon_type_identifier}") if v2: # V2 carries both bounds; the writer policy is irrelevant return meta.model_copy(update={"meta_version": BaboonTypeMetaCodec.META_VERSION_2, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index f009c70eb..2f0283f87 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict +from .baboon_exceptions import BaboonCodecException + T = TypeVar("T") @@ -55,11 +57,13 @@ def baboon_forward_readable(self) -> dict[str, str]: # Writer-side inverse of baboon_forward_readable: guarantee tier -> oldest domain # version whose codec can decode THIS version's encoding of this type. The # "identical" bound equals baboon_same_in_versions[0]; the "json-additive" bound is - # published as `$rv`. Non-abstract default (= "no bound beyond byte-identity") keeps - # hand-written stubs working; generated classes override it with a ClassVar. + # published as `$rv`, the prefix-* bounds feed the binary envelope. Abstract: the + # envelope writer fails fast when a tier is missing, so every implementation must + # provide all four (generated classes do so with a ClassVar). @property + @abstractmethod def baboon_min_reader_versions(self) -> dict[str, str]: - return {} + raise NotImplementedError class BaboonAdtMemberMeta(ABC): @property @@ -318,7 +322,8 @@ def write_datetime(self, d: datetime): self.write_i64(cs_local_millis_0001) self.write_i64(offset_ms) - kind = 1 if offset_ms == 0 else 2 + # docs/ueba-format.md: kind is a pure function of the offset (1 = UTC, 0 = otherwise) + kind = 1 if offset_ms == 0 else 0 self.write_byte(kind) def write_bytes(self, b: bytes): @@ -583,9 +588,10 @@ def from_instance(value: BaboonGenerated) -> 'BaboonTypeMeta': type_identifier = value.baboon_type_identifier min_compat = value.baboon_same_in_versions[0] - # baboon_min_reader_versions has a non-abstract default (see BaboonGenerated); a missing - # json-additive bound means "no forward-read beyond byte-identity", i.e. = min_compat - readable_min = value.baboon_min_reader_versions.get(BaboonTypeMeta.JSON_READABLE_TIER, min_compat) + readable_min = value.baboon_min_reader_versions.get(BaboonTypeMeta.JSON_READABLE_TIER) + if readable_min is None: + raise BaboonCodecException.EncoderFailure( + f"baboon_min_reader_versions lacks '{BaboonTypeMeta.JSON_READABLE_TIER}' for type {value.baboon_type_identifier}") return BaboonTypeMeta( meta_version=BaboonTypeMetaCodec.META_VERSION, domain_identifier=value.baboon_domain_identifier, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index cdd57e693..f94eb7e32 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -36,10 +36,9 @@ pub trait BaboonGeneratedDyn: std::any::Any + Send + Sync { /// Writer-side inverse of `baboon_forward_readable_dyn`: `(guarantee tier, oldest domain /// version whose codec can decode THIS version's encoding of this type)`. The "identical" /// bound equals `baboon_same_in_versions_dyn()[0]`; the "json-additive" bound is published - /// as `$rv`. Default is empty (= no bound beyond byte-identity); generated impls override. - fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { - Vec::new() - } + /// as `$rv`, the prefix-* bounds feed the binary envelope. No default: the envelope writer + /// fails fast when a tier is missing, so every impl must provide all four (generated impls do). + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)>; fn as_any(&self) -> &dyn std::any::Any; /// Consume the box and recover an `Any` for downcasting via `Box::downcast`. Mirrors /// the C# `if (current is TTo result)` pattern used in `Convert`. @@ -849,10 +848,10 @@ impl BaboonCodecsFacade { /// `baboon_same_in_versions_dyn()` is non-empty. Index `[0]` here panics on violation. /// Envelope for a UEBA payload written under `ctx`: `type_meta_from` with /// `domain_version_min_compat` lowered to the prefix bound of the context's index mode when the - /// writer policy is `Tolerant`. A missing prefix bound (trait default is empty) means "no - /// forward-read beyond byte-identity", i.e. min_compat is kept. - fn bin_type_meta(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool, ctx: &BaboonCodecContext) -> BaboonTypeMeta { - let mut meta = self.type_meta_from(value, is_adt_trait); + /// writer policy is `Tolerant`, or with both bounds under v2. Fails fast when the value lacks + /// the tier. + fn bin_type_meta(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool, ctx: &BaboonCodecContext) -> Result { + let mut meta = self.type_meta_from(value, is_adt_trait)?; let v2 = ctx.envelope_version() == BaboonEnvelopeVersion::V2; if v2 || ctx.forward_write_policy() == ForwardWritePolicy::Tolerant { let tier = if ctx.use_indices() { @@ -860,12 +859,7 @@ impl BaboonCodecsFacade { } else { BaboonTypeMeta::UEBA_PREFIX_COMPACT_TIER }; - let bound = value - .baboon_min_reader_versions_dyn() - .into_iter() - .find(|(t, _)| t == tier) - .map(|(_, v)| v) - .unwrap_or_else(|| meta.domain_version_min_compat.clone()); + let bound = Self::min_reader_bound(value, tier)?; if v2 { // V2 carries both bounds; the writer policy is irrelevant meta.meta_version = BaboonTypeMeta::META_VERSION_2; @@ -874,10 +868,26 @@ impl BaboonCodecsFacade { meta.domain_version_min_compat = bound; } } - meta + Ok(meta) } - fn type_meta_from(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool) -> BaboonTypeMeta { + fn min_reader_bound(value: &dyn BaboonGeneratedDyn, tier: &str) -> Result { + value + .baboon_min_reader_versions_dyn() + .into_iter() + .find(|(t, _)| t == tier) + .map(|(_, v)| v) + .ok_or_else(|| { + BaboonCodecError::encoder_failure(format!( + "baboon_min_reader_versions_dyn lacks '{}' for type [{}.{}]", + tier, + value.baboon_domain_identifier_dyn(), + value.baboon_type_identifier_dyn() + )) + }) + } + + fn type_meta_from(&self, value: &dyn BaboonGeneratedDyn, is_adt_trait: bool) -> Result { let type_identifier = if is_adt_trait { match value.as_adt_member_meta_dyn() { Some(adt) => adt.baboon_adt_type_identifier_dyn(), @@ -896,22 +906,15 @@ impl BaboonCodecsFacade { value.baboon_type_identifier_dyn() ); let min_compat = same_in[0].clone(); - // `baboon_min_reader_versions_dyn` has a trait default (empty); a missing json-additive - // bound means "no forward-read beyond byte-identity", i.e. = min_compat - let readable_min = value - .baboon_min_reader_versions_dyn() - .into_iter() - .find(|(tier, _)| tier == BaboonTypeMeta::JSON_READABLE_TIER) - .map(|(_, v)| v) - .unwrap_or_else(|| min_compat.clone()); - BaboonTypeMeta::new( + let readable_min = Self::min_reader_bound(value, BaboonTypeMeta::JSON_READABLE_TIER)?; + Ok(BaboonTypeMeta::new( BaboonTypeMeta::META_VERSION, value.baboon_domain_identifier_dyn(), value.baboon_domain_version_dyn(), min_compat, type_identifier, ) - .with_readable_min(readable_min) + .with_readable_min(readable_min)) } pub fn encode_to_bin_with_override( @@ -934,7 +937,7 @@ impl BaboonCodecsFacade { type_meta_override: Option<&BaboonTypeMeta>, is_adt_trait: bool, ) -> Result, BaboonCodecError> { - let type_meta = self.bin_type_meta(value, is_adt_trait, ctx); + let type_meta = self.bin_type_meta(value, is_adt_trait, ctx)?; let codec = self.get_bin_codec(&type_meta, true)?; let effective = type_meta_override.unwrap_or(&type_meta); let mut buf = Vec::new(); @@ -975,7 +978,7 @@ impl BaboonCodecsFacade { type_meta_override: Option<&BaboonTypeMeta>, is_adt_trait: bool, ) -> Result { - let type_meta = self.type_meta_from(value, is_adt_trait); + let type_meta = self.type_meta_from(value, is_adt_trait)?; let codec = self.get_json_codec(&type_meta, true)?; let content = codec .encode_json_dyn(&BaboonCodecContext::Compact, value) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 125eecdde..7cf81586a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -1330,9 +1330,10 @@ public protocol BaboonMetaProvider { // Default keeps hand-written conforming stubs source-compatible; generated types // override it with the real per-type table. +// `baboonMinReaderVersions` has no default: the envelope writer fails fast when a tier is missing, +// so every conformance must provide all four (generated types do). public extension BaboonMetaProvider { var baboonForwardReadable: [String: String] { [:] } - var baboonMinReaderVersions: [String: String] { [:] } } // Implemented by generated ADT branches. Mirrors Kotlin/Dart `BaboonAdtMember` for the diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift index 3ec6f8d8c..69a43fe02 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_type_meta.swift @@ -193,9 +193,11 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { "BaboonTypeMeta.from: empty baboonSameInVersions for type [\(meta.baboonDomainIdentifier).\(typeId)]" ) } - // baboonMinReaderVersions has a protocol-extension default (see BaboonMetaProvider); a - // missing json-additive bound means "no forward-read beyond byte-identity", i.e. = minCompat - let readableMin = meta.baboonMinReaderVersions[BaboonTypeMeta.jsonReadableTier] ?? sameIn[0] + guard let readableMin = meta.baboonMinReaderVersions[BaboonTypeMeta.jsonReadableTier] else { + throw BaboonException( + "BaboonTypeMeta.from: baboonMinReaderVersions lacks \"\(BaboonTypeMeta.jsonReadableTier)\" for type [\(meta.baboonDomainIdentifier).\(typeId)]" + ) + } return BaboonTypeMeta( BaboonTypeMetaCodec.metaVersion, meta.baboonDomainIdentifier, @@ -207,9 +209,8 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { } // Envelope for a UEBA payload written under `ctx`: `from` with `domainVersionMinCompat` lowered - // to the prefix bound of the context's index mode when the writer policy is `.tolerant`. A - // missing prefix bound (protocol-extension default) means "no forward-read beyond - // byte-identity", i.e. minCompat is kept. + // to the prefix bound of the context's index mode when the writer policy is `.tolerant`, or with + // both bounds under v2. Fails fast when the value lacks the tier. public static func forBin(_ value: Any, _ ctx: BaboonCodecContext, useAdtIdentifier: Bool = false) throws -> BaboonTypeMeta { let meta = try from(value, useAdtIdentifier: useAdtIdentifier) let v2 = ctx.envelopeVersion == .v2 @@ -217,7 +218,11 @@ public struct BaboonTypeMeta: Hashable, CustomStringConvertible { return meta } let tier = ctx.useIndices ? BaboonTypeMeta.uebaPrefixAnyModeTier : BaboonTypeMeta.uebaPrefixCompactTier - let bound = provider.baboonMinReaderVersions[tier] ?? meta.domainVersionMinCompat + guard let bound = provider.baboonMinReaderVersions[tier] else { + throw BaboonException( + "BaboonTypeMeta.forBin: baboonMinReaderVersions lacks \"\(tier)\" for type [\(meta.domainIdentifier).\(meta.typeIdentifier)]" + ) + } // v2 carries both bounds (the writer policy is irrelevant); v1 tolerant puts the prefix bound in its single slot return v2 ? BaboonTypeMeta(BaboonTypeMetaCodec.metaVersion2, meta.domainIdentifier, meta.domainVersion, meta.domainVersionMinCompat, meta.typeIdentifier, bound) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 18113be13..ba8e34405 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -87,7 +87,10 @@ ranges — generated types carry `baboonMinReaderVersions` (tier → oldest read version; the `identical` bound equals `baboonSameInVersions.head`, the `json-additive` bound feeds `$rv`). `$rv` is elided when equal to the effective `$uv`, so envelopes of unchanged types are byte-identical to before; invariant -`$rv <= $uv <= $v`. Old readers ignore the key. +`$rv <= $uv <= $v`. Old readers ignore the key. A value whose +`baboonMinReaderVersions` lacks the tier a writer needs is an encoder failure in +every runtime — generated types always carry all four tiers, so this only +guards hand-written implementations. Readers choose via `ForwardReadPolicy`: @@ -505,20 +508,23 @@ Two consequences of scheme 2: (`ForwardCompatComparatorTest`, isolated `fwd-compat-ok` fixtures) and the runtime suites cover the negative side only: an indexed context does not lower the bound for a variable-length append. -- **Missing-tier handling is not uniform.** When a value's - `baboonMinReaderVersions` lacks the tier the writer needs, Scala, TypeScript, - C#, Kotlin, KMP, Java and Dart fail fast; Python, Swift and Rust fall back to - the byte-identical bound (no lowering), following their pre-existing - leniency for the JSON bound, because those runtimes give - `baboonMinReaderVersions` a default in the base trait. The compiler emits all - four tiers for every generated type, so the difference is unreachable for - generated code and only visible to hand-written `BaboonGenerated` - implementations. -- **Cross-runtime timestamp round trips are not byte-stable.** The `tso`/`tsu` - kind byte is derived from the writer's local time zone independently in each - runtime; unrelated to the envelope but it makes the converter round-trip - test (`RTCodecTest`) intermittently fail on random fixtures. Tracked as - https://github.com/7mind/baboon/issues/91. + +Closed gaps, kept for the record: + +- *Missing-tier handling* used to differ: Python, Swift and Rust fell back to + the byte-identical bound when `baboonMinReaderVersions` lacked a tier, the + other seven runtimes failed fast. All ten now fail fast (encoder failure), + `baboonMinReaderVersions` has no default in any runtime's base type, and each + of the three has a regression test with a hand-written value lacking the tiers. +- *Timestamp kind byte round trips* (https://github.com/7mind/baboon/issues/91): + the C# `RpDateTime` keeps carrying its `DateTimeKind` on the wire (Local when + the offset matches the writer's zone) — this is intentional and unchanged, as + is the Scala side — so byte-for-byte equality of a `tso`/`tsu` across writers + is not guaranteed (`docs/ueba-format.md`). The converter round-trip test + (`RTCodecTest`) now falls back to a structural, equal-length comparison when + bytes differ, which is the property the round trip actually promises. Python + wrote `2` (Local) for every non-zero offset; it now writes `0` like the other + non-.NET runtimes. ## Out of scope (recorded) diff --git a/docs/ueba-format.md b/docs/ueba-format.md index 513af6817..10f8bb605 100644 --- a/docs/ueba-format.md +++ b/docs/ueba-format.md @@ -20,7 +20,7 @@ UEBA has a sister format, [SICK](https://github.com/7mind/sick), which is an ind - `str`: UTF‑8 bytes prefixed by a varint length (LEB128, 7 bits per byte, continuation bit 0x80). - `bytes`: length `i32` then raw bytes. - `uid`: 16 bytes in .NET GUID mixed‑endian layout (fields Data1/2/3 little‑endian, Data4 big‑endian). -- `tsu`/`tso`: two `i64` values (milliseconds since epoch, offset millis) plus one `i8` “kind” flag. +- `tsu`/`tso`: two `i64` values (local milliseconds since the .NET epoch, offset millis) plus one `i8` "kind" flag mirroring .NET `DateTimeKind`: `0` Unspecified, `1` Utc, `2` Local. C# writes the `RpDateTime.Kind` it holds (`DetectKind`: Utc for a zero offset, Local when the offset equals `TimeZoneInfo.Local`'s offset for that instant, else Unspecified), and the compiler's converter emulates that with the JVM's zone rules; every other runtime writes `1` for a zero offset and `0` otherwise. Readers other than C# ignore the byte beyond range-checking. Because "Local" depends on the writer's time zone, byte-for-byte equality of a timestamp across writers is not guaranteed — only structural equality is. ## Collections diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_min_reader_tiers_fail_fast.py b/test/py-stub/BaboonTests/RuntimeTests/test_min_reader_tiers_fail_fast.py new file mode 100644 index 000000000..9e2979f32 --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_min_reader_tiers_fail_fast.py @@ -0,0 +1,54 @@ +# Every runtime fails fast when a value's `baboon_min_reader_versions` lacks the tier the envelope +# needs (docs/forward-compat.md, "Envelope integration"). Generated types always carry all four +# tiers; this guards hand-written implementations. +import unittest + +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext, BaboonEnvelopeVersion, ForwardWritePolicy +from BaboonDefinitions.Generated.baboon_codecs_facade import BaboonCodecsFacade +from BaboonDefinitions.Generated.baboon_exceptions import BaboonCodecException +from BaboonDefinitions.Generated.baboon_runtime_shared import BaboonGenerated, BaboonTypeMeta + + +class _NoTiers(BaboonGenerated): + def __init__(self, tiers: dict[str, str]): + self._tiers = tiers + + @property + def baboon_domain_identifier(self) -> str: + return "t.d" + + @property + def baboon_domain_version(self) -> str: + return "1.0.0" + + @property + def baboon_type_identifier(self) -> str: + return "t.d/:#T" + + @property + def baboon_same_in_versions(self) -> list[str]: + return ["1.0.0"] + + @property + def baboon_min_reader_versions(self) -> dict[str, str]: + return self._tiers + + +class TestMinReaderTiersFailFast(unittest.TestCase): + def test_json_bound_missing_raises(self): + with self.assertRaisesRegex(BaboonCodecException, "json-additive"): + BaboonTypeMeta.from_instance(_NoTiers({})) + # with the JSON bound present the meta is built normally + self.assertEqual("1.0.0", BaboonTypeMeta.from_instance(_NoTiers({"json-additive": "1.0.0"})).domain_version_readable_min) + + def test_prefix_bound_missing_raises_for_v2(self): + json_only = _NoTiers({"json-additive": "1.0.0"}) + v2 = BaboonCodecContext.custom(False, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V2, None) + with self.assertRaisesRegex(BaboonCodecException, "prefix-compact"): + BaboonCodecsFacade._bin_type_meta(json_only, v2) + # the default v1/Strict context needs no prefix tier + self.assertEqual(1, BaboonCodecsFacade._bin_type_meta(json_only, BaboonCodecContext.Compact).meta_version) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_timestamp_kind_byte.py b/test/py-stub/BaboonTests/RuntimeTests/test_timestamp_kind_byte.py new file mode 100644 index 000000000..a9bf6e067 --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_timestamp_kind_byte.py @@ -0,0 +1,26 @@ +# docs/ueba-format.md: the trailing `i8` kind byte of a `tso`/`tsu` value is a pure function of the +# offset -- 1 when the offset is zero, 0 otherwise -- and never the writer's environment +# (https://github.com/7mind/baboon/issues/91). +import unittest +from datetime import datetime, timedelta, timezone +from io import BytesIO + +from BaboonDefinitions.Generated.baboon_runtime_shared import LEDataOutputStream + + +class TestTimestampKindByte(unittest.TestCase): + def _kind(self, d: datetime) -> int: + out = LEDataOutputStream(BytesIO()) + out.write_datetime(d) + b = out.stream.getvalue() + self.assertEqual(17, len(b)) + return b[16] + + def test_kind_byte_is_a_pure_function_of_the_offset(self): + self.assertEqual(0, self._kind(datetime(2026, 9, 16, 12, tzinfo=timezone(timedelta(hours=5, minutes=30))))) + self.assertEqual(0, self._kind(datetime(2026, 9, 16, 12, tzinfo=timezone(timedelta(hours=-3))))) + self.assertEqual(1, self._kind(datetime(2026, 9, 16, 12, tzinfo=timezone.utc))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/rs-stub/tests/any_meta_codec_tests.rs b/test/rs-stub/tests/any_meta_codec_tests.rs index 4773e3b07..29a5ac5c4 100644 --- a/test/rs-stub/tests/any_meta_codec_tests.rs +++ b/test/rs-stub/tests/any_meta_codec_tests.rs @@ -494,6 +494,7 @@ impl BaboonGeneratedDyn for SampleV1 { fn baboon_domain_identifier_dyn(&self) -> &str { "my.dom" } fn baboon_type_identifier_dyn(&self) -> &str { "Sample" } fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["1.0.0".to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![("identical".to_string(), "1.0.0".to_string()), ("prefix-any-mode".to_string(), "1.0.0".to_string()), ("prefix-compact".to_string(), "1.0.0".to_string()), ("json-additive".to_string(), "1.0.0".to_string())] } fn as_any(&self) -> &dyn std::any::Any { self } fn into_any(self: Box) -> Box { self } } @@ -510,6 +511,7 @@ impl BaboonGeneratedDyn for SampleV2 { // Min-compat distinct from current version: this exercises the D04 fix (was previously // collapsed to current version, suppressing $uv emission and has-min-compat=0 in binary). fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["1.0.0".to_string(), "2.0.0".to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![("identical".to_string(), "1.0.0".to_string()), ("prefix-any-mode".to_string(), "1.0.0".to_string()), ("prefix-compact".to_string(), "1.0.0".to_string()), ("json-additive".to_string(), "1.0.0".to_string())] } fn as_any(&self) -> &dyn std::any::Any { self } fn into_any(self: Box) -> Box { self } } diff --git a/test/rs-stub/tests/any_round_trip_tests.rs b/test/rs-stub/tests/any_round_trip_tests.rs index b16babc16..144c3194a 100644 --- a/test/rs-stub/tests/any_round_trip_tests.rs +++ b/test/rs-stub/tests/any_round_trip_tests.rs @@ -44,6 +44,7 @@ impl BaboonGeneratedDyn for InnerDyn { fn baboon_domain_identifier_dyn(&self) -> &str { DOMAIN_ID } fn baboon_type_identifier_dyn(&self) -> &str { INNER_TYPE } fn baboon_same_in_versions_dyn(&self) -> Vec { vec![VERSION_STR.to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![("identical".to_string(), "1.0.0".to_string()), ("prefix-any-mode".to_string(), "1.0.0".to_string()), ("prefix-compact".to_string(), "1.0.0".to_string()), ("json-additive".to_string(), "1.0.0".to_string())] } fn as_any(&self) -> &dyn std::any::Any { self } fn into_any(self: Box) -> Box { self } } diff --git a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs index 62dd98e6b..4757a0e2a 100644 --- a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs +++ b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs @@ -37,6 +37,7 @@ impl BaboonGeneratedDyn for InnerDyn { fn baboon_domain_identifier_dyn(&self) -> &str { DOMAIN_ID } fn baboon_type_identifier_dyn(&self) -> &str { INNER_TYPE } fn baboon_same_in_versions_dyn(&self) -> Vec { vec![VERSION_STR.to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![("identical".to_string(), "1.0.0".to_string()), ("prefix-any-mode".to_string(), "1.0.0".to_string()), ("prefix-compact".to_string(), "1.0.0".to_string()), ("json-additive".to_string(), "1.0.0".to_string())] } fn as_any(&self) -> &dyn std::any::Any { self } fn into_any(self: Box) -> Box { self } } diff --git a/test/rs-stub/tests/baboon_type_meta_codec_tests.rs b/test/rs-stub/tests/baboon_type_meta_codec_tests.rs index 915d71ce3..882f5f178 100644 --- a/test/rs-stub/tests/baboon_type_meta_codec_tests.rs +++ b/test/rs-stub/tests/baboon_type_meta_codec_tests.rs @@ -31,6 +31,7 @@ impl BaboonGeneratedDyn for TestVal { fn baboon_domain_identifier_dyn(&self) -> &str { "test.dom" } fn baboon_type_identifier_dyn(&self) -> &str { "TestType" } fn baboon_same_in_versions_dyn(&self) -> Vec { vec!["1.0.0".to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![("identical".to_string(), "1.0.0".to_string()), ("prefix-any-mode".to_string(), "1.0.0".to_string()), ("prefix-compact".to_string(), "1.0.0".to_string()), ("json-additive".to_string(), "1.0.0".to_string())] } fn as_any(&self) -> &dyn std::any::Any { self } fn into_any(self: Box) -> Box { self } } diff --git a/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs b/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs new file mode 100644 index 000000000..e3d6795fc --- /dev/null +++ b/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs @@ -0,0 +1,104 @@ +// Every runtime fails fast when a value's `baboon_min_reader_versions_dyn` lacks the tier the +// envelope needs (docs/forward-compat.md, "Envelope integration"). Generated types always carry all +// four tiers; this guards hand-written impls, which used to be silently written with the +// byte-identical bound in place of the missing one. +use baboon_rs_stub::any_opaque::BaboonCodecError; +use baboon_rs_stub::baboon_codecs_facade::{ + AbstractBaboonJsonCodecsImpl, AbstractBaboonUebaCodecsImpl, BaboonAnyBinCodec, BaboonAnyJsonCodec, + BaboonAnyMeta, BaboonCodecsFacade, BaboonDomainVersion, BaboonGeneratedDyn, +}; +use baboon_rs_stub::baboon_runtime::{BaboonCodecContext, BaboonEnvelopeVersion, ForwardWritePolicy}; +use std::io::{Read, Write}; +use std::sync::Arc; + +const DOMAIN: &str = "t.d"; +const VERSION: &str = "1.0.0"; +const TYPE_ID: &str = "t.d/:#T"; + +/// A hand-written value whose min-reader table is chosen per test. +#[derive(Debug, Clone, PartialEq)] +struct NoTiers { + tiers: Vec<(String, String)>, +} + +impl NoTiers { + fn none() -> Self { NoTiers { tiers: Vec::new() } } + fn json_only() -> Self { NoTiers { tiers: vec![("json-additive".to_string(), VERSION.to_string())] } } +} + +impl BaboonGeneratedDyn for NoTiers { + fn baboon_domain_version_dyn(&self) -> &str { VERSION } + fn baboon_domain_identifier_dyn(&self) -> &str { DOMAIN } + fn baboon_type_identifier_dyn(&self) -> &str { TYPE_ID } + fn baboon_same_in_versions_dyn(&self) -> Vec { vec![VERSION.to_string()] } + fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { self.tiers.clone() } + fn as_any(&self) -> &dyn std::any::Any { self } + fn into_any(self: Box) -> Box { self } +} + +struct NoTiersJsonCodec; +impl BaboonAnyJsonCodec for NoTiersJsonCodec { + fn type_identifier(&self) -> &str { TYPE_ID } + fn encode_json_dyn(&self, _ctx: &BaboonCodecContext, _value: &dyn BaboonGeneratedDyn) -> Result { + Ok(serde_json::json!({})) + } + fn decode_json_dyn(&self, _ctx: &BaboonCodecContext, _wire: &serde_json::Value) -> Result, BaboonCodecError> { + Ok(Box::new(NoTiers::none())) + } +} + +struct NoTiersBinCodec; +impl BaboonAnyBinCodec for NoTiersBinCodec { + fn type_identifier(&self) -> &str { TYPE_ID } + fn encode_dyn(&self, _ctx: &BaboonCodecContext, writer: &mut dyn Write, _value: &dyn BaboonGeneratedDyn) -> Result<(), BaboonCodecError> { + writer.write_all(&[0u8]).map_err(|e| BaboonCodecError::encoder_failure(format!("{}", e))) + } + fn decode_dyn(&self, _ctx: &BaboonCodecContext, reader: &mut dyn Read) -> Result, BaboonCodecError> { + let mut b = [0u8; 1]; + reader.read_exact(&mut b).map_err(|e| BaboonCodecError::decoder_failure(format!("{}", e)))?; + Ok(Box::new(NoTiers::none())) + } +} + +struct NoTiersMeta; +impl BaboonAnyMeta for NoTiersMeta { + fn same_in_versions(&self, _type_id: &str) -> Vec { vec![VERSION.to_string()] } + fn forward_readable_versions(&self, _type_id: &str) -> Vec<(String, String)> { Vec::new() } +} + +fn facade() -> BaboonCodecsFacade { + let f = BaboonCodecsFacade::new(); + f.register_with_meta( + BaboonDomainVersion::new(DOMAIN, VERSION), + || { + let mut t = AbstractBaboonJsonCodecsImpl::new(); + t.register(TYPE_ID, || Arc::new(NoTiersJsonCodec) as Arc); + Arc::new(t) + }, + || { + let mut t = AbstractBaboonUebaCodecsImpl::new(); + t.register(TYPE_ID, || Arc::new(NoTiersBinCodec) as Arc); + Arc::new(t) + }, + || Arc::new(NoTiersMeta) as Arc, + ); + f +} + +#[test] +fn json_bound_missing_is_an_encoder_failure() { + let err = facade().encode_to_json(&NoTiers::none()).err().expect("a value without the json-additive tier must not be silently encoded"); + assert!(format!("{:?}", err).contains("json-additive"), "got {:?}", err); + // with the JSON bound present the value encodes normally + assert!(facade().encode_to_json(&NoTiers::json_only()).is_ok()); +} + +#[test] +fn prefix_bound_missing_is_an_encoder_failure_for_v2() { + let v2 = BaboonCodecContext::custom(false, ForwardWritePolicy::Strict, BaboonEnvelopeVersion::V2, None); + // json-additive present, prefix-compact absent: v2 needs the prefix bound and must refuse + let err = facade().encode_to_bin(&v2, &NoTiers::json_only()).err().expect("must fail"); + assert!(format!("{:?}", err).contains("prefix-compact"), "must fail because of the missing prefix tier, got {:?}", err); + // the default v1/Strict context needs no prefix tier and encodes normally + assert!(facade().encode_to_bin(&BaboonCodecContext::Compact, &NoTiers::json_only()).is_ok()); +} diff --git a/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift b/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift index 0835d0463..bbe7f1dd7 100644 --- a/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift +++ b/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift @@ -565,6 +565,7 @@ final class AnyMetaCodecTests: XCTestCase { var baboonDomainIdentifier: String { "stub.dom" } var baboonTypeIdentifier: String { "StubT" } var baboonSameInVersions: [String] { ["1.0.0"] } + var baboonMinReaderVersions: [String: String] { ["identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0"] } } final class StubBinCodec: BaboonBinCodecBase { @@ -624,6 +625,7 @@ final class AnyMetaCodecTests: XCTestCase { var baboonDomainIdentifier: String { "stub.dom" } var baboonTypeIdentifier: String { "StubT" } var baboonSameInVersions: [String] { ["1.0.0"] } + var baboonMinReaderVersions: [String: String] { ["identical": "1.0.0", "prefix-any-mode": "1.0.0", "prefix-compact": "1.0.0", "json-additive": "1.0.0"] } } func testDecodeFromJsonLatest_absentEnvelope_returnsNil() throws { diff --git a/test/sw-stub/Tests/RuntimeTests/BaboonExtTests.swift b/test/sw-stub/Tests/RuntimeTests/BaboonExtTests.swift index 248863dc0..4ff59ecc7 100644 --- a/test/sw-stub/Tests/RuntimeTests/BaboonExtTests.swift +++ b/test/sw-stub/Tests/RuntimeTests/BaboonExtTests.swift @@ -16,6 +16,7 @@ private struct StubMeta: BaboonMetaProvider { var baboonDomainIdentifier: String var baboonTypeIdentifier: String var baboonSameInVersions: [String] + var baboonMinReaderVersions: [String: String] = [:] } // Minimal stub conforming to BaboonMeta (instance-side registry). diff --git a/test/sw-stub/Tests/RuntimeTests/MinReaderTiersFailFastTests.swift b/test/sw-stub/Tests/RuntimeTests/MinReaderTiersFailFastTests.swift new file mode 100644 index 000000000..f7833b66c --- /dev/null +++ b/test/sw-stub/Tests/RuntimeTests/MinReaderTiersFailFastTests.swift @@ -0,0 +1,35 @@ +// Every runtime fails fast when a value's `baboonMinReaderVersions` lacks the tier the envelope +// needs (docs/forward-compat.md, "Envelope integration"). Generated types always carry all four +// tiers; this guards hand-written conformances. +import XCTest +@testable import BaboonRuntime + +/// A hand-written value whose min-reader table is chosen per test. +private struct NoTiers: BaboonMetaProvider { + let tiers: [String: String] + var baboonDomainVersion: String { "1.0.0" } + var baboonDomainIdentifier: String { "t.d" } + var baboonTypeIdentifier: String { "t.d/:#T" } + var baboonSameInVersions: [String] { ["1.0.0"] } + var baboonMinReaderVersions: [String: String] { tiers } +} + +final class MinReaderTiersFailFastTests: XCTestCase { + func testJsonBoundMissingThrows() throws { + XCTAssertThrowsError(try BaboonTypeMeta.from(NoTiers(tiers: [:]))) { error in + XCTAssertTrue("\(error)".contains("json-additive"), "\(error)") + } + // with the JSON bound present the meta is built normally + XCTAssertEqual(try BaboonTypeMeta.from(NoTiers(tiers: ["json-additive": "1.0.0"])).domainVersionReadableMin, "1.0.0") + } + + func testPrefixBoundMissingThrowsForV2() throws { + let jsonOnly = NoTiers(tiers: ["json-additive": "1.0.0"]) + let v2 = BaboonCodecContext.custom(false, .strict, .v2, nil) + XCTAssertThrowsError(try BaboonTypeMeta.forBin(jsonOnly, v2)) { error in + XCTAssertTrue("\(error)".contains("prefix-compact"), "\(error)") + } + // the default v1/Strict context needs no prefix tier + XCTAssertEqual(try BaboonTypeMeta.forBin(jsonOnly, BaboonCodecContext.compact).metaVersion, 1) + } +} From 4aef4b5063c3a3209b01aa40dd190ea9acdc5e83 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 17 Sep 2026 16:47:24 +0100 Subject: [PATCH 19/48] refactor(backends): consolidate codec policies and runtime helpers Address the backend/runtime review across all targets with shared semantic plans, backend-local scalar and service emitters, and runtime-owned envelope and MCP helpers. Fix renamed ADT branch evolution and source/target conversion validation, preserving custom validator contracts. Add behavioral regressions, compatibility coverage, and the implementation ledger. Verified the full 205-action CI matrix, 200 serialization checks, and 162 RPC checks; repeated clean JVM/JS compilation, compiler tests, and native build before commit. --- .mdl/defs/tests.md | 66 +- .../baboon/tests/AnyFieldPlanTest.scala | 37 + .../tests/DocCommentSchemaEmissionTest.scala | 35 +- .../tests/DomainProductTranslatorTest.scala | 69 ++ .../tests/IdentifierRuntimeParityTest.scala | 30 + .../tests/McpInputSchemaEmissionTest.scala | 47 +- .../RenamedAdtBranchConversionTest.scala | 94 +++ .../baboon/tests/ScalarCodecEmitterTest.scala | 160 +++++ .../baboon/tests/ServiceMethodPlanTest.scala | 101 +++ .../tests/SharedTranslatorPolicyTest.scala | 87 +++ ...SchemaProjectionCharacterizationTest.scala | 60 ++ .../baboon-runtime/cs/BaboonAnyBinCodec.cs | 101 +++ .../baboon-runtime/cs/BaboonAnyJsonCodec.cs | 78 ++ .../baboon-runtime/cs/BaboonCodecs.cs | 19 +- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 67 +- .../baboon-runtime/cs/BaboonConversions.cs | 16 +- .../baboon-runtime/cs/BaboonMcpRuntime.cs | 397 ++++------- .../baboon-runtime/cs/BaboonTools.cs | 6 +- .../dart/baboon_any_opaque.dart | 176 +++++ .../dart/baboon_mcp_runtime.dart | 168 ++--- .../baboon-runtime/dart/baboon_runtime.dart | 68 +- .../java/AbstractBaboonMcpServer.java | 14 +- .../java/BaboonAnyBinCodec.java | 100 +++ .../java/BaboonAnyJsonCodec.java | 69 ++ .../baboon-runtime/java/BaboonBinCodec.java | 17 +- .../java/BaboonCodecContext.java | 10 + .../java/BaboonCodecsFacade.java | 8 +- .../baboon-runtime/java/BaboonExt.java | 27 +- .../java/BaboonMetadataAccess.java | 46 ++ .../baboon-runtime/java/BaboonTypeMeta.java | 41 +- .../kotlin-common/BaboonAnyJsonCodec.kt | 58 ++ .../BaboonCodecVersionSelection.kt | 39 + .../BaboonEither.kt | 0 .../BaboonExceptions.kt | 0 .../BaboonServiceWiring.kt | 0 .../kotlin-kmp/BaboonAnyBinCodec.kt | 87 +++ .../kotlin-kmp/BaboonCodecsFacade.kt | 41 +- .../kotlin-kmp/BaboonConversions.kt | 17 +- .../kotlin-kmp/BaboonIdentifierRepr.kt | 11 +- .../kotlin-kmp/BaboonOffsetDateTime.kt | 29 +- .../baboon-runtime/kotlin-kmp/BaboonTools.kt | 6 + .../kotlin/BaboonAnyBinCodec.kt | 91 +++ .../kotlin/BaboonCodecsFacade.kt | 41 +- .../kotlin/BaboonConversions.kt | 17 +- .../baboon-runtime/kotlin/BaboonEither.kt | 6 - .../baboon-runtime/kotlin/BaboonExceptions.kt | 9 - .../baboon-runtime/kotlin/BaboonMcpRuntime.kt | 188 ++--- .../kotlin/BaboonServiceWiring.kt | 145 ---- .../baboon-runtime/python/baboon_codecs.py | 25 +- .../python/baboon_codecs_facade.py | 24 +- .../python/baboon_conversions.py | 40 +- .../python/baboon_mcp_runtime.py | 327 +++------ .../python/baboon_runtime_shared.py | 13 +- .../baboon-runtime/rust/any_opaque.rs | 91 +++ .../rust/baboon_codecs_facade.rs | 119 ++-- .../baboon-runtime/rust/baboon_mcp_server.rs | 234 ++---- .../rust/baboon_service_wiring.rs | 94 ++- .../scala/BaboonAnyBinCodec.scala | 71 ++ .../scala/BaboonAnyJsonCodec.scala | 50 ++ .../baboon-runtime/scala/BaboonCodecs.scala | 57 +- .../scala/BaboonConversions.scala | 19 +- .../scala/BaboonMcpRuntime.scala | 188 ++--- .../swift/BaboonIdentifierRepr.swift | 23 +- .../swift/baboon_any_field_codec.swift | 203 ++++++ .../swift/baboon_mcp_runtime.swift | 501 ++++--------- .../baboon-runtime/swift/baboon_runtime.swift | 79 +-- .../typescript/BaboonAnyOpaque.ts | 132 +++- .../typescript/BaboonMcpRuntime.ts | 374 +++------- .../typescript/BaboonSharedRuntime.ts | 177 +++-- .../baboon/translator/AnyFieldPlan.scala | 26 + .../translator/BaboonRuntimeResources.scala | 1 + .../translator/DocumentationRenderer.scala | 36 + .../translator/DomainProductTranslator.scala | 25 + .../translator/IdentifierFieldKind.scala | 47 ++ .../translator/McpServerGeneratorHook.scala | 18 - .../csharp/CSBaboonTranslator.scala | 47 +- .../csharp/CSConversionTranslator.scala | 15 +- .../translator/csharp/CSDefnTranslator.scala | 80 +-- .../csharp/CSJsonCodecGenerator.scala | 174 +---- .../csharp/CSScalarCodecEmitter.scala | 73 ++ .../csharp/CSServiceMethodPlan.scala | 17 + .../csharp/CSServiceWiringTranslator.scala | 668 +++++++----------- .../baboon/translator/csharp/CSTypeInfo.scala | 7 +- .../baboon/translator/csharp/CSTypes.scala | 88 ++- .../csharp/CSUEBACodecGenerator.scala | 204 +----- .../csharp/CsMcpServerGenerator.scala | 38 +- .../translator/dart/DtAnyFieldPlan.scala | 17 + .../translator/dart/DtBaboonTranslator.scala | 83 +-- .../dart/DtConversionTranslator.scala | 17 +- .../translator/dart/DtDefnTranslator.scala | 76 +- .../translator/dart/DtForeignWirePlan.scala | 21 + .../dart/DtJsonCodecGenerator.scala | 137 +--- .../dart/DtMcpServerGenerator.scala | 28 +- .../translator/dart/DtScalarCodecOps.scala | 83 +++ .../dart/DtServiceWiringTranslator.scala | 506 ++----------- .../baboon/translator/dart/DtTypes.scala | 20 +- .../dart/DtUEBACodecGenerator.scala | 245 +------ .../graphql/GqlBaboonTranslator.scala | 60 +- .../graphql/GqlTypeTranslator.scala | 33 +- .../translator/java/JvBaboonTranslator.scala | 14 +- .../java/JvConversionTranslator.scala | 8 +- .../translator/java/JvDefnTranslator.scala | 81 +-- .../java/JvJsonCodecGenerator.scala | 172 +---- .../java/JvMcpServerGenerator.scala | 58 +- .../java/JvScalarCodecEmitter.scala | 91 +++ .../translator/java/JvServiceMethodPlan.scala | 18 + .../java/JvServiceWiringTranslator.scala | 419 ++++------- .../baboon/translator/java/JvTypes.scala | 8 +- .../java/JvUEBACodecGenerator.scala | 217 +----- .../kotlin/KtBaboonTranslator.scala | 44 +- .../kotlin/KtConversionTranslator.scala | 16 +- .../translator/kotlin/KtDefnTranslator.scala | 80 +-- .../kotlin/KtJsonCodecGenerator.scala | 161 +---- .../kotlin/KtMcpServerGenerator.scala | 16 +- .../kotlin/KtScalarCodecEmitter.scala | 107 +++ .../kotlin/KtServiceMethodPlan.scala | 20 + .../kotlin/KtServiceWiringTranslator.scala | 469 +++++------- .../translator/kotlin/KtTreeTools.scala | 33 +- .../baboon/translator/kotlin/KtTypes.scala | 2 + .../kotlin/KtUEBACodecGenerator.scala | 218 +----- .../mcp/McpInputSchemaEmitter.scala | 100 +-- .../openapi/OasBaboonTranslator.scala | 205 ++---- .../openapi/OasTypeTranslator.scala | 144 ++-- .../python/PyBaboonTranslator.scala | 13 +- .../python/PyConversionTranslator.scala | 65 +- .../translator/python/PyDefnTranslator.scala | 104 +-- .../translator/python/PyFieldPlan.scala | 73 ++ .../python/PyJsonCodecGenerator.scala | 166 ++--- .../python/PyMcpServerGenerator.scala | 34 +- .../translator/python/PyScalarCodecOps.scala | 62 ++ .../python/PyServiceWiringTranslator.scala | 86 +-- .../python/PyUEBACodecGenerator.scala | 64 +- .../translator/rust/RsBaboonTranslator.scala | 26 +- .../rust/RsConversionTranslator.scala | 140 ++-- .../translator/rust/RsDefnTranslator.scala | 168 +---- .../rust/RsFieldRepresentation.scala | 70 ++ .../rust/RsMcpServerGenerator.scala | 30 +- .../rust/RsServiceWiringTranslator.scala | 227 +++--- .../rust/RsUEBACodecGenerator.scala | 149 +--- .../baboon/translator/schema/JsonSchema.scala | 20 + .../translator/schema/SchemaReferences.scala | 40 ++ .../translator/scl/ScBaboonTranslator.scala | 35 +- .../scl/ScConversionTranslator.scala | 24 +- .../translator/scl/ScDefnTranslator.scala | 71 +- .../translator/scl/ScDomainTreeTools.scala | 37 +- .../translator/scl/ScJsonCodecGenerator.scala | 139 +--- .../translator/scl/ScMcpServerGenerator.scala | 22 +- .../translator/scl/ScScalarCodecEmitter.scala | 91 +++ .../translator/scl/ScServiceMethodPlan.scala | 20 + .../scl/ScServiceWiringTranslator.scala | 189 ++--- .../baboon/translator/scl/ScTreeTools.scala | 33 +- .../baboon/translator/scl/ScTypes.scala | 62 +- .../translator/scl/ScUEBACodecGenerator.scala | 176 +---- .../swift/SwAnyFieldRendering.scala | 17 + .../translator/swift/SwBaboonTranslator.scala | 7 +- .../swift/SwConversionTranslator.scala | 8 +- .../translator/swift/SwDefnTranslator.scala | 128 ++-- .../swift/SwJsonCodecGenerator.scala | 216 +----- .../swift/SwMcpServerGenerator.scala | 34 +- .../translator/swift/SwScalarCodecs.scala | 97 +++ .../swift/SwServiceWiringTranslator.scala | 186 ++--- .../translator/swift/SwTypeTranslator.scala | 135 +--- .../swift/SwUEBACodecGenerator.scala | 238 +------ .../typescript/TsAnyFieldPlan.scala | 19 + .../typescript/TsBaboonTranslator.scala | 96 +-- .../typescript/TsCodecActivation.scala | 21 + .../typescript/TsConversionTranslator.scala | 167 +++-- .../typescript/TsDefnTranslator.scala | 105 ++- .../typescript/TsJsonCodecGenerator.scala | 191 +---- .../typescript/TsMcpServerGenerator.scala | 31 +- .../typescript/TsScalarCodecOps.scala | 120 ++++ .../TsServiceWiringTranslator.scala | 217 ++---- .../translator/typescript/TsTreeTools.scala | 35 +- .../typescript/TsTypeTranslator.scala | 14 +- .../translator/typescript/TsTypes.scala | 4 + .../typescript/TsUEBACodecGenerator.scala | 224 +----- .../baboon/typer/BaboonComparator.scala | 24 +- docs/cli-reference.md | 2 + .../20260916-any-envelope-runtime-helpers.md | 9 + ...0260916-scala-kotlin-runtime-boundaries.md | 25 + .../20260916-backend-runtime-review/README.md | 100 +++ .../cross-cutting.md | 65 ++ .../20260916-backend-runtime-review/csharp.md | 69 ++ .../20260916-backend-runtime-review/dart.md | 69 ++ .../graphql.md | 35 + .../implementation.md | 160 +++++ .../20260916-backend-runtime-review/java.md | 71 ++ .../20260916-backend-runtime-review/kotlin.md | 70 ++ .../openapi.md | 35 + .../python-reproductions.md | 34 + .../20260916-backend-runtime-review/python.md | 65 ++ .../20260916-backend-runtime-review/rust.md | 122 ++++ .../20260916-backend-runtime-review/scala.md | 71 ++ .../20260916-backend-runtime-review/swift.md | 73 ++ .../typescript.md | 73 ++ .../ConvTest/RenamedConversionTests.cs | 41 ++ .../example/RenamedAdtConversionTest.kt | 23 + .../example/RenamedAdtConversionTest.kt | 23 + test/conv-test-py/test_conversions.py | 13 + .../example/RenamedAdtConversionSpec.scala | 31 + .../McpTests/McpDispatchRuntimeTests.cs | 101 +++ test/cs-stub-mcp-overlay/McpTests/McpTests.cs | 8 + .../BaboonTests/AnyEnvelopeCodecTests.cs | 76 ++ .../BaboonTests/CollectionHashTests.cs | 65 ++ .../BaboonTests/ConversionValidationTests.cs | 149 ++++ .../BaboonTests/FacadeRegistrationTests.cs | 122 ++++ .../BaboonTests/IndexConsumptionTests.cs | 67 ++ .../test/dispatch_contract_test.dart | 57 ++ .../test/runtime/any_field_helpers_test.dart | 36 + .../test/runtime/binary_storage_test.dart | 58 ++ test/dt-stub/test/runtime/pair_hash_test.dart | 19 + .../java/mcp/McpMetadataOwnershipTest.java | 84 +++ .../java/runtime/AnyEnvelopeCodecTest.java | 39 + .../java/runtime/IndexConsumptionTest.java | 61 ++ .../test/java/runtime/JsonParsingTest.java | 29 + .../test/java/runtime/MetadataAccessTest.java | 117 +++ .../kotlin/mcp/McpProtocolOwnershipTest.kt | 57 ++ .../kotlin/runtime/AnyEnvelopeCodecTest.kt | 61 ++ .../runtime/BinaryWriterTransferTest.kt | 40 ++ .../kotlin/runtime/CivilDateFormattingTest.kt | 43 ++ .../runtime/CodecVersionSelectionTest.kt | 44 ++ .../runtime/ConversionValidationTest.kt | 85 +++ .../kotlin/runtime/AnyEnvelopeCodecTest.kt | 65 ++ .../runtime/CodecVersionSelectionTest.kt | 44 ++ .../runtime/ConversionValidationTest.kt | 85 +++ .../BaboonTests/mcp/test_mcp.py | 9 + .../BaboonTests/mcp/test_dispatch_runtime.py | 104 +++ .../BaboonTests/mcp/test_mcp.py | 9 + .../RuntimeTests/test_collection_evolution.py | 40 ++ .../test_conversion_identifiers.py | 159 +++++ .../RuntimeTests/test_domain_facade.py | 21 + .../RuntimeTests/test_index_consumption.py | 41 ++ .../RuntimeTests/test_json_value_api.py | 54 ++ .../RuntimeTests/test_runtime_primitives.py | 41 ++ test/py-stub/ReviewModels/facade-v1.baboon | 5 + test/py-stub/ReviewModels/facade-v2.baboon | 8 + test/py-stub/ReviewModels/field-plan.baboon | 14 + test/py-stub/ReviewModels/scalar-rpc.baboon | 18 + test/py-stub/ReviewTests/test_field_plan.py | 31 + test/py-stub/ReviewTests/test_scalar_rpc.py | 70 ++ .../ReviewTests/test_versioned_facade.py | 60 ++ test/rs-conversion-regression/Cargo.toml | 23 + .../models/any_adt.baboon | 11 + .../rs-conversion-regression/models/v1.baboon | 27 + .../rs-conversion-regression/models/v2.baboon | 27 + test/rs-conversion-regression/test.sh | 11 + .../tests/conversions.rs | 68 ++ .../tests/mcp_mux_tests.rs | 2 + .../tests/runtime_dispatch_tests.rs | 73 ++ test/rs-stub/tests/any_field_framing_tests.rs | 52 ++ .../tests/lazy_codec_concurrency_tests.rs | 32 + test/rs-stub/tests/service_muxer_tests.rs | 50 ++ .../scala/runtime/AnyEnvelopeCodecSpec.scala | 55 ++ .../runtime/ConversionValidationSpec.scala | 83 +++ .../runtime/GeneratedMetadataReuseSpec.scala | 29 + .../test/scala/runtime/IndexCountSpec.scala | 42 ++ .../test/scala/McpProtocolOwnershipSpec.scala | 73 ++ ...untimePrimitiveCharacterizationTests.swift | 69 ++ ...RuntimeDispatchCharacterizationTests.swift | 92 +++ .../src/dispatch-preparation.test.ts | 92 +++ test/ts-stub-mcp-overlay/src/mcp.test.ts | 11 + test/ts-stub/ReviewModels/v1.baboon | 49 ++ test/ts-stub/ReviewModels/v2.baboon | 53 ++ test/ts-stub/package-lock.json | 18 + test/ts-stub/package.json | 1 + .../src/runtime-tests/BinStorage.test.ts | 71 ++ .../runtime-tests/CollectionEncoding.test.ts | 24 + .../runtime-tests/CollectionEvolution.test.ts | 80 +++ .../src/runtime-tests/RecordEvolution.test.ts | 15 + .../src/runtime-tests/ServiceRegistry.test.ts | 34 + 270 files changed, 12076 insertions(+), 8967 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/AnyFieldPlanTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DomainProductTranslatorTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierRuntimeParityTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenamedAdtBranchConversionTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SharedTranslatorPolicyTest.scala create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMetadataAccess.java create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonCodecVersionSelection.kt rename baboon-compiler/src/main/resources/baboon-runtime/{kotlin-kmp => kotlin-common}/BaboonEither.kt (100%) rename baboon-compiler/src/main/resources/baboon-runtime/{kotlin-kmp => kotlin-common}/BaboonExceptions.kt (100%) rename baboon-compiler/src/main/resources/baboon-runtime/{kotlin-kmp => kotlin-common}/BaboonServiceWiring.kt (100%) create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt delete mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonEither.kt delete mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonExceptions.kt delete mode 100644 baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonServiceWiring.kt create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala create mode 100644 baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_any_field_codec.swift create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/AnyFieldPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DocumentationRenderer.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainProductTranslator.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/IdentifierFieldKind.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtAnyFieldPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtForeignWirePlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtScalarCodecOps.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyFieldPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyScalarCodecOps.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsFieldRepresentation.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/JsonSchema.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/SchemaReferences.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwAnyFieldRendering.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwScalarCodecs.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsAnyFieldPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecActivation.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala create mode 100644 docs/drafts/20260916-any-envelope-runtime-helpers.md create mode 100644 docs/drafts/20260916-scala-kotlin-runtime-boundaries.md create mode 100644 docs/research/20260916-backend-runtime-review/README.md create mode 100644 docs/research/20260916-backend-runtime-review/cross-cutting.md create mode 100644 docs/research/20260916-backend-runtime-review/csharp.md create mode 100644 docs/research/20260916-backend-runtime-review/dart.md create mode 100644 docs/research/20260916-backend-runtime-review/graphql.md create mode 100644 docs/research/20260916-backend-runtime-review/implementation.md create mode 100644 docs/research/20260916-backend-runtime-review/java.md create mode 100644 docs/research/20260916-backend-runtime-review/kotlin.md create mode 100644 docs/research/20260916-backend-runtime-review/openapi.md create mode 100644 docs/research/20260916-backend-runtime-review/python-reproductions.md create mode 100644 docs/research/20260916-backend-runtime-review/python.md create mode 100644 docs/research/20260916-backend-runtime-review/rust.md create mode 100644 docs/research/20260916-backend-runtime-review/scala.md create mode 100644 docs/research/20260916-backend-runtime-review/swift.md create mode 100644 docs/research/20260916-backend-runtime-review/typescript.md create mode 100644 test/conv-test-cs/ConvTest/RenamedConversionTests.cs create mode 100644 test/conv-test-kt-kmp/src/test/kotlin/example/RenamedAdtConversionTest.kt create mode 100644 test/conv-test-kt/src/test/kotlin/example/RenamedAdtConversionTest.kt create mode 100644 test/conv-test-sc/src/test/scala/example/RenamedAdtConversionSpec.scala create mode 100644 test/cs-stub-mcp-overlay/McpTests/McpDispatchRuntimeTests.cs create mode 100644 test/cs-stub/BaboonTests/AnyEnvelopeCodecTests.cs create mode 100644 test/cs-stub/BaboonTests/CollectionHashTests.cs create mode 100644 test/cs-stub/BaboonTests/ConversionValidationTests.cs create mode 100644 test/cs-stub/BaboonTests/FacadeRegistrationTests.cs create mode 100644 test/cs-stub/BaboonTests/IndexConsumptionTests.cs create mode 100644 test/dart-stub-mcp-overlay/test/dispatch_contract_test.dart create mode 100644 test/dt-stub/test/runtime/any_field_helpers_test.dart create mode 100644 test/dt-stub/test/runtime/binary_storage_test.dart create mode 100644 test/dt-stub/test/runtime/pair_hash_test.dart create mode 100644 test/java-stub-mcp-overlay/src/test/java/mcp/McpMetadataOwnershipTest.java create mode 100644 test/jv-stub/src/test/java/runtime/AnyEnvelopeCodecTest.java create mode 100644 test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java create mode 100644 test/jv-stub/src/test/java/runtime/JsonParsingTest.java create mode 100644 test/jv-stub/src/test/java/runtime/MetadataAccessTest.java create mode 100644 test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpProtocolOwnershipTest.kt create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/BinaryWriterTransferTest.kt create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/CivilDateFormattingTest.kt create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/CodecVersionSelectionTest.kt create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/ConversionValidationTest.kt create mode 100644 test/kt-stub/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt create mode 100644 test/kt-stub/src/test/kotlin/runtime/CodecVersionSelectionTest.kt create mode 100644 test/kt-stub/src/test/kotlin/runtime/ConversionValidationTest.kt create mode 100644 test/py-stub-mcp-overlay/BaboonTests/mcp/test_dispatch_runtime.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_collection_evolution.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_conversion_identifiers.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_json_value_api.py create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_runtime_primitives.py create mode 100644 test/py-stub/ReviewModels/facade-v1.baboon create mode 100644 test/py-stub/ReviewModels/facade-v2.baboon create mode 100644 test/py-stub/ReviewModels/field-plan.baboon create mode 100644 test/py-stub/ReviewModels/scalar-rpc.baboon create mode 100644 test/py-stub/ReviewTests/test_field_plan.py create mode 100644 test/py-stub/ReviewTests/test_scalar_rpc.py create mode 100644 test/py-stub/ReviewTests/test_versioned_facade.py create mode 100644 test/rs-conversion-regression/Cargo.toml create mode 100644 test/rs-conversion-regression/models/any_adt.baboon create mode 100644 test/rs-conversion-regression/models/v1.baboon create mode 100644 test/rs-conversion-regression/models/v2.baboon create mode 100644 test/rs-conversion-regression/test.sh create mode 100644 test/rs-conversion-regression/tests/conversions.rs create mode 100644 test/rs-stub-mcp-mux-overlay/tests/runtime_dispatch_tests.rs create mode 100644 test/rs-stub/tests/any_field_framing_tests.rs create mode 100644 test/rs-stub/tests/lazy_codec_concurrency_tests.rs create mode 100644 test/rs-stub/tests/service_muxer_tests.rs create mode 100644 test/sc-stub/src/test/scala/runtime/AnyEnvelopeCodecSpec.scala create mode 100644 test/sc-stub/src/test/scala/runtime/ConversionValidationSpec.scala create mode 100644 test/sc-stub/src/test/scala/runtime/GeneratedMetadataReuseSpec.scala create mode 100644 test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala create mode 100644 test/scala-stub-mcp-overlay/src/test/scala/McpProtocolOwnershipSpec.scala create mode 100644 test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift create mode 100644 test/swift-stub-mcp-mux-overlay/Tests/McpMuxTests/RuntimeDispatchCharacterizationTests.swift create mode 100644 test/ts-stub-mcp-overlay/src/dispatch-preparation.test.ts create mode 100644 test/ts-stub/ReviewModels/v1.baboon create mode 100644 test/ts-stub/ReviewModels/v2.baboon create mode 100644 test/ts-stub/src/runtime-tests/BinStorage.test.ts create mode 100644 test/ts-stub/src/runtime-tests/CollectionEncoding.test.ts create mode 100644 test/ts-stub/src/runtime-tests/CollectionEvolution.test.ts create mode 100644 test/ts-stub/src/runtime-tests/RecordEvolution.test.ts create mode 100644 test/ts-stub/src/runtime-tests/ServiceRegistry.test.ts diff --git a/.mdl/defs/tests.md b/.mdl/defs/tests.md index aabe1fc40..e48e2c516 100644 --- a/.mdl/defs/tests.md +++ b/.mdl/defs/tests.md @@ -236,6 +236,7 @@ Run Python tests with regular adt codecs. ```bash dep action.test-cs-regular +BABOON_BIN="$(realpath "${action.build.binary}")" TEST_DIR="${action.test-gen-regular-adt.test_dir}" pushd "$TEST_DIR/py-stub" python3 -m venv .venv @@ -243,6 +244,15 @@ if [ -f ".venv/Scripts/activate" ]; then source .venv/Scripts/activate; else sou python3 -m pip install -r requirements.txt python3 -m unittest discover -s BaboonTests/GeneratedTests/testpkg/pkg0 python3 -m unittest discover -s BaboonTests/RuntimeTests +"$BABOON_BIN" --model-dir ReviewModels --lockfile GeneratedReview.lock \ + :python --output GeneratedReview/BaboonDefinitions/Generated \ + --fixture-output GeneratedReview/BaboonTests/GeneratedFixtures \ + --test-output GeneratedReview/BaboonTests/GeneratedTests \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --py-wrapped-adt-branch-codecs=false +pushd GeneratedReview +python3 -m unittest discover -s ../ReviewTests +popd popd ret success:bool=true @@ -261,6 +271,16 @@ popd ret success:bool=true ``` +# action: test-rust-conversion-regression + +Compile and exercise typed Rust evolution on isolated nested-collection models. + +```bash +BABOON_BIN="$(realpath "${action.build.binary}")" +bash ./test/rs-conversion-regression/test.sh "$BABOON_BIN" +ret success:bool=true +``` + # action: test-typescript-regular Run TypeScript tests with regular ADT codecs. @@ -268,8 +288,17 @@ Run TypeScript tests with regular ADT codecs. ```bash dep action.test-cs-regular +BABOON_BIN="$(realpath "${action.build.binary}")" TEST_DIR="${action.test-gen-regular-adt.test_dir}" pushd "$TEST_DIR/ts-stub" +"$BABOON_BIN" --model-dir ReviewModels --lockfile generated-review.lock \ + :typescript --output src/generated-review \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --ts-enum-lowercase-values=true --ts-wrapped-adt-branch-codecs=false +"$BABOON_BIN" --model-dir ReviewModels --lockfile generated-review-records.lock \ + :typescript --output src/generated-review-records \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --ts-enum-lowercase-values=true --ts-maps-as-records=true --ts-wrapped-adt-branch-codecs=false npm install npm run build npm test @@ -543,6 +572,7 @@ Run Python tests with wrapped ADT codecs ```bash dep action.test-cs-wrapped +BABOON_BIN="$(realpath "${action.build.binary}")" TEST_DIR="${action.test-gen-wrapped-adt.test_dir}" pushd "$TEST_DIR/py-stub" python3 -m venv .venv @@ -550,6 +580,15 @@ if [ -f ".venv/Scripts/activate" ]; then source .venv/Scripts/activate; else sou python3 -m pip install -r requirements.txt python3 -m unittest discover -s BaboonTests/GeneratedTests/testpkg/pkg0 python3 -m unittest discover -s BaboonTests/RuntimeTests +"$BABOON_BIN" --model-dir ReviewModels --lockfile GeneratedReview.lock \ + :python --output GeneratedReview/BaboonDefinitions/Generated \ + --fixture-output GeneratedReview/BaboonTests/GeneratedFixtures \ + --test-output GeneratedReview/BaboonTests/GeneratedTests \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --py-wrapped-adt-branch-codecs=true +pushd GeneratedReview +python3 -m unittest discover -s ../ReviewTests +popd popd ret success:bool=true @@ -575,8 +614,17 @@ Run TypeScript tests with wrapped ADT codecs. ```bash dep action.test-cs-wrapped +BABOON_BIN="$(realpath "${action.build.binary}")" TEST_DIR="${action.test-gen-wrapped-adt.test_dir}" pushd "$TEST_DIR/ts-stub" +"$BABOON_BIN" --model-dir ReviewModels --lockfile generated-review.lock \ + :typescript --output src/generated-review \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --ts-enum-lowercase-values=true --ts-wrapped-adt-branch-codecs=true +"$BABOON_BIN" --model-dir ReviewModels --lockfile generated-review-records.lock \ + :typescript --output src/generated-review-records \ + --generate-json-codecs-by-default=true --generate-ueba-codecs-by-default=true \ + --ts-enum-lowercase-values=true --ts-maps-as-records=true --ts-wrapped-adt-branch-codecs=true npm install npm test popd @@ -1654,7 +1702,7 @@ validation on every returned inputSchema. TEST_DIR="${action.test-gen-ts-mcp.test_dir}" pushd "$TEST_DIR/ts-stub" npm install -npx vitest run src/mcp.test.ts +npx vitest run src/mcp.test.ts src/dispatch-preparation.test.ts popd ret success:bool=true @@ -1818,7 +1866,7 @@ to T7 reference) and exercises negative controls. ```bash TEST_DIR="${action.test-gen-kotlin-mcp.test_dir}" pushd "$TEST_DIR/kt-stub" -gradle --no-daemon clean test --tests "mcp.McpTests" +gradle --no-daemon clean test --tests "mcp.McpTests" --tests "mcp.McpProtocolOwnershipTest" popd ret success:bool=true @@ -1929,7 +1977,7 @@ to T7 reference) and exercises negative controls. ```bash TEST_DIR="${action.test-gen-java-mcp.test_dir}" pushd "$TEST_DIR/jv-stub" -mvn clean test -Dtest=mcp.McpTests +mvn clean test -Dtest=mcp.McpTests,mcp.McpMetadataOwnershipTest popd ret success:bool=true @@ -3238,7 +3286,7 @@ pushd "$TEST_DIR/py-stub" python3 -m venv .venv if [ -f ".venv/Scripts/activate" ]; then source .venv/Scripts/activate; else source .venv/bin/activate; fi python3 -m pip install -r requirements.txt -python3 -m unittest BaboonTests.mcp.test_mcp +python3 -m unittest BaboonTests.mcp.test_mcp BaboonTests.mcp.test_dispatch_runtime popd ret success:bool=true @@ -3508,6 +3556,7 @@ dep action.test-cs-regular dep action.test-scala-regular dep action.test-python-regular dep action.test-rust-regular +dep action.test-rust-conversion-regression dep action.test-typescript-regular dep action.test-kotlin-regular dep action.test-kotlin-kmp-regular @@ -4203,8 +4252,8 @@ pushd "$TEST_DIR/dt-stub" dart pub get # Analyze only the generated lib and MCP test directory (runtime/ tests reference # the full model which is not generated in the MCP-only pass). -dart analyze --fatal-warnings lib/ test/mcp/ -dart test test/mcp/mcp_tests.dart +dart analyze --fatal-warnings lib/ test/mcp/ test/dispatch_contract_test.dart +dart test test/mcp/mcp_tests.dart test/dispatch_contract_test.dart popd ret success:bool=true @@ -6143,7 +6192,7 @@ collision, and NoMatchingTool (-32602) against `AbstractMcpMuxer`. ```bash TEST_DIR="${action.test-gen-rs-mcp-mux.test_dir}" pushd "$TEST_DIR/rs-stub" -RUSTFLAGS="-D warnings" cargo test --test mcp_mux_tests +RUSTFLAGS="-D warnings" cargo test --test mcp_mux_tests --test runtime_dispatch_tests popd ret success:bool=true @@ -6188,6 +6237,7 @@ $BABOON_BIN \ --rs-async-services=true rsync -a ./test/rs-stub-mcp-mux-async-overlay/ "$TEST_DIR/rs-stub/" +cp ./test/rs-stub-mcp-mux-overlay/tests/runtime_dispatch_tests.rs "$TEST_DIR/rs-stub/tests/runtime_dispatch_tests.rs" ret success:bool=true ret test_dir:string="$TEST_DIR" @@ -6204,7 +6254,7 @@ async-generated `McpServer`s. ```bash TEST_DIR="${action.test-gen-rs-mcp-mux-async.test_dir}" pushd "$TEST_DIR/rs-stub" -RUSTFLAGS="-D warnings" cargo test --test mcp_mux_tests +RUSTFLAGS="-D warnings" cargo test --test mcp_mux_tests --test runtime_dispatch_tests popd ret success:bool=true diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/AnyFieldPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/AnyFieldPlanTest.scala new file mode 100644 index 000000000..cb58c9e6a --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/AnyFieldPlanTest.scala @@ -0,0 +1,37 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.BaboonLoader +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.typer.model.{DomainMember, TypeRef} +import izumi.functional.bio.Error2 +import izumi.reflect.TagKK + +final class AnyFieldPlanTest extends AnyFieldPlanTestBase[Either] + +abstract class AnyFieldPlanTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + "Any field planning" should { + "retain all six kind bytes and static fallback tuples" in { + (loader: BaboonLoader[F]) => + for { + family <- loadPkg(loader) + } yield { + val domain = family.domains.toMap.values.head.versions.toMap.values.head + val user = domain.defs.meta.nodes.values.collectFirst { case member: DomainMember.User => member.id }.get + val underlying = Some(TypeRef.Scalar(user)) + val dom = Some(domain.id.toString) + val ver = Some(domain.version.v.toString) + val tid = Some(user.toString) + val cases = List( + (TypeRef.Any(TypeRef.AnyVariant.Global, None), AnyFieldPlan(0x07.toByte, None, None, None)), + (TypeRef.Any(TypeRef.AnyVariant.ThisDom, None), AnyFieldPlan(0x03.toByte, dom, None, None)), + (TypeRef.Any(TypeRef.AnyVariant.Current, None), AnyFieldPlan(0x01.toByte, dom, ver, None)), + (TypeRef.Any(TypeRef.AnyVariant.Global, underlying), AnyFieldPlan(0x06.toByte, None, None, tid)), + (TypeRef.Any(TypeRef.AnyVariant.ThisDom, underlying), AnyFieldPlan(0x02.toByte, dom, None, tid)), + (TypeRef.Any(TypeRef.AnyVariant.Current, underlying), AnyFieldPlan(0x00.toByte, dom, ver, tid)), + ) + cases.foreach { case (field, expected) => assert(AnyFieldPlan.forField(field, domain) == expected) } + } + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DocCommentSchemaEmissionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DocCommentSchemaEmissionTest.scala index 94a2e2199..67dee4d51 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DocCommentSchemaEmissionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DocCommentSchemaEmissionTest.scala @@ -43,7 +43,7 @@ abstract class DocCommentGraphqlEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo codecTestIterations = 0 ), language = GqlOptions( - pragmas = Map.empty, + pragmas = Map.empty ), ) @@ -224,7 +224,7 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo codecTestIterations = 0 ), language = OasOptions( - pragmas = Map.empty, + pragmas = Map.empty ), ) @@ -279,7 +279,9 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo // Type-level description on DocItem component assert( - schemaFile.contains("\"description\": \"A simple item with field-level docs.\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocItem").get[String]("description").toOption.contains("A simple item with field-level docs."), s"Expected type-level description on DocItem schema.\n$schemaFile", ) } @@ -299,7 +301,10 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo // Field-level description on DocItem.name property assert( - schemaFile.contains("\"description\": \"Display name of the item.\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocItem").downField("properties").downField("name") + .get[String]("description").toOption.contains("Display name of the item."), s"Expected field description on DocItem.name property.\n$schemaFile", ) // price has both prefix and suffix docs @@ -327,7 +332,9 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo }.getOrElse(fail(s"DocSeverity schema not found in OpenAPI output. Paths: ${all.map(_._1)}")) assert( - schemaFile.contains("\"description\": \"Severity levels.\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocSeverity").get[String]("description").toOption.contains("Severity levels."), s"Expected type-level description on DocSeverity schema.\n$schemaFile", ) } @@ -346,11 +353,16 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo }.getOrElse(fail(s"DocOk ADT arm schema not found in OpenAPI output. Paths: ${all.map(_._1)}")) assert( - schemaFile.contains("\"description\": \"Successful payload variant.\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocResult_DocOk").get[String]("description").toOption.contains("Successful payload variant."), s"Expected arm-level description on DocOk schema.\n$schemaFile", ) assert( - schemaFile.contains("\"description\": \"the carried payload\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocResult_DocOk").downField("properties").downField("value") + .get[String]("description").toOption.contains("the carried payload"), s"Expected field description on DocOk.value property.\n$schemaFile", ) } @@ -370,12 +382,17 @@ abstract class DocCommentOpenapiEmissionTestBase[F[+_, +_]: Error2: TagKK: Baboo // DocPage has a type-level doc; its fields (items, total) have none. assert( - schemaFile.contains("\"description\": \"Paged doc results.\""), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocPage").get[String]("description").toOption.contains("Paged doc results."), s"Expected type-level description on DocPage schema.\n$schemaFile", ) // Fields without docs must not produce `"description": ""` assert( - !schemaFile.contains("\"description\": \"\","), + io.circe.parser + .parse(schemaFile).toOption.get.hcursor.downField("components").downField("schemas") + .downField("m30_sc_docs_DocPage").downField("properties").focus.get.asObject.get.values + .forall(_.hcursor.downField("description").focus.isEmpty), s"Unexpected empty description on a field in DocPage schema.\n$schemaFile", ) } diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DomainProductTranslatorTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DomainProductTranslatorTest.scala new file mode 100644 index 000000000..19414c43b --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/DomainProductTranslatorTest.scala @@ -0,0 +1,69 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.{BaboonLoader, CompilerProduct} +import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.translator.DomainProductTranslator +import io.septimalmind.baboon.typer.model.{BaboonFamily, Domain, DomainMember, TypeId} +import izumi.functional.bio.{Error2, F} +import izumi.fundamentals.collections.nonempty.NEList +import izumi.reflect.TagKK + +final class DomainProductTranslatorTest extends DomainProductTranslatorTestBase[Either] + +abstract class DomainProductTranslatorTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + private def domain(family: BaboonFamily): Domain = family.domains.toMap.values.head.versions.toMap.values.head + + "Domain product translation" should { + "preserve member iteration order and exclude builtins" in { + (loader: BaboonLoader[F]) => + for { + family <- loadPkg(loader) + selected = domain(family) + actual <- DomainProductTranslator.translate[F, TypeId.User]( + selected, + Set(CompilerProduct.Definition), + CompilerProduct.Definition, + member => F.pure(List(member.id)), + ) + } yield { + val expected = selected.defs.meta.nodes.toList.collect { case (_, member: DomainMember.User) => member.id } + assert(expected.nonEmpty) + assert(actual == expected) + } + } + + "leave disabled product callbacks unevaluated" in { + (loader: BaboonLoader[F]) => + for { + family <- loadPkg(loader) + actual <- DomainProductTranslator.translate[F, TypeId.User]( + domain(family), + Set(CompilerProduct.Definition), + CompilerProduct.Fixture, + _ => throw new AssertionError("disabled product callback evaluated"), + ) + } yield assert(actual.isEmpty) + } + + "accumulate every member failure through the existing error channel" in { + (loader: BaboonLoader[F]) => + for { + family <- loadPkg(loader) + selected = domain(family) + actual <- DomainProductTranslator + .translate[F, TypeId.User]( + selected, + Set(CompilerProduct.Test), + CompilerProduct.Test, + _ => F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())), + ).map(Right(_): Either[NEList[BaboonIssue], List[TypeId.User]]).catchAll(errors => F.pure(Left(errors))) + } yield { + val expectedCount = selected.defs.meta.nodes.values.count(_.isInstanceOf[DomainMember.User]) + val errors = actual.left.getOrElse(throw new AssertionError("member failures were lost")) + assert(expectedCount > 0) + assert(errors.toList.size == expectedCount) + } + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierRuntimeParityTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierRuntimeParityTest.scala new file mode 100644 index 000000000..fa714db72 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierRuntimeParityTest.scala @@ -0,0 +1,30 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.BaboonRuntimeResources +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path, Paths} + +class IdentifierRuntimeParityTest extends AnyFunSuite { + test("compile-side and embedded Scala IdentifierRepr implementations remain identical") { + val relative = Paths.get("baboon-compiler/src/main/scala/baboon/runtime/shared/BaboonRuntimeShared.scala") + val cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath + val source = Iterator + .iterate[Path](cwd)(_.getParent).takeWhile(_ != null) + .map(_.resolve(relative)).find(Files.isRegularFile(_)) + .getOrElse(fail(s"Cannot locate compile-side identifier source from $cwd")) + val mirror = new String(Files.readAllBytes(source), StandardCharsets.UTF_8) + val embedded = BaboonRuntimeResources.read("baboon-runtime/scala/BaboonRuntimeShared.scala") + assert(identifierObject(mirror) == identifierObject(embedded)) + } + + private def identifierObject(source: String): String = { + val declaration = " object IdentifierRepr {" + val start = source.indexOf(declaration) + assert(start >= 0, "IdentifierRepr declaration is missing") + val end = source.indexOf("\n }", start) + assert(end > start, "IdentifierRepr closing delimiter is missing") + source.substring(start, end + "\n }".length) + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala index 85f6489c7..2304350d3 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala @@ -71,8 +71,12 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe } }.flatten.toList assert(services.size == 1, s"expected one service, got ${services.map(_.id)}") + val prepared = emitter.prepare(domain) services.head.methods.iterator.map { - m => m.name.name -> emitter.emitInputSchema(m.sig, domain) + m => + val schema = emitter.emitInputSchema(m.sig, prepared) + assert(schema == emitter.emitInputSchema(m.sig, domain)) + m.name.name -> schema }.toMap } @@ -105,15 +109,20 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe /** Validate a conforming instance — proves the schema (incl. its `$defs` * closure) actually resolves and accepts valid data, with zero validation - * messages. */ + * messages. + */ private def assertAccepts(name: String, schema: Json, instance: Json): org.scalatest.Assertion = { val compiled = compileSchema(schema) val messages = compiled.validate(instance.noSpaces, InputFormat.JSON).asScala.toList - assert(messages.isEmpty, s"tool '$name' rejected a conforming instance: ${messages.map(_.getMessage)}\nschema: ${schema.spaces2}\ninstance: ${instance.noSpaces}") + assert( + messages.isEmpty, + s"tool '$name' rejected a conforming instance: ${messages.map(_.getMessage)}\nschema: ${schema.spaces2}\ninstance: ${instance.noSpaces}", + ) } /** Validate a NON-conforming instance is rejected — proves constraints are - * live (the validator is actually enforcing the schema, not a no-op). */ + * live (the validator is actually enforcing the schema, not a no-op). + */ private def assertRejects(name: String, schema: Json, instance: Json): org.scalatest.Assertion = { val compiled = compileSchema(schema) val messages = compiled.validate(instance.noSpaces, InputFormat.JSON).asScala.toList @@ -145,10 +154,11 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe s"unexpected tool set: ${tools.keySet}", ) // (b) EVERY emitted inputSchema must be well-formed JSON Schema. - tools.foreach { case (name, schema) => - assert(schema.asObject.flatMap(_("$schema")).flatMap(_.asString).contains(McpInputSchemaEmitter.schemaDialect), s"tool '$name' missing $$schema dialect") - assert(field(schema, "type").asString.contains("object"), s"tool '$name' root is not type:object") - assertWellFormed(name, schema) + tools.foreach { + case (name, schema) => + assert(schema.asObject.flatMap(_("$schema")).flatMap(_.asString).contains(McpInputSchemaEmitter.schemaDialect), s"tool '$name' missing $$schema dialect") + assert(field(schema, "type").asString.contains("object"), s"tool '$name' root is not type:object") + assertWellFormed(name, schema) } } } @@ -182,7 +192,7 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe assert(nestedRef.startsWith("#/$defs/"), s"nested ref must be local, got: $nestedRef") // maybePoint: opt[Point] -> oneOf[ {$ref}, {type:null} ] - val maybeOneOf = field(field(props, "maybePoint"), "oneOf").asArray.getOrElse(fail("maybePoint not oneOf")).toList + val maybeOneOf = field(field(props, "maybePoint"), "oneOf").asArray.getOrElse(fail("maybePoint not oneOf")).toList def key(j: Json, k: String): Option[Json] = j.asObject.flatMap(_(k)) assert(maybeOneOf.exists(j => key(j, "$ref").flatMap(_.asString).exists(_.startsWith("#/$defs/"))), "maybePoint missing local $ref branch") assert(maybeOneOf.exists(j => key(j, "type").flatMap(_.asString).contains("null")), "maybePoint missing null branch") @@ -191,7 +201,10 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe assert(field(field(props, "color"), "$ref").asString.exists(_.startsWith("#/$defs/")), "color ref not local") // fancy: FFancyStr foreign, maps to string in all 9 langs -> precise scalar, NOT opaque object - assert(field(props, "fancy") == Json.obj("type" -> Json.fromString("string")), s"fancy foreign type not resolved to string scalar: ${field(props, "fancy").noSpaces}") + assert( + field(props, "fancy") == Json.obj("type" -> Json.fromString("string")), + s"fancy foreign type not resolved to string scalar: ${field(props, "fancy").noSpaces}", + ) // $defs closure contains Nested, Point, Color (FFancyStr collapsed to scalar -> not in $defs) val defs = field(schema, "$defs").asObject.getOrElse(fail("no $defs")).keys.toSet @@ -285,8 +298,8 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe assert(defs.keys.exists(_.endsWith("_Rect")), s"Rect branch missing from $$defs: ${defs.keys}") // tree: Tree -> recursive; its $defs entry self-refs locally (terminates) - val treeRef = field(field(props, "tree"), "$ref").asString.getOrElse(fail("tree no $ref")) - val treeDef = defs(treeRef.stripPrefix("#/$defs/")).getOrElse(fail("Tree not in $$defs")) + val treeRef = field(field(props, "tree"), "$ref").asString.getOrElse(fail("tree no $ref")) + val treeDef = defs(treeRef.stripPrefix("#/$defs/")).getOrElse(fail("Tree not in $$defs")) val treeProps = field(treeDef, "properties") // children: lst[Tree] -> array whose items $ref back to Tree locally val childItems = field(field(treeProps, "children"), "items") @@ -321,7 +334,7 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe val defs = field(schema, "$defs").asObject.getOrElse(fail("no $defs")) // tagged: Tagged -> local ref to a oneOf ADT entry - val taggedRef = field(field(props, "tagged"), "$ref").asString.getOrElse(fail("tagged has no $ref")) + val taggedRef = field(field(props, "tagged"), "$ref").asString.getOrElse(fail("tagged has no $ref")) assert(taggedRef.startsWith("#/$defs/"), s"tagged ref must be local, got: $taggedRef") val taggedDefName = taggedRef.stripPrefix("#/$defs/") val taggedDef = defs(taggedDefName).getOrElse(fail(s"$taggedDefName not in $$defs")) @@ -351,12 +364,12 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe assert(field(tagBIdSchema, "type").asString.contains("string"), s"TagB.id must be type:string, got: ${tagBIdSchema.noSpaces}") // branch-specific fields are also present (tag / weight) - assert(tagAProps.contains("tag"), s"TagA missing its own field 'tag'") + assert(tagAProps.contains("tag"), s"TagA missing its own field 'tag'") assert(tagBProps.contains("weight"), s"TagB missing its own field 'weight'") // id is required in both branches (non-optional field from the contract) - assert(requiredSet(tagADef).contains("id"), s"TagA: contract field 'id' must be required") - assert(requiredSet(tagBDef).contains("id"), s"TagB: contract field 'id' must be required") + assert(requiredSet(tagADef).contains("id"), s"TagA: contract field 'id' must be required") + assert(requiredSet(tagBDef).contains("id"), s"TagB: contract field 'id' must be required") // No allOf present in either branch entry (H3: merge already happened at typing, not emission) assert(!hasKey(tagADef, "allOf"), s"TagA branch must NOT have allOf (fields are merged at typing, not emission): ${tagADef.noSpaces}") @@ -387,7 +400,7 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe val pageRef = field(field(props, "page"), "$ref").asString.getOrElse(fail("page no $ref")) assert(pageRef.startsWith("#/$defs/")) - val pageDef = defs(pageRef.stripPrefix("#/$defs/")).getOrElse(fail("Page def missing")) + val pageDef = defs(pageRef.stripPrefix("#/$defs/")).getOrElse(fail("Page def missing")) val pageProps = field(pageDef, "properties") // items: lst[Point] -> array whose items $ref Point locally val itemsItems = field(field(pageProps, "items"), "items") diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenamedAdtBranchConversionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenamedAdtBranchConversionTest.scala new file mode 100644 index 000000000..83a2b3050 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenamedAdtBranchConversionTest.scala @@ -0,0 +1,94 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.parser.BaboonParser +import io.septimalmind.baboon.parser.model.FSPath +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.typer.BaboonFamilyManager +import io.septimalmind.baboon.typer.model.{AdtOp, Conversion, EvolutionStep, Owner, Pkg, TypedefDiff, Version} +import io.septimalmind.baboon.typer.model.Conversion.FieldOp +import izumi.functional.bio.Error2 +import izumi.fundamentals.collections.nonempty.{NEList, NEString} +import izumi.reflect.TagKK + +final class RenamedAdtBranchConversionTest extends RenamedAdtBranchConversionTestBase[Either] + +abstract class RenamedAdtBranchConversionTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + private val before = + """model review.renamed.branches + |version "1.0.0" + |root adt OldShape { + | data Same { value: str } + | data Widen { value: i32 } + | data Manual { value: i32 } + |} + |root adt Stable { + | data Original { value: str } + | data Untouched { value: str } + |} + |""".stripMargin + + private val after = + """model review.renamed.branches + |version "2.0.0" + |root adt NewShape : was[OldShape] { + | data Same { value: str } + | data Widen { + | value: i64 + | label: opt[str] + | } + | data Manual { value: str } + |} + |root adt Stable { + | data Renamed : was[Original] { value: str } + | data Untouched { value: str } + |} + |""".stripMargin + + "Renamed ADT branches" should { + "derive branch conversions under the new owner and retain custom conversion requirements" in { + (manager: BaboonFamilyManager[F]) => + for { + family <- manager.load( + List( + BaboonParser.Input(FSPath.parse(NEString.unsafeFrom("v1.baboon")), before), + BaboonParser.Input(FSPath.parse(NEString.unsafeFrom("v2.baboon")), after), + ) + ) + } yield { + val lineage = family.domains.toMap(Pkg(NEList("review", "renamed", "branches"))) + val step = EvolutionStep(Version.parse("1.0.0"), Version.parse("2.0.0")) + val rules = lineage.evolution.rules(step) + val parent = rules.conversions.collectFirst { + case c: Conversion.CopyAdtBranchByName if c.sourceTpe.name.name == "OldShape" => c + }.getOrElse(fail("Missing renamed ADT conversion")) + + def dto(name: String): Conversion.DtoConversion = rules.conversions.collectFirst { + case c: Conversion.DtoConversion if c.sourceTpe.name.name == name && c.sourceTpe.owner == Owner.Adt(parent.sourceTpe) => c + }.getOrElse(fail(s"Missing renamed branch conversion: $name")) + + val same = dto("Same") + assert(same.targetTpe.owner == Owner.Adt(parent.targetTpe)) + assert(same.ops.forall(_.isInstanceOf[FieldOp.Transfer])) + val widen = dto("Widen") + assert(widen.targetTpe.owner == Owner.Adt(parent.targetTpe)) + assert(widen.ops.exists(_.isInstanceOf[FieldOp.ExpandPrecision])) + assert(widen.ops.exists(_.isInstanceOf[FieldOp.InitializeWithDefault])) + assert(rules.conversions.exists { + case c: Conversion.CustomConversionRequired => + c.sourceTpe.name.name == "Manual" && c.sourceTpe.owner == Owner.Adt(parent.sourceTpe) && c.targetTpe.owner == Owner.Adt(parent.targetTpe) + case _ => false + }) + val explicit = rules.conversions.collectFirst { + case c: Conversion.CopyAdtBranchByName if c.sourceTpe.name.name == "Stable" => c + }.getOrElse(fail("Missing explicitly renamed branch owner conversion")) + assert(explicit.branchMapping("Original").name.name == "Renamed") + for (adt <- List(parent, explicit)) { + val diff = lineage.evolution.diffs(step).diffs(adt.sourceTpe).asInstanceOf[TypedefDiff.AdtDiff] + val kept = diff.ops.collect { case op: AdtOp.KeepBranch => op.id } + assert(kept.distinct.size == kept.size, s"Duplicate retained branch operations: $kept") + assert(kept.size == adt.oldDefn.members.size) + } + } + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala new file mode 100644 index 000000000..41455d1e9 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala @@ -0,0 +1,160 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.kotlin.{KtScalarCodecEmitter, KtTypes, KtValue} +import io.septimalmind.baboon.translator.csharp.{CSScalarCodecEmitter, CSValue} +import io.septimalmind.baboon.translator.java.{JvScalarCodecEmitter, JvValue} +import io.septimalmind.baboon.translator.scl.{ScScalarCodecEmitter, ScValue} +import io.septimalmind.baboon.typer.model.TypeId +import io.septimalmind.baboon.typer.model.TypeId.Builtins.* +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { + private def kotlin(tree: TextTree[KtValue]): String = tree.mapRender { + case t: KtValue.KtType => t.name + case t: KtValue.KtTypeName => t.name + } + + private def scala(tree: TextTree[ScValue]): String = tree.mapRender { case t: ScValue.ScType => t.name } + + "C# scalar emission" should "keep the existing RPC boolean representation explicit" in { + def render(tree: TextTree[CSValue]): String = tree.mapRender { + case t: CSValue.CSType => t.name + case t: CSValue.CSTypeName => t.name + } + render(CSScalarCodecEmitter.jsonEncode(bit, q"value", CSScalarCodecEmitter.JsonBooleanFormat.BooleanValue)) shouldBe "new JValue(value)" + render( + CSScalarCodecEmitter.jsonEncode(bit, q"value", CSScalarCodecEmitter.JsonBooleanFormat.LowercaseString) + ) shouldBe "new JValue(value.ToString().ToLowerInvariant())" + render(CSScalarCodecEmitter.uebaDecode(uid, q"wire")) shouldBe "new Guid(wire.ReadBytes(16))" + render(CSScalarCodecEmitter.uebaEncode(uid, q"writer", q"value")) shouldBe "writer.Write(value.ToByteArray())" + } + + "Java scalar emission" should "retain unsigned widths and lenient textual number decoding" in { + def render(tree: TextTree[JvValue]): String = tree.mapRender { + case t: JvValue.JvType => t.name + case t: JvValue.JvTypeName => t.name + } + render(JvScalarCodecEmitter.jsonDecode(i64, q"wire")) shouldBe "(wire.isTextual() ? Long.parseLong(wire.textValue()) : wire.longValue())" + render(JvScalarCodecEmitter.jsonDecode(u64, q"wire")) shouldBe "(wire.isTextual() ? Long.parseUnsignedLong(wire.textValue()) : wire.longValue())" + render(JvScalarCodecEmitter.jsonEncode(u64, q"value")) shouldBe "new TextNode(Long.toUnsignedString(value))" + render(JvScalarCodecEmitter.uebaDecode(u32, q"wire")) shouldBe "(wire.readInt() & 0xFFFFFFFFL)" + render(JvScalarCodecEmitter.uebaEncode(u32, q"writer", q"value")) shouldBe "writer.writeInt((int) (value & 0xFFFFFFFFL));" + } + + private val kotlinJsonCases = List( + (bit, "value", "boolean"), + (i08, "value.toInt()", "int.toByte()"), + (i16, "value.toInt()", "int.toShort()"), + (i32, "value", "int"), + (i64, "value", "long"), + (u08, "value.toInt()", "int.toUByte()"), + (u16, "value.toInt()", "int.toUShort()"), + (u32, "value.toLong()", "long.toUInt()"), + (u64, "value.toLong()", "long.toULong()"), + (f32, "value", "float"), + (f64, "value", "double"), + (str, "value", "content"), + ) + + "Kotlin scalar emission" should "retain JSON primitive narrowing and unsigned expressions on both platforms" in { + List(false, true).foreach { + multiplatform => + val emitter = new KtScalarCodecEmitter(new KtTypes(multiplatform)) + kotlinJsonCases.foreach { + case (id, value, accessor) => + kotlin(emitter.jsonEncode(id, q"value")) shouldBe s"JsonPrimitive($value)" + kotlin(emitter.jsonDecode(id, q"wire")) shouldBe s"wire.jsonPrimitive.$accessor" + } + kotlin(emitter.jsonEncode(bytes, q"value")) shouldBe "JsonPrimitive(value.toHexString())" + kotlin(emitter.jsonDecode(bytes, q"wire")) shouldBe "ByteString.fromHexString(wire.jsonPrimitive.content)" + kotlin(emitter.jsonEncode(uid, q"value")) shouldBe "JsonPrimitive(value.toString())" + List(tsu -> "Tsu", tso -> "Tso").foreach { + case (id, suffix) => + kotlin(emitter.jsonEncode(id, q"value")) shouldBe s"JsonPrimitive(BaboonTimeFormats.format$suffix(value))" + kotlin(emitter.jsonDecode(id, q"wire")) shouldBe s"BaboonTimeFormats.parse$suffix(wire.jsonPrimitive.content)" + } + val decimalEncode = if (multiplatform) "value.toString()" else "value.toPlainString()" + val decimalDecode = if (multiplatform) "BaboonDecimal.fromString" else "java.math.BigDecimal" + val uuidDecode = if (multiplatform) "kotlin.uuid.Uuid.parse" else "java.util.UUID.fromString" + kotlin(emitter.jsonEncode(f128, q"value")) shouldBe s"JsonPrimitive($decimalEncode)" + kotlin(emitter.jsonDecode(f128, q"wire")) shouldBe s"$decimalDecode(wire.jsonPrimitive.content)" + kotlin(emitter.jsonDecode(uid, q"wire")) shouldBe s"$uuidDecode(wire.jsonPrimitive.content)" + } + } + + private val binaryCases = List( + (bit, "Boolean", "value", ""), + (i08, "Byte", "value.toInt", ""), + (i16, "Short", "value.toInt", ""), + (i32, "Int", "value", ""), + (i64, "Long", "value", ""), + (u08, "Byte", "value.toInt", ".toUByte()"), + (u16, "Short", "value.toInt", ".toUShort()"), + (u32, "Int", "value", ".toUInt()"), + (u64, "Long", "value", ".toULong()"), + (f32, "Float", "value", ""), + (f64, "Double", "value", ""), + ) + + "Scalar binary emission" should "retain primitive widths, casts and platform-specific runtime calls" in { + binaryCases.foreach { + case (id, method, scValue, unsignedRead) => + scala(ScScalarCodecEmitter.uebaDecode(id, q"wire")) shouldBe s"wire.read$method()" + scala(ScScalarCodecEmitter.uebaEncode(id, q"writer", q"value")) shouldBe s"writer.write$method($scValue)" + List(false, true).foreach { + multiplatform => + val emitter = new KtScalarCodecEmitter(new KtTypes(multiplatform)) + val ktValue = id match { + case TypeId.Builtins.u32 => "value.toInt()" + case TypeId.Builtins.u64 => "value.toLong()" + case _ => if (scValue.endsWith(".toInt")) scValue + "()" else scValue + } + kotlin(emitter.uebaDecode(id, q"wire")) shouldBe s"wire.read$method()$unsignedRead" + kotlin(emitter.uebaEncode(id, q"writer", q"value")) shouldBe s"writer.write$method($ktValue)" + } + } + val runtimeCases = List(str -> "String", bytes -> "ByteString", uid -> "Uid", tsu -> "Timestamp", tso -> "Timestamp", f128 -> "BigDecimal") + runtimeCases.foreach { + case (id, scSuffix) => + scala(ScScalarCodecEmitter.uebaDecode(id, q"wire")) shouldBe s"BaboonBinTools.read$scSuffix(wire)" + scala(ScScalarCodecEmitter.uebaEncode(id, q"writer", q"value")) shouldBe s"BaboonBinTools.write$scSuffix(writer, value)" + List(false, true).foreach { + multiplatform => + val emitter = new KtScalarCodecEmitter(new KtTypes(multiplatform)) + val suffix = if (multiplatform && id == tso) "TimestampOffset" else if (multiplatform && id == f128) "BaboonDecimal" else scSuffix + kotlin(emitter.uebaDecode(id, q"wire")) shouldBe s"BaboonBinTools.read$suffix(wire)" + kotlin(emitter.uebaEncode(id, q"writer", q"value")) shouldBe s"BaboonBinTools.write$suffix(writer, value)" + } + } + } + + "Scala scalar emission" should "retain unsigned JSON encoding and lenient value decoders" in { + val cases = List( + (bit, "fromBoolean(value)", "decodeBoolean"), + (i08, "fromInt(value.toInt)", "decodeByte"), + (i16, "fromInt(value.toInt)", "decodeShort"), + (i32, "fromInt(value)", "decodeInt"), + (i64, "fromLong(value)", "decodeLong"), + (u08, "fromInt(java.lang.Byte.toUnsignedInt(value))", "decodeByte"), + (u16, "fromInt(java.lang.Short.toUnsignedInt(value))", "decodeShort"), + (u32, "fromLong(java.lang.Integer.toUnsignedLong(value))", "decodeInt"), + (u64, "fromBigInt(BaboonBinTools.toUnsignedBigInt(value))", "decodeLong"), + (f32, "fromFloat(value).get", "decodeFloat"), + (f64, "fromDouble(value).get", "decodeDouble"), + (f128, "fromBigDecimal(value)", "decodeBigDecimalLenient"), + (str, "fromString(value)", "decodeString"), + (bytes, "fromString(value.toHexString)", "decodeByteString"), + (uid, "fromString(value.toString())", "decodeUUID"), + (tsu, "fromString(BaboonTimeFormats.formatTsu(value))", "decodeTsu"), + (tso, "fromString(BaboonTimeFormats.formatTso(value))", "decodeTso"), + ) + cases.foreach { + case (id, encoded, decoder) => + scala(ScScalarCodecEmitter.jsonEncode(id, q"value")) shouldBe s"Json.$encoded" + scala(ScScalarCodecEmitter.jsonDecoder(id)) shouldBe decoder + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala new file mode 100644 index 000000000..c7aa72c7c --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala @@ -0,0 +1,101 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.ResolvedServiceResult +import io.septimalmind.baboon.translator.csharp.CSServiceMethodPlan +import io.septimalmind.baboon.translator.java.JvServiceMethodPlan +import io.septimalmind.baboon.translator.kotlin.KtServiceMethodPlan +import io.septimalmind.baboon.translator.scl.ScServiceMethodPlan +import io.septimalmind.baboon.typer.model.{TypeId, TypeRef, Typedef} +import io.septimalmind.baboon.typer.model.Typedef.MethodName +import izumi.fundamentals.platform.strings.TextTree.* +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { + private val input = TypeRef.Scalar(TypeId.Builtins.str) + private val output = TypeRef.Scalar(TypeId.Builtins.i32) + private val error = TypeRef.Scalar(TypeId.Builtins.bytes) + private val method = Typedef.MethodDef(MethodName("when"), input, Some(output), Some(error)) + + "Kotlin method facts" should "retain keyword escaping and defer type resolution until its output is used" in { + var resolvedTypes = List.empty[TypeRef] + val plan = new KtServiceMethodPlan( + method, + tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, + ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + ) + plan.methodName shouldBe "`when`" + plan.hasError shouldBe true + resolvedTypes shouldBe Nil + plan.input.dump shouldBe "Type" + plan.input.dump shouldBe "Type" + resolvedTypes shouldBe List(input) + plan.output.map(_.dump) shouldBe Some("Type") + resolvedTypes shouldBe List(input, output) + plan.error.map(_.dump) shouldBe Some("Type") + resolvedTypes shouldBe List(input, output, error) + } + + "Scala method facts" should "retain raw method spelling and configured error suppression" in { + var resolvedTypes = List.empty[TypeRef] + val plan = new ScServiceMethodPlan( + method, + tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, + ResolvedServiceResult(noErrors = true, Some("Either"), Some("[$error, $success]"), None), + ) + plan.methodName shouldBe "when" + plan.hasError shouldBe false + resolvedTypes shouldBe Nil + plan.input.dump shouldBe "Type" + plan.output.map(_.dump) shouldBe Some("Type") + resolvedTypes shouldBe List(input, output) + } + + it should "keep absent output and error types unresolved" in { + val empty = method.copy(out = None, err = None) + val result = ResolvedServiceResult(noErrors = false, Some("Either"), Some("[$error, $success]"), None) + val sc = new ScServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result) + val kt = new KtServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result) + sc.output shouldBe None + sc.error shouldBe None + sc.hasError shouldBe false + kt.output shouldBe None + kt.error shouldBe None + kt.hasError shouldBe false + } + + "Java method facts" should "escape declarations and resolve requested types once" in { + var resolvedTypes = List.empty[TypeRef] + val plan = new JvServiceMethodPlan( + method.copy(name = MethodName("class")), + tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, + ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + ) + plan.declarationName shouldBe "class_" + plan.hasError shouldBe true + plan.hasOutput shouldBe true + resolvedTypes shouldBe Nil + plan.input.dump shouldBe "Type" + plan.input.dump shouldBe "Type" + plan.output.map(_.dump) shouldBe Some("Type") + plan.error.map(_.dump) shouldBe Some("Type") + resolvedTypes shouldBe List(input, output, error) + } + + "C# method facts" should "retain PascalCase and resolve each requested type once" in { + var resolvedTypes = List.empty[TypeRef] + val plan = new CSServiceMethodPlan( + method, + tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, + ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + ) + plan.methodName shouldBe "When" + plan.hasError shouldBe true + resolvedTypes shouldBe Nil + plan.input.dump shouldBe "Type" + plan.input.dump shouldBe "Type" + plan.output.map(_.dump) shouldBe Some("Type") + plan.error.map(_.dump) shouldBe Some("Type") + resolvedTypes shouldBe List(input, output, error) + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SharedTranslatorPolicyTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SharedTranslatorPolicyTest.scala new file mode 100644 index 000000000..18c64bc02 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SharedTranslatorPolicyTest.scala @@ -0,0 +1,87 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.IdentifierFieldKind +import io.septimalmind.baboon.translator.kotlin.KtTreeTools +import io.septimalmind.baboon.translator.scl.ScTreeTools +import io.septimalmind.baboon.translator.typescript.TsTreeTools +import io.septimalmind.baboon.typer.model.{DocComment, Docs, Owner, Pkg, TypeId, TypeName, TypeRef} +import izumi.fundamentals.collections.nonempty.NEList +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class SharedTranslatorPolicyTest extends AnyFlatSpec with Matchers { + "Identifier classification" should "retain every supported scalar category" in { + import TypeId.Builtins.* + val cases = List( + bit -> IdentifierFieldKind.Bit, + i08 -> IdentifierFieldKind.SignedInt, + i16 -> IdentifierFieldKind.SignedInt, + i32 -> IdentifierFieldKind.SignedInt, + i64 -> IdentifierFieldKind.SignedLong, + u08 -> IdentifierFieldKind.UnsignedSmallInt, + u16 -> IdentifierFieldKind.UnsignedSmallInt, + u32 -> IdentifierFieldKind.UnsignedSmallInt, + u64 -> IdentifierFieldKind.UnsignedLong, + str -> IdentifierFieldKind.Str, + uid -> IdentifierFieldKind.Uid, + tsu -> IdentifierFieldKind.Tsu, + tso -> IdentifierFieldKind.Tso, + bytes -> IdentifierFieldKind.Bytes, + ) + cases.foreach { case (scalar, expected) => IdentifierFieldKind.classify(TypeRef.Scalar(scalar)) shouldBe expected } + } + + it should "preserve nested identifier identity" in { + val id = TypeId.User(Pkg(NEList("example")), Owner.Toplevel, TypeName("Nested")) + IdentifierFieldKind.classify(TypeRef.Scalar(id)) shouldBe IdentifierFieldKind.NestedId(id) + } + + it should "reject unsupported scalars, collections and opaque values at the invariant boundary" in { + import TypeId.Builtins.* + List(f32, f64, f128).foreach { + scalar => + val error = intercept[IllegalStateException](IdentifierFieldKind.classify(TypeRef.Scalar(scalar))) + error.getMessage shouldBe s"Identifier field has unsupported scalar $scalar; validator should have rejected this." + } + val invalid = List( + TypeRef.Constructor(lst, NEList(TypeRef.Scalar(str))), + TypeRef.Any(TypeRef.AnyVariant.Global, None), + ) + invalid.foreach { + ref => + val error = intercept[IllegalStateException](IdentifierFieldKind.classify(ref)) + error.getMessage shouldBe s"Identifier field has unsupported TypeRef $ref; validator should have rejected this." + } + } + + "Javadoc-style backend renderers" should "preserve exact comments through TextTree rendering" in { + val renderers: List[(Docs, String) => String] = List( + new KtTreeTools.KtTreeToolsImpl().renderDocs _, + new ScTreeTools.ScTreeToolsImpl().renderDocs _, + new TsTreeTools.TsTreeToolsImpl().renderDocs _, + ) + def docs(prefix: Option[String], suffix: Option[String]): Docs = + Docs(prefix.map(s => DocComment(s, s)), suffix.map(s => DocComment(s, s))) + + val cases = List( + (docs(None, None), ""), + (docs(Some(""), None), " /** */\n"), + (docs(Some("one"), None), " /** one */\n"), + (docs(None, Some("suffix")), " /** suffix */\n"), + (docs(Some("one\ntwo"), Some("tail")), " /**\n * one\n * two\n *\n * tail\n */\n"), + (docs(Some("line\n"), None), " /**\n * line\n *\n */\n"), + (docs(Some("λ C:\\Windows */"), None), " /** λ C:\\Windows */ */\n"), + ) + renderers.foreach { + render => + cases.foreach { + case (input, expected) => + val rendered = render(input, " ") + val tree: TextTree[Nothing] = q"$rendered" + tree.dump shouldBe expected + } + } + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala new file mode 100644 index 000000000..e84a2996f --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala @@ -0,0 +1,60 @@ +package io.septimalmind.baboon.translator.openapi + +import io.circe.Json +import io.septimalmind.baboon.translator.graphql.GqlTypeTranslator +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.collections.nonempty.NEList +import org.scalatest.wordspec.AnyWordSpec + +class SchemaProjectionCharacterizationTest extends AnyWordSpec { + private val oas = new OasTypeTranslator + private val gql = new GqlTypeTranslator + private val pkg = Pkg(NEList("schema", "test")) + private val alias = TypeId.User(pkg, Owner.Toplevel, TypeName("Alias")) + private val color = TypeId.User(pkg, Owner.Toplevel, TypeName("Color")) + private def parse(s: String): Json = io.circe.parser.parse(s).fold(throw _, identity) + + "Schema projections" should { + "preserve scalar formats and nullable collection structure" in { + assert(parse(oas.scalarSchemaJson(TypeId.Builtins.u64)) == parse("""{"type":"integer","format":"int64","minimum":0}""")) + val ref = TypeRef.Constructor(TypeId.Builtins.opt, NEList(TypeRef.Constructor(TypeId.Builtins.set, NEList(TypeRef.Scalar(TypeId.Builtins.str))))) + assert(parse(oas.typeRefSchema(ref)) == parse("""{"oneOf":[{"type":"array","items":{"type":"string"},"uniqueItems":true},{"type":"null"}]}""")) + assert(gql.fieldTypeStr(ref) == "[String!]") + } + + "preserve distinct enum and numeric map projections" in { + val enumMap = TypeRef.Constructor(TypeId.Builtins.map, NEList(TypeRef.Scalar(color), TypeRef.Scalar(TypeId.Builtins.str))) + val schema = parse(oas.typeRefSchema(enumMap, Set(color))) + assert(schema.hcursor.downField("propertyNames").get[String]("$ref").toOption.contains("#/components/schemas/schema_test_Color")) + val numericMap = TypeRef.Constructor(TypeId.Builtins.map, NEList(TypeRef.Scalar(TypeId.Builtins.i32), TypeRef.Scalar(TypeId.Builtins.str))) + assert(parse(oas.typeRefSchema(numericMap)).hcursor.get[String]("type").toOption.contains("array")) + assert(gql.fieldTypeStr(numericMap) == "[BaboonMapEntry_Int_String!]!") + } + + "resolve foreign aliases recursively without changing unresolved references" in { + val resolutions = Map(alias -> Some(TypeRef.Scalar(color))) + val ref = TypeRef.Constructor(TypeId.Builtins.lst, NEList(TypeRef.Scalar(alias))) + val expected = TypeRef.Constructor(TypeId.Builtins.lst, NEList(TypeRef.Scalar(color))) + assert(oas.resolveTypeRef(ref, resolutions) == expected) + assert(gql.resolveTypeRef(ref, resolutions) == expected) + assert(oas.resolveTypeRef(TypeRef.Scalar(color), resolutions) == TypeRef.Scalar(color)) + assert(gql.typeName(color) == oas.schemaName(color)) + } + + "retain the documented permissive any envelope" in { + val schema = parse(oas.baboonAnySchema) + assert(schema.hcursor.get[List[String]]("required").toOption.contains(List("$ak", "$c"))) + assert(schema.hcursor.downField("properties").downField("$ak").get[Int]("maximum").toOption.contains(7)) + assert(gql.renderGqlDescription(Docs.empty, " ").isEmpty) + } + + "render typed fragments only at the text boundary" in { + val ref = TypeRef.Constructor(TypeId.Builtins.lst, NEList(TypeRef.Scalar(color))) + assert(oas.typeRefSchemaValue(ref, Set(color)) == parse(oas.typeRefSchema(ref, Set(color)))) + assert(oas.scalarSchemaValue(TypeId.Builtins.u32) == parse(oas.scalarSchemaJson(TypeId.Builtins.u32))) + assert(oas.baboonAnySchemaValue == parse(oas.baboonAnySchema)) + assert(gql.descriptionLiteral(List("first", "second"), " ") == " \"\"\"\nfirst\nsecond\n \"\"\"\n") + assert(gql.descriptionLiteral(List("single"), " ") == " \"single\"\n") + } + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs new file mode 100644 index 000000000..5bd7c5825 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs @@ -0,0 +1,101 @@ +#nullable enable + +using System.IO; +using Newtonsoft.Json.Linq; + +namespace Baboon.Runtime.Shared +{ + public static class BaboonAnyBinCodec + { + public static void Encode( + BaboonCodecContext ctx, + BinaryWriter writer, + byte expectedKind, + string? staticDomain, + string? staticVersion, + string? staticTypeid, + AnyOpaque value) + { + if (value.Meta.Kind != expectedKind) + { + throw new BaboonCodecException.EncoderFailure( + $"any: meta-kind 0x{value.Meta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); + } + byte[] anyBlob; + if (value is AnyOpaqueUeba anyUeba) + { + anyBlob = anyUeba.Bytes; + } + else if (value is AnyOpaqueJson anyJson) + { + var f = ctx.Facade ?? throw new BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueUeba directly."); + var anyConvResult = f.JsonToUebaBytes(anyJson.Meta, anyJson.Json, staticDomain, staticVersion, staticTypeid); + if (anyConvResult is Either.Left anyConvL) + { + throw anyConvL.Value; + } + anyBlob = ((Either.Right)anyConvResult).Value; + } + else + { + throw new BaboonCodecException.EncoderFailure( + $"unexpected AnyOpaque subclass: {value.GetType()}"); + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + using var anyMetaBuf = new MemoryStream(); + using (var anyMetaWriter = new BinaryWriter(anyMetaBuf)) + { + AnyMetaCodec.WriteBin(value.Meta, anyMetaWriter); + anyMetaWriter.Flush(); + } + var anyMetaBytes = anyMetaBuf.ToArray(); + var anyTotalLength = 4 + anyMetaBytes.Length + anyBlob.Length; + writer.Write(anyTotalLength); + writer.Write(anyMetaBytes.Length); + writer.Write(anyMetaBytes); + writer.Write(anyBlob); + } + + public static AnyOpaqueUeba Decode(BinaryReader wire, byte expectedKind) + { + var anyTotalLength = wire.ReadInt32(); + var anyMetaLength = wire.ReadInt32(); + if (anyMetaLength < 0) + { + throw new BaboonCodecException.DecoderFailure( + $"any: invalid meta-length {anyMetaLength}"); + } + if (anyTotalLength < 4 + anyMetaLength) + { + throw new BaboonCodecException.DecoderFailure( + $"any: total-length {anyTotalLength} smaller than 4 + meta-length {anyMetaLength}"); + } + var (anyMeta, anyBytesRead) = AnyMetaCodec.ReadBinWithLength(wire); + if (anyBytesRead > anyMetaLength) + { + throw new BaboonCodecException.DecoderFailure( + $"any: meta-bytes-read {anyBytesRead} exceeded meta-length window {anyMetaLength}"); + } + if (anyBytesRead < anyMetaLength) + { + // Skip future meta extension bytes within the meta-length window (forward-compat). + var anySkip = anyMetaLength - anyBytesRead; + wire.ReadBytes(anySkip); + } + if (anyMeta.Kind != expectedKind) + { + throw new BaboonCodecException.DecoderFailure( + $"any: wire kind 0x{anyMeta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); + } + var anyBlobLen = anyTotalLength - 4 - anyMetaLength; + var anyBlob = wire.ReadBytes(anyBlobLen); + if (anyBlob.Length != anyBlobLen) + { + throw new BaboonCodecException.DecoderFailure( + $"any: short read on blob, expected {anyBlobLen} got {anyBlob.Length}"); + } + return new AnyOpaqueUeba(anyMeta, anyBlob); + } + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs new file mode 100644 index 000000000..220247db9 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs @@ -0,0 +1,78 @@ +#nullable enable + +using System.IO; +using Newtonsoft.Json.Linq; + +namespace Baboon.Runtime.Shared +{ + public static class BaboonAnyJsonCodec + { + private const string AnyEnvelopeContentKey = "$c"; + + public static JToken Encode( + BaboonCodecContext ctx, + byte expectedKind, + string? staticDomain, + string? staticVersion, + string? staticTypeid, + AnyOpaque value) + { + if (value.Meta.Kind != expectedKind) + { + throw new BaboonCodecException.EncoderFailure( + $"any: meta-kind 0x{value.Meta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); + } + JToken anyInner; + if (value is AnyOpaqueJson anyJson) + { + anyInner = anyJson.Json; + } + else if (value is AnyOpaqueUeba anyUeba) + { + var f = ctx.Facade ?? throw new BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueJson directly."); + var anyConvResult = f.UebaToJson(anyUeba.Meta, anyUeba.Bytes, staticDomain, staticVersion, staticTypeid); + if (anyConvResult is Either.Left anyConvL) + { + throw anyConvL.Value; + } + anyInner = ((Either.Right)anyConvResult).Value; + } + else + { + throw new BaboonCodecException.EncoderFailure( + $"unexpected AnyOpaque subclass: {value.GetType()}"); + } + var anyEnvelope = (JObject)AnyMetaCodec.WriteJson(value.Meta); + anyEnvelope.Add(AnyEnvelopeContentKey, anyInner); + return anyEnvelope; + } + + public static AnyOpaqueJson Decode(byte expectedKind, JToken? wire) + { + if (wire is null) + { + throw new BaboonCodecException.DecoderFailure( + "any: missing JSON envelope (null token)"); + } + var anyMetaResult = AnyMetaCodec.ReadJson(wire); + if (anyMetaResult is Either.Left anyMetaL) + { + throw anyMetaL.Value; + } + var anyMeta = ((Either.Right)anyMetaResult).Value; + if (anyMeta.Kind != expectedKind) + { + throw new BaboonCodecException.DecoderFailure( + $"any: wire kind 0x{anyMeta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); + } + var anyContent = wire[AnyEnvelopeContentKey]; + if (anyContent is null) + { + throw new BaboonCodecException.DecoderFailure( + $"any: JSON envelope missing '{AnyEnvelopeContentKey}' content key"); + } + return new AnyOpaqueJson(anyMeta, anyContent); + } + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs index 5acaaf265..95f2d5be6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs @@ -192,10 +192,22 @@ public interface IBaboonBinCodecIndexed ushort IndexElementsCount(BaboonCodecContext ctx); List ReadIndex(BaboonCodecContext ctx, BinaryReader wire) + { + var result = new List(); + ReadIndexEntries(ctx, wire, result); + return result; + } + + ushort ConsumeIndex(BaboonCodecContext ctx, BinaryReader wire) + { + return ReadIndexEntries(ctx, wire, null); + } + + private ushort ReadIndexEntries(BaboonCodecContext ctx, BinaryReader wire, List? entries) { var header = wire.ReadByte(); var isIndexed = (header & 0b0000001) != 0; - var result = new List(); + ushort count = 0; uint prevoffset = 0; uint prevlen = 0; // ReSharper disable once InvertIf @@ -208,14 +220,15 @@ List ReadIndex(BaboonCodecContext ctx, BinaryReader wire) var len = wire.ReadUInt32(); Debug.Assert(len > 0); Debug.Assert(offset >= prevoffset + prevlen); - result.Add(new BaboonIndexEntry(offset, len)); + if (entries != null) entries.Add(new BaboonIndexEntry(offset, len)); + count++; left = (ushort) (left - 1); prevoffset = offset; prevlen = len; } } - return result; + return count; } void WriteIndexFixedLenField(BinaryWriter writer, int expected, Action doWrite) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index 9ad57b3d7..f4ea3c05f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -50,7 +51,35 @@ public class BaboonCodecsFacade private readonly ConcurrentDictionary> _versionsCodecsBin = new(); private readonly ConcurrentDictionary> _versionsConversions = new(); private readonly ConcurrentDictionary> _versionsMeta = new(); - private readonly ConcurrentDictionary> _domainVersions = new(); + private sealed class RegisteredVersion + { + public RegisteredVersion(BaboonDomainVersion id) + { + Id = id; + var culture = CultureInfo.CurrentCulture; + _numberFormat = culture.GetType() == typeof(CultureInfo) ? culture.NumberFormat : null; + _version = new Lazy(() => id.Version); + } + + public BaboonDomainVersion Id { get; } + private readonly NumberFormatInfo? _numberFormat; + private readonly Lazy _version; + public BaboonVersion Version + { + get + { + var culture = CultureInfo.CurrentCulture; + // The public parser observes mutable formats and subclass GetFormat overrides. + return _numberFormat is { IsReadOnly: true } + && culture.GetType() == typeof(CultureInfo) + && ReferenceEquals(culture.NumberFormat, _numberFormat) + ? _version.Value + : Id.Version; + } + } + } + + private readonly ConcurrentDictionary> _domainVersions = new(); private readonly object _domainVersionsLock = new(); /// @@ -203,7 +232,7 @@ public void Verify() throw new BaboonCodecException.CodecNotFound("Baboon codecs must have at least one domain registered."); } - foreach (var dv in _domainVersions.Values.SelectMany(v => v)) + foreach (var dv in _domainVersions.Values.SelectMany(v => v).Select(v => v.Id)) { if (!_versionsConversions.ContainsKey(dv)) { @@ -832,7 +861,7 @@ public Either Convert(TFrom value) )); } - if (versions.All(v => v != dvFrom)) + if (versions.All(v => v.Id != dvFrom)) { return Either.Left(new BaboonCodecException.ConverterFailure( $"Unknown domain version' {dvFrom}'." @@ -841,8 +870,9 @@ public Either Convert(TFrom value) // Iterate from first to latest, applying each step's conversion when needed. IBaboonGenerated current = value; - foreach (var toVersion in versions) + foreach (var registered in versions) { + var toVersion = registered.Id; // Mid-walk early-exit: if the current value already matches the target type // (e.g. an intermediate non-latest TO has been reached), stop walking and return. // Matches OLD facade semantics for non-latest TO calls. @@ -850,7 +880,7 @@ public Either Convert(TFrom value) if ( current.BaboonDomainVersion() == toVersion.DomainVersion || - new BaboonDomainVersion(current.BaboonDomainIdentifier(), current.BaboonDomainVersion()).Version >= toVersion.Version + new BaboonDomainVersion(current.BaboonDomainIdentifier(), current.BaboonDomainVersion()).Version >= registered.Version ) { continue; @@ -951,9 +981,10 @@ public bool IsDeprecated(IBaboonGenerated value) var currentType = value.GetType(); var adtType = (value as IBaboonAdtMemberMeta)?.BaboonAdtType(); - foreach (var toVersion in versions) + foreach (var registered in versions) { - if (dvFrom.Version >= toVersion.Version) continue; + var toVersion = registered.Id; + if (dvFrom.Version >= registered.Version) continue; if (!_versionsConversions.TryGetValue(toVersion, out var lazyConversions)) { @@ -1037,13 +1068,13 @@ bool tolerant return Either.Left(new BaboonCodecException.CodecNotFound($"Unknown domain {typeMeta.DomainIdentifier}.")); } - var minVersion = versions[0]; - var maxVersion = versions[^1]; + var minVersion = versions[0].Id; + var maxVersion = versions[^1].Id; var modelVersion = typeMeta.VersionRef; var modelV = modelVersion.Version; - var maxV = maxVersion.Version; - var minV = minVersion.Version; + var maxV = versions[^1].Version; + var minV = versions[0].Version; if (!exact && modelV > maxV) { @@ -1074,12 +1105,12 @@ bool tolerant if (modelV >= minV && modelV < maxV) { - return GetCodecMaxCompat(versionsCodecs, modelVersion, maxVersion, typeMeta.TypeIdentifier); + return GetCodecMaxCompat(versionsCodecs, modelVersion, versions[^1], typeMeta.TypeIdentifier); } if (modelV < minV) { - return GetCodecMaxCompat(versionsCodecs, minVersion, maxVersion, typeMeta.TypeIdentifier); + return GetCodecMaxCompat(versionsCodecs, minVersion, versions[^1], typeMeta.TypeIdentifier); } return Either.Left(new BaboonCodecException.CodecNotFound( @@ -1114,7 +1145,7 @@ string typeIdentifier private Either GetCodecMaxCompat( ConcurrentDictionary> versionsCodecs, BaboonDomainVersion modelVersion, - BaboonDomainVersion maxVersion, + RegisteredVersion maxVersion, string typeIdentifier ) where TCodecs : AbstractBaboonCodecs @@ -1131,7 +1162,7 @@ string typeIdentifier for (var i = sameVersions.Count - 1; i >= 0; i--) { var sv = sameVersions[i]; - if (sv == maxVersion.DomainVersion || BaboonVersion.From(sv) <= maxVersion.Version) + if (sv == maxVersion.Id.DomainVersion || BaboonVersion.From(sv) <= maxVersion.Version) { bestSame = sv; break; @@ -1157,14 +1188,14 @@ private void RegisterVersion(BaboonDomainVersion domainVersion) { if (_domainVersions.TryGetValue(domainVersion.DomainIdentifier, out var existing)) { - if (existing.Contains(domainVersion)) return; - var updated = new List(existing) {domainVersion}; + if (existing.Any(v => v.Id == domainVersion)) return; + var updated = new List(existing) {new RegisteredVersion(domainVersion)}; updated.Sort((a, b) => a.Version.CompareTo(b.Version)); _domainVersions[domainVersion.DomainIdentifier] = updated; } else { - _domainVersions[domainVersion.DomainIdentifier] = new List {domainVersion}; + _domainVersions[domainVersion.DomainIdentifier] = new List {new RegisteredVersion(domainVersion)}; } } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs index c821c02b7..caa4990d3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs @@ -47,6 +47,11 @@ public abstract class AbstractConversion : IBaboonGeneratedConversio public abstract string TypeId(); + protected virtual string TargetTypeId() + { + return TypeId(); + } + public Type TypeFrom() { return typeof(TFrom); @@ -58,10 +63,15 @@ public Type TypeTo() } protected void ValidateBaboonType(object? obj) + { + ValidateBaboonType(obj, false); + } + + private void ValidateBaboonType(object? obj, bool validateTarget) { if (obj is IBaboonGenerated bgf) { - var tid = TypeId(); + var tid = validateTarget ? TargetTypeId() : TypeId(); var conversionTypeIsExactType = tid == bgf.BaboonTypeIdentifier(); if (obj is IBaboonAdtMemberMeta bga) { @@ -90,7 +100,7 @@ public TTo Convert(TCtx? context, AbstractBaboonConversions conversions, T if (ConversionValidationEnabled()) { ValidateBaboonType(from); - ValidateBaboonType(result); + ValidateBaboonType(result, true); } return result; } @@ -267,4 +277,4 @@ public ConvertDslFrom Convert(TFrom from) return new ConvertDslFrom(from, this); } } -} \ No newline at end of file +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonMcpRuntime.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonMcpRuntime.cs index f24f3ec4a..bdbda6c57 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonMcpRuntime.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonMcpRuntime.cs @@ -79,6 +79,40 @@ public sealed class McpSession // carried as a constant value — the runtime does not compute schemas. public sealed record McpToolEntry(string Name, BaboonMethodId Method, JToken InputSchema, string? Description = null); + public sealed class McpToolRegistry + { + private readonly List _tools = new(); + private readonly Dictionary _byName = new(); + + public McpToolRegistry(IEnumerable tools) + { + foreach (var tool in tools) + { + var owned = tool with { InputSchema = tool.InputSchema.DeepClone() }; + _tools.Add(owned); + _byName[owned.Name] = owned; + } + } + + public IReadOnlyList Snapshot() + { + var result = new List(_tools.Count); + foreach (var tool in _tools) result.Add(tool with { InputSchema = tool.InputSchema.DeepClone() }); + return result.AsReadOnly(); + } + + public bool TryFind(string name, out BaboonMethodId method) + { + if (_byName.TryGetValue(name, out var found)) + { + method = found.Method; + return true; + } + method = null!; + return false; + } + } + public sealed record McpServerInfo(string Name, string Version); // --- Dispatch interface --- @@ -154,6 +188,60 @@ public delegate Task> McpJsonInvokeAsync serverInfo) + { + var pv = request.Params?["protocolVersion"]; + if (request.Params == null || pv == null) + return new JsonRpcResponse(request.Id, null, new JsonRpcError(JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion")); + session.Initialized = true; + return new JsonRpcResponse(request.Id, new JObject + { + ["protocolVersion"] = McpProtocol.Version, + ["capabilities"] = new JObject { ["tools"] = new JObject() }, + ["serverInfo"] = new JObject { ["name"] = serverInfo().Name, ["version"] = serverInfo().Version }, + }); + } + + internal static JsonRpcResponse List(JsonRpcRequest request, McpSession session, Func tools) + { + if (!session.Initialized) + return new JsonRpcResponse(request.Id, null, new JsonRpcError(JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize")); + return new JsonRpcResponse(request.Id, new JObject { ["tools"] = tools() }); + } + + internal static JArray ToolsList(IEnumerable entries) + { + var tools = new JArray(); + foreach (var t in entries) + { + var entry = new JObject { ["name"] = t.Name, ["inputSchema"] = t.InputSchema }; + if (t.Description != null) entry["description"] = t.Description; + tools.Add(entry); + } + return tools; + } + + internal static JsonRpcResponse? ValidateCall(JsonRpcRequest request, McpSession session, out string toolName) + { + toolName = ""; + if (!session.Initialized) + return new JsonRpcResponse(request.Id, null, new JsonRpcError(JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize")); + var name = request.Params?["name"]; + if (name == null || name.Type != JTokenType.String) + return new JsonRpcResponse(request.Id, null, new JsonRpcError(JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name")); + toolName = name.Value()!; + return null; + } + + internal static JsonRpcResponse ToolResult(JToken? id, string text, bool isError) + { + var content = new JArray { new JObject { ["type"] = "text", ["text"] = text } }; + return new JsonRpcResponse(id, new JObject { ["content"] = content, ["isError"] = isError }); + } + } + // --- Transport-abstract dispatch base --- // // Shared `Handle` state machine. The generated `McpServer` extends @@ -178,6 +266,17 @@ public Either RouteToolCall(BaboonMethodId method, st return InvokeJson(method, data, ctx, codecCtx); } + protected virtual bool TryFindTool(string name, out BaboonMethodId method) + { + if (ByName().TryGetValue(name, out var found)) + { + method = found.Method; + return true; + } + method = null!; + return false; + } + private Dictionary ByName() { var m = new Dictionary(); @@ -194,75 +293,24 @@ private Dictionary ByName() switch (request.Method) { case "initialize": - { - var pv = request.Params?["protocolVersion"]; - if (request.Params == null || pv == null) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion"); - } - session.Initialized = true; - var result = new JObject - { - ["protocolVersion"] = McpProtocol.Version, - ["capabilities"] = new JObject { ["tools"] = new JObject() }, - ["serverInfo"] = new JObject { ["name"] = ServerInfo.Name, ["version"] = ServerInfo.Version }, - }; - return new JsonRpcResponse(id, result); - } + return McpProtocolDispatch.Initialize(request, session, () => ServerInfo); case "notifications/initialized": return null; case "tools/list": - { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize"); - } - var tools = new JArray(); - foreach (var t in Tools) - { - var entry = new JObject - { - ["name"] = t.Name, - ["inputSchema"] = t.InputSchema, - }; - if (t.Description != null) - { - entry["description"] = t.Description; - } - tools.Add(entry); - } - return new JsonRpcResponse(id, new JObject { ["tools"] = tools }); - } + return McpProtocolDispatch.List(request, session, () => McpProtocolDispatch.ToolsList(Tools)); case "tools/call": { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize"); - } - var name = request.Params?["name"]; - if (name == null || name.Type != JTokenType.String) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name"); - } - var toolName = name.Value()!; - if (!ByName().TryGetValue(toolName, out var entry)) + var invalid = McpProtocolDispatch.ValidateCall(request, session, out var toolName); + if (invalid != null) return invalid; + if (!TryFindTool(toolName, out var method)) { return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, $"tools/call: unknown tool '{toolName}'"); } var argsToken = request.Params?["arguments"] ?? new JObject(); var argsJson = argsToken.ToString(Newtonsoft.Json.Formatting.None); - var result = InvokeJson(entry.Method, argsJson, ctx, codecCtx); - if (result.IsRight) - { - var content = new JArray { new JObject { ["type"] = "text", ["text"] = result.GetRight() } }; - return new JsonRpcResponse(id, new JObject { ["content"] = content, ["isError"] = false }); - } - else - { - // Channel B: a valid protocol call whose domain payload failed. - var content = new JArray { new JObject { ["type"] = "text", ["text"] = DescribeWiringError(result.GetLeft()) } }; - return new JsonRpcResponse(id, new JObject { ["content"] = content, ["isError"] = true }); - } + var result = InvokeJson(method, argsJson, ctx, codecCtx); + return McpProtocolDispatch.ToolResult(id, + result.IsRight ? result.GetRight() : DescribeWiringError(result.GetLeft()), !result.IsRight); } default: return ErrorResponse(id, JsonRpcErrorCodes.MethodNotFound, $"Method not found: {request.Method}"); @@ -377,65 +425,25 @@ public void Register(IBaboonRoutableMcpServer server) switch (request.Method) { case "initialize": - { - var pv = request.Params?["protocolVersion"]; - if (request.Params == null || pv == null) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion"); - } - session.Initialized = true; - var result = new Newtonsoft.Json.Linq.JObject - { - ["protocolVersion"] = McpProtocol.Version, - ["capabilities"] = new Newtonsoft.Json.Linq.JObject { ["tools"] = new Newtonsoft.Json.Linq.JObject() }, - ["serverInfo"] = new Newtonsoft.Json.Linq.JObject { ["name"] = _mergedServerInfo.Name, ["version"] = _mergedServerInfo.Version }, - }; - return new JsonRpcResponse(id, result); - } + return McpProtocolDispatch.Initialize(request, session, () => _mergedServerInfo); case "notifications/initialized": return null; case "tools/list": - { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize"); - } - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["tools"] = ToolsListUnion() }); - } + return McpProtocolDispatch.List(request, session, ToolsListUnion); case "tools/call": { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize"); - } - var name = request.Params?["name"]; - if (name == null || name.Type != Newtonsoft.Json.Linq.JTokenType.String) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name"); - } - var toolName = name.Value()!; + var invalid = McpProtocolDispatch.ValidateCall(request, session, out var toolName); + if (invalid != null) return invalid; if (!_route.TryGetValue(toolName, out var server)) { - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), - // so the bytes are identical whether one server or the muxer rejects. return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, $"tools/call: unknown tool '{toolName}'"); } var entry = _entries[toolName]; - var argsToken = request.Params?["arguments"] ?? new Newtonsoft.Json.Linq.JObject(); + var argsToken = request.Params?["arguments"] ?? new JObject(); var argsJson = argsToken.ToString(Newtonsoft.Json.Formatting.None); var result = server.RouteToolCall(entry.Method, argsJson, ctx, codecCtx); - if (result.IsRight) - { - var content = new Newtonsoft.Json.Linq.JArray { new Newtonsoft.Json.Linq.JObject { ["type"] = "text", ["text"] = result.GetRight() } }; - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["content"] = content, ["isError"] = false }); - } - else - { - // Channel B: a valid protocol call whose domain payload failed. - var content = new Newtonsoft.Json.Linq.JArray { new Newtonsoft.Json.Linq.JObject { ["type"] = "text", ["text"] = DescribeWiringError(result.GetLeft()) } }; - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["content"] = content, ["isError"] = true }); - } + return McpProtocolDispatch.ToolResult(id, + result.IsRight ? result.GetRight() : DescribeWiringError(result.GetLeft()), !result.IsRight); } default: return ErrorResponse(id, JsonRpcErrorCodes.MethodNotFound, $"Method not found: {request.Method}"); @@ -445,24 +453,14 @@ public void Register(IBaboonRoutableMcpServer server) // Backs tools/list (§3.2): the union of all registered servers' tool entries // in registration-then-declaration order (insertion order of `_toolOrder`), // each in the same shape the per-service base emits. - private Newtonsoft.Json.Linq.JArray ToolsListUnion() + private JArray ToolsListUnion() { - var tools = new Newtonsoft.Json.Linq.JArray(); - foreach (var name in _toolOrder) - { - var t = _entries[name]; - var entry = new Newtonsoft.Json.Linq.JObject - { - ["name"] = t.Name, - ["inputSchema"] = t.InputSchema, - }; - if (t.Description != null) - { - entry["description"] = t.Description; - } - tools.Add(entry); - } - return tools; + return McpProtocolDispatch.ToolsList(OrderedTools()); + } + + private IEnumerable OrderedTools() + { + foreach (var name in _toolOrder) yield return _entries[name]; } protected JsonRpcResponse ErrorResponse(Newtonsoft.Json.Linq.JToken? id, int code, string message) @@ -520,86 +518,39 @@ public void Register(IBaboonRoutableMcpServerAsync server) switch (request.Method) { case "initialize": - { - var pv = request.Params?["protocolVersion"]; - if (request.Params == null || pv == null) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion"); - } - session.Initialized = true; - var result = new Newtonsoft.Json.Linq.JObject - { - ["protocolVersion"] = McpProtocol.Version, - ["capabilities"] = new Newtonsoft.Json.Linq.JObject { ["tools"] = new Newtonsoft.Json.Linq.JObject() }, - ["serverInfo"] = new Newtonsoft.Json.Linq.JObject { ["name"] = _mergedServerInfo.Name, ["version"] = _mergedServerInfo.Version }, - }; - return new JsonRpcResponse(id, result); - } + return McpProtocolDispatch.Initialize(request, session, () => _mergedServerInfo); case "notifications/initialized": return null; case "tools/list": - { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize"); - } - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["tools"] = ToolsListUnion() }); - } + return McpProtocolDispatch.List(request, session, ToolsListUnion); case "tools/call": { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize"); - } - var name = request.Params?["name"]; - if (name == null || name.Type != Newtonsoft.Json.Linq.JTokenType.String) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name"); - } - var toolName = name.Value()!; + var invalid = McpProtocolDispatch.ValidateCall(request, session, out var toolName); + if (invalid != null) return invalid; if (!_route.TryGetValue(toolName, out var server)) { return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, $"tools/call: unknown tool '{toolName}'"); } var entry = _entries[toolName]; - var argsToken = request.Params?["arguments"] ?? new Newtonsoft.Json.Linq.JObject(); + var argsToken = request.Params?["arguments"] ?? new JObject(); var argsJson = argsToken.ToString(Newtonsoft.Json.Formatting.None); var result = await server.RouteToolCall(entry.Method, argsJson, ctx, codecCtx); - if (result.IsRight) - { - var content = new Newtonsoft.Json.Linq.JArray { new Newtonsoft.Json.Linq.JObject { ["type"] = "text", ["text"] = result.GetRight() } }; - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["content"] = content, ["isError"] = false }); - } - else - { - // Channel B: a valid protocol call whose domain payload failed. - var content = new Newtonsoft.Json.Linq.JArray { new Newtonsoft.Json.Linq.JObject { ["type"] = "text", ["text"] = DescribeWiringError(result.GetLeft()) } }; - return new JsonRpcResponse(id, new Newtonsoft.Json.Linq.JObject { ["content"] = content, ["isError"] = true }); - } + return McpProtocolDispatch.ToolResult(id, + result.IsRight ? result.GetRight() : DescribeWiringError(result.GetLeft()), !result.IsRight); } default: return ErrorResponse(id, JsonRpcErrorCodes.MethodNotFound, $"Method not found: {request.Method}"); } } - private Newtonsoft.Json.Linq.JArray ToolsListUnion() + private JArray ToolsListUnion() { - var tools = new Newtonsoft.Json.Linq.JArray(); - foreach (var name in _toolOrder) - { - var t = _entries[name]; - var entry = new Newtonsoft.Json.Linq.JObject - { - ["name"] = t.Name, - ["inputSchema"] = t.InputSchema, - }; - if (t.Description != null) - { - entry["description"] = t.Description; - } - tools.Add(entry); - } - return tools; + return McpProtocolDispatch.ToolsList(OrderedTools()); + } + + private IEnumerable OrderedTools() + { + foreach (var name in _toolOrder) yield return _entries[name]; } protected JsonRpcResponse ErrorResponse(Newtonsoft.Json.Linq.JToken? id, int code, string message) @@ -635,6 +586,17 @@ public Task> RouteToolCall(BaboonMethodId meth return InvokeJson(method, data, ctx, codecCtx); } + protected virtual bool TryFindTool(string name, out BaboonMethodId method) + { + if (ByName().TryGetValue(name, out var found)) + { + method = found.Method; + return true; + } + method = null!; + return false; + } + private Dictionary ByName() { var m = new Dictionary(); @@ -651,75 +613,24 @@ private Dictionary ByName() switch (request.Method) { case "initialize": - { - var pv = request.Params?["protocolVersion"]; - if (request.Params == null || pv == null) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion"); - } - session.Initialized = true; - var result = new JObject - { - ["protocolVersion"] = McpProtocol.Version, - ["capabilities"] = new JObject { ["tools"] = new JObject() }, - ["serverInfo"] = new JObject { ["name"] = ServerInfo.Name, ["version"] = ServerInfo.Version }, - }; - return new JsonRpcResponse(id, result); - } + return McpProtocolDispatch.Initialize(request, session, () => ServerInfo); case "notifications/initialized": return null; case "tools/list": - { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize"); - } - var tools = new JArray(); - foreach (var t in Tools) - { - var entry = new JObject - { - ["name"] = t.Name, - ["inputSchema"] = t.InputSchema, - }; - if (t.Description != null) - { - entry["description"] = t.Description; - } - tools.Add(entry); - } - return new JsonRpcResponse(id, new JObject { ["tools"] = tools }); - } + return McpProtocolDispatch.List(request, session, () => McpProtocolDispatch.ToolsList(Tools)); case "tools/call": { - if (!session.Initialized) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize"); - } - var name = request.Params?["name"]; - if (name == null || name.Type != JTokenType.String) - { - return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name"); - } - var toolName = name.Value()!; - if (!ByName().TryGetValue(toolName, out var entry)) + var invalid = McpProtocolDispatch.ValidateCall(request, session, out var toolName); + if (invalid != null) return invalid; + if (!TryFindTool(toolName, out var method)) { return ErrorResponse(id, JsonRpcErrorCodes.InvalidParams, $"tools/call: unknown tool '{toolName}'"); } var argsToken = request.Params?["arguments"] ?? new JObject(); var argsJson = argsToken.ToString(Newtonsoft.Json.Formatting.None); - var result = await InvokeJson(entry.Method, argsJson, ctx, codecCtx); - if (result.IsRight) - { - var content = new JArray { new JObject { ["type"] = "text", ["text"] = result.GetRight() } }; - return new JsonRpcResponse(id, new JObject { ["content"] = content, ["isError"] = false }); - } - else - { - // Channel B: a valid protocol call whose domain payload failed. - var content = new JArray { new JObject { ["type"] = "text", ["text"] = DescribeWiringError(result.GetLeft()) } }; - return new JsonRpcResponse(id, new JObject { ["content"] = content, ["isError"] = true }); - } + var result = await InvokeJson(method, argsJson, ctx, codecCtx); + return McpProtocolDispatch.ToolResult(id, + result.IsRight ? result.GetRight() : DescribeWiringError(result.GetLeft()), !result.IsRight); } default: return ErrorResponse(id, JsonRpcErrorCodes.MethodNotFound, $"Method not found: {request.Method}"); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTools.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTools.cs index 3debece02..f83b36c76 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTools.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTools.cs @@ -69,12 +69,12 @@ public static int SeqHashcode(IEnumerable value, Func mk) public static int SetHashcode(ISet value, Func mk) { - return value.Select(item => mk(item)).OrderBy(c => c).Aggregate(0x1EAFDEAD, (current, item) => current ^ item); + return value.Select(item => mk(item)).Aggregate(0x1EAFDEAD, (current, item) => current ^ item); } public static int MapHashcode(IEnumerable> value, Func mkk, Func mkv) { - return (value.Select(item0 => HashCode.Combine(mkk(item0.Key), mkv(item0.Value))).OrderBy(c => c).Aggregate(0x1EAFDEAD, (current, item0) => current ^ item0)); + return (value.Select(item0 => HashCode.Combine(mkk(item0.Key), mkv(item0.Value))).Aggregate(0x1EAFDEAD, (current, item0) => current ^ item0)); } public static bool MapEquals(IReadOnlyDictionary left, IReadOnlyDictionary right, Func cmp) @@ -259,4 +259,4 @@ private static bool HasStubChild(DirectoryInfo dir) } } } -} \ No newline at end of file +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart index 764d014c6..3b6df0d95 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart @@ -16,6 +16,182 @@ import 'dart:typed_data'; import 'baboon_runtime.dart'; +import 'baboon_codecs_facade.dart'; + +Object? encodeAnyJsonField( + BaboonCodecContext ctx, + int expectedKind, + String? staticDomain, + String? staticVersion, + String? staticTypeid, + AnyOpaque value, +) { + if (value.meta.kind != expectedKind) { + throw BaboonEncoderFailure( + 'any: meta-kind 0x' + (value.meta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + + ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), + ); + } + Object? anyInner; + switch (value) { + case AnyOpaqueJson(:final json): + anyInner = json; + case AnyOpaqueUeba(:final meta, :final bytes): + final anyFacadeBase = ctx.facade; + if (anyFacadeBase == null) { + throw BaboonEncoderFailure( + 'Cannot encode AnyOpaqueUeba into JSON without a facade reference. ' + 'Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), ' + 'or supply AnyOpaqueJson directly.', + ); + } + // Downcast to the concrete facade — the marker base is empty by design (PR 8.1 + // import-cycle break). Construction goes through BaboonCodecContext.withFacade + // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. + final anyFacade = anyFacadeBase as BaboonCodecsFacade; + final anyConvResult = anyFacade.uebaToJson( + meta, + bytes, + staticDomain: staticDomain, + staticVersion: staticVersion, + staticTypeid: staticTypeid, + ); + switch (anyConvResult) { + case BaboonLeft(:final value): + throw value; + case BaboonRight(:final value): + anyInner = value; + } + } + final anyEnvelope = AnyMetaCodec.writeJson(value.meta); + anyEnvelope[AnyMetaCodec.ANY_CONTENT_KEY] = anyInner; + return anyEnvelope; +} + +AnyOpaque decodeAnyJsonField(int expectedKind, Object? wire) { + if (wire is! Map) { + throw BaboonDecoderFailure('any: JSON envelope must be an object'); + } + final anyMetaResult = AnyMetaCodec.readJson(wire); + final AnyMeta anyMeta; + switch (anyMetaResult) { + case BaboonLeft(:final value): + throw value; + case BaboonRight(:final value): + anyMeta = value; + } + if (anyMeta.kind != expectedKind) { + throw BaboonDecoderFailure( + 'any: wire kind 0x' + (anyMeta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + + ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), + ); + } + if (!wire.containsKey(AnyMetaCodec.ANY_CONTENT_KEY)) { + throw BaboonDecoderFailure( + "any: JSON envelope missing '" + AnyMetaCodec.ANY_CONTENT_KEY + "' content key", + ); + } + final anyContent = wire[AnyMetaCodec.ANY_CONTENT_KEY]; + return AnyOpaqueJson(anyMeta, anyContent); +} + +void encodeAnyUebaField( + BaboonCodecContext ctx, + BaboonBinWriter writer, + int expectedKind, + String? staticDomain, + String? staticVersion, + String? staticTypeid, + AnyOpaque value, +) { + if (value.meta.kind != expectedKind) { + throw BaboonEncoderFailure( + 'any: meta-kind 0x' + (value.meta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + + ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), + ); + } + Uint8List anyBlob; + switch (value) { + case AnyOpaqueUeba(:final bytes): + anyBlob = bytes; + case AnyOpaqueJson(:final meta, :final json): + final anyFacadeBase = ctx.facade; + if (anyFacadeBase == null) { + throw BaboonEncoderFailure( + 'Cannot encode AnyOpaqueJson into UEBA without a facade reference. ' + 'Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), ' + 'or supply AnyOpaqueUeba directly.', + ); + } + // Downcast to the concrete facade — the marker base is empty by design (PR 8.1 + // import-cycle break). Construction goes through BaboonCodecContext.withFacade + // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. + final anyFacade = anyFacadeBase as BaboonCodecsFacade; + final anyConvResult = anyFacade.jsonToUebaBytes( + meta, + json, + staticDomain: staticDomain, + staticVersion: staticVersion, + staticTypeid: staticTypeid, + ); + switch (anyConvResult) { + case BaboonLeft(:final value): + throw value; + case BaboonRight(:final value): + anyBlob = value; + } + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + final anyMetaBuf = BaboonBinWriter(); + AnyMetaCodec.writeBin(value.meta, anyMetaBuf); + final anyMetaBytes = anyMetaBuf.toBytes(); + final anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; + writer.writeI32(anyTotalLength); + writer.writeI32(anyMetaBytes.length); + writer.writeAll(anyMetaBytes); + writer.writeAll(anyBlob); +} + +AnyOpaque decodeAnyUebaField(BaboonBinReader wire, int expectedKind) { + final anyTotalLength = wire.readI32(); + if (anyTotalLength < 0) { + throw BaboonDecoderFailure( + 'any: negative total-length $anyTotalLength', + ); + } + final anyMetaLength = wire.readI32(); + if (anyMetaLength < 0) { + throw BaboonDecoderFailure( + 'any: negative meta-length $anyMetaLength', + ); + } + if (anyTotalLength < 4 + anyMetaLength) { + throw BaboonDecoderFailure( + 'any: total-length $anyTotalLength smaller than 4 + meta-length $anyMetaLength', + ); + } + final anyReadResult = AnyMetaCodec.readBinWithLength(wire); + final anyMeta = anyReadResult.$1; + final anyBytesRead = anyReadResult.$2; + if (anyBytesRead > anyMetaLength) { + throw BaboonDecoderFailure( + 'any: meta bytes-read $anyBytesRead exceeded meta-length window $anyMetaLength', + ); + } + if (anyBytesRead < anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + wire.skipBytes(anyMetaLength - anyBytesRead); + } + if (anyMeta.kind != expectedKind) { + throw BaboonDecoderFailure( + 'any: wire kind 0x' + (anyMeta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + + ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), + ); + } + final anyBlobLen = anyTotalLength - 4 - anyMetaLength; + final anyBlob = wire.readNBytes(anyBlobLen); + return AnyOpaqueUeba(anyMeta, anyBlob); +} // --- AnyMeta ----------------------------------------------------------------------------------- diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_mcp_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_mcp_runtime.dart index f9a66e7f4..0c6c1e7a4 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_mcp_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_mcp_runtime.dart @@ -138,97 +138,20 @@ abstract class AbstractBaboonMcpServer implements IBaboonMcpServer, IB String routeToolCall(BaboonMethodId method, String data, Ctx ctx, BaboonCodecContext codecCtx) => invokeJsonFn(method, data, ctx, codecCtx); - Map _byName() { - final m = {}; - for (final t in tools) m[t.name] = t; - return m; - } - - @override - JsonRpcResponse? handle(JsonRpcRequest request, McpSession session, Ctx ctx, BaboonCodecContext codecCtx) { - final id = request.id; - switch (request.method) { - case 'initialize': { - final params = request.params; - if (params == null || params is! Map) { - return _errorResponse(id, jsonRpcErrorInvalidParams, 'initialize: missing params'); - } - if (params['protocolVersion'] == null) { - return _errorResponse(id, jsonRpcErrorInvalidParams, 'initialize: missing protocolVersion'); - } - session.initialized = true; - return JsonRpcResponse(id, result: { - 'protocolVersion': mcpProtocolVersion, - 'capabilities': {'tools': {}}, - 'serverInfo': {'name': serverInfo.name, 'version': serverInfo.version}, - }); - } - case 'notifications/initialized': - return null; - case 'tools/list': { - if (!session.initialized) { - return _errorResponse(id, jsonRpcErrorInvalidRequest, 'tools/list before initialize'); - } - final toolsArr = tools.map((t) { - final entry = { - 'name': t.name, - 'inputSchema': t.inputSchema, - }; - if (t.description != null) entry['description'] = t.description; - return entry; - }).toList(); - return JsonRpcResponse(id, result: {'tools': toolsArr}); - } - case 'tools/call': { - if (!session.initialized) { - return _errorResponse(id, jsonRpcErrorInvalidRequest, 'tools/call before initialize'); - } - final params = request.params; - if (params == null || params is! Map) { - return _errorResponse(id, jsonRpcErrorInvalidParams, 'tools/call: missing params'); - } - final toolName = params['name']; - if (toolName == null || toolName is! String) { - return _errorResponse(id, jsonRpcErrorInvalidParams, 'tools/call: missing tool name'); - } - final entry = _byName()[toolName]; - if (entry == null) { - return _errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '$toolName'"); - } - final argsRaw = params['arguments'] ?? {}; - final argsJson = jsonEncode(argsRaw); - try { - final resultStr = invokeJsonFn(entry.method, argsJson, ctx, codecCtx); - return JsonRpcResponse(id, result: { - 'content': [{'type': 'text', 'text': resultStr}], - 'isError': false, - }); - } on BaboonWiringException catch (e) { - // Channel B: a valid protocol call whose domain payload failed. - return JsonRpcResponse(id, result: { - 'content': [{'type': 'text', 'text': _describeWiringError(e.error)}], - 'isError': true, - }); - } catch (e) { - // Channel B: unexpected error during dispatch. - return JsonRpcResponse(id, result: { - 'content': [{'type': 'text', 'text': e.toString()}], - 'isError': true, - }); - } - } - default: - return _errorResponse(id, jsonRpcErrorMethodNotFound, 'Method not found: ${request.method}'); + McpToolEntry? _findTool(String name) { + McpToolEntry? found; + for (final entry in tools) { + if (entry.name == name) found = entry; } + return found; } - JsonRpcResponse _errorResponse(Object? id, int code, String message) { - return JsonRpcResponse(id, error: JsonRpcError(code, message)); - } - - String _describeWiringError(BaboonWiringError e) { - return e.toString(); - } + @override + JsonRpcResponse? handle(JsonRpcRequest request, McpSession session, Ctx ctx, BaboonCodecContext codecCtx) => + _handleMcp(request, session, () => serverInfo, () => tools, (name) { + final entry = _findTool(name); + return entry == null ? null : _McpRoute((data) => invokeJsonFn(entry.method, data, ctx, codecCtx)); + }); } // --- MCP-muxer error taxonomy (tasks:T112; contract §6) --- @@ -324,7 +247,31 @@ class AbstractMcpMuxer implements IBaboonMcpServer { } @override - JsonRpcResponse? handle(JsonRpcRequest request, McpSession session, Ctx ctx, BaboonCodecContext codecCtx) { + JsonRpcResponse? handle(JsonRpcRequest request, McpSession session, Ctx ctx, BaboonCodecContext codecCtx) => + _handleMcp(request, session, () => _mergedServerInfo, () => _entries.values, (name) { + final server = _route[name]; + if (server == null) return null; + final entry = _entries[name]!; + return _McpRoute((data) => server.routeToolCall(entry.method, data, ctx, codecCtx)); + }); + +} + +class _McpRoute { + final String Function(String) invoke; + _McpRoute(this.invoke); +} + +JsonRpcResponse _errorResponse(Object? id, int code, String message) => + JsonRpcResponse(id, error: JsonRpcError(code, message)); + +JsonRpcResponse? _handleMcp( + JsonRpcRequest request, + McpSession session, + McpServerInfo Function() serverInfo, + Iterable Function() tools, + _McpRoute? Function(String) lookup, +) { final id = request.id; switch (request.method) { case 'initialize': { @@ -339,7 +286,7 @@ class AbstractMcpMuxer implements IBaboonMcpServer { return JsonRpcResponse(id, result: { 'protocolVersion': mcpProtocolVersion, 'capabilities': {'tools': {}}, - 'serverInfo': {'name': _mergedServerInfo.name, 'version': _mergedServerInfo.version}, + 'serverInfo': {'name': serverInfo().name, 'version': serverInfo().version}, }); } case 'notifications/initialized': @@ -348,7 +295,15 @@ class AbstractMcpMuxer implements IBaboonMcpServer { if (!session.initialized) { return _errorResponse(id, jsonRpcErrorInvalidRequest, 'tools/list before initialize'); } - return JsonRpcResponse(id, result: {'tools': _toolsListUnion()}); + final toolsArr = tools().map((t) { + final entry = { + 'name': t.name, + 'inputSchema': t.inputSchema, + }; + if (t.description != null) entry['description'] = t.description; + return entry; + }).toList(); + return JsonRpcResponse(id, result: {'tools': toolsArr}); } case 'tools/call': { if (!session.initialized) { @@ -362,18 +317,14 @@ class AbstractMcpMuxer implements IBaboonMcpServer { if (toolName == null || toolName is! String) { return _errorResponse(id, jsonRpcErrorInvalidParams, 'tools/call: missing tool name'); } - final server = _route[toolName]; - if (server == null) { - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), - // so the bytes are identical whether one server or the muxer rejects. + final route = lookup(toolName); + if (route == null) { return _errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '$toolName'"); } - final entry = _entries[toolName]!; final argsRaw = params['arguments'] ?? {}; final argsJson = jsonEncode(argsRaw); try { - final resultStr = server.routeToolCall(entry.method, argsJson, ctx, codecCtx); + final resultStr = route.invoke(argsJson); return JsonRpcResponse(id, result: { 'content': [{'type': 'text', 'text': resultStr}], 'isError': false, @@ -396,24 +347,3 @@ class AbstractMcpMuxer implements IBaboonMcpServer { return _errorResponse(id, jsonRpcErrorMethodNotFound, 'Method not found: ${request.method}'); } } - - // Backs tools/list (§3.2): the union of all registered servers' tool entries - // in registration-then-declaration order (the insertion order of `_entries`), - // each in the same shape the per-service base emits. - List> _toolsListUnion() { - final out = >[]; - for (final t in _entries.values) { - final entry = { - 'name': t.name, - 'inputSchema': t.inputSchema, - }; - if (t.description != null) entry['description'] = t.description; - out.add(entry); - } - return out; - } - - JsonRpcResponse _errorResponse(Object? id, int code, String message) { - return JsonRpcResponse(id, error: JsonRpcError(code, message)); - } -} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index aafa51432..efb8817e6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -216,21 +216,25 @@ class BaboonIndexEntry { class BaboonBinWriter { static const int _dotnetEpochOffsetMs = 62135596800000; Uint8List _buf; + late ByteData _view; int _pos; BaboonBinWriter([int initialCapacity = 256]) : _buf = Uint8List(initialCapacity), - _pos = 0; + _pos = 0 { + _view = ByteData.sublistView(_buf); + } void _ensureCapacity(int needed) { if (_pos + needed > _buf.length) { - var newCap = _buf.length * 2; + var newCap = _buf.isEmpty ? 1 : _buf.length * 2; while (newCap < _pos + needed) { newCap *= 2; } final newBuf = Uint8List(newCap); newBuf.setRange(0, _pos, _buf); _buf = newBuf; + _view = ByteData.sublistView(newBuf); } } @@ -243,72 +247,55 @@ class BaboonBinWriter { void writeI8(int value) { _ensureCapacity(1); - final bd = ByteData(1); - bd.setInt8(0, value); - _buf[_pos++] = bd.getUint8(0); + _view.setInt8(_pos, value); + _pos += 1; } void writeU16(int value) { _ensureCapacity(2); - final bd = ByteData(2); - bd.setUint16(0, value, Endian.little); - _buf.setRange(_pos, _pos + 2, bd.buffer.asUint8List()); + _view.setUint16(_pos, value, Endian.little); _pos += 2; } void writeI16(int value) { _ensureCapacity(2); - final bd = ByteData(2); - bd.setInt16(0, value, Endian.little); - _buf.setRange(_pos, _pos + 2, bd.buffer.asUint8List()); + _view.setInt16(_pos, value, Endian.little); _pos += 2; } void writeU32(int value) { _ensureCapacity(4); - final bd = ByteData(4); - bd.setUint32(0, value, Endian.little); - _buf.setRange(_pos, _pos + 4, bd.buffer.asUint8List()); + _view.setUint32(_pos, value, Endian.little); _pos += 4; } void writeI32(int value) { _ensureCapacity(4); - final bd = ByteData(4); - bd.setInt32(0, value, Endian.little); - _buf.setRange(_pos, _pos + 4, bd.buffer.asUint8List()); + _view.setInt32(_pos, value, Endian.little); _pos += 4; } void writeU64(int value) { _ensureCapacity(8); - final bd = ByteData(8); - bd.setUint64(0, value, Endian.little); - _buf.setRange(_pos, _pos + 8, bd.buffer.asUint8List()); + _view.setUint64(_pos, value, Endian.little); _pos += 8; } void writeI64(int value) { _ensureCapacity(8); - final bd = ByteData(8); - bd.setInt64(0, value, Endian.little); - _buf.setRange(_pos, _pos + 8, bd.buffer.asUint8List()); + _view.setInt64(_pos, value, Endian.little); _pos += 8; } void writeF32(double value) { _ensureCapacity(4); - final bd = ByteData(4); - bd.setFloat32(0, value, Endian.little); - _buf.setRange(_pos, _pos + 4, bd.buffer.asUint8List()); + _view.setFloat32(_pos, value, Endian.little); _pos += 4; } void writeF64(double value) { _ensureCapacity(8); - final bd = ByteData(8); - bd.setFloat64(0, value, Endian.little); - _buf.setRange(_pos, _pos + 8, bd.buffer.asUint8List()); + _view.setFloat64(_pos, value, Endian.little); _pos += 8; } @@ -362,9 +349,7 @@ class BaboonBinWriter { void _writeRawI32(int value) { _ensureCapacity(4); - final bd = ByteData(4); - bd.setUint32(0, value & 0xFFFFFFFF, Endian.little); - _buf.setRange(_pos, _pos + 4, bd.buffer.asUint8List()); + _view.setUint32(_pos, value & 0xFFFFFFFF, Endian.little); _pos += 4; } @@ -415,7 +400,14 @@ class BaboonBinWriter { } Uint8List toBytes() { - return Uint8List.fromList(_buf.sublist(0, _pos)); + return _buf.sublist(0, _pos); + } + + void writeBuffer(BaboonBinWriter source) { + final length = source._pos; + _ensureCapacity(length); + _buf.setRange(_pos, _pos + length, source._buf); + _pos += length; } } @@ -516,7 +508,7 @@ class BaboonBinReader { final length = readI32(); final bytes = _buf.sublist(_pos, _pos + length); _pos += length; - return Uint8List.fromList(bytes); + return bytes; } /// Read exactly [count] raw bytes from the wire (no length prefix). Used by the `any`-field @@ -526,7 +518,7 @@ class BaboonBinReader { Uint8List readNBytes(int count) { final bytes = _buf.sublist(_pos, _pos + count); _pos += count; - return Uint8List.fromList(bytes); + return bytes; } /// Skip [count] raw bytes without materialising them. Used by the `any`-field decoder helper to @@ -777,7 +769,7 @@ class BaboonDateTimeOffset { offsetMillis == other.offsetMillis; @override - int get hashCode => Object.hashAll([epochMillis, offsetMillis]); + int get hashCode => Object.hash(epochMillis, offsetMillis); @override String toString() => BaboonTimeFormats.formatOffset(this); @@ -876,7 +868,7 @@ class BaboonMethodId { other is BaboonMethodId && serviceId == other.serviceId && methodName == other.methodName; @override - int get hashCode => Object.hashAll([serviceId, methodName]); + int get hashCode => Object.hash(serviceId, methodName); @override String toString() => '$serviceId.$methodName'; @@ -1624,7 +1616,7 @@ int baboonDeepHashCode(dynamic value) { if (value == null) return 0; if (value is Map) { return Object.hashAllUnordered( - value.entries.map((e) => Object.hashAll([baboonDeepHashCode(e.key), baboonDeepHashCode(e.value)])), + value.entries.map((e) => Object.hash(baboonDeepHashCode(e.key), baboonDeepHashCode(e.value))), ); } if (value is Set) { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/AbstractBaboonMcpServer.java b/baboon-compiler/src/main/resources/baboon-runtime/java/AbstractBaboonMcpServer.java index 8a0410638..fffb2bf60 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/AbstractBaboonMcpServer.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/AbstractBaboonMcpServer.java @@ -39,10 +39,12 @@ public BaboonEither routeToolCall( return invokeJson(method, data, ctx, codecCtx); } - private Map byName() { - Map m = new HashMap<>(); + // Arbitrary subclasses may expose changing tools. Generated final servers + // override this with an owned immutable index containing no schema nodes. + protected Map toolMethods() { + Map m = new HashMap<>(); for (McpToolEntry t : tools()) { - m.put(t.name, t); + m.put(t.name, t.method); } return Collections.unmodifiableMap(m); } @@ -103,8 +105,8 @@ public JsonRpcResponse handle(JsonRpcRequest request, McpSession session, Ctx ct return errorResponse(id, McpProtocol.INVALID_PARAMS, "tools/call: tool name must be a string"); } String toolName = nameEl.textValue(); - McpToolEntry entry = byName().get(toolName); - if (entry == null) { + Map methods = toolMethods(); + if (!methods.containsKey(toolName)) { return errorResponse(id, McpProtocol.INVALID_PARAMS, "tools/call: unknown tool '" + toolName + "'"); } JsonNode argsEl = (paramsNode != null && paramsNode.isObject() && paramsNode.has("arguments")) @@ -116,7 +118,7 @@ public JsonRpcResponse handle(JsonRpcRequest request, McpSession session, Ctx ct } catch (Exception e) { return errorResponse(id, McpProtocol.INTERNAL_ERROR, "tools/call: failed to serialize arguments: " + e.getMessage()); } - BaboonEither callResult = invokeJson(entry.method, argsJson, ctx, codecCtx); + BaboonEither callResult = invokeJson(methods.get(toolName), argsJson, ctx, codecCtx); if (callResult instanceof BaboonEither.Right right) { ArrayNode content = MAPPER.createArrayNode(); ObjectNode textItem = MAPPER.createObjectNode(); diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java new file mode 100644 index 000000000..56f079aa4 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java @@ -0,0 +1,100 @@ +package baboon.runtime.shared; + +import baboon.runtime.shared.BaboonAnyOpaque.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; + +public final class BaboonAnyBinCodec { + private BaboonAnyBinCodec() {} + + public static void encode( + BaboonCodecContext ctx, + LEDataOutputStream writer, + byte expectedKind, + String staticDomain, + String staticVersion, + String staticTypeid, + AnyOpaque value + ) throws Exception { + if (value.meta().kind() != expectedKind) { + throw new BaboonCodecException.EncoderFailure( + "any: meta-kind 0x" + String.format("%02x", value.meta().kind() & 0xFF) + + " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); + } + byte[] anyBlob; + if (value instanceof AnyOpaqueUeba anyUeba) { + anyBlob = anyUeba.bytes(); + } else if (value instanceof AnyOpaqueJson anyJson) { + var anyFacade = ctx.facade(); + if (anyFacade == null) { + throw new BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly."); + } + var anyConvResult = anyFacade.jsonToUebaBytes(anyJson.meta(), anyJson.json(), staticDomain, staticVersion, staticTypeid); + if (anyConvResult instanceof BaboonEither.Left anyConvL) { + throw anyConvL.value(); + } + anyBlob = ((BaboonEither.Right) anyConvResult).value(); + } else { + throw new BaboonCodecException.EncoderFailure( + "unexpected AnyOpaque subclass: " + value.getClass().getName()); + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + var anyMetaBuf = new ByteArrayOutputStream(); + var anyMetaWriter = new LEDataOutputStream(anyMetaBuf); + AnyMetaCodec.writeBin(value.meta(), anyMetaWriter); + anyMetaWriter.flush(); + byte[] anyMetaBytes = anyMetaBuf.toByteArray(); + int anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; + writer.writeInt(anyTotalLength); + writer.writeInt(anyMetaBytes.length); + writer.write(anyMetaBytes); + writer.write(anyBlob); + } + + public static AnyOpaqueUeba decode(LEDataInputStream wire, byte expectedKind) throws Exception { + int anyTotalLength = wire.readInt(); + if (anyTotalLength < 0) { + throw new BaboonCodecException.DecoderFailure( + "any: negative total-length " + anyTotalLength); + } + int anyMetaLength = wire.readInt(); + if (anyMetaLength < 0) { + throw new BaboonCodecException.DecoderFailure( + "any: negative meta-length " + anyMetaLength); + } + if (anyTotalLength < 4 + anyMetaLength) { + throw new BaboonCodecException.DecoderFailure( + "any: total-length " + anyTotalLength + " smaller than 4 + meta-length " + anyMetaLength); + } + var anyReadResult = AnyMetaCodec.readBinWithLength(wire); + var anyMeta = anyReadResult.meta(); + int anyBytesRead = anyReadResult.bytesRead(); + if (anyBytesRead > anyMetaLength) { + throw new BaboonCodecException.DecoderFailure( + "any: meta bytes-read " + anyBytesRead + " exceeded meta-length window " + anyMetaLength); + } + if (anyBytesRead < anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + int anySkip = anyMetaLength - anyBytesRead; + byte[] anySkipBuf = wire.readNBytes(anySkip); + if (anySkipBuf.length != anySkip) { + throw new BaboonCodecException.DecoderFailure( + "any: short read while skipping meta-extension bytes, expected " + anySkip + " got " + anySkipBuf.length); + } + } + if (anyMeta.kind() != expectedKind) { + throw new BaboonCodecException.DecoderFailure( + "any: wire kind 0x" + String.format("%02x", anyMeta.kind() & 0xFF) + + " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); + } + int anyBlobLen = anyTotalLength - 4 - anyMetaLength; + byte[] anyBlob = wire.readNBytes(anyBlobLen); + if (anyBlob.length != anyBlobLen) { + throw new BaboonCodecException.DecoderFailure( + "any: short read on blob, expected " + anyBlobLen + " got " + anyBlob.length); + } + return new AnyOpaqueUeba(anyMeta, anyBlob); + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java new file mode 100644 index 000000000..e7c2190e4 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java @@ -0,0 +1,69 @@ +package baboon.runtime.shared; + +import baboon.runtime.shared.BaboonAnyOpaque.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; + +public final class BaboonAnyJsonCodec { + private BaboonAnyJsonCodec() {} + + public static JsonNode encode( + BaboonCodecContext ctx, + byte expectedKind, + String staticDomain, + String staticVersion, + String staticTypeid, + AnyOpaque value + ) { + if (value.meta().kind() != expectedKind) { + throw new BaboonCodecException.EncoderFailure( + "any: meta-kind 0x" + String.format("%02x", value.meta().kind() & 0xFF) + + " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); + } + JsonNode anyInner; + if (value instanceof AnyOpaqueJson anyJson) { + anyInner = anyJson.json(); + } else if (value instanceof AnyOpaqueUeba anyUeba) { + var anyFacade = ctx.facade(); + if (anyFacade == null) { + throw new BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly."); + } + var anyConvResult = anyFacade.uebaToJson(anyUeba.meta(), anyUeba.bytes(), staticDomain, staticVersion, staticTypeid); + if (anyConvResult instanceof BaboonEither.Left anyConvL) { + throw anyConvL.value(); + } + anyInner = ((BaboonEither.Right) anyConvResult).value(); + } else { + throw new BaboonCodecException.EncoderFailure( + "unexpected AnyOpaque subclass: " + value.getClass().getName()); + } + var anyEnvelope = (ObjectNode) AnyMetaCodec.writeJson(value.meta()); + anyEnvelope.set(AnyMetaCodec.ANY_CONTENT_KEY, anyInner); + return anyEnvelope; + } + + public static AnyOpaqueJson decode(byte expectedKind, JsonNode wire) { + if (wire == null) { + throw new BaboonCodecException.DecoderFailure( + "any: missing JSON envelope (null token)"); + } + var anyMetaResult = AnyMetaCodec.readJson(wire); + if (anyMetaResult instanceof BaboonEither.Left anyMetaL) { + throw anyMetaL.value(); + } + var anyMeta = ((BaboonEither.Right) anyMetaResult).value(); + if (anyMeta.kind() != expectedKind) { + throw new BaboonCodecException.DecoderFailure( + "any: wire kind 0x" + String.format("%02x", anyMeta.kind() & 0xFF) + + " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); + } + var anyContent = wire.get(AnyMetaCodec.ANY_CONTENT_KEY); + if (anyContent == null) { + throw new BaboonCodecException.DecoderFailure( + "any: JSON envelope missing '" + AnyMetaCodec.ANY_CONTENT_KEY + "' content key"); + } + return new AnyOpaqueJson(anyMeta, anyContent); + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java index c61b9ec19..67c5da2b3 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java @@ -59,16 +59,27 @@ protected List readIndex(BaboonCodecContext ctx, LEDataInputStream input) } static List readIndex(BaboonCodecContext ctx, LEDataInputStream input, int expectedElements) throws Exception { + List index = new ArrayList<>(); + readIndexEntries(input, expectedElements, index); + return index; + } + + static int consumeIndex(BaboonCodecContext ctx, LEDataInputStream input, int expectedElements) throws Exception { + return readIndexEntries(input, expectedElements, null); + } + + private static int readIndexEntries(LEDataInputStream input, int expectedElements, List index) throws Exception { byte header = input.readByte(); boolean hasIndex = (header & 1) != 0; - List index = new ArrayList<>(); + int count = 0; if (hasIndex) { for (int i = 0; i < expectedElements; i++) { int offset = input.readInt(); int length = input.readInt(); - index.add(new int[]{offset, length}); + if (index != null) index.add(new int[]{offset, length}); + count++; } } - return index; + return count; } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java index d2706534d..35ca89438 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecContext.java @@ -1,5 +1,10 @@ package baboon.runtime.shared; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; + /** * Codec context: pairs a `useIndices` flag with an optional `BaboonCodecsFacade` reference. The * facade is threaded through generated codec calls so the `any`-feature cross-format conversion @@ -39,6 +44,7 @@ public enum BaboonEnvelopeVersion { V1, V2 } private final ForwardWritePolicy forwardWritePolicy; private final BaboonEnvelopeVersion envelopeVersion; private final BaboonCodecsFacade facade; + private final Lazy jsonReader = new Lazy<>(() -> new ObjectMapper().reader()); private BaboonCodecContext(boolean useIndices, ForwardWritePolicy forwardWritePolicy, BaboonEnvelopeVersion envelopeVersion, BaboonCodecsFacade facade) { this.useIndices = useIndices; @@ -63,6 +69,10 @@ public BaboonCodecsFacade facade() { return facade; } + public JsonNode parseJson(String value) throws JsonProcessingException { + return jsonReader.get().readTree(value); + } + public static final BaboonCodecContext Indexed = new BaboonCodecContext(true, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V1, null); public static final BaboonCodecContext Compact = new BaboonCodecContext(false, ForwardWritePolicy.STRICT, BaboonEnvelopeVersion.V1, null); public static final BaboonCodecContext Default = Compact; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java index 4105d05d9..d32668938 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java @@ -542,17 +542,13 @@ public Baboon if (targetClass.isInstance(value)) return BaboonEither.right((TTo) value); Class actual = value.getClass(); - String fromDomainId; - String fromDomainVersion; + BaboonDomainVersion dvFrom; try { - fromDomainId = (String) actual.getField("baboonDomainIdentifier").get(null); - fromDomainVersion = (String) actual.getField("baboonDomainVersion").get(null); + dvFrom = BaboonMetadataAccess.conversionDomainVersion(actual); } catch (ReflectiveOperationException e) { return BaboonEither.left(new BaboonCodecException.ConverterFailure( "Cannot read baboon domain metadata from " + actual.getName(), e)); } - BaboonDomainVersion dvFrom = new BaboonDomainVersion(fromDomainId, fromDomainVersion); - List versions = domainVersions.get(dvFrom.domainIdentifier()); if (versions == null || versions.isEmpty()) { return BaboonEither.left(new BaboonCodecException.ConverterFailure( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonExt.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonExt.java index 8bfd412ac..2dd379817 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonExt.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonExt.java @@ -1,42 +1,21 @@ package baboon.runtime.shared; -import java.util.List; - public final class BaboonExt { private BaboonExt() {} public static BaboonDomainVersion domainVersion(BaboonGenerated g) { Class klass = g.getClass(); - String identifier = readStaticString(klass, "baboonDomainIdentifier"); - String version = readStaticString(klass, "baboonDomainVersion"); + String identifier = BaboonMetadataAccess.requiredString(klass, "baboonDomainIdentifier"); + String version = BaboonMetadataAccess.requiredString(klass, "baboonDomainVersion"); return new BaboonDomainVersion(identifier, version); } - @SuppressWarnings("unchecked") public static String baboonUnmodifiedSinceVersion(BaboonGenerated g) { - Class klass = g.getClass(); - List sameIn; - try { - sameIn = (List) klass.getField("baboonSameInVersions").get(null); - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + klass.getName() + " is missing static field 'baboonSameInVersions'", e); - } - return sameIn.get(0); + return BaboonMetadataAccess.sameInVersions(g.getClass()).get(0); } public static String unmodifiedSinceVersion(BaboonMeta meta, String typeId) { return meta.sameInVersions(typeId).get(0); } - private static String readStaticString(Class klass, String name) { - try { - Object v = klass.getField(name).get(null); - if (!(v instanceof String s)) { - throw new BaboonException("Type " + klass.getName() + " field '" + name + "' is not a String"); - } - return s; - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + klass.getName() + " is missing static field '" + name + "'", e); - } - } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMetadataAccess.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMetadataAccess.java new file mode 100644 index 000000000..8f2d14cfa --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonMetadataAccess.java @@ -0,0 +1,46 @@ +package baboon.runtime.shared; + +import java.util.List; +import java.util.Map; + +final class BaboonMetadataAccess { + private BaboonMetadataAccess() {} + + static String requiredString(Class klass, String name) { + Object value = requiredField(klass, name); + if (!(value instanceof String text)) { + throw new BaboonException("Type " + klass.getName() + " field '" + name + "' is not a String"); + } + return text; + } + + @SuppressWarnings("unchecked") + static List sameInVersions(Class klass) { + return (List) requiredField(klass, "baboonSameInVersions"); + } + + @SuppressWarnings("unchecked") + static Map minReaderVersions(Class klass) { + return (Map) requiredField(klass, "baboonMinReaderVersions"); + } + + // Conversion has a distinct checked-exception boundary and historically uses + // casts rather than the envelope accessor's explicit String validation. + static BaboonDomainVersion conversionDomainVersion(Class klass) throws ReflectiveOperationException { + String identifier = (String) readField(klass, "baboonDomainIdentifier"); + String version = (String) readField(klass, "baboonDomainVersion"); + return new BaboonDomainVersion(identifier, version); + } + + private static Object requiredField(Class klass, String name) { + try { + return readField(klass, name); + } catch (ReflectiveOperationException e) { + throw new BaboonException("Type " + klass.getName() + " is missing static field '" + name + "'", e); + } + } + + private static Object readField(Class klass, String name) throws ReflectiveOperationException { + return klass.getField(name).get(null); + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java index 46b9a0428..38381c021 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java @@ -64,32 +64,26 @@ public JsonNode writeJson() { * the ADT meta envelope. When the user-declared type is the concrete branch, use the branch * identifier directly. */ - @SuppressWarnings("unchecked") public static BaboonTypeMeta from(BaboonGenerated value, Class declaredType) { Class actual = value.getClass(); String typeIdentifier; if (value instanceof BaboonAdtMemberMeta && declaredType != null && declaredType.isInterface()) { - typeIdentifier = readStaticString(actual, "baboonAdtTypeIdentifier"); + typeIdentifier = BaboonMetadataAccess.requiredString(actual, "baboonAdtTypeIdentifier"); } else { - typeIdentifier = readStaticString(actual, "baboonTypeIdentifier"); + typeIdentifier = BaboonMetadataAccess.requiredString(actual, "baboonTypeIdentifier"); } - String domainIdentifier = readStaticString(actual, "baboonDomainIdentifier"); - String domainVersion = readStaticString(actual, "baboonDomainVersion"); + String domainIdentifier = BaboonMetadataAccess.requiredString(actual, "baboonDomainIdentifier"); + String domainVersion = BaboonMetadataAccess.requiredString(actual, "baboonDomainVersion"); // Codegen invariant (mirrors PR-08-D02): baboonSameInVersions is always non-empty. Index // directly so a violation throws IndexOutOfBoundsException rather than silently masquerading // as a same-version meta. - List sameIn; - try { - sameIn = (List) actual.getField("baboonSameInVersions").get(null); - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + actual.getName() + " is missing static field 'baboonSameInVersions'", e); - } + List sameIn = BaboonMetadataAccess.sameInVersions(actual); String minCompat = sameIn.get(0); - Map minReaders = readMinReaderVersions(actual); + Map minReaders = BaboonMetadataAccess.minReaderVersions(actual); String readableMin = minReaders.get(JSON_READABLE_TIER); if (readableMin == null) { throw new BaboonException("Type " + actual.getName() + ": baboonMinReaderVersions lacks '" + JSON_READABLE_TIER + "'"); @@ -116,7 +110,7 @@ public static BaboonTypeMeta forBin(BaboonGenerated value, Class declaredType return meta; } String tier = ctx.useIndices() ? UEBA_PREFIX_ANY_MODE_TIER : UEBA_PREFIX_COMPACT_TIER; - String bound = readMinReaderVersions(value.getClass()).get(tier); + String bound = BaboonMetadataAccess.minReaderVersions(value.getClass()).get(tier); if (bound == null) { throw new BaboonException("Type " + value.getClass().getName() + ": baboonMinReaderVersions lacks '" + tier + "'"); } @@ -126,27 +120,6 @@ public static BaboonTypeMeta forBin(BaboonGenerated value, Class declaredType : new BaboonTypeMeta(meta.metaVersion(), meta.domainIdentifier(), meta.domainVersion(), bound, meta.typeIdentifier(), meta.domainVersionReadableMin()); } - @SuppressWarnings("unchecked") - private static Map readMinReaderVersions(Class actual) { - try { - return (Map) actual.getField("baboonMinReaderVersions").get(null); - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + actual.getName() + " is missing static field 'baboonMinReaderVersions'", e); - } - } - - private static String readStaticString(Class klass, String name) { - try { - Object v = klass.getField(name).get(null); - if (!(v instanceof String s)) { - throw new BaboonException("Type " + klass.getName() + " field '" + name + "' is not a String"); - } - return s; - } catch (ReflectiveOperationException e) { - throw new BaboonException("Type " + klass.getName() + " is missing static field '" + name + "'", e); - } - } - public static BaboonTypeMeta readMeta(LEDataInputStream reader) throws Exception { return BaboonTypeMetaCodec.readMeta(reader); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt new file mode 100644 index 000000000..664919274 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt @@ -0,0 +1,58 @@ +package baboon.runtime.shared + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +object BaboonAnyJsonCodec { + private val anyEnvelopeContentKey: String = "${'$'}c" + + fun encode( + ctx: BaboonCodecContext, + expectedKind: Byte, + staticDomain: String?, + staticVersion: String?, + staticTypeid: String?, + value: AnyOpaque, + ): JsonElement { + if (value.meta.kind != expectedKind) { + throw BaboonCodecException.EncoderFailure( + "any: meta-kind 0x" + (value.meta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyInner: JsonElement = when (value) { + is AnyOpaqueJson -> value.json + is AnyOpaqueUeba -> { + val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." + ) + val anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid) + when (anyConvResult) { + is Either.Left -> throw anyConvResult.value + is Either.Right -> anyConvResult.value + } + } + } + val anyMetaJson = AnyMetaCodec.writeJson(value.meta) as JsonObject + return JsonObject(anyMetaJson.toMutableMap().apply { put(anyEnvelopeContentKey, anyInner) }) + } + + fun decode(expectedKind: Byte, wire: JsonElement): AnyOpaqueJson { + val anyMetaResult = AnyMetaCodec.readJson(wire) + val anyMeta = when (anyMetaResult) { + is Either.Left -> throw anyMetaResult.value + is Either.Right -> anyMetaResult.value + } + if (anyMeta.kind != expectedKind) { + throw BaboonCodecException.DecoderFailure( + "any: wire kind 0x" + (anyMeta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyContent = (wire as? JsonObject)?.get(anyEnvelopeContentKey) + ?: throw BaboonCodecException.DecoderFailure( + "any: JSON envelope missing '" + anyEnvelopeContentKey + "' content key" + ) + return AnyOpaqueJson(anyMeta, anyContent) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonCodecVersionSelection.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonCodecVersionSelection.kt new file mode 100644 index 000000000..dee3287ab --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonCodecVersionSelection.kt @@ -0,0 +1,39 @@ +package baboon.runtime.shared + +internal sealed class BaboonCodecVersionSelection { + data class Exact(val version: BaboonDomainVersion) : BaboonCodecVersionSelection() + data class Compatible(val version: BaboonDomainVersion) : BaboonCodecVersionSelection() + data class UnsupportedForward(val version: BaboonDomainVersion) : BaboonCodecVersionSelection() + data class Unsupported(val version: BaboonDomainVersion) : BaboonCodecVersionSelection() + + companion object { + fun select( + typeMeta: BaboonTypeMeta, + minVersion: BaboonDomainVersion, + maxVersion: BaboonDomainVersion, + exact: Boolean, + tolerant: Boolean, + ): BaboonCodecVersionSelection { + val modelVersion = typeMeta.version() + if (!exact && modelVersion.version > maxVersion.version) { + // A newer payload is readable when its published compatibility + // bound reaches the newest registered codec. Tolerant reads use + // the readable bound; lossless reads use the byte-identical bound. + val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() + return if (lowerBound != null && lowerBound.version <= maxVersion.version) { + Exact(maxVersion) + } else { + UnsupportedForward(modelVersion) + } + } + return when { + // Equality must use exact lookup even for a non-exact request, + // including the single-version case (PR-07-D02). + modelVersion.version == maxVersion.version -> Exact(modelVersion) + modelVersion.version >= minVersion.version && modelVersion.version < maxVersion.version -> Compatible(modelVersion) + modelVersion.version < minVersion.version -> Compatible(minVersion) + else -> Unsupported(modelVersion) + } + } + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonEither.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonEither.kt similarity index 100% rename from baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonEither.kt rename to baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonEither.kt diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonExceptions.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonExceptions.kt similarity index 100% rename from baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonExceptions.kt rename to baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonExceptions.kt diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonServiceWiring.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonServiceWiring.kt similarity index 100% rename from baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonServiceWiring.kt rename to baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonServiceWiring.kt diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt new file mode 100644 index 000000000..a8c5a8fa4 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt @@ -0,0 +1,87 @@ +package baboon.runtime.shared + +object BaboonAnyBinCodec { + fun encode( + ctx: BaboonCodecContext, + writer: BaboonBinaryWriter, + expectedKind: Byte, + staticDomain: String?, + staticVersion: String?, + staticTypeid: String?, + value: AnyOpaque, + ) { + if (value.meta.kind != expectedKind) { + throw BaboonCodecException.EncoderFailure( + "any: meta-kind 0x" + (value.meta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyBlob: ByteArray = when (value) { + is AnyOpaqueUeba -> value.bytes + // @baboon:json-start + is AnyOpaqueJson -> { + val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." + ) + val anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid) + when (anyConvResult) { + is Either.Left -> throw anyConvResult.value + is Either.Right -> anyConvResult.value + } + } + // @baboon:json-end + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + val anyMetaWriter = BaboonBinaryWriter() + AnyMetaCodec.writeBin(value.meta, anyMetaWriter) + val anyMetaBytes = anyMetaWriter.toByteArray() + val anyTotalLength: Int = 4 + anyMetaBytes.size + anyBlob.size + writer.writeInt(anyTotalLength) + writer.writeInt(anyMetaBytes.size) + writer.write(anyMetaBytes) + writer.write(anyBlob) + } + + fun decode(wire: BaboonBinaryReader, expectedKind: Byte): AnyOpaqueUeba { + val anyTotalLength = wire.readInt() + if (anyTotalLength < 0) { + throw BaboonCodecException.DecoderFailure( + "any: negative total-length " + anyTotalLength + ) + } + val anyMetaLength = wire.readInt() + if (anyMetaLength < 0) { + throw BaboonCodecException.DecoderFailure( + "any: negative meta-length " + anyMetaLength + ) + } + if (anyTotalLength < 4 + anyMetaLength) { + throw BaboonCodecException.DecoderFailure( + "any: total-length " + anyTotalLength + " smaller than 4 + meta-length " + anyMetaLength + ) + } + val anyReadResult = AnyMetaCodec.readBinWithLength(wire) + val anyMeta = anyReadResult.first + val anyBytesRead = anyReadResult.second + if (anyBytesRead > anyMetaLength) { + throw BaboonCodecException.DecoderFailure( + "any: meta bytes-read " + anyBytesRead + " exceeded meta-length window " + anyMetaLength + ) + } + if (anyBytesRead < anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + val anySkip = ByteArray(anyMetaLength - anyBytesRead) + wire.readFully(anySkip) + } + if (anyMeta.kind != expectedKind) { + throw BaboonCodecException.DecoderFailure( + "any: wire kind 0x" + (anyMeta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyBlobLen = anyTotalLength - 4 - anyMetaLength + val anyBlob = ByteArray(anyBlobLen) + wire.readFully(anyBlob) + return AnyOpaqueUeba(anyMeta, anyBlob) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt index ab7b260c2..b108e72db 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt @@ -244,36 +244,15 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() - val modelVersion = typeMeta.version() - if (!exact && modelVersion.version > maxVersion.version) { - // a payload from a NEWER version than we register. The oldest version whose codec may - // decode it is the bound the writer published (byte-identical or, under its Tolerant - // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. - // Forward-readability is monotone along the version chain, so once the bound reaches a - // registered version our newest codec reads the payload (losing at most the fields - // appended after our version). - val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() - if (lowerBound != null && lowerBound.version <= maxVersion.version) { - return getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier) - } - throw BaboonCodecException.CodecNotFound("Unsupported domain version '$modelVersion'.") - } - - return when { - exact && modelVersion.version == maxVersion.version -> - getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier) - // PR-07-D02 fix: non-exact lookup at the latest registered version routes to exact - // lookup. Without this arm a single-version domain (min == max == model) falls through - // every other arm because the next one's strict `<` excludes equality, producing a - // misleading "Unsupported domain version" error. Mirrors Scala/C# fix. - !exact && modelVersion.version == maxVersion.version -> - getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier) - modelVersion.version >= minVersion.version && modelVersion.version < maxVersion.version -> - getCodecMaxCompat(versionsCodecs, modelVersion, maxVersion, typeMeta.typeIdentifier) - modelVersion.version < minVersion.version -> - getCodecMaxCompat(versionsCodecs, minVersion, maxVersion, typeMeta.typeIdentifier) - else -> - throw BaboonCodecException.CodecNotFound("Unsupported domain version '$$modelVersion'.") + return when (val selection = BaboonCodecVersionSelection.select(typeMeta, minVersion, maxVersion, exact, tolerant)) { + is BaboonCodecVersionSelection.Exact -> + getCodecExact(versionsCodecs, selection.version, typeMeta.typeIdentifier) + is BaboonCodecVersionSelection.Compatible -> + getCodecMaxCompat(versionsCodecs, selection.version, maxVersion, typeMeta.typeIdentifier) + is BaboonCodecVersionSelection.UnsupportedForward -> + throw BaboonCodecException.CodecNotFound("Unsupported domain version '${selection.version}'.") + is BaboonCodecVersionSelection.Unsupported -> + throw BaboonCodecException.CodecNotFound("Unsupported domain version '$${selection.version}'.") } } @@ -319,7 +298,9 @@ open class BaboonCodecsFacade { fun preload() { try { + // @baboon:json-start versionsCodecsJson.values.forEach { it.value } + // @baboon:json-end versionsCodecsBin.values.forEach { it.value } } catch (_: Throwable) {} } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonConversions.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonConversions.kt index bba6aa9c8..74ff31e3b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonConversions.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonConversions.kt @@ -20,23 +20,26 @@ abstract class AbstractConversion( ) : BaboonGeneratedConversion { protected abstract fun doConvert(context: Ctx?, conversions: AbstractBaboonConversions, from: From): To - private fun validateBaboonType(obj: Any) { + protected open val targetTypeId: String get() = typeId + + private fun validateBaboonType(obj: Any, target: Boolean) { + fun expectedTypeId(): String = if (target) targetTypeId else typeId when (obj) { is BaboonGenerated -> { - val conversionTypeIsExactType = typeId == obj.baboonTypeIdentifier + val conversionTypeIsExactType = expectedTypeId() == obj.baboonTypeIdentifier when (obj) { is BaboonAdtMemberMeta -> { - val conversionTypeIsAdtType = typeId == obj.baboonAdtTypeIdentifier + val conversionTypeIsAdtType = expectedTypeId() == obj.baboonAdtTypeIdentifier if (!conversionTypeIsAdtType && !conversionTypeIsExactType) { throw IllegalArgumentException( - "Provided instance is adt=$${obj.baboonAdtTypeIdentifier} exact=$${obj.baboonTypeIdentifier}, one of which must be $$typeId" + "Provided instance is adt=$${obj.baboonAdtTypeIdentifier} exact=$${obj.baboonTypeIdentifier}, one of which must be $${expectedTypeId()}" ) } } else -> { if (!conversionTypeIsExactType) { throw IllegalArgumentException( - "Provided instance is $${obj.baboonTypeIdentifier} but must be $$typeId" + "Provided instance is $${obj.baboonTypeIdentifier} but must be $${expectedTypeId()}" ) } } @@ -46,9 +49,9 @@ abstract class AbstractConversion( } fun convert(context: Ctx?, conversions: AbstractBaboonConversions, from: From): To { - validateBaboonType(from) + validateBaboonType(from, target = false) val result = doConvert(context, conversions, from) - validateBaboonType(result) + validateBaboonType(result, target = true) return result } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonIdentifierRepr.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonIdentifierRepr.kt index 9872dd9f2..ecd2bc81f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonIdentifierRepr.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonIdentifierRepr.kt @@ -145,16 +145,7 @@ object BaboonIdentifierRepr { val seconds = timeOfDay % 60 // Gregorian calendar from days since epoch (1970-01-01). - val z = days + 719468 - val era = (if (z >= 0) z else z - 146096) / 146097 - val doe = z - era * 146097 - val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 - val y = yoe + era * 400 - val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) - val mp = (5 * doy + 2) / 153 - val d = doy - (153 * mp + 2) / 5 + 1 - val m = mp + (if (mp < 10) 3 else -9) - val year = y + (if (m <= 2) 1 else 0) + val (year, m, d) = BaboonCivilDate.fromEpochDays(days) return "${pad(year, 4)}-${pad(m, 2)}-${pad(d, 2)}T${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}Z" } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonOffsetDateTime.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonOffsetDateTime.kt index da45417a8..3d1eaa7c4 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonOffsetDateTime.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonOffsetDateTime.kt @@ -3,6 +3,24 @@ package baboon.runtime.shared import kotlinx.datetime.Instant import kotlinx.datetime.UtcOffset +internal data class BaboonCivilDate(val year: Int, val month: Int, val day: Int) { + companion object { + fun fromEpochDays(days: Int): BaboonCivilDate { + val z = days + 719468 + val era = (if (z >= 0) z else z - 146096) / 146097 + val doe = z - era * 146097 + val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + val y = yoe + era * 400 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val d = doy - (153 * mp + 2) / 5 + 1 + val m = mp + (if (mp < 10) 3 else -9) + val year = y + (if (m <= 2) 1 else 0) + return BaboonCivilDate(year, m, d) + } + } +} + /** * Multiplatform offset date-time wrapper. Represents a point in time with a UTC offset. * Compatible with Baboon's tso (timestamp with offset) type. @@ -33,16 +51,7 @@ data class BaboonOffsetDateTime( val seconds = timeOfDay % 60 // Gregorian calendar from days since epoch (1970-01-01) - val z = days + 719468 - val era = (if (z >= 0) z else z - 146096) / 146097 - val doe = z - era * 146097 - val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 - val y = yoe + era * 400 - val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) - val mp = (5 * doy + 2) / 153 - val d = doy - (153 * mp + 2) / 5 + 1 - val m = mp + (if (mp < 10) 3 else -9) - val year = y + (if (m <= 2) 1 else 0) + val (year, m, d) = BaboonCivilDate.fromEpochDays(days) val sign = if (totalSeconds >= 0) "+" else "-" val absOffset = kotlin.math.abs(totalSeconds) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonTools.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonTools.kt index 0687580ad..74f62d4aa 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonTools.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonTools.kt @@ -256,5 +256,11 @@ class BaboonBinaryWriter { fun toByteArray(): ByteArray = buf.copyOf(pos) + fun writeTo(destination: BaboonBinaryWriter) { + destination.ensureCapacity(pos) + buf.copyInto(destination.buf, destination.pos, 0, pos) + destination.pos += pos + } + fun size(): Int = pos } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt new file mode 100644 index 000000000..8a9b1cf4b --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt @@ -0,0 +1,91 @@ +package baboon.runtime.shared + +import java.io.ByteArrayOutputStream + +object BaboonAnyBinCodec { + fun encode( + ctx: BaboonCodecContext, + writer: LEDataOutputStream, + expectedKind: Byte, + staticDomain: String?, + staticVersion: String?, + staticTypeid: String?, + value: AnyOpaque, + ) { + if (value.meta.kind != expectedKind) { + throw BaboonCodecException.EncoderFailure( + "any: meta-kind 0x" + (value.meta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyBlob: ByteArray = when (value) { + is AnyOpaqueUeba -> value.bytes + // @baboon:json-start + is AnyOpaqueJson -> { + val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." + ) + val anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid) + when (anyConvResult) { + is Either.Left -> throw anyConvResult.value + is Either.Right -> anyConvResult.value + } + } + // @baboon:json-end + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + val anyMetaBuf = ByteArrayOutputStream() + val anyMetaWriter = LEDataOutputStream(anyMetaBuf) + AnyMetaCodec.writeBin(value.meta, anyMetaWriter) + anyMetaWriter.flush() + val anyMetaBytes = anyMetaBuf.toByteArray() + val anyTotalLength: Int = 4 + anyMetaBytes.size + anyBlob.size + writer.writeInt(anyTotalLength) + writer.writeInt(anyMetaBytes.size) + writer.write(anyMetaBytes) + writer.write(anyBlob) + } + + fun decode(wire: LEDataInputStream, expectedKind: Byte): AnyOpaqueUeba { + val anyTotalLength = wire.readInt() + if (anyTotalLength < 0) { + throw BaboonCodecException.DecoderFailure( + "any: negative total-length " + anyTotalLength + ) + } + val anyMetaLength = wire.readInt() + if (anyMetaLength < 0) { + throw BaboonCodecException.DecoderFailure( + "any: negative meta-length " + anyMetaLength + ) + } + if (anyTotalLength < 4 + anyMetaLength) { + throw BaboonCodecException.DecoderFailure( + "any: total-length " + anyTotalLength + " smaller than 4 + meta-length " + anyMetaLength + ) + } + val anyReadResult = AnyMetaCodec.readBinWithLength(wire) + val anyMeta = anyReadResult.first + val anyBytesRead = anyReadResult.second + if (anyBytesRead > anyMetaLength) { + throw BaboonCodecException.DecoderFailure( + "any: meta bytes-read " + anyBytesRead + " exceeded meta-length window " + anyMetaLength + ) + } + if (anyBytesRead < anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + val anySkip = ByteArray(anyMetaLength - anyBytesRead) + wire.readFully(anySkip) + } + if (anyMeta.kind != expectedKind) { + throw BaboonCodecException.DecoderFailure( + "any: wire kind 0x" + (anyMeta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + + " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') + ) + } + val anyBlobLen = anyTotalLength - 4 - anyMetaLength + val anyBlob = ByteArray(anyBlobLen) + wire.readFully(anyBlob) + return AnyOpaqueUeba(anyMeta, anyBlob) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt index e69c63802..eedc03050 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt @@ -249,36 +249,15 @@ open class BaboonCodecsFacade { val minVersion = versions.first() val maxVersion = versions.last() - val modelVersion = typeMeta.version() - if (!exact && modelVersion.version > maxVersion.version) { - // a payload from a NEWER version than we register. The oldest version whose codec may - // decode it is the bound the writer published (byte-identical or, under its Tolerant - // policy, prefix-readable), or — for tolerant JSON reads — the json-additive bound. - // Forward-readability is monotone along the version chain, so once the bound reaches a - // registered version our newest codec reads the payload (losing at most the fields - // appended after our version). - val lowerBound = if (tolerant) typeMeta.versionReadableMin() else typeMeta.versionMinCompat() - if (lowerBound != null && lowerBound.version <= maxVersion.version) { - return getCodecExact(versionsCodecs, maxVersion, typeMeta.typeIdentifier) - } - throw BaboonCodecException.CodecNotFound("Unsupported domain version '$modelVersion'.") - } - - return when { - exact && modelVersion.version == maxVersion.version -> - getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier) - // PR-07-D02 fix: non-exact lookup at the latest registered version routes to exact - // lookup. Without this arm a single-version domain (min == max == model) falls through - // every other arm because the next one's strict `<` excludes equality, producing a - // misleading "Unsupported domain version" error. Mirrors Scala/C# fix. - !exact && modelVersion.version == maxVersion.version -> - getCodecExact(versionsCodecs, modelVersion, typeMeta.typeIdentifier) - modelVersion.version >= minVersion.version && modelVersion.version < maxVersion.version -> - getCodecMaxCompat(versionsCodecs, modelVersion, maxVersion, typeMeta.typeIdentifier) - modelVersion.version < minVersion.version -> - getCodecMaxCompat(versionsCodecs, minVersion, maxVersion, typeMeta.typeIdentifier) - else -> - throw BaboonCodecException.CodecNotFound("Unsupported domain version '$modelVersion'.") + return when (val selection = BaboonCodecVersionSelection.select(typeMeta, minVersion, maxVersion, exact, tolerant)) { + is BaboonCodecVersionSelection.Exact -> + getCodecExact(versionsCodecs, selection.version, typeMeta.typeIdentifier) + is BaboonCodecVersionSelection.Compatible -> + getCodecMaxCompat(versionsCodecs, selection.version, maxVersion, typeMeta.typeIdentifier) + is BaboonCodecVersionSelection.UnsupportedForward -> + throw BaboonCodecException.CodecNotFound("Unsupported domain version '${selection.version}'.") + is BaboonCodecVersionSelection.Unsupported -> + throw BaboonCodecException.CodecNotFound("Unsupported domain version '${selection.version}'.") } } @@ -328,7 +307,9 @@ open class BaboonCodecsFacade { fun preload() { Thread { try { + // @baboon:json-start versionsCodecsJson.values.forEach { it.value } + // @baboon:json-end versionsCodecsBin.values.forEach { it.value } } catch (_: Throwable) {} }.start() diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonConversions.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonConversions.kt index e5880db6b..65e807c9e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonConversions.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonConversions.kt @@ -18,23 +18,26 @@ abstract class AbstractConversion( ) : BaboonGeneratedConversion { protected abstract fun doConvert(context: Ctx?, conversions: AbstractBaboonConversions, from: From): To - private fun validateBaboonType(obj: Any) { + protected open val targetTypeId: String get() = typeId + + private fun validateBaboonType(obj: Any, target: Boolean) { + fun expectedTypeId(): String = if (target) targetTypeId else typeId when (obj) { is BaboonGenerated -> { - val conversionTypeIsExactType = typeId == obj.baboonTypeIdentifier + val conversionTypeIsExactType = expectedTypeId() == obj.baboonTypeIdentifier when (obj) { is BaboonAdtMemberMeta -> { - val conversionTypeIsAdtType = typeId == obj.baboonAdtTypeIdentifier + val conversionTypeIsAdtType = expectedTypeId() == obj.baboonAdtTypeIdentifier if (!conversionTypeIsAdtType && !conversionTypeIsExactType) { throw IllegalArgumentException( - "Provided instance is adt=${obj.baboonAdtTypeIdentifier} exact=${obj.baboonTypeIdentifier}, one of which must be $typeId" + "Provided instance is adt=${obj.baboonAdtTypeIdentifier} exact=${obj.baboonTypeIdentifier}, one of which must be ${expectedTypeId()}" ) } } else -> { if (!conversionTypeIsExactType) { throw IllegalArgumentException( - "Provided instance is ${obj.baboonTypeIdentifier} but must be $typeId" + "Provided instance is ${obj.baboonTypeIdentifier} but must be ${expectedTypeId()}" ) } } @@ -44,9 +47,9 @@ abstract class AbstractConversion( } fun convert(context: Ctx?, conversions: AbstractBaboonConversions, from: From): To { - validateBaboonType(from) + validateBaboonType(from, target = false) val result = doConvert(context, conversions, from) - validateBaboonType(result) + validateBaboonType(result, target = true) return result } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonEither.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonEither.kt deleted file mode 100644 index 20c60df4f..000000000 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonEither.kt +++ /dev/null @@ -1,6 +0,0 @@ -package baboon.runtime.shared - -sealed class Either { - data class Left(val value: L) : Either() - data class Right(val value: R) : Either() -} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonExceptions.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonExceptions.kt deleted file mode 100644 index 9fccc3447..000000000 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonExceptions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package baboon.runtime.shared - -sealed class BaboonCodecException(override val message: String, cause: Throwable? = null) : Exception(message, cause) { - class EncoderFailure(message: String, cause: Throwable? = null) : BaboonCodecException(message, cause) - class DecoderFailure(message: String, cause: Throwable? = null) : BaboonCodecException(message, cause) - class ConverterFailure(message: String, cause: Throwable? = null) : BaboonCodecException(message, cause) - class CodecNotFound(message: String) : BaboonCodecException(message) - class ConversionNotFound(message: String) : BaboonCodecException(message) -} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonMcpRuntime.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonMcpRuntime.kt index 0a6330f78..b946a7c3e 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonMcpRuntime.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonMcpRuntime.kt @@ -134,41 +134,31 @@ typealias McpJsonInvoke = (method: BaboonMethodId, data: String, ctx: Ctx, // All JSON-RPC method strings ("tools/list" …) and result keys ("protocolVersion", // "inputSchema" …) are literal lowercase strings, NOT subject to any per-language // symbol casing. -abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRoutableMcpServer { - // PUBLIC routable-server surface (tasks:T114): the muxer reads `serverInfo` / - // `tools` and routes via `routeToolCall`, never via the private `byName()` - // and never via `handle`. - abstract override val serverInfo: McpServerInfo - abstract override val tools: List - protected abstract fun invokeJson(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either - - // PUBLIC dispatch entry (tasks:T114): the same path `handle` drives for its - // own tools/call arm, exposed for the muxer to reuse Channel-A/B unchanged. - final override fun routeToolCall(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either = - invokeJson(method, data, ctx, codecCtx) - - private fun byName(): Map { - val m = mutableMapOf() - for (t in tools) m[t.name] = t - return m - } +private interface McpDispatchOwner { + val serverInfo: McpServerInfo + val tools: Collection + fun lookup(name: String): Pair>? + fun errorResponse(id: JsonElement?, code: Int, message: String): JsonRpcResponse + fun describeWiringError(error: BaboonWiringError): String +} - override fun handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse? { +private object McpProtocolDispatch { + fun handle(owner: McpDispatchOwner, request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse? { val id = request.id return when (request.method) { "initialize" -> { val params = request.params val pv = if (params is JsonObject) params["protocolVersion"] else null if (params == null || pv == null) { - errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "initialize: missing protocolVersion") + owner.errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "initialize: missing protocolVersion") } else { session.initialized = true val result = buildJsonObject { put("protocolVersion", JsonPrimitive(McpProtocol.VERSION)) put("capabilities", buildJsonObject { put("tools", buildJsonObject { }) }) put("serverInfo", buildJsonObject { - put("name", JsonPrimitive(serverInfo.name)) - put("version", JsonPrimitive(serverInfo.version)) + put("name", JsonPrimitive(owner.serverInfo.name)) + put("version", JsonPrimitive(owner.serverInfo.version)) }) } JsonRpcResponse(id, result) @@ -177,10 +167,10 @@ abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRout "notifications/initialized" -> null "tools/list" -> { if (!session.initialized) { - errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/list before initialize") + owner.errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/list before initialize") } else { val toolsArray = buildJsonArray { - for (t in tools) { + for (t in owner.tools) { val entry = buildJsonObject { put("name", JsonPrimitive(t.name)) put("inputSchema", t.inputSchema) @@ -194,20 +184,20 @@ abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRout } "tools/call" -> { if (!session.initialized) { - errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/call before initialize") + owner.errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/call before initialize") } else { val paramsObj = request.params as? JsonObject val nameEl = paramsObj?.get("name") if (nameEl == null || nameEl is JsonNull) { - errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: missing tool name") + owner.errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: missing tool name") } else { val toolName = (nameEl as? JsonPrimitive)?.contentOrNull - ?: return errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: tool name must be a string") - val entry = byName()[toolName] - ?: return errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: unknown tool '$toolName'") + ?: return owner.errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: tool name must be a string") + val (entry, server) = owner.lookup(toolName) + ?: return owner.errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: unknown tool '$toolName'") val argsEl = paramsObj["arguments"] ?: buildJsonObject { } val argsJson = Json.encodeToString(JsonElement.serializer(), argsEl) - val result = invokeJson(entry.method, argsJson, ctx, codecCtx) + val result = server.routeToolCall(entry.method, argsJson, ctx, codecCtx) when (result) { is Either.Right -> { val content = buildJsonArray { @@ -226,7 +216,7 @@ abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRout val content = buildJsonArray { add(buildJsonObject { put("type", JsonPrimitive("text")) - put("text", JsonPrimitive(describeWiringError(result.value))) + put("text", JsonPrimitive(owner.describeWiringError(result.value))) }) } JsonRpcResponse(id, buildJsonObject { @@ -238,10 +228,45 @@ abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRout } } } - else -> errorResponse(id, JsonRpcErrorCodes.METHOD_NOT_FOUND, "Method not found: ${request.method}") + else -> owner.errorResponse(id, JsonRpcErrorCodes.METHOD_NOT_FOUND, "Method not found: ${request.method}") } } +} + +abstract class AbstractBaboonMcpServer : IBaboonMcpServer, IBaboonRoutableMcpServer { + // PUBLIC routable-server surface (tasks:T114): the muxer reads `serverInfo` / + // `tools` and routes via `routeToolCall`, never via the private `byName()` + // and never via `handle`. + abstract override val serverInfo: McpServerInfo + abstract override val tools: List + protected abstract fun invokeJson(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either + + // PUBLIC dispatch entry (tasks:T114): the same path `handle` drives for its + // own tools/call arm, exposed for the muxer to reuse Channel-A/B unchanged. + final override fun routeToolCall(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either = + invokeJson(method, data, ctx, codecCtx) + + private fun byName(): Map { + val m = mutableMapOf() + for (t in tools) m[t.name] = t + return m + } + + private val dispatchOwner = object : McpDispatchOwner { + override val serverInfo: McpServerInfo get() = this@AbstractBaboonMcpServer.serverInfo + override val tools: Collection get() = this@AbstractBaboonMcpServer.tools + override fun lookup(name: String): Pair>? = + byName()[name]?.let { it to this@AbstractBaboonMcpServer } + override fun errorResponse(id: JsonElement?, code: Int, message: String): JsonRpcResponse = + this@AbstractBaboonMcpServer.errorResponse(id, code, message) + override fun describeWiringError(error: BaboonWiringError): String = + this@AbstractBaboonMcpServer.describeWiringError(error) + } + + override fun handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse? = + McpProtocolDispatch.handle(dispatchOwner, request, session, ctx, codecCtx) + protected fun errorResponse(id: JsonElement?, code: Int, message: String): JsonRpcResponse { return JsonRpcResponse(id, null, JsonRpcError(code, message)) } @@ -325,95 +350,20 @@ class AbstractMcpMuxer( } } - override fun handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse? { - val id = request.id - return when (request.method) { - "initialize" -> { - val params = request.params - val pv = if (params is JsonObject) params["protocolVersion"] else null - if (params == null || pv == null) { - errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "initialize: missing protocolVersion") - } else { - session.initialized = true - val result = buildJsonObject { - put("protocolVersion", JsonPrimitive(McpProtocol.VERSION)) - put("capabilities", buildJsonObject { put("tools", buildJsonObject { }) }) - put("serverInfo", buildJsonObject { - put("name", JsonPrimitive(mergedServerInfo.name)) - put("version", JsonPrimitive(mergedServerInfo.version)) - }) - } - JsonRpcResponse(id, result) - } - } - "notifications/initialized" -> null - "tools/list" -> { - if (!session.initialized) { - errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/list before initialize") - } else { - val toolsArray = buildJsonArray { - for (t in entries.values) { - val entry = buildJsonObject { - put("name", JsonPrimitive(t.name)) - put("inputSchema", t.inputSchema) - t.description?.let { put("description", JsonPrimitive(it)) } - } - add(entry) - } - } - JsonRpcResponse(id, buildJsonObject { put("tools", toolsArray) }) - } - } - "tools/call" -> { - if (!session.initialized) { - errorResponse(id, JsonRpcErrorCodes.INVALID_REQUEST, "tools/call before initialize") - } else { - val paramsObj = request.params as? JsonObject - val nameEl = paramsObj?.get("name") - if (nameEl == null || nameEl is JsonNull) { - errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: missing tool name") - } else { - val toolName = (nameEl as? JsonPrimitive)?.contentOrNull - ?: return errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: tool name must be a string") - val server = route[toolName] - ?: return errorResponse(id, JsonRpcErrorCodes.INVALID_PARAMS, "tools/call: unknown tool '$toolName'") - val entry = entries[toolName]!! - val argsEl = paramsObj["arguments"] ?: buildJsonObject { } - val argsJson = Json.encodeToString(JsonElement.serializer(), argsEl) - when (val result = server.routeToolCall(entry.method, argsJson, ctx, codecCtx)) { - is Either.Right -> { - val content = buildJsonArray { - add(buildJsonObject { - put("type", JsonPrimitive("text")) - put("text", JsonPrimitive(result.value)) - }) - } - JsonRpcResponse(id, buildJsonObject { - put("content", content) - put("isError", JsonPrimitive(false)) - }) - } - is Either.Left -> { - // Channel B: a valid protocol call whose domain payload failed. - val content = buildJsonArray { - add(buildJsonObject { - put("type", JsonPrimitive("text")) - put("text", JsonPrimitive(describeWiringError(result.value))) - }) - } - JsonRpcResponse(id, buildJsonObject { - put("content", content) - put("isError", JsonPrimitive(true)) - }) - } - } - } - } - } - else -> errorResponse(id, JsonRpcErrorCodes.METHOD_NOT_FOUND, "Method not found: ${request.method}") - } + private val dispatchOwner = object : McpDispatchOwner { + override val serverInfo: McpServerInfo get() = mergedServerInfo + override val tools: Collection get() = entries.values + override fun lookup(name: String): Pair>? = + route[name]?.let { entries[name]!! to it } + override fun errorResponse(id: JsonElement?, code: Int, message: String): JsonRpcResponse = + this@AbstractMcpMuxer.errorResponse(id, code, message) + override fun describeWiringError(error: BaboonWiringError): String = + this@AbstractMcpMuxer.describeWiringError(error) } + override fun handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse? = + McpProtocolDispatch.handle(dispatchOwner, request, session, ctx, codecCtx) + protected fun errorResponse(id: JsonElement?, code: Int, message: String): JsonRpcResponse { return JsonRpcResponse(id, null, JsonRpcError(code, message)) } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonServiceWiring.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonServiceWiring.kt deleted file mode 100644 index 4c1117ff1..000000000 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonServiceWiring.kt +++ /dev/null @@ -1,145 +0,0 @@ -package baboon.runtime.shared - -data class BaboonMethodId(val serviceName: String, val methodName: String) - -sealed class BaboonWiringError { - data class NoMatchingMethod(val method: BaboonMethodId) : BaboonWiringError() - data class NoMatchingService(val method: BaboonMethodId) : BaboonWiringError() - data class DuplicateService(val serviceName: String) : BaboonWiringError() - data class DecoderFailed(val method: BaboonMethodId, val exception: Throwable) : BaboonWiringError() - data class EncoderFailed(val method: BaboonMethodId, val exception: Throwable) : BaboonWiringError() - data class CallFailed(val method: BaboonMethodId, val domainError: Any) : BaboonWiringError() -} - -class BaboonWiringException(val error: BaboonWiringError) : RuntimeException(error.toString()) - -// --- Service muxers --- -// -// Cross-domain composable dispatch. A muxer holds a set of services from any -// model(s) and routes an `(method, data, ctx)` call to the right one by -// `method.serviceName`. The R type parameter encodes the return shape so the -// same class supports both sync invokers (R = String / ByteArray) and -// container-wrapped invokers (R = Either, etc.). -// The per-service wrapper classes emitted alongside `invokeJson` / `invokeUeba` -// carry the matching parameterisation. - -interface IBaboonJsonService { - val serviceName: String - fun invoke(method: BaboonMethodId, data: String, ctx: BaboonCodecContext): R -} - -interface IBaboonUebaService { - val serviceName: String - fun invoke(method: BaboonMethodId, data: ByteArray, ctx: BaboonCodecContext): R -} - -class JsonMuxer(vararg services: IBaboonJsonService) { - private val table: MutableMap> = mutableMapOf() - - init { - for (s in services) register(s) - } - - fun register(service: IBaboonJsonService) { - if (table.containsKey(service.serviceName)) { - throw BaboonWiringException(BaboonWiringError.DuplicateService(service.serviceName)) - } - table[service.serviceName] = service - } - - fun invoke(method: BaboonMethodId, data: String, ctx: BaboonCodecContext): R { - val service = table[method.serviceName] - ?: throw BaboonWiringException(BaboonWiringError.NoMatchingService(method)) - return service.invoke(method, data, ctx) - } - - fun serviceNames(): Set = table.keys.toSet() -} - -class UebaMuxer(vararg services: IBaboonUebaService) { - private val table: MutableMap> = mutableMapOf() - - init { - for (s in services) register(s) - } - - fun register(service: IBaboonUebaService) { - if (table.containsKey(service.serviceName)) { - throw BaboonWiringException(BaboonWiringError.DuplicateService(service.serviceName)) - } - table[service.serviceName] = service - } - - fun invoke(method: BaboonMethodId, data: ByteArray, ctx: BaboonCodecContext): R { - val service = table[method.serviceName] - ?: throw BaboonWiringException(BaboonWiringError.NoMatchingService(method)) - return service.invoke(method, data, ctx) - } - - fun serviceNames(): Set = table.keys.toSet() -} - -// --- Context-carrying service variants --- -// -// Emitted when a service.context mode (`abstract` or `type`) is active. The -// abstract/concrete service context `Ctx` is supplied per-invocation (alongside -// the codec context) rather than baked into the wrapper, so callers thread a -// fresh context through each dispatch. The context-free interfaces above are -// left untouched so `--service-context-mode none` output (and the -// service-acceptance matrix) is byte-identical. - -interface IBaboonJsonServiceCtx { - val serviceName: String - fun invoke(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): R -} - -interface IBaboonUebaServiceCtx { - val serviceName: String - fun invoke(method: BaboonMethodId, data: ByteArray, ctx: Ctx, codecCtx: BaboonCodecContext): R -} - -class JsonMuxerCtx(vararg services: IBaboonJsonServiceCtx) { - private val table: MutableMap> = mutableMapOf() - - init { - for (s in services) register(s) - } - - fun register(service: IBaboonJsonServiceCtx) { - if (table.containsKey(service.serviceName)) { - throw BaboonWiringException(BaboonWiringError.DuplicateService(service.serviceName)) - } - table[service.serviceName] = service - } - - fun invoke(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): R { - val service = table[method.serviceName] - ?: throw BaboonWiringException(BaboonWiringError.NoMatchingService(method)) - return service.invoke(method, data, ctx, codecCtx) - } - - fun serviceNames(): Set = table.keys.toSet() -} - -class UebaMuxerCtx(vararg services: IBaboonUebaServiceCtx) { - private val table: MutableMap> = mutableMapOf() - - init { - for (s in services) register(s) - } - - fun register(service: IBaboonUebaServiceCtx) { - if (table.containsKey(service.serviceName)) { - throw BaboonWiringException(BaboonWiringError.DuplicateService(service.serviceName)) - } - table[service.serviceName] = service - } - - fun invoke(method: BaboonMethodId, data: ByteArray, ctx: Ctx, codecCtx: BaboonCodecContext): R { - val service = table[method.serviceName] - ?: throw BaboonWiringException(BaboonWiringError.NoMatchingService(method)) - return service.invoke(method, data, ctx, codecCtx) - } - - fun serviceNames(): Set = table.keys.toSet() -} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py index e72537d01..723ec6c05 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod +import json from enum import Enum from typing import Any, Optional, TypeVar, Generic @@ -53,7 +54,11 @@ def decode(self, context: 'BaboonCodecContext', wire: TIn) -> T: raise NotImplementedError class BaboonJsonCodec(BaboonValueCodec[T, TCodec], BaboonSingleton[TCodec]): - pass + def encode_value(self, context: 'BaboonCodecContext', value: T) -> Any: + return json.loads(self.encode(context, value)) + + def decode_value(self, context: 'BaboonCodecContext', value: Any) -> T: + return self.decode(context, json.dumps(value)) class BaboonBinCodec(BaboonStreamCodec[T, 'LEDataInputStream', 'LEDataOutputStream'], BaboonSingleton[TCodec]): pass @@ -226,9 +231,17 @@ class BaboonBinCodecIndexed(ABC): def index_elements_count(self, ctx: BaboonCodecContext) -> int: ... def read_index(self, ctx: BaboonCodecContext, wire: 'LEDataInputStream') -> list[BaboonIndexEntry]: + result: list[BaboonIndexEntry] = [] + self._read_index(ctx, wire, result) + return result + + def consume_index(self, ctx: BaboonCodecContext, wire: 'LEDataInputStream') -> int: + return self._read_index(ctx, wire, None) + + def _read_index(self, ctx: BaboonCodecContext, wire: 'LEDataInputStream', entries: Optional[list[BaboonIndexEntry]]) -> int: header = wire.read_byte() is_indexed = (header & 0b00000001) != 0 - result: list[BaboonIndexEntry] = [] + count = 0 prev_offset = 0 prev_len = 0 @@ -242,12 +255,14 @@ def read_index(self, ctx: BaboonCodecContext, wire: 'LEDataInputStream') -> list assert length > 0, "Length must be positive" assert offset >= prev_offset + prev_len, f"Offset violation: {offset} < {prev_offset + prev_len}" - result.append(BaboonIndexEntry(offset=offset, length=length)) + if entries is not None: + entries.append(BaboonIndexEntry(offset=offset, length=length)) + count += 1 left -= 1 prev_offset = offset prev_len = length - return result + return count class AbstractBaboonCodecs: def __init__(self): @@ -270,4 +285,4 @@ class AbstractBaboonJsonCodecs(AbstractBaboonCodecs): pass class AbstractBaboonUebaCodecs(AbstractBaboonCodecs): - pass \ No newline at end of file + pass diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index e4fc6998f..050392dcd 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -199,7 +199,7 @@ def encode_to_json(self, except Exception as e: raise BaboonCodecException.EncoderFailure( f"Can not encode to json form type [{value.baboon_type_identifier}] " - f"of version '{value.domain_version}'.", + f"of version '{value.baboon_domain_version}'.", e ) @@ -219,14 +219,14 @@ def decode_from_json(self, value: str) -> BaboonGenerated: ) def decode_from_json_latest(self, value: str, target_type: Type[TO]) -> TO: - baboon = self.decode_from_json_string(value) + baboon = self.decode_from_json(value) return self.convert(baboon, target_type) def convert(self, value: BaboonGenerated, target_type: Type[TO]) -> TO: if type(value) is target_type: return value - domain_version = value.domain_version + domain_version = BaboonDomainVersion(value.baboon_domain_identifier, value.baboon_domain_version) versions = self.domain_versions.get(domain_version.domain_identifier, []) if not versions: @@ -253,7 +253,8 @@ def _try_convert(self, value: BaboonGenerated, versions: List[BaboonDomainVersio from_model = value for to_version in versions: - if from_model.domain_version.version >= to_version.version: + from_version = BaboonDomainVersion(from_model.baboon_domain_identifier, from_model.baboon_domain_version) + if from_version.version >= to_version.version: continue conversions = self.versions_conversions.get(to_version) @@ -283,7 +284,7 @@ def _try_convert(self, value: BaboonGenerated, versions: List[BaboonDomainVersio except Exception as e: raise BaboonCodecException.ConverterFailure( f"Exception while converting type [{type(from_model).__name__}] " - f"of version '{from_model.domain_version}' to version '{to_version}'.", + f"of version '{from_version}' to version '{to_version}'.", e ) @@ -419,6 +420,13 @@ def decode_any(self, opaque: AnyOpaque) -> BaboonEither: (`json_to_ueba_bytes` / `ueba_to_json`) which accept static fallbacks. PR-04-D02: errors thread through `BaboonEither` rather than raising. """ + return self._decode_any(opaque, False) + + def decode_any_value(self, opaque: AnyOpaque) -> BaboonEither: + """Decode native JSON content without interpreting string values as JSON text.""" + return self._decode_any(opaque, True) + + def _decode_any(self, opaque: AnyOpaque, native_json: bool) -> BaboonEither: meta = opaque.meta type_meta_result = self._build_synthetic_type_meta(meta, None, None, None) if isinstance(type_meta_result, BaboonLeft): @@ -443,7 +451,11 @@ def decode_any(self, opaque: AnyOpaque) -> BaboonEither: elif isinstance(opaque, AnyOpaqueJson): codec = self._get_json_codec(type_meta, exact=False) try: - return BaboonRight(codec.decode(BaboonCodecContext.Compact, opaque.json)) + # Preserve the legacy facade's JSON-text string input. The value API + # is unambiguous for payloads whose native JSON value is itself a string. + if not native_json and isinstance(opaque.json, str): + return BaboonRight(codec.decode(BaboonCodecContext.Compact, opaque.json)) + return BaboonRight(codec.decode_value(BaboonCodecContext.Compact, opaque.json)) except Exception as e: return BaboonLeft( BaboonCodecException.DecoderFailure( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_conversions.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_conversions.py index 82c69dba7..586f33ade 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_conversions.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_conversions.py @@ -47,21 +47,41 @@ def __init__(self, from_type: type[From], to_type: type[To]): self._type_from = from_type self._type_to = to_type + @property + def _target_type_id(self) -> str | None: + # An absent target retains the public source validator, including overrides. + return None + def validate_baboon_type(self, obj): if isinstance(obj, BaboonGenerated): - tid = self.type_id - conversion_type_is_exact = tid == obj.baboon_type_identifier - if isinstance(obj, BaboonAdtMemberMeta): - conversion_type_is_adt_type = tid == obj.baboon_adt_type_identifier - if not conversion_type_is_exact and not conversion_type_is_adt_type: - raise ValueError( - f"Provided instance is adt={obj.baboon_adt_type_identifier} " - f"exact={obj.baboon_adt_type} one of which must be {tid}" - ) + # Public overrides validate either endpoint; convert enforces direction. + target_id = self._target_type_id + if target_id is not None and ( + target_id == obj.baboon_type_identifier + or isinstance(obj, BaboonAdtMemberMeta) and target_id == obj.baboon_adt_type_identifier + ): + return + self._validate_baboon_type_id(obj, self.type_id) + + def _validate_baboon_type_id(self, obj, tid: str): + conversion_type_is_exact = tid == obj.baboon_type_identifier + if isinstance(obj, BaboonAdtMemberMeta): + conversion_type_is_adt_type = tid == obj.baboon_adt_type_identifier + if not conversion_type_is_exact and not conversion_type_is_adt_type: + raise ValueError( + f"Provided instance is adt={obj.baboon_adt_type_identifier} " + f"exact={obj.baboon_adt_type} one of which must be {tid}" + ) def convert(self, context, conversions: 'AbstractBaboonConversions', convert_from: From) -> To: + if isinstance(convert_from, BaboonGenerated) and self._target_type_id is not None: + self._validate_baboon_type_id(convert_from, self.type_id) self.validate_baboon_type(convert_from) result = self.do_convert(context, conversions, convert_from) + if isinstance(result, BaboonGenerated): + target_id = self._target_type_id + if target_id is not None: + self._validate_baboon_type_id(result, target_id) self.validate_baboon_type(result) return result @@ -138,4 +158,4 @@ def versions_from(self) -> list[str]: @property def version_to(self) -> str: - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_mcp_runtime.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_mcp_runtime.py index a0aaa46e3..45192516c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_mcp_runtime.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_mcp_runtime.py @@ -14,7 +14,7 @@ import json from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar +from typing import Any, Callable, Dict, Generic, Iterable, List, Optional, TypeVar from .baboon_service_wiring import BaboonLeft, BaboonMethodId, BaboonRight, BaboonWiringError @@ -56,6 +56,57 @@ def __init__(self) -> None: self.initialized: bool = False +def _rpc_error(req_id: Any, code: int, message: str) -> Dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def _initialize_response(req_id: Any, params: Any, session: McpSession, + server_info: Callable[[], McpServerInfo], + error: Callable[[Any, int, str], Dict[str, Any]]) -> Dict[str, Any]: + pv = params.get("protocolVersion") if isinstance(params, dict) else None + if pv is None: + return error(req_id, JSONRPC_INVALID_PARAMS, "initialize: missing protocolVersion") + session.initialized = True + return { + "jsonrpc": "2.0", "id": req_id, + "result": { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": server_info().name, "version": server_info().version}, + }, + } + + +def _tool_entries(tools: Iterable[McpToolEntry]) -> List[Dict[str, Any]]: + result = [] + for tool in tools: + entry: Dict[str, Any] = {"name": tool.name, "inputSchema": tool.input_schema} + if tool.description is not None: + entry["description"] = tool.description + result.append(entry) + return result + + +def _validate_tool_call(req_id: Any, params: Any, session: McpSession, + error: Callable[[Any, int, str], Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not session.initialized: + return error(req_id, JSONRPC_INVALID_REQUEST, "tools/call before initialize") + if not isinstance(params, dict): + return error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing params") + if not isinstance(params.get("name"), str): + return error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing tool name") + return None + + +def _tool_call_response(req_id: Any, result: Any, describe_error: Callable[[BaboonWiringError], str]) -> Dict[str, Any]: + success = isinstance(result, BaboonRight) + text = result.value if success else describe_error(result.value) + return { + "jsonrpc": "2.0", "id": req_id, + "result": {"content": [{"type": "text", "text": text}], "isError": not success}, + } + + # --- Dispatch interface --- # # The single generated entrypoint, analogous to `IBaboonJsonServiceCtx[Ctx, R]`. @@ -139,24 +190,7 @@ def handle( params = request.get("params") if method == "initialize": - pv = None - if isinstance(params, dict): - pv = params.get("protocolVersion") - if pv is None: - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "initialize: missing protocolVersion") - session.initialized = True - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {"tools": {}}, - "serverInfo": { - "name": self.server_info.name, - "version": self.server_info.version, - }, - }, - } + return _initialize_response(req_id, params, session, lambda: self.server_info, self._error_response) elif method == "notifications/initialized": # Notification -- no response. @@ -165,12 +199,7 @@ def handle( elif method == "tools/list": if not session.initialized: return self._error_response(req_id, JSONRPC_INVALID_REQUEST, "tools/list before initialize") - tools_list = [] - for t in self.tools: - entry: Dict[str, Any] = {"name": t.name, "inputSchema": t.input_schema} - if t.description is not None: - entry["description"] = t.description - tools_list.append(entry) + tools_list = _tool_entries(self.tools) return { "jsonrpc": "2.0", "id": req_id, @@ -178,48 +207,23 @@ def handle( } elif method == "tools/call": - if not session.initialized: - return self._error_response(req_id, JSONRPC_INVALID_REQUEST, "tools/call before initialize") - if not isinstance(params, dict): - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing params") - name = params.get("name") - if not isinstance(name, str): - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing tool name") + failure = _validate_tool_call(req_id, params, session, self._error_response) + if failure is not None: + return failure + name = params["name"] entry = self._by_name().get(name) if entry is None: return self._error_response(req_id, JSONRPC_INVALID_PARAMS, f"tools/call: unknown tool '{name}'") args = params.get("arguments") or {} args_json = json.dumps(args) result = self.invoke_json(entry.method, args_json, ctx, codec_ctx) - if isinstance(result, BaboonRight): - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": result.value}], - "isError": False, - }, - } - else: - # Channel B: a valid protocol call whose domain payload failed. - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": self._describe_wiring_error(result.value)}], - "isError": True, - }, - } + return _tool_call_response(req_id, result, self._describe_wiring_error) else: return self._error_response(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}") def _error_response(self, req_id: Any, code: int, message: str) -> Dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": code, "message": message}, - } + return _rpc_error(req_id, code, message) def _describe_wiring_error(self, e: BaboonWiringError) -> str: return repr(e) @@ -306,24 +310,7 @@ async def handle( params = request.get("params") if method == "initialize": - pv = None - if isinstance(params, dict): - pv = params.get("protocolVersion") - if pv is None: - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "initialize: missing protocolVersion") - session.initialized = True - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {"tools": {}}, - "serverInfo": { - "name": self.server_info.name, - "version": self.server_info.version, - }, - }, - } + return _initialize_response(req_id, params, session, lambda: self.server_info, self._error_response) elif method == "notifications/initialized": # Notification -- no response. @@ -332,12 +319,7 @@ async def handle( elif method == "tools/list": if not session.initialized: return self._error_response(req_id, JSONRPC_INVALID_REQUEST, "tools/list before initialize") - tools_list = [] - for t in self.tools: - entry: Dict[str, Any] = {"name": t.name, "inputSchema": t.input_schema} - if t.description is not None: - entry["description"] = t.description - tools_list.append(entry) + tools_list = _tool_entries(self.tools) return { "jsonrpc": "2.0", "id": req_id, @@ -345,48 +327,23 @@ async def handle( } elif method == "tools/call": - if not session.initialized: - return self._error_response(req_id, JSONRPC_INVALID_REQUEST, "tools/call before initialize") - if not isinstance(params, dict): - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing params") - name = params.get("name") - if not isinstance(name, str): - return self._error_response(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing tool name") + failure = _validate_tool_call(req_id, params, session, self._error_response) + if failure is not None: + return failure + name = params["name"] entry = self._by_name().get(name) if entry is None: return self._error_response(req_id, JSONRPC_INVALID_PARAMS, f"tools/call: unknown tool '{name}'") args = params.get("arguments") or {} args_json = json.dumps(args) result = await self.invoke_json(entry.method, args_json, ctx, codec_ctx) - if isinstance(result, BaboonRight): - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": result.value}], - "isError": False, - }, - } - else: - # Channel B: a valid protocol call whose domain payload failed. - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": self._describe_wiring_error(result.value)}], - "isError": True, - }, - } + return _tool_call_response(req_id, result, self._describe_wiring_error) else: return self._error_response(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}") def _error_response(self, req_id: Any, code: int, message: str) -> Dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": code, "message": message}, - } + return _rpc_error(req_id, code, message) def _describe_wiring_error(self, e: BaboonWiringError) -> str: return repr(e) @@ -435,6 +392,14 @@ def __init__(self, error: BaboonMcpWiringError) -> None: self.error = error +def _register_tools(server: Any, route: Dict[str, Any], entries: Dict[str, McpToolEntry]) -> None: + for tool in server.tools: + if tool.name in route: + raise BaboonMcpWiringException(DuplicateTool(tool.name)) + route[tool.name] = server + entries[tool.name] = tool + + # --- Cross-service MCP muxer (tasks:T108; contract: # docs/research/mcp-muxer-runtime-contract.md) --- # @@ -471,11 +436,7 @@ def __init__(self, merged_server_info: McpServerInfo, *servers: Any) -> None: # across servers (the exact MCP-tier analogue of JsonMuxer.register raising # DuplicateService). def register(self, server: Any) -> None: - for t in server.tools: - if t.name in self._route: - raise BaboonMcpWiringException(DuplicateTool(t.name)) - self._route[t.name] = server - self._entries[t.name] = t + _register_tools(server, self._route, self._entries) def handle( self, @@ -489,24 +450,7 @@ def handle( params = request.get("params") if method == "initialize": - pv = None - if isinstance(params, dict): - pv = params.get("protocolVersion") - if pv is None: - return self._mux_error(req_id, JSONRPC_INVALID_PARAMS, "initialize: missing protocolVersion") - session.initialized = True - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {"tools": {}}, - "serverInfo": { - "name": self._merged_server_info.name, - "version": self._merged_server_info.version, - }, - }, - } + return _initialize_response(req_id, params, session, lambda: self._merged_server_info, self._mux_error) elif method == "notifications/initialized": # Notification -- no response. @@ -522,13 +466,10 @@ def handle( } elif method == "tools/call": - if not session.initialized: - return self._mux_error(req_id, JSONRPC_INVALID_REQUEST, "tools/call before initialize") - if not isinstance(params, dict): - return self._mux_error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing params") - name = params.get("name") - if not isinstance(name, str): - return self._mux_error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing tool name") + failure = _validate_tool_call(req_id, params, session, self._mux_error) + if failure is not None: + return failure + name = params["name"] server = self._route.get(name) if server is None: # NoMatchingTool: surfaced as the SAME wire response the per-service @@ -539,25 +480,7 @@ def handle( args = params.get("arguments") or {} args_json = json.dumps(args) result = server.route_tool_call(entry.method, args_json, ctx, codec_ctx) - if isinstance(result, BaboonRight): - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": result.value}], - "isError": False, - }, - } - else: - # Channel B: a valid protocol call whose domain payload failed. - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": repr(result.value)}], - "isError": True, - }, - } + return _tool_call_response(req_id, result, repr) else: return self._mux_error(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}") @@ -566,20 +489,10 @@ def handle( # in registration-then-declaration order (the insertion order of `_entries`), # each in the same shape the per-service base emits. def _tools_list_union(self) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for t in self._entries.values(): - entry: Dict[str, Any] = {"name": t.name, "inputSchema": t.input_schema} - if t.description is not None: - entry["description"] = t.description - out.append(entry) - return out + return _tool_entries(self._entries.values()) def _mux_error(self, req_id: Any, code: int, message: str) -> Dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": code, "message": message}, - } + return _rpc_error(req_id, code, message) # --- Async cross-service MCP muxer (tasks:T108; contract §7) --- @@ -600,11 +513,7 @@ def __init__(self, merged_server_info: McpServerInfo, *servers: Any) -> None: self.register(server) def register(self, server: Any) -> None: - for t in server.tools: - if t.name in self._route: - raise BaboonMcpWiringException(DuplicateTool(t.name)) - self._route[t.name] = server - self._entries[t.name] = t + _register_tools(server, self._route, self._entries) async def handle( self, @@ -618,24 +527,7 @@ async def handle( params = request.get("params") if method == "initialize": - pv = None - if isinstance(params, dict): - pv = params.get("protocolVersion") - if pv is None: - return self._async_mux_error(req_id, JSONRPC_INVALID_PARAMS, "initialize: missing protocolVersion") - session.initialized = True - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {"tools": {}}, - "serverInfo": { - "name": self._merged_server_info.name, - "version": self._merged_server_info.version, - }, - }, - } + return _initialize_response(req_id, params, session, lambda: self._merged_server_info, self._async_mux_error) elif method == "notifications/initialized": return None @@ -650,13 +542,10 @@ async def handle( } elif method == "tools/call": - if not session.initialized: - return self._async_mux_error(req_id, JSONRPC_INVALID_REQUEST, "tools/call before initialize") - if not isinstance(params, dict): - return self._async_mux_error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing params") - name = params.get("name") - if not isinstance(name, str): - return self._async_mux_error(req_id, JSONRPC_INVALID_PARAMS, "tools/call: missing tool name") + failure = _validate_tool_call(req_id, params, session, self._async_mux_error) + if failure is not None: + return failure + name = params["name"] server = self._route.get(name) if server is None: return self._async_mux_error(req_id, JSONRPC_INVALID_PARAMS, f"tools/call: unknown tool '{name}'") @@ -664,41 +553,13 @@ async def handle( args = params.get("arguments") or {} args_json = json.dumps(args) result = await server.route_tool_call(entry.method, args_json, ctx, codec_ctx) - if isinstance(result, BaboonRight): - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": result.value}], - "isError": False, - }, - } - else: - # Channel B: a valid protocol call whose domain payload failed. - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": repr(result.value)}], - "isError": True, - }, - } + return _tool_call_response(req_id, result, repr) else: return self._async_mux_error(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}") def _async_tools_list_union(self) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for t in self._entries.values(): - entry: Dict[str, Any] = {"name": t.name, "inputSchema": t.input_schema} - if t.description is not None: - entry["description"] = t.description - out.append(entry) - return out + return _tool_entries(self._entries.values()) def _async_mux_error(self, req_id: Any, code: int, message: str) -> Dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": code, "message": message}, - } + return _rpc_error(req_id, code, message) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index 2f0283f87..71ca7ed4d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -78,9 +78,8 @@ def baboon_adt_type(self) -> type: class BaboonMeta(ABC): - @property @abstractmethod - def same_in_versions(self) -> list[str]: + def same_in_versions(self, type_id_string: str) -> list[str]: raise NotImplementedError # Forward-readability per type: newer version -> guarantee tier (see @@ -293,7 +292,7 @@ def write_str(self, s: str): value >>= 7 if value != 0: current_byte |= 0x80 - self.write_byte(current_byte) + self.write_ubyte(current_byte) if value == 0: break self.stream.write(bytes_data) @@ -445,7 +444,7 @@ def value(self) -> T: @property def is_value_created(self) -> bool: - return self._value_ref is not None + return self._value is not None class BaboonSingleton(ABC, Generic[T]): _lazy_instance: Lazy[T] @@ -832,8 +831,4 @@ def baboon_unmodified_since_version(g: BaboonGenerated) -> str: def unmodified_since_version(meta: BaboonMeta, type_id: str) -> str: - # NOTE: The abstract BaboonMeta.same_in_versions property returns list[str] per - # its declaration, but concrete implementations (e.g. generated BaboonMetadata) - # return a callable(typeId) -> list[str] (matching C#/Scala sameInVersions(typeId)). - # We call it as a callable per the C# reference signature. - return meta.same_in_versions(type_id)[0] \ No newline at end of file + return meta.same_in_versions(type_id)[0] diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index 4292015af..ef6d45755 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -113,6 +113,97 @@ impl fmt::Display for BaboonCodecError { } } +pub mod any_field_codec { + pub fn encode_any_field( + ctx: &crate::baboon_runtime::BaboonCodecContext, + writer: &mut dyn std::io::Write, + expected_kind: u8, + static_domain: Option<&str>, + static_version: Option<&str>, + static_typeid: Option<&str>, + value: &crate::any_opaque::AnyOpaque, + ) -> std::io::Result<()> { + if value.meta().kind != expected_kind { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "any: meta-kind 0x{:02x} does not match field-declared 0x{:02x}", + value.meta().kind, expected_kind + ), + )); + } + let any_blob: std::borrow::Cow<'_, [u8]> = match value { + crate::any_opaque::AnyOpaque::Ueba(u) => std::borrow::Cow::Borrowed(&u.bytes), + crate::any_opaque::AnyOpaque::Json(j) => { + let f = ctx.facade().ok_or_else(|| std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Cannot encode AnyOpaque::Json into UEBA without a facade reference. Construct the codec context via BaboonCodecContext::with_facade(use_indices, facade), or supply AnyOpaque::Ueba directly." + ))?; + std::borrow::Cow::Owned(f.json_to_ueba_bytes(&j.meta, &j.json, static_domain, static_version, static_typeid) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)))?) + } + }; + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + let mut any_meta_buf: Vec = Vec::new(); + crate::any_opaque::any_meta_codec::write_bin(value.meta(), &mut any_meta_buf)?; + let any_total_length: i32 = (4 + any_meta_buf.len() + any_blob.len()) as i32; + crate::baboon_runtime::bin_tools::write_i32(writer, any_total_length)?; + crate::baboon_runtime::bin_tools::write_i32(writer, any_meta_buf.len() as i32)?; + writer.write_all(&any_meta_buf)?; + writer.write_all(&any_blob)?; + Ok(()) + } + + pub fn decode_any_field( + wire: &mut dyn std::io::Read, + expected_kind: u8, + ) -> Result> { + let any_total_length_i = crate::baboon_runtime::bin_tools::read_i32(wire)?; + if any_total_length_i < 0 { + return Err(format!( + "any: negative total-length {}", any_total_length_i + ).into()); + } + let any_total_length = any_total_length_i as usize; + let any_meta_length_i = crate::baboon_runtime::bin_tools::read_i32(wire)?; + if any_meta_length_i < 0 { + return Err(format!( + "any: negative meta-length {}", any_meta_length_i + ).into()); + } + let any_meta_length = any_meta_length_i as usize; + if any_total_length < 4 + any_meta_length { + return Err(format!( + "any: total-length {} smaller than 4 + meta-length {}", + any_total_length, any_meta_length + ).into()); + } + let (any_meta, any_bytes_read) = crate::any_opaque::any_meta_codec::read_bin_with_length(wire) + .map_err(|e| -> Box { Box::new(e) })?; + if any_bytes_read > any_meta_length { + return Err(format!( + "any: meta-bytes-read {} exceeded meta-length window {}", + any_bytes_read, any_meta_length + ).into()); + } + if any_bytes_read < any_meta_length { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + let mut any_skip = vec![0u8; any_meta_length - any_bytes_read]; + wire.read_exact(&mut any_skip)?; + } + if any_meta.kind != expected_kind { + return Err(format!( + "any: wire kind 0x{:02x} does not match field-declared 0x{:02x}", + any_meta.kind, expected_kind + ).into()); + } + let any_blob_len = any_total_length - 4 - any_meta_length; + let mut any_blob = vec![0u8; any_blob_len]; + wire.read_exact(&mut any_blob)?; + Ok(crate::any_opaque::AnyOpaqueUeba::new(any_meta, any_blob)) + } +} + impl std::error::Error for BaboonCodecError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index f94eb7e32..fbafb64b4 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -25,6 +25,9 @@ pub trait BaboonGeneratedDyn: std::any::Any + Send + Sync { /// Codegen invariant: the slice is never empty (`first()` is called for `domainVersionMinCompat` /// in `BaboonTypeMeta::from`); a violation panics with a fail-fast message. fn baboon_same_in_versions_dyn(&self) -> Vec; + fn baboon_first_same_in_version_dyn(&self) -> Option> { + self.baboon_same_in_versions_dyn().into_iter().next().map(std::borrow::Cow::Owned) + } /// Forward-readability: newer domain versions whose encoded data THIS version's codec can /// decode, as `(version, tier)` pairs with tier one of /// "identical" | "prefix-any-mode" | "prefix-compact" | "json-additive". @@ -39,6 +42,10 @@ pub trait BaboonGeneratedDyn: std::any::Any + Send + Sync { /// as `$rv`, the prefix-* bounds feed the binary envelope. No default: the envelope writer /// fails fast when a tier is missing, so every impl must provide all four (generated impls do). fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)>; + fn baboon_min_reader_version_dyn(&self, tier: &str) -> Option> { + self.baboon_min_reader_versions_dyn().into_iter() + .find(|(t, _)| t == tier).map(|(_, v)| std::borrow::Cow::Owned(v)) + } fn as_any(&self) -> &dyn std::any::Any; /// Consume the box and recover an `Any` for downcasting via `Box::downcast`. Mirrors /// the C# `if (current is TTo result)` pattern used in `Convert`. @@ -230,41 +237,61 @@ pub use crate::baboon_type_meta::{baboon_type_meta_codec, BaboonTypeMeta}; // access only. A registered codec table can call into lots of types' registrations during // construction; lazy is what avoids paying that cost up front. -pub struct LazyCodec { - cell: OnceLock>, +struct LazyValue { + cell: OnceLock, init: Mutex T + Send>>>, } +impl LazyValue { + fn new T + Send + 'static>(init: F) -> Self { + Self { cell: OnceLock::new(), init: Mutex::new(Some(Box::new(init))) } + } + + fn from_value(value: T) -> Self { + let cell = OnceLock::new(); + let _ = cell.set(value); + Self { cell, init: Mutex::new(None) } + } + + fn get(&self) -> &T { + self.cell.get_or_init(|| { + let mut guard = self.init.lock().expect("LazyCodec mutex poisoned"); + guard.take().expect("LazyCodec missing both init and value")() + }) + } +} + +pub struct LazyCodec { + value: LazyValue>, +} + impl LazyCodec { pub fn new T + Send + 'static>(init: F) -> Self { - LazyCodec { cell: OnceLock::new(), init: Mutex::new(Some(Box::new(init))) } + Self { value: LazyValue::new(move || Arc::new(init())) } } pub fn from_value(value: T) -> Self { - let cell = OnceLock::new(); - let _ = cell.set(Arc::new(value)); - LazyCodec { cell, init: Mutex::new(None) } + Self { value: LazyValue::from_value(Arc::new(value)) } } pub fn get(&self) -> Arc { - if let Some(v) = self.cell.get() { - return Arc::clone(v); - } - let value = { - let mut guard = self.init.lock().expect("LazyCodec mutex poisoned"); - match guard.take() { - Some(thunk) => thunk(), - None => { - drop(guard); - return Arc::clone(self.cell.get().expect("LazyCodec missing both init and value")); - } - } - }; - let arc = Arc::new(value); - match self.cell.set(Arc::clone(&arc)) { - Ok(()) => arc, - Err(_) => Arc::clone(self.cell.get().expect("LazyCodec set failed but cell empty")), - } + Arc::clone(self.value.get()) + } +} + +struct CodecRegistry { + codecs: HashMap>>, +} + +impl CodecRegistry { + fn new() -> Self { Self { codecs: HashMap::new() } } + + fn register Arc + Send + 'static>(&mut self, id: &str, thunk: F) { + self.codecs.insert(id.to_string(), LazyValue::new(thunk)); + } + + fn try_find(&self, id: &str) -> Option> { + self.codecs.get(id).map(|lazy| Arc::clone(lazy.get())) } } @@ -280,7 +307,7 @@ pub trait AbstractBaboonCodecs: Send + Sync { } pub struct AbstractBaboonJsonCodecsImpl { - codecs: HashMap>>, + codecs: CodecRegistry, } impl Default for AbstractBaboonJsonCodecsImpl { @@ -291,7 +318,7 @@ impl Default for AbstractBaboonJsonCodecsImpl { impl AbstractBaboonJsonCodecsImpl { pub fn new() -> Self { - AbstractBaboonJsonCodecsImpl { codecs: HashMap::new() } + AbstractBaboonJsonCodecsImpl { codecs: CodecRegistry::new() } } pub fn register Arc + Send + 'static>( @@ -299,16 +326,16 @@ impl AbstractBaboonJsonCodecsImpl { id: &str, thunk: F, ) { - self.codecs.insert(id.to_string(), LazyCodec::new(thunk)); + self.codecs.register(id, thunk); } pub fn try_find(&self, id: &str) -> Option> { - self.codecs.get(id).map(|lazy| (*lazy.get()).clone()) + self.codecs.try_find(id) } } pub struct AbstractBaboonUebaCodecsImpl { - codecs: HashMap>>, + codecs: CodecRegistry, } impl Default for AbstractBaboonUebaCodecsImpl { @@ -319,7 +346,7 @@ impl Default for AbstractBaboonUebaCodecsImpl { impl AbstractBaboonUebaCodecsImpl { pub fn new() -> Self { - AbstractBaboonUebaCodecsImpl { codecs: HashMap::new() } + AbstractBaboonUebaCodecsImpl { codecs: CodecRegistry::new() } } pub fn register Arc + Send + 'static>( @@ -327,11 +354,11 @@ impl AbstractBaboonUebaCodecsImpl { id: &str, thunk: F, ) { - self.codecs.insert(id.to_string(), LazyCodec::new(thunk)); + self.codecs.register(id, thunk); } pub fn try_find(&self, id: &str) -> Option> { - self.codecs.get(id).map(|lazy| (*lazy.get()).clone()) + self.codecs.try_find(id) } } @@ -731,20 +758,19 @@ impl BaboonCodecsFacade { exact: bool, tolerant: bool, ) -> Result { - let versions = { + let bounds = { let map = self.domain_versions.lock().expect("mutex poisoned"); - map.get(&type_meta.domain_identifier).cloned() + map.get(&type_meta.domain_identifier).and_then(|versions| { + Some((versions.first()?.clone(), versions.last()?.clone())) + }) }; - let versions = versions.filter(|v| !v.is_empty()).ok_or_else(|| { + let (min_version, max_version) = bounds.ok_or_else(|| { BaboonCodecError::codec_not_found(format!( "Unknown domain {}.", type_meta.domain_identifier )) })?; - let min_version = &versions[0]; - let max_version = &versions[versions.len() - 1]; - let lookup_version = type_meta.version_ref(); let lookup_v = lookup_version.version()?; let max_v = max_version.version()?; @@ -782,10 +808,10 @@ impl BaboonCodecsFacade { return Ok(model_version); } if model_v >= min_v && model_v < max_v { - return self.resolve_max_compat(&model_version, max_version, &type_meta.type_identifier); + return self.resolve_max_compat(&model_version, &max_version, &type_meta.type_identifier); } if model_v < min_v { - return self.resolve_max_compat(min_version, max_version, &type_meta.type_identifier); + return self.resolve_max_compat(&min_version, &max_version, &type_meta.type_identifier); } Err(BaboonCodecError::codec_not_found(format!( "Unsupported domain version '{}'.", @@ -873,10 +899,8 @@ impl BaboonCodecsFacade { fn min_reader_bound(value: &dyn BaboonGeneratedDyn, tier: &str) -> Result { value - .baboon_min_reader_versions_dyn() - .into_iter() - .find(|(t, _)| t == tier) - .map(|(_, v)| v) + .baboon_min_reader_version_dyn(tier) + .map(std::borrow::Cow::into_owned) .ok_or_else(|| { BaboonCodecError::encoder_failure(format!( "baboon_min_reader_versions_dyn lacks '{}' for type [{}.{}]", @@ -896,16 +920,16 @@ impl BaboonCodecsFacade { } else { value.baboon_type_identifier_dyn().to_string() }; - let same_in = value.baboon_same_in_versions_dyn(); + let same_in = value.baboon_first_same_in_version_dyn(); // PR-08-D02 fail-fast invariant: codegen must always emit a non-empty same-in-versions // list. A panic here surfaces a generator regression (not a recoverable user error). assert!( - !same_in.is_empty(), + same_in.is_some(), "BaboonGeneratedDyn::baboon_same_in_versions_dyn returned empty; codegen invariant violated for [{}.{}]", value.baboon_domain_identifier_dyn(), value.baboon_type_identifier_dyn() ); - let min_compat = same_in[0].clone(); + let min_compat = same_in.expect("checked above").into_owned(); let readable_min = Self::min_reader_bound(value, BaboonTypeMeta::JSON_READABLE_TIER)?; Ok(BaboonTypeMeta::new( BaboonTypeMeta::META_VERSION, @@ -1281,4 +1305,3 @@ impl BaboonCodecsFacade { } } } - diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_mcp_server.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_mcp_server.rs index 1573e89fb..8e2d31fee 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_mcp_server.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_mcp_server.rs @@ -175,6 +175,33 @@ impl BaboonMcpServerBase { codec_ctx: &BaboonCodecContext, invoke_json: &dyn Fn(&BaboonMethodId, &str, Ctx, &BaboonCodecContext) -> Result, ) -> Option { + dispatch_request(request, session, ctx, codec_ctx, &self.server_info, + || self.tools.iter().map(tool_json).collect(), + |name| self.by_name(name), + |entry, args, ctx, codec_ctx| invoke_json(&entry.method, args, ctx, codec_ctx), + ) + } + +} + +fn tool_json(tool: &McpToolEntry) -> serde_json::Value { + let mut entry = serde_json::json!({"name": tool.name, "inputSchema": tool.input_schema.clone()}); + if let Some(description) = tool.description { + entry["description"] = serde_json::Value::String(description.to_string()); + } + entry +} + +fn dispatch_request( + request: &JsonRpcRequest, + session: &mut McpSession, + ctx: Ctx, + codec_ctx: &BaboonCodecContext, + server_info: &McpServerInfo, + list: impl Fn() -> Vec, + find: impl Fn(&str) -> Option, + invoke: impl Fn(Entry, &str, Ctx, &BaboonCodecContext) -> Result, +) -> Option { let id = request.id.clone(); match request.method.as_str() { "initialize" => { @@ -182,7 +209,7 @@ impl BaboonMcpServerBase { .and_then(|p| p.get("protocolVersion")) .is_some(); if !has_pv { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_PARAMS, "initialize: missing protocolVersion")); + return Some(error_response(id, json_rpc_error_codes::INVALID_PARAMS, "initialize: missing protocolVersion")); } session.initialized = true; Some(JsonRpcResponse { @@ -190,7 +217,7 @@ impl BaboonMcpServerBase { result: Some(serde_json::json!({ "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": { "tools": {} }, - "serverInfo": { "name": self.server_info.name, "version": self.server_info.version } + "serverInfo": { "name": server_info.name, "version": server_info.version } })), error: None, }) @@ -198,18 +225,9 @@ impl BaboonMcpServerBase { "notifications/initialized" => None, "tools/list" => { if !session.initialized { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/list before initialize")); + return Some(error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/list before initialize")); } - let tools: Vec = self.tools.iter().map(|t| { - let mut entry = serde_json::json!({ - "name": t.name, - "inputSchema": t.input_schema.clone(), - }); - if let Some(desc) = t.description { - entry["description"] = serde_json::Value::String(desc.to_string()); - } - entry - }).collect(); + let tools = list(); Some(JsonRpcResponse { id, result: Some(serde_json::json!({ "tools": tools })), @@ -218,7 +236,7 @@ impl BaboonMcpServerBase { } "tools/call" => { if !session.initialized { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/call before initialize")); + return Some(error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/call before initialize")); } let name = request.params.as_ref() .and_then(|p| p.get("name")) @@ -226,13 +244,13 @@ impl BaboonMcpServerBase { let name = match name { Some(n) => n, None => { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_PARAMS, "tools/call: missing tool name")); + return Some(error_response(id, json_rpc_error_codes::INVALID_PARAMS, "tools/call: missing tool name")); } }; - let entry = match self.by_name(name) { + let entry = match find(name) { Some(e) => e, None => { - return Some(self.error_response( + return Some(error_response( id, json_rpc_error_codes::INVALID_PARAMS, &format!("tools/call: unknown tool '{}'", name), @@ -246,14 +264,14 @@ impl BaboonMcpServerBase { let args_json = match serde_json::to_string(&args) { Ok(s) => s, Err(e) => { - return Some(self.error_response( + return Some(error_response( id, json_rpc_error_codes::INTERNAL_ERROR, &format!("tools/call: failed to serialize arguments: {}", e), )); } }; - let result = invoke_json(&entry.method, &args_json, ctx, codec_ctx); + let result = invoke(entry, &args_json, ctx, codec_ctx); match result { Ok(json_str) => { Some(JsonRpcResponse { @@ -278,15 +296,15 @@ impl BaboonMcpServerBase { } } } - _ => Some(self.error_response( + _ => Some(error_response( id, json_rpc_error_codes::METHOD_NOT_FOUND, &format!("Method not found: {}", request.method), )), } - } +} - fn error_response(&self, id: Option, code: i32, message: &str) -> JsonRpcResponse { +fn error_response( id: Option, code: i32, message: &str) -> JsonRpcResponse { JsonRpcResponse { id, result: None, @@ -297,7 +315,6 @@ impl BaboonMcpServerBase { }), } } -} // --- Cross-service MCP muxer (tasks:T109; contract: // docs/research/mcp-muxer-runtime-contract.md) --- @@ -358,13 +375,19 @@ impl std::fmt::Display for BaboonMcpWiringError { impl std::error::Error for BaboonMcpWiringError {} +struct ToolRoute { + server_index: usize, + tool_index: usize, + method: BaboonMethodId, +} + pub struct AbstractMcpMuxer { // Insertion-ordered union table (registration order of servers, then each - // server's tool declaration order — contract §5). A `Vec` of `(name, server-idx, - // entry-method)` preserves that order deterministically without an extra crate; - // `route` is the name -> server-idx lookup built at registration (contract §2). + // server's tool declaration order — contract §5). Stable server/tool indices + // preserve that order deterministically without an extra crate; + // `route` maps tool names to indices in the ordered entry table. servers: Vec>>, - entries: Vec<(String, usize, BaboonMethodId)>, + entries: Vec, route: std::collections::HashMap, merged_server_info: McpServerInfo, } @@ -383,13 +406,17 @@ impl AbstractMcpMuxer { // returns `Err(DuplicateTool)` on a tool-name collision across servers (the // exact MCP-tier analogue of `JsonMuxer::register` returning `DuplicateService`). pub fn register(&mut self, server: Box>) -> Result<(), BaboonMcpWiringError> { - let idx = self.servers.len(); - for t in server.tools() { - if self.route.contains_key(t.name) { - return Err(BaboonMcpWiringError::DuplicateTool(t.name.to_string())); + let tools = server.tools(); + let mut names = std::collections::HashSet::new(); + for tool in tools { + if self.route.contains_key(tool.name) || !names.insert(tool.name) { + return Err(BaboonMcpWiringError::DuplicateTool(tool.name.to_string())); } - self.route.insert(t.name.to_string(), idx); - self.entries.push((t.name.to_string(), idx, t.method.clone())); + } + let server_index = self.servers.len(); + for (tool_index, tool) in tools.iter().enumerate() { + self.route.insert(tool.name.to_string(), self.entries.len()); + self.entries.push(ToolRoute { server_index, tool_index, method: tool.method.clone() }); } self.servers.push(server); Ok(()) @@ -400,24 +427,13 @@ impl AbstractMcpMuxer { Ok(self) } - fn entry(&self, name: &str) -> Option<&(String, usize, BaboonMethodId)> { - self.entries.iter().find(|(n, _, _)| n == name) + fn entry(&self, name: &str) -> Option<&ToolRoute> { + self.route.get(name).map(|index| &self.entries[*index]) } - // Backs tools/list (contract §3.2): the union of all registered servers' tool - // entries in registration-then-declaration order (the insertion order of - // `entries`), each in the same shape the per-service base emits. fn tools_list_union(&self) -> Vec { - self.entries.iter().map(|(name, srv_idx, _)| { - let entry_tool = self.servers[*srv_idx].tools().iter().find(|t| t.name == name).unwrap(); - let mut entry = serde_json::json!({ - "name": entry_tool.name, - "inputSchema": entry_tool.input_schema.clone(), - }); - if let Some(desc) = entry_tool.description { - entry["description"] = serde_json::Value::String(desc.to_string()); - } - entry + self.entries.iter().map(|route| { + tool_json(&self.servers[route.server_index].tools()[route.tool_index]) }).collect() } @@ -428,122 +444,10 @@ impl AbstractMcpMuxer { ctx: Ctx, codec_ctx: &BaboonCodecContext, ) -> Option { - let id = request.id.clone(); - match request.method.as_str() { - "initialize" => { - let has_pv = request.params.as_ref() - .and_then(|p| p.get("protocolVersion")) - .is_some(); - if !has_pv { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_PARAMS, "initialize: missing protocolVersion")); - } - session.initialized = true; - Some(JsonRpcResponse { - id, - result: Some(serde_json::json!({ - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": self.merged_server_info.name, "version": self.merged_server_info.version } - })), - error: None, - }) - } - "notifications/initialized" => None, - "tools/list" => { - if !session.initialized { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/list before initialize")); - } - Some(JsonRpcResponse { - id, - result: Some(serde_json::json!({ "tools": self.tools_list_union() })), - error: None, - }) - } - "tools/call" => { - if !session.initialized { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_REQUEST, "tools/call before initialize")); - } - let name = request.params.as_ref() - .and_then(|p| p.get("name")) - .and_then(|v| v.as_str()); - let name = match name { - Some(n) => n, - None => { - return Some(self.error_response(id, json_rpc_error_codes::INVALID_PARAMS, "tools/call: missing tool name")); - } - }; - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), so - // the bytes are identical whether one server or the muxer rejects. - let entry = match self.entry(name) { - Some(e) => e, - None => { - return Some(self.error_response( - id, - json_rpc_error_codes::INVALID_PARAMS, - &format!("tools/call: unknown tool '{}'", name), - )); - } - }; - let (_name, srv_idx, method) = entry; - let args = request.params.as_ref() - .and_then(|p| p.get("arguments")) - .cloned() - .unwrap_or_else(|| serde_json::json!({})); - let args_json = match serde_json::to_string(&args) { - Ok(s) => s, - Err(e) => { - return Some(self.error_response( - id, - json_rpc_error_codes::INTERNAL_ERROR, - &format!("tools/call: failed to serialize arguments: {}", e), - )); - } - }; - // T114 public dispatch entry — NOT handle(). Reuses the owning - // server's Channel-A/Channel-B mapping unchanged. - let result = self.servers[*srv_idx].route_tool_call(method, &args_json, ctx, codec_ctx); - match result { - Ok(json_str) => { - Some(JsonRpcResponse { - id, - result: Some(serde_json::json!({ - "content": [{ "type": "text", "text": json_str }], - "isError": false - })), - error: None, - }) - } - Err(wiring_err) => { - // Channel B: a valid protocol call whose domain payload failed. - Some(JsonRpcResponse { - id, - result: Some(serde_json::json!({ - "content": [{ "type": "text", "text": wiring_err.to_string() }], - "isError": true - })), - error: None, - }) - } - } - } - _ => Some(self.error_response( - id, - json_rpc_error_codes::METHOD_NOT_FOUND, - &format!("Method not found: {}", request.method), - )), - } - } - - fn error_response(&self, id: Option, code: i32, message: &str) -> JsonRpcResponse { - JsonRpcResponse { - id, - result: None, - error: Some(JsonRpcError { - code, - message: message.to_string(), - data: None, - }), - } + dispatch_request(request, session, ctx, codec_ctx, &self.merged_server_info, + || self.tools_list_union(), + |name| self.entry(name), + |entry, args, ctx, codec_ctx| self.servers[entry.server_index].route_tool_call(&entry.method, args, ctx, codec_ctx), + ) } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_service_wiring.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_service_wiring.rs index 67d4cf778..617e735d7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_service_wiring.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_service_wiring.rs @@ -60,22 +60,44 @@ pub trait IBaboonUebaService { fn invoke(&self, method: &BaboonMethodId, data: &[u8], ctx: &BaboonCodecContext) -> R; } +struct ServiceRegistry { + table: std::collections::BTreeMap>, +} + +impl ServiceRegistry { + fn new() -> Self { Self { table: std::collections::BTreeMap::new() } } + + fn register(&mut self, name: String, service: Box) -> Result<(), BaboonWiringError> { + match self.table.entry(name) { + std::collections::btree_map::Entry::Occupied(entry) => + Err(BaboonWiringError::DuplicateService(entry.key().clone())), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(service); + Ok(()) + } + } + } + + fn find(&self, method: &BaboonMethodId) -> Result<&S, BaboonWiringError> { + self.table.get(&method.service_name).map(|service| service.as_ref()) + .ok_or_else(|| BaboonWiringError::NoMatchingService(method.clone())) + } + + fn names(&self) -> Vec { self.table.keys().cloned().collect() } +} + pub struct JsonMuxer { - table: std::collections::BTreeMap>>, + table: ServiceRegistry>, } impl JsonMuxer { pub fn new() -> Self { - Self { table: std::collections::BTreeMap::new() } + Self { table: ServiceRegistry::new() } } pub fn register(&mut self, service: Box>) -> Result<(), BaboonWiringError> { let name = service.service_name().to_string(); - if self.table.contains_key(&name) { - return Err(BaboonWiringError::DuplicateService(name)); - } - self.table.insert(name, service); - Ok(()) + self.table.register(name, service) } pub fn with(mut self, service: Box>) -> Result { @@ -84,14 +106,11 @@ impl JsonMuxer { } pub fn invoke(&self, method: &BaboonMethodId, data: &str, ctx: &BaboonCodecContext) -> Result { - match self.table.get(&method.service_name) { - Some(svc) => Ok(svc.invoke(method, data, ctx)), - None => Err(BaboonWiringError::NoMatchingService(method.clone())), - } + Ok(self.table.find(method)?.invoke(method, data, ctx)) } pub fn service_names(&self) -> Vec { - self.table.keys().cloned().collect() + self.table.names() } } @@ -100,21 +119,17 @@ impl Default for JsonMuxer { } pub struct UebaMuxer { - table: std::collections::BTreeMap>>, + table: ServiceRegistry>, } impl UebaMuxer { pub fn new() -> Self { - Self { table: std::collections::BTreeMap::new() } + Self { table: ServiceRegistry::new() } } pub fn register(&mut self, service: Box>) -> Result<(), BaboonWiringError> { let name = service.service_name().to_string(); - if self.table.contains_key(&name) { - return Err(BaboonWiringError::DuplicateService(name)); - } - self.table.insert(name, service); - Ok(()) + self.table.register(name, service) } pub fn with(mut self, service: Box>) -> Result { @@ -123,14 +138,11 @@ impl UebaMuxer { } pub fn invoke(&self, method: &BaboonMethodId, data: &[u8], ctx: &BaboonCodecContext) -> Result { - match self.table.get(&method.service_name) { - Some(svc) => Ok(svc.invoke(method, data, ctx)), - None => Err(BaboonWiringError::NoMatchingService(method.clone())), - } + Ok(self.table.find(method)?.invoke(method, data, ctx)) } pub fn service_names(&self) -> Vec { - self.table.keys().cloned().collect() + self.table.names() } } @@ -159,21 +171,17 @@ pub trait IBaboonUebaServiceCtx { } pub struct JsonMuxerCtx { - table: std::collections::BTreeMap>>, + table: ServiceRegistry>, } impl JsonMuxerCtx { pub fn new() -> Self { - Self { table: std::collections::BTreeMap::new() } + Self { table: ServiceRegistry::new() } } pub fn register(&mut self, service: Box>) -> Result<(), BaboonWiringError> { let name = service.service_name().to_string(); - if self.table.contains_key(&name) { - return Err(BaboonWiringError::DuplicateService(name)); - } - self.table.insert(name, service); - Ok(()) + self.table.register(name, service) } pub fn with(mut self, service: Box>) -> Result { @@ -182,14 +190,11 @@ impl JsonMuxerCtx { } pub fn invoke(&self, method: &BaboonMethodId, data: &str, ctx: Ctx, codec_ctx: &BaboonCodecContext) -> Result { - match self.table.get(&method.service_name) { - Some(svc) => Ok(svc.invoke(method, data, ctx, codec_ctx)), - None => Err(BaboonWiringError::NoMatchingService(method.clone())), - } + Ok(self.table.find(method)?.invoke(method, data, ctx, codec_ctx)) } pub fn service_names(&self) -> Vec { - self.table.keys().cloned().collect() + self.table.names() } } @@ -198,21 +203,17 @@ impl Default for JsonMuxerCtx { } pub struct UebaMuxerCtx { - table: std::collections::BTreeMap>>, + table: ServiceRegistry>, } impl UebaMuxerCtx { pub fn new() -> Self { - Self { table: std::collections::BTreeMap::new() } + Self { table: ServiceRegistry::new() } } pub fn register(&mut self, service: Box>) -> Result<(), BaboonWiringError> { let name = service.service_name().to_string(); - if self.table.contains_key(&name) { - return Err(BaboonWiringError::DuplicateService(name)); - } - self.table.insert(name, service); - Ok(()) + self.table.register(name, service) } pub fn with(mut self, service: Box>) -> Result { @@ -221,14 +222,11 @@ impl UebaMuxerCtx { } pub fn invoke(&self, method: &BaboonMethodId, data: &[u8], ctx: Ctx, codec_ctx: &BaboonCodecContext) -> Result { - match self.table.get(&method.service_name) { - Some(svc) => Ok(svc.invoke(method, data, ctx, codec_ctx)), - None => Err(BaboonWiringError::NoMatchingService(method.clone())), - } + Ok(self.table.find(method)?.invoke(method, data, ctx, codec_ctx)) } pub fn service_names(&self) -> Vec { - self.table.keys().cloned().collect() + self.table.names() } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala new file mode 100644 index 000000000..4a333cf4f --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala @@ -0,0 +1,71 @@ +package baboon.runtime.shared + +import java.io.ByteArrayOutputStream + +object BaboonAnyBinCodec { + def encode( + ctx: BaboonCodecContext, + writer: LEDataOutputStream, + expectedKind: Byte, + staticDomain: Option[String], + staticVersion: Option[String], + staticTypeid: Option[String], + value: AnyOpaque, + ): Unit = { + if (value.meta.kind != expectedKind) { + throw BaboonCodecException.EncoderFailure( + s"any: meta-kind mismatch on encode: expected 0x${(expectedKind & 0xFF).toHexString}, got 0x${(value.meta.kind & 0xFF).toHexString}" + ) + } + val anyBytes: Array[Byte] = value match { + case anyUeba: AnyOpaqueUeba => + anyUeba.bytes + case anyJson: AnyOpaqueJson => + val f = ctx.facade.getOrElse( + throw BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." + ) + ) + f.jsonToUebaBytes(anyJson.meta, anyJson.json, staticDomain, staticVersion, staticTypeid) match { + case Right(b) => b + case Left(e) => throw e + } + } + val anyMetaBuf = new ByteArrayOutputStream() + val anyMetaWriter = new LEDataOutputStream(anyMetaBuf) + try { + AnyMetaCodec.writeBin(value.meta, anyMetaWriter) + } finally { + anyMetaWriter.close() + } + val anyMetaBytes = anyMetaBuf.toByteArray + val anyLength = 4 + anyMetaBytes.length + anyBytes.length + writer.writeInt(anyLength) + writer.writeInt(anyMetaBytes.length) + writer.write(anyMetaBytes) + writer.write(anyBytes) + } + + def decode(wire: LEDataInputStream, expectedKind: Byte): AnyOpaqueUeba = { + val anyLength = wire.readInt() + val anyMetaLen = wire.readInt() + val (anyMeta, anyMetaBytesRead) = AnyMetaCodec.readBinWithLength(wire) + if (anyMetaBytesRead > anyMetaLen) { + throw BaboonCodecException.DecoderFailure(s"any: meta consumed $anyMetaBytesRead bytes but meta-length=$anyMetaLen") + } + if (anyMetaBytesRead < anyMetaLen) { + // Skip future meta extension bytes within the meta-length window (forward-compat). + wire.skipBytes(anyMetaLen - anyMetaBytesRead) + } + if (anyMeta.kind != expectedKind) { + throw new RuntimeException(s"any: meta-kind mismatch: expected 0x${(expectedKind & 0xFF).toHexString}, got 0x${(anyMeta.kind & 0xFF).toHexString}") + } + val anyBlobLen = anyLength - 4 - anyMetaLen + if (anyBlobLen < 0) { + throw new RuntimeException(s"any: negative blob length $anyBlobLen (length=$anyLength, metaLen=$anyMetaLen)") + } + val anyBlob = new Array[Byte](anyBlobLen) + wire.readFully(anyBlob) + AnyOpaqueUeba(anyMeta, anyBlob) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala new file mode 100644 index 000000000..bb54c9402 --- /dev/null +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala @@ -0,0 +1,50 @@ +package baboon.runtime.shared + +import io.circe.Json + +object BaboonAnyJsonCodec { + def encode( + ctx: BaboonCodecContext, + expectedKind: Byte, + staticDomain: Option[String], + staticVersion: Option[String], + staticTypeid: Option[String], + value: AnyOpaque, + ): Json = { + if (value.meta.kind != expectedKind) { + throw BaboonCodecException.EncoderFailure( + s"any: meta-kind mismatch on encode: expected 0x${(expectedKind & 0xFF).toHexString}, got 0x${(value.meta.kind & 0xFF).toHexString}" + ) + } + val innerJson: Json = value match { + case anyJson: AnyOpaqueJson => + anyJson.json + case anyUeba: AnyOpaqueUeba => + val f = ctx.facade.getOrElse( + throw BaboonCodecException.EncoderFailure( + "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." + ) + ) + f.uebaToJson(anyUeba.meta, anyUeba.bytes, staticDomain, staticVersion, staticTypeid) match { + case Right(j) => j + case Left(e) => throw e + } + } + AnyMetaCodec.writeJson(value.meta).mapObject(_.add("$c", innerJson)) + } + + def decode(expectedKind: Byte, wire: Json): Either[Throwable, AnyOpaqueJson] = { + AnyMetaCodec.readJson(wire) match { + case Left(e) => Left(e) + case Right(meta) => + if (meta.kind != expectedKind) { + Left(BaboonCodecException.DecoderFailure(s"any: meta-kind mismatch: expected 0x${(expectedKind & 0xFF).toHexString}, got 0x${(meta.kind & 0xFF).toHexString}")) + } else { + wire.hcursor.downField("$c").as[Json] match { + case Right(content) => Right(AnyOpaqueJson(meta, content)) + case Left(err) => Left(BaboonCodecException.DecoderFailure(s"any: missing or unreadable content key: ${err.getMessage}")) + } + } + } + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala index b7d17a7f3..270459d06 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecs.scala @@ -47,7 +47,7 @@ package baboon.runtime.shared { trait BaboonCodecContext { def useIndices: Boolean def forwardWritePolicy: ForwardWritePolicy = ForwardWritePolicy.Strict - def envelopeVersion: BaboonEnvelopeVersion = BaboonEnvelopeVersion.V1 + def envelopeVersion: BaboonEnvelopeVersion = BaboonEnvelopeVersion.V1 // Optional facade reference, supplied only when an `any`-bearing codec needs to cross-convert // between UEBA and JSON branches (`AnyOpaqueJson` → UEBA, `AnyOpaqueUeba` → JSON). Defaults to // `None` so existing call sites stay compatible; users who need cross-convert pass @@ -223,29 +223,44 @@ package baboon.runtime.shared { def readIndex(ctx: BaboonCodecContext, wire: LEDataInputStream): Either[Throwable, List[BaboonIndexEntry]] = { Try { - val header = wire.readByte() - val isIndexed = (header & 0x01) != 0 - val result = scala.collection.mutable.ListBuffer.empty[BaboonIndexEntry] - var prevOffset: Long = 0L - var prevLen: Long = 0L - if (isIndexed) { - var left = indexElementsCount(ctx).toInt - while (left > 0) { - val offset = wire.readInt() - val len = wire.readInt() - - require(len > 0, "Length must be positive") - require(offset >= prevOffset + prevLen, s"Offset violation: $offset not >= ${prevOffset + prevLen}") - - result += BaboonIndexEntry(offset.toLong, len.toLong) - left = left - 1 - prevOffset = offset.toLong - prevLen = len.toLong - } - } + val result = scala.collection.mutable.ListBuffer.empty[BaboonIndexEntry] + readIndexInto(ctx, wire, Some(result)) result.toList }.toEither } + + def readIndexCount(ctx: BaboonCodecContext, wire: LEDataInputStream): Either[Throwable, Int] = + Try(readIndexInto(ctx, wire, None)).toEither + + private def readIndexInto( + ctx: BaboonCodecContext, + wire: LEDataInputStream, + entries: Option[scala.collection.mutable.ListBuffer[BaboonIndexEntry]], + ): Int = { + val header = wire.readByte() + val isIndexed = (header & 0x01) != 0 + var prevOffset: Long = 0L + var prevLen: Long = 0L + var count = 0 + if (isIndexed) { + var left = indexElementsCount(ctx).toInt + while (left > 0) { + val offset = wire.readInt() + val len = wire.readInt() + require(len > 0, "Length must be positive") + require(offset >= prevOffset + prevLen, s"Offset violation: $offset not >= ${prevOffset + prevLen}") + entries match { + case Some(result) => result += BaboonIndexEntry(offset.toLong, len.toLong) + case None => () + } + count += 1 + left -= 1 + prevOffset = offset.toLong + prevLen = len.toLong + } + } + count + } } trait AbstractBaboonCodecs { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonConversions.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonConversions.scala index d2a45558c..ad1264d80 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonConversions.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonConversions.scala @@ -23,22 +23,25 @@ package baboon.runtime.shared { ) extends BaboonGeneratedConversion { protected def doConvert[Ctx](context: Option[Ctx], conversions: AbstractBaboonConversions, from: From): To - private def validateBaboonType(obj: Any): Unit = { + protected def targetTypeId: String = typeId + + private def validateBaboonType(obj: Any, target: Boolean): Unit = { + def expectedTypeId: String = if (target) targetTypeId else typeId obj match { case bg: BaboonGenerated => - val conversionTypeIsExactType = typeId == bg.baboonTypeIdentifier + val conversionTypeIsExactType = expectedTypeId == bg.baboonTypeIdentifier bg match { case bga: BaboonAdtMemberMeta => - val conversionTypeIsAdtType = typeId == bga.baboonAdtTypeIdentifier + val conversionTypeIsAdtType = expectedTypeId == bga.baboonAdtTypeIdentifier if (!conversionTypeIsAdtType && !conversionTypeIsExactType) { throw new IllegalArgumentException( - s"Provided instance is adt=${bga.baboonAdtTypeIdentifier} exact=${bg.baboonTypeIdentifier}, one of which must be $typeId" + s"Provided instance is adt=${bga.baboonAdtTypeIdentifier} exact=${bg.baboonTypeIdentifier}, one of which must be $expectedTypeId" ) } case _ => if (!conversionTypeIsExactType) { throw new IllegalArgumentException( - s"Provided instance is ${bg.baboonTypeIdentifier} but must be $typeId" + s"Provided instance is ${bg.baboonTypeIdentifier} but must be $expectedTypeId" ) } } @@ -47,9 +50,9 @@ package baboon.runtime.shared { } def convert[Ctx](context: Option[Ctx], conversions: AbstractBaboonConversions, from: From): To = { - validateBaboonType(from) - val result = doConvert(context, conversions, from) - validateBaboonType(result) + validateBaboonType(from, target = false) + val result = doConvert(context, conversions, from) + validateBaboonType(result, target = true) result } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonMcpRuntime.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonMcpRuntime.scala index a025ebdcd..1116f8ab9 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonMcpRuntime.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonMcpRuntime.scala @@ -23,7 +23,7 @@ package baboon.runtime.shared { sealed trait JsonRpcId object JsonRpcId { final case class StringId(value: String) extends JsonRpcId - final case class LongId(value: Long) extends JsonRpcId + final case class LongId(value: Long) extends JsonRpcId } final case class JsonRpcRequest( @@ -37,7 +37,7 @@ package baboon.runtime.shared { final case class JsonRpcResponse( id: Option[JsonRpcId], result: Option[io.circe.Json] = None, - error: Option[JsonRpcError] = None, + error: Option[JsonRpcError] = None, ) // JSON-RPC / MCP protocol constants (wire contract K4). @@ -125,37 +125,30 @@ package baboon.runtime.shared { // All JSON-RPC method strings ("tools/list" …) and result keys ("protocolVersion", // "inputSchema" …) are literal lowercase strings, NOT subject to any per-language // symbol casing. - abstract class AbstractBaboonMcpServer[Ctx] extends IBaboonMcpServer[Ctx] with IBaboonRoutableMcpServer[Ctx] { - // PUBLIC routable-server surface (tasks:T114): the muxer reads `serverInfo` / - // `tools` and routes via `routeToolCall`, never via the private `byName()` - // and never via `handle`. + private trait McpDispatchOwner[Ctx] { def serverInfo: McpServerInfo def tools: Seq[McpToolEntry] - protected def invokeJson(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either[BaboonWiringError, String] - - // PUBLIC dispatch entry (tasks:T114): the same path `handle` drives for its - // own tools/call arm, exposed for the muxer to reuse Channel-A/B unchanged. - final def routeToolCall(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either[BaboonWiringError, String] = - invokeJson(method, data, ctx, codecCtx) - - private def byName(): Map[String, McpToolEntry] = - tools.map(t => t.name -> t).toMap + def lookup(name: String): Option[(McpToolEntry, IBaboonRoutableMcpServer[Ctx])] + def errorResponse(id: Option[JsonRpcId], code: Int, message: String): JsonRpcResponse + def describeWiringError(error: BaboonWiringError): String + } - override final def handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Option[JsonRpcResponse] = { + private object McpProtocolDispatch { + def handle[Ctx](owner: McpDispatchOwner[Ctx], request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Option[JsonRpcResponse] = { val id = request.id request.method match { case "initialize" => val pv = request.params.flatMap(_.hcursor.downField("protocolVersion").as[String].toOption) if (request.params.isEmpty || pv.isEmpty) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion")) + Some(owner.errorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion")) } else { session.initialized = true val result = io.circe.Json.obj( "protocolVersion" -> io.circe.Json.fromString(McpProtocol.Version), "capabilities" -> io.circe.Json.obj("tools" -> io.circe.Json.obj()), - "serverInfo" -> io.circe.Json.obj( - "name" -> io.circe.Json.fromString(serverInfo.name), - "version" -> io.circe.Json.fromString(serverInfo.version), + "serverInfo" -> io.circe.Json.obj( + "name" -> io.circe.Json.fromString(owner.serverInfo.name), + "version" -> io.circe.Json.fromString(owner.serverInfo.version), ), ) Some(JsonRpcResponse(id, result = Some(result))) @@ -166,9 +159,9 @@ package baboon.runtime.shared { case "tools/list" => if (!session.initialized) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize")) + Some(owner.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize")) } else { - val toolsArray = tools.map { + val toolsArray = owner.tools.map { t => val base = Seq( "name" -> io.circe.Json.fromString(t.name), @@ -183,30 +176,32 @@ package baboon.runtime.shared { case "tools/call" => if (!session.initialized) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize")) + Some(owner.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize")) } else { val nameOpt = request.params.flatMap(_.hcursor.downField("name").as[String].toOption) nameOpt match { case None => - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name")) + Some(owner.errorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name")) case Some(name) => - byName().get(name) match { + owner.lookup(name) match { case None => - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, s"tools/call: unknown tool '$name'")) - case Some(entry) => + Some(owner.errorResponse(id, JsonRpcErrorCodes.InvalidParams, s"tools/call: unknown tool '$name'")) + case Some((entry, server)) => val argsJson = request.params .flatMap(_.hcursor.downField("arguments").as[io.circe.Json].toOption) .getOrElse(io.circe.Json.obj()) .noSpaces - invokeJson(entry.method, argsJson, ctx, codecCtx) match { + server.routeToolCall(entry.method, argsJson, ctx, codecCtx) match { case Right(text) => val content = io.circe.Json.arr(io.circe.Json.obj("type" -> io.circe.Json.fromString("text"), "text" -> io.circe.Json.fromString(text))) val result = io.circe.Json.obj("content" -> content, "isError" -> io.circe.Json.fromBoolean(false)) Some(JsonRpcResponse(id, result = Some(result))) case Left(err) => // Channel B: a valid protocol call whose domain payload failed. - val content = io.circe.Json.arr(io.circe.Json.obj("type" -> io.circe.Json.fromString("text"), "text" -> io.circe.Json.fromString(describeWiringError(err)))) - val result = io.circe.Json.obj("content" -> content, "isError" -> io.circe.Json.fromBoolean(true)) + val content = io.circe.Json.arr( + io.circe.Json.obj("type" -> io.circe.Json.fromString("text"), "text" -> io.circe.Json.fromString(owner.describeWiringError(err))) + ) + val result = io.circe.Json.obj("content" -> content, "isError" -> io.circe.Json.fromBoolean(true)) Some(JsonRpcResponse(id, result = Some(result))) } } @@ -214,10 +209,42 @@ package baboon.runtime.shared { } case other => - Some(errorResponse(id, JsonRpcErrorCodes.MethodNotFound, s"Method not found: $other")) + Some(owner.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, s"Method not found: $other")) } } + } + + abstract class AbstractBaboonMcpServer[Ctx] extends IBaboonMcpServer[Ctx] with IBaboonRoutableMcpServer[Ctx] { + // PUBLIC routable-server surface (tasks:T114): the muxer reads `serverInfo` / + // `tools` and routes via `routeToolCall`, never via the private `byName()` + // and never via `handle`. + def serverInfo: McpServerInfo + def tools: Seq[McpToolEntry] + protected def invokeJson(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either[BaboonWiringError, String] + + // PUBLIC dispatch entry (tasks:T114): the same path `handle` drives for its + // own tools/call arm, exposed for the muxer to reuse Channel-A/B unchanged. + final def routeToolCall(method: BaboonMethodId, data: String, ctx: Ctx, codecCtx: BaboonCodecContext): Either[BaboonWiringError, String] = + invokeJson(method, data, ctx, codecCtx) + + private def byName(): Map[String, McpToolEntry] = + tools.map(t => t.name -> t).toMap + + private lazy val dispatchOwner = new McpDispatchOwner[Ctx] { + override def serverInfo: McpServerInfo = AbstractBaboonMcpServer.this.serverInfo + override def tools: Seq[McpToolEntry] = AbstractBaboonMcpServer.this.tools + override def lookup(name: String): Option[(McpToolEntry, IBaboonRoutableMcpServer[Ctx])] = + byName().get(name).map(entry => (entry, AbstractBaboonMcpServer.this)) + override def errorResponse(id: Option[JsonRpcId], code: Int, message: String): JsonRpcResponse = + AbstractBaboonMcpServer.this.errorResponse(id, code, message) + override def describeWiringError(error: BaboonWiringError): String = + AbstractBaboonMcpServer.this.describeWiringError(error) + } + + override final def handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Option[JsonRpcResponse] = + McpProtocolDispatch.handle(dispatchOwner, request, session, ctx, codecCtx) + protected def errorResponse(id: Option[JsonRpcId], code: Int, message: String): JsonRpcResponse = JsonRpcResponse(id, error = Some(JsonRpcError(code, message))) @@ -275,11 +302,11 @@ package baboon.runtime.shared { // `initialize` returns a single merged `serverInfo` supplied to the ctor. class AbstractMcpMuxer[Ctx]( mergedServerInfo: McpServerInfo, - initServers: IBaboonRoutableMcpServer[Ctx]*, + initServers: IBaboonRoutableMcpServer[Ctx]* ) extends IBaboonMcpServer[Ctx] { // Registration-order-preserving table: tool name -> owning server. // Built at registration time (contract §2), never per request. - private val route = scala.collection.mutable.LinkedHashMap.empty[String, IBaboonRoutableMcpServer[Ctx]] + private val route = scala.collection.mutable.LinkedHashMap.empty[String, IBaboonRoutableMcpServer[Ctx]] private val entries = scala.collection.mutable.LinkedHashMap.empty[String, McpToolEntry] initServers.foreach(register) @@ -298,92 +325,19 @@ package baboon.runtime.shared { } } - override final def handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Option[JsonRpcResponse] = { - val id = request.id - request.method match { - case "initialize" => - val pv = request.params.flatMap(_.hcursor.downField("protocolVersion").as[String].toOption) - if (request.params.isEmpty || pv.isEmpty) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion")) - } else { - session.initialized = true - val result = io.circe.Json.obj( - "protocolVersion" -> io.circe.Json.fromString(McpProtocol.Version), - "capabilities" -> io.circe.Json.obj("tools" -> io.circe.Json.obj()), - "serverInfo" -> io.circe.Json.obj( - "name" -> io.circe.Json.fromString(mergedServerInfo.name), - "version" -> io.circe.Json.fromString(mergedServerInfo.version), - ), - ) - Some(JsonRpcResponse(id, result = Some(result))) - } - - case "notifications/initialized" => - None - - case "tools/list" => - if (!session.initialized) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize")) - } else { - val toolsArray = toolsListUnion() - val result = io.circe.Json.obj("tools" -> io.circe.Json.arr(toolsArray: _*)) - Some(JsonRpcResponse(id, result = Some(result))) - } - - case "tools/call" => - if (!session.initialized) { - Some(errorResponse(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize")) - } else { - val nameOpt = request.params.flatMap(_.hcursor.downField("name").as[String].toOption) - nameOpt match { - case None => - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name")) - case Some(name) => - route.get(name) match { - case None => - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), - // so the bytes are identical whether one server or the muxer rejects. - Some(errorResponse(id, JsonRpcErrorCodes.InvalidParams, s"tools/call: unknown tool '$name'")) - case Some(server) => - val entry = entries(name) - val argsJson = request.params - .flatMap(_.hcursor.downField("arguments").as[io.circe.Json].toOption) - .getOrElse(io.circe.Json.obj()) - .noSpaces - server.routeToolCall(entry.method, argsJson, ctx, codecCtx) match { - case Right(text) => - val content = io.circe.Json.arr(io.circe.Json.obj("type" -> io.circe.Json.fromString("text"), "text" -> io.circe.Json.fromString(text))) - val result = io.circe.Json.obj("content" -> content, "isError" -> io.circe.Json.fromBoolean(false)) - Some(JsonRpcResponse(id, result = Some(result))) - case Left(err) => - // Channel B: a valid protocol call whose domain payload failed. - val content = io.circe.Json.arr(io.circe.Json.obj("type" -> io.circe.Json.fromString("text"), "text" -> io.circe.Json.fromString(describeWiringError(err)))) - val result = io.circe.Json.obj("content" -> content, "isError" -> io.circe.Json.fromBoolean(true)) - Some(JsonRpcResponse(id, result = Some(result))) - } - } - } - } - - case other => - Some(errorResponse(id, JsonRpcErrorCodes.MethodNotFound, s"Method not found: $other")) - } + private lazy val dispatchOwner = new McpDispatchOwner[Ctx] { + override def serverInfo: McpServerInfo = mergedServerInfo + override def tools: Seq[McpToolEntry] = entries.values.toSeq + override def lookup(name: String): Option[(McpToolEntry, IBaboonRoutableMcpServer[Ctx])] = + route.get(name).map(server => (entries(name), server)) + override def errorResponse(id: Option[JsonRpcId], code: Int, message: String): JsonRpcResponse = + AbstractMcpMuxer.this.errorResponse(id, code, message) + override def describeWiringError(error: BaboonWiringError): String = + AbstractMcpMuxer.this.describeWiringError(error) } - // Backs tools/list (§3.2): the union of all registered servers' tool entries - // in registration-then-declaration order (the insertion order of `entries`), - // each in the same shape the per-service base emits. - private def toolsListUnion(): Seq[io.circe.Json] = - entries.values.map { - t => - val base = Seq( - "name" -> io.circe.Json.fromString(t.name), - "inputSchema" -> t.inputSchema, - ) - val withDesc = t.description.fold(base)(d => base :+ ("description" -> io.circe.Json.fromString(d))) - io.circe.Json.obj(withDesc: _*) - }.toSeq + override final def handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Option[JsonRpcResponse] = + McpProtocolDispatch.handle(dispatchOwner, request, session, ctx, codecCtx) protected def errorResponse(id: Option[JsonRpcId], code: Int, message: String): JsonRpcResponse = JsonRpcResponse(id, error = Some(JsonRpcError(code, message))) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonIdentifierRepr.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonIdentifierRepr.swift index f8afea078..95f55c12f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonIdentifierRepr.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonIdentifierRepr.swift @@ -216,15 +216,12 @@ public enum BaboonIdentifierRepr { /// /// Reference type so the emitted decoder can mutate `pos` in place across /// nested-id recursive calls. Stores source as `[UInt8]` (ASCII metachars - /// are byte-comparable) but reads back as Swift `String` slices via the - /// retained `String` for non-ASCII passthrough. + /// are byte-comparable) and decodes only consumed slices for non-ASCII text. public final class Cursor { - private let source: String private let bytes: [UInt8] private(set) public var pos: Int = 0 public init(_ source: String) { - self.source = source self.bytes = Array(source.utf8) } @@ -285,22 +282,20 @@ public enum BaboonIdentifierRepr { /// lexeme. Used for tsu/tso (which contain `:` characters inside the /// lexeme). public func readFixed(_ n: Int) -> BaboonEither { - let remaining = String(bytes: bytes[pos.. Any { + if value.meta.kind != expectedKind { + preconditionFailure( + "any: meta-kind 0x\(String(format: "%02x", value.meta.kind & 0xFF)) " + + "does not match field-declared 0x\(String(format: "%02x", expectedKind & 0xFF))" + ) + } + let anyInner: Any? + switch value { + case .json(_, let jsonValue): + anyInner = jsonValue + case .ueba(let uebaMeta, let uebaBytes): + guard let anyFacadeBase = ctx.facade else { + preconditionFailure( + "Cannot encode AnyOpaque.ueba into JSON without a facade reference. " + + "Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), " + + "or supply AnyOpaque.json directly." + ) + } + // Downcast to the concrete facade — the marker base is empty by design (PR 9.1 + // import-cycle break). Construction goes through BaboonCodecContext.withFacade + // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. + guard let anyFacade = anyFacadeBase as? BaboonCodecsFacade else { + preconditionFailure( + "BaboonCodecContext.facade is not a BaboonCodecsFacade: " + + "\(type(of: anyFacadeBase))" + ) + } + let anyConvResult = anyFacade.uebaToJson( + uebaMeta, + uebaBytes, + staticDomain: staticDomain, + staticVersion: staticVersion, + staticTypeid: staticTypeid + ) + switch anyConvResult { + case .failure(let err): + preconditionFailure("any: uebaToJson failed: \(err)") + case .success(let json): + anyInner = json + } + } + var anyEnvelope = AnyMetaCodec.writeJson(value.meta) + // Swift `[String: Any]` treats `dict[k] = nil` as "remove key" — use NSNull() so the + // content envelope key is preserved when the inner JSON is null (PR-08-D06 analog: + // keep wire-shape consistent with Java/Dart/TS regardless of payload-null state). + anyEnvelope[AnyMetaCodec.ANY_CONTENT_KEY] = anyInner ?? NSNull() + return anyEnvelope + } + + public static func decodeAnyField(_ expectedKind: UInt8, _ wire: Any) throws -> AnyOpaque { + guard let anyEnvelope = wire as? [String: Any] else { + throw BaboonCodecException.decoderFailure( + "any: JSON envelope must be an object", + nil + ) + } + let anyMetaResult = AnyMetaCodec.readJson(anyEnvelope) + let anyMeta: AnyMeta + switch anyMetaResult { + case .failure(let err): + throw err + case .success(let m): + anyMeta = m + } + if anyMeta.kind != expectedKind { + throw BaboonCodecException.decoderFailure( + "any: wire kind 0x\(String(format: "%02x", anyMeta.kind & 0xFF)) " + + "does not match field-declared 0x\(String(format: "%02x", expectedKind & 0xFF))", + nil + ) + } + guard anyEnvelope.keys.contains(AnyMetaCodec.ANY_CONTENT_KEY) else { + throw BaboonCodecException.decoderFailure( + "any: JSON envelope missing '\(AnyMetaCodec.ANY_CONTENT_KEY)' content key", + nil + ) + } + // `keys.contains(...)` confirmed presence above; the value can still be NSNull() for an + // explicit JSON null payload — pass through to the AnyOpaque.json variant. Swift + // dictionary lookup returns `Any?` (None means absent — already excluded), so the + // force-unwrap is safe inside the contains-guard. + let anyContent = anyEnvelope[AnyMetaCodec.ANY_CONTENT_KEY]! + return .json(meta: anyMeta, json: anyContent) + } +} + +public enum BaboonAnyUebaFieldCodec { + public static func encodeAnyField( + _ ctx: BaboonCodecContext, + _ writer: BaboonBinWriter, + _ expectedKind: UInt8, + _ staticDomain: String?, + _ staticVersion: String?, + _ staticTypeid: String?, + _ value: AnyOpaque + ) { + if value.meta.kind != expectedKind { + preconditionFailure( + "any: meta-kind 0x\(String(format: "%02x", value.meta.kind & 0xFF)) " + + "does not match field-declared 0x\(String(format: "%02x", expectedKind & 0xFF))" + ) + } + let anyBlob: Data + switch value { + case .ueba(_, let bytes): + anyBlob = bytes + case .json(let jsonMeta, let jsonValue): + guard let anyFacadeBase = ctx.facade else { + preconditionFailure( + "Cannot encode AnyOpaque.json into UEBA without a facade reference. " + + "Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), " + + "or supply AnyOpaque.ueba directly." + ) + } + // Downcast to the concrete facade — the marker base is empty by design (PR 9.1 + // import-cycle break). Construction goes through BaboonCodecContext.withFacade + // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. + guard let anyFacade = anyFacadeBase as? BaboonCodecsFacade else { + preconditionFailure( + "BaboonCodecContext.facade is not a BaboonCodecsFacade: " + + "\(type(of: anyFacadeBase))" + ) + } + let anyConvResult = anyFacade.jsonToUebaBytes( + jsonMeta, + jsonValue, + staticDomain: staticDomain, + staticVersion: staticVersion, + staticTypeid: staticTypeid + ) + switch anyConvResult { + case .failure(let err): + preconditionFailure("any: jsonToUebaBytes failed: \(err)") + case .success(let bytes): + anyBlob = bytes + } + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + let anyMetaBuf = BaboonBinWriter() + AnyMetaCodec.writeBin(value.meta, anyMetaBuf) + let anyMetaBytes = anyMetaBuf.toData() + let anyTotalLength = Int32(4 + anyMetaBytes.count + anyBlob.count) + writer.writeI32(anyTotalLength) + writer.writeI32(Int32(anyMetaBytes.count)) + writer.writeAll(anyMetaBytes) + writer.writeAll(anyBlob) + } + + public static func decodeAnyField(_ wire: BaboonBinReader, _ expectedKind: UInt8) throws -> AnyOpaque { + let anyTotalLength = wire.readI32() + if anyTotalLength < 0 { + throw BaboonCodecException.decoderFailure( + "any: negative total-length \(anyTotalLength)", + nil + ) + } + let anyMetaLength = wire.readI32() + if anyMetaLength < 0 { + throw BaboonCodecException.decoderFailure( + "any: negative meta-length \(anyMetaLength)", + nil + ) + } + if anyTotalLength < 4 + anyMetaLength { + throw BaboonCodecException.decoderFailure( + "any: total-length \(anyTotalLength) smaller than 4 + meta-length \(anyMetaLength)", + nil + ) + } + let (anyMeta, anyBytesRead) = try AnyMetaCodec.readBinWithLength(wire) + if anyBytesRead > Int(anyMetaLength) { + throw BaboonCodecException.decoderFailure( + "any: meta bytes-read \(anyBytesRead) exceeded meta-length window \(anyMetaLength)", + nil + ) + } + if anyBytesRead < Int(anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + wire.skipBytes(Int(anyMetaLength) - anyBytesRead) + } + if anyMeta.kind != expectedKind { + throw BaboonCodecException.decoderFailure( + "any: wire kind 0x\(String(format: "%02x", anyMeta.kind & 0xFF)) " + + "does not match field-declared 0x\(String(format: "%02x", expectedKind & 0xFF))", + nil + ) + } + let anyBlobLen = Int(anyTotalLength) - 4 - Int(anyMetaLength) + let anyBlob = wire.readNBytes(anyBlobLen) + return .ueba(meta: anyMeta, bytes: anyBlob) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_mcp_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_mcp_runtime.swift index 3fc066214..4c9d761a0 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_mcp_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_mcp_runtime.swift @@ -109,6 +109,86 @@ public struct McpServerInfo { } } + +private enum PreparedMcpCall { + case immediate(JsonRpcResponse?) + case invoke(Route, String) +} + +private enum McpProtocolDispatch { + static func tool(_ tool: McpToolEntry) -> [String: Any] { + var entry: [String: Any] = ["name": tool.name, "inputSchema": tool.inputSchema] + if let description = tool.description { entry["description"] = description } + return entry + } + + static func error(_ id: Any?, _ code: Int, _ message: String) -> JsonRpcResponse { + JsonRpcResponse(id, error: JsonRpcError(code, message)) + } + + static func result(_ id: Any?, _ text: String, isError: Bool) -> JsonRpcResponse { + JsonRpcResponse(id, result: ["content": [["type": "text", "text": text]], "isError": isError]) + } + + static func failedCall(_ id: Any?, _ error: Error) -> JsonRpcResponse { + let text: String + if let wiring = error as? BaboonWiringException { text = "\(wiring.error)" } + else { text = "\(error)" } + return result(id, text, isError: true) + } + + static func prepare( + _ request: JsonRpcRequest, _ session: McpSession, + info: () -> McpServerInfo, tools: () -> [[String: Any]], find: (String) -> Route? + ) -> PreparedMcpCall { + let id = request.id + switch request.method { + case "initialize": + guard let params = request.params as? [String: Any] else { + return .immediate(error(id, jsonRpcErrorInvalidParams, "initialize: missing params")) + } + if params["protocolVersion"] == nil { + return .immediate(error(id, jsonRpcErrorInvalidParams, "initialize: missing protocolVersion")) + } + session.initialized = true + return .immediate(JsonRpcResponse(id, result: [ + "protocolVersion": mcpProtocolVersion, + "capabilities": ["tools": [String: Any]()], + "serverInfo": ["name": info().name, "version": info().version], + ])) + case "notifications/initialized": + return .immediate(nil) + case "tools/list": + if !session.initialized { + return .immediate(error(id, jsonRpcErrorInvalidRequest, "tools/list before initialize")) + } + return .immediate(JsonRpcResponse(id, result: ["tools": tools()])) + case "tools/call": + if !session.initialized { + return .immediate(error(id, jsonRpcErrorInvalidRequest, "tools/call before initialize")) + } + guard let params = request.params as? [String: Any] else { + return .immediate(error(id, jsonRpcErrorInvalidParams, "tools/call: missing params")) + } + guard let name = params["name"] as? String else { + return .immediate(error(id, jsonRpcErrorInvalidParams, "tools/call: missing tool name")) + } + guard let route = find(name) else { + return .immediate(error(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '\(name)'")) + } + let args: Any = params["arguments"] ?? [String: Any]() + do { + let data = try JSONSerialization.data(withJSONObject: args, options: [.sortedKeys, .fragmentsAllowed]) + return .invoke(route, String(data: data, encoding: .utf8)!) + } catch { + return .immediate(self.error(id, jsonRpcErrorInternalError, "tools/call: failed to serialize arguments: \(error)")) + } + default: + return .immediate(error(id, jsonRpcErrorMethodNotFound, "Method not found: \(request.method)")) + } + } +} + // --- Dispatch interface --- // // The single generated entrypoint, analogous to `IBaboonJsonServiceCtx`. It is @@ -128,6 +208,7 @@ public protocol IBaboonMcpServer { associatedtype Ctx var serverInfo: McpServerInfo { get } var tools: [McpToolEntry] { get } + func tool(named name: String) -> McpToolEntry? // The JSON `tools/call` delegate the generated server supplies: routes one // tool invocation into the already-generated service dispatch (the @@ -148,10 +229,8 @@ public protocol IBaboonMcpServer { // keys ("protocolVersion", "inputSchema" …) are literal lowercase strings, NOT // subject to any per-language symbol casing. extension IBaboonMcpServer { - private func byName() -> [String: McpToolEntry] { - var m: [String: McpToolEntry] = [:] - for t in tools { m[t.name] = t } - return m + public func tool(named name: String) -> McpToolEntry? { + tools.last { $0.name == name } } // --- PUBLIC routable-server surface (tasks:T114) --- @@ -161,106 +240,25 @@ extension IBaboonMcpServer { // `handle` drives for its own `tools/call` arm (`invokeJson`). The // cross-service MCP muxer (AbstractMcpMuxer) composes registered servers // through `serverInfo`/`tools`/`routeToolCall` ONLY — never via the private - // `byName()` and never via `handle`. It reuses Channel-A/Channel-B mapping + // tool lookup and never via `handle`. It reuses Channel-A/Channel-B mapping // unchanged. public func routeToolCall(_ method: BaboonMethodId, _ data: String, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) throws -> String { return try invokeJson(method, data, ctx, codecCtx) } - private func errorResponse(_ id: Any?, _ code: Int, _ message: String) -> JsonRpcResponse { - return JsonRpcResponse(id, error: JsonRpcError(code, message)) - } - public func handle(_ request: JsonRpcRequest, _ session: McpSession, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) -> JsonRpcResponse? { - let id = request.id - switch request.method { - case "initialize": - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing params") - } - if params["protocolVersion"] == nil { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing protocolVersion") - } - session.initialized = true - let result: [String: Any] = [ - "protocolVersion": mcpProtocolVersion, - "capabilities": ["tools": [String: Any]()], - "serverInfo": ["name": serverInfo.name, "version": serverInfo.version], - ] - return JsonRpcResponse(id, result: result) - - case "notifications/initialized": - return nil - - case "tools/list": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/list before initialize") - } - var toolsArr: [[String: Any]] = [] - for t in tools { - var entry: [String: Any] = [ - "name": t.name, - "inputSchema": t.inputSchema, - ] - if let d = t.description { - entry["description"] = d - } - toolsArr.append(entry) - } - return JsonRpcResponse(id, result: ["tools": toolsArr]) - - case "tools/call": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/call before initialize") - } - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing params") - } - guard let toolName = params["name"] as? String else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing tool name") - } - guard let entry = byName()[toolName] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '\(toolName)'") - } - let argsRaw: Any = params["arguments"] ?? [String: Any]() - let argsJson: String + let prepared: PreparedMcpCall = McpProtocolDispatch.prepare(request, session, info: { self.serverInfo }, tools: { self.tools.map(McpProtocolDispatch.tool) }, find: { self.tool(named: $0) }) + switch prepared { + case .immediate(let response): return response + case .invoke(let route, let args): do { - let data = try JSONSerialization.data(withJSONObject: argsRaw, options: [.sortedKeys, .fragmentsAllowed]) - argsJson = String(data: data, encoding: .utf8)! + let text = try invokeJson(route.method, args, ctx, codecCtx) + return McpProtocolDispatch.result(request.id, text, isError: false) } catch { - return errorResponse(id, jsonRpcErrorInternalError, "tools/call: failed to serialize arguments: \(error)") + return McpProtocolDispatch.failedCall(request.id, error) } - do { - let resultStr = try invokeJson(entry.method, argsJson, ctx, codecCtx) - let result: [String: Any] = [ - "content": [["type": "text", "text": resultStr]], - "isError": false, - ] - return JsonRpcResponse(id, result: result) - } catch let e as BaboonWiringException { - // Channel B: a valid protocol call whose domain payload failed. - let result: [String: Any] = [ - "content": [["type": "text", "text": describeWiringError(e.error)]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } catch { - // Channel B: unexpected error during dispatch. - let result: [String: Any] = [ - "content": [["type": "text", "text": "\(error)"]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } - - default: - return errorResponse(id, jsonRpcErrorMethodNotFound, "Method not found: \(request.method)") } } - - private func describeWiringError(_ e: BaboonWiringError) -> String { - return "\(e)" - } } // --- Type-erasing box --- @@ -308,13 +306,12 @@ public struct AnyMcpServer { // suspension points) and identical to the sync state machine; only the // `invokeJson` call is awaited. // -// The sync `IBaboonMcpServer` protocol + extension above are UNTOUCHED, so with -// `--sw-async-services=false` the generated output is byte-identical to baseline. // =========================================================================== public protocol IBaboonAsyncMcpServer { associatedtype Ctx var serverInfo: McpServerInfo { get } var tools: [McpToolEntry] { get } + func tool(named name: String) -> McpToolEntry? // The async `tools/call` delegate the generated server supplies: routes one // tool invocation into the generated async service dispatch (the @@ -326,10 +323,8 @@ public protocol IBaboonAsyncMcpServer { } extension IBaboonAsyncMcpServer { - private func byName() -> [String: McpToolEntry] { - var m: [String: McpToolEntry] = [:] - for t in tools { m[t.name] = t } - return m + public func tool(named name: String) -> McpToolEntry? { + tools.last { $0.name == name } } // PUBLIC routable-server surface (tasks:T114), async flavour: `routeToolCall` @@ -338,100 +333,19 @@ extension IBaboonAsyncMcpServer { return try await invokeJson(method, data, ctx, codecCtx) } - private func errorResponse(_ id: Any?, _ code: Int, _ message: String) -> JsonRpcResponse { - return JsonRpcResponse(id, error: JsonRpcError(code, message)) - } - public func handle(_ request: JsonRpcRequest, _ session: McpSession, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) async -> JsonRpcResponse? { - let id = request.id - switch request.method { - case "initialize": - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing params") - } - if params["protocolVersion"] == nil { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing protocolVersion") - } - session.initialized = true - let result: [String: Any] = [ - "protocolVersion": mcpProtocolVersion, - "capabilities": ["tools": [String: Any]()], - "serverInfo": ["name": serverInfo.name, "version": serverInfo.version], - ] - return JsonRpcResponse(id, result: result) - - case "notifications/initialized": - return nil - - case "tools/list": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/list before initialize") - } - var toolsArr: [[String: Any]] = [] - for t in tools { - var entry: [String: Any] = [ - "name": t.name, - "inputSchema": t.inputSchema, - ] - if let d = t.description { - entry["description"] = d - } - toolsArr.append(entry) - } - return JsonRpcResponse(id, result: ["tools": toolsArr]) - - case "tools/call": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/call before initialize") - } - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing params") - } - guard let toolName = params["name"] as? String else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing tool name") - } - guard let entry = byName()[toolName] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '\(toolName)'") - } - let argsRaw: Any = params["arguments"] ?? [String: Any]() - let argsJson: String + let prepared: PreparedMcpCall = McpProtocolDispatch.prepare(request, session, info: { self.serverInfo }, tools: { self.tools.map(McpProtocolDispatch.tool) }, find: { self.tool(named: $0) }) + switch prepared { + case .immediate(let response): return response + case .invoke(let route, let args): do { - let data = try JSONSerialization.data(withJSONObject: argsRaw, options: [.sortedKeys, .fragmentsAllowed]) - argsJson = String(data: data, encoding: .utf8)! + let text = try await invokeJson(route.method, args, ctx, codecCtx) + return McpProtocolDispatch.result(request.id, text, isError: false) } catch { - return errorResponse(id, jsonRpcErrorInternalError, "tools/call: failed to serialize arguments: \(error)") + return McpProtocolDispatch.failedCall(request.id, error) } - do { - let resultStr = try await invokeJson(entry.method, argsJson, ctx, codecCtx) - let result: [String: Any] = [ - "content": [["type": "text", "text": resultStr]], - "isError": false, - ] - return JsonRpcResponse(id, result: result) - } catch let e as BaboonWiringException { - // Channel B: a valid protocol call whose domain payload failed. - let result: [String: Any] = [ - "content": [["type": "text", "text": describeWiringError(e.error)]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } catch { - // Channel B: unexpected error during dispatch. - let result: [String: Any] = [ - "content": [["type": "text", "text": "\(error)"]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } - - default: - return errorResponse(id, jsonRpcErrorMethodNotFound, "Method not found: \(request.method)") } } - - private func describeWiringError(_ e: BaboonWiringError) -> String { - return "\(e)" - } } // Type-erasing box for the async dispatch surface, the async analogue of @@ -547,10 +461,13 @@ public final class AbstractMcpMuxer { // duplicateTool on a tool-name collision across servers (the exact MCP-tier // analogue of JsonMuxer.register throwing duplicateService). public func register(_ server: AnyRoutableMcpServer) throws { + var incoming = Set() for t in server.tools { - if _route[t.name] != nil { + if _route[t.name] != nil || !incoming.insert(t.name).inserted { throw BaboonMcpWiringException(BaboonMcpWiringError.duplicateTool(t.name)) } + } + for t in server.tools { _route[t.name] = server _entries[t.name] = t _toolOrder.append(t.name) @@ -558,111 +475,29 @@ public final class AbstractMcpMuxer { _servers.append(server) } - private func errorResponse(_ id: Any?, _ code: Int, _ message: String) -> JsonRpcResponse { - return JsonRpcResponse(id, error: JsonRpcError(code, message)) - } - public func handle(_ request: JsonRpcRequest, _ session: McpSession, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) -> JsonRpcResponse? { - let id = request.id - switch request.method { - case "initialize": - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing params") - } - if params["protocolVersion"] == nil { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing protocolVersion") - } - session.initialized = true - let result: [String: Any] = [ - "protocolVersion": mcpProtocolVersion, - "capabilities": ["tools": [String: Any]()], - "serverInfo": ["name": _mergedServerInfo.name, "version": _mergedServerInfo.version], - ] - return JsonRpcResponse(id, result: result) - - case "notifications/initialized": - return nil - - case "tools/list": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/list before initialize") - } - return JsonRpcResponse(id, result: ["tools": toolsListUnion()]) - - case "tools/call": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/call before initialize") - } - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing params") - } - guard let toolName = params["name"] as? String else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing tool name") - } - guard let server = _route[toolName], let entry = _entries[toolName] else { - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), - // so the bytes are identical whether one server or the muxer rejects. - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '\(toolName)'") - } - let argsRaw: Any = params["arguments"] ?? [String: Any]() - let argsJson: String + let prepared: PreparedMcpCall<(AnyRoutableMcpServer, McpToolEntry)> = McpProtocolDispatch.prepare(request, session, info: { self._mergedServerInfo }, tools: { self.toolsListUnion() }, find: { name in + guard let server = self._route[name], let entry = self._entries[name] else { return nil } + return (server, entry) + }) + switch prepared { + case .immediate(let response): return response + case .invoke(let route, let args): do { - let data = try JSONSerialization.data(withJSONObject: argsRaw, options: [.sortedKeys, .fragmentsAllowed]) - argsJson = String(data: data, encoding: .utf8)! + let text = try route.0.routeToolCall(route.1.method, args, ctx, codecCtx) + return McpProtocolDispatch.result(request.id, text, isError: false) } catch { - return errorResponse(id, jsonRpcErrorInternalError, "tools/call: failed to serialize arguments: \(error)") + return McpProtocolDispatch.failedCall(request.id, error) } - do { - let resultStr = try server.routeToolCall(entry.method, argsJson, ctx, codecCtx) - let result: [String: Any] = [ - "content": [["type": "text", "text": resultStr]], - "isError": false, - ] - return JsonRpcResponse(id, result: result) - } catch let e as BaboonWiringException { - // Channel B: a valid protocol call whose domain payload failed. - let result: [String: Any] = [ - "content": [["type": "text", "text": describeWiringError(e.error)]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } catch { - // Channel B: unexpected error during dispatch. - let result: [String: Any] = [ - "content": [["type": "text", "text": "\(error)"]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) - } - - default: - return errorResponse(id, jsonRpcErrorMethodNotFound, "Method not found: \(request.method)") } } - // Backs tools/list (§3.2): the union of all registered servers' tool entries // in registration-then-declaration order (the insertion order of `_toolOrder`), // each in the same shape the per-service base emits. private func toolsListUnion() -> [[String: Any]] { - var toolsArr: [[String: Any]] = [] - for name in _toolOrder { - guard let t = _entries[name] else { continue } - var entry: [String: Any] = [ - "name": t.name, - "inputSchema": t.inputSchema, - ] - if let d = t.description { - entry["description"] = d - } - toolsArr.append(entry) - } - return toolsArr + _toolOrder.compactMap { _entries[$0] }.map(McpProtocolDispatch.tool) } - private func describeWiringError(_ e: BaboonWiringError) -> String { - return "\(e)" - } } // --- Type-erasing routable-server box (async) --- @@ -715,10 +550,13 @@ public final class AbstractAsyncMcpMuxer { } public func register(_ server: AnyAsyncRoutableMcpServer) throws { + var incoming = Set() for t in server.tools { - if _route[t.name] != nil { + if _route[t.name] != nil || !incoming.insert(t.name).inserted { throw BaboonMcpWiringException(BaboonMcpWiringError.duplicateTool(t.name)) } + } + for t in server.tools { _route[t.name] = server _entries[t.name] = t _toolOrder.append(t.name) @@ -726,101 +564,24 @@ public final class AbstractAsyncMcpMuxer { _servers.append(server) } - private func errorResponse(_ id: Any?, _ code: Int, _ message: String) -> JsonRpcResponse { - return JsonRpcResponse(id, error: JsonRpcError(code, message)) - } - public func handle(_ request: JsonRpcRequest, _ session: McpSession, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) async -> JsonRpcResponse? { - let id = request.id - switch request.method { - case "initialize": - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing params") - } - if params["protocolVersion"] == nil { - return errorResponse(id, jsonRpcErrorInvalidParams, "initialize: missing protocolVersion") - } - session.initialized = true - let result: [String: Any] = [ - "protocolVersion": mcpProtocolVersion, - "capabilities": ["tools": [String: Any]()], - "serverInfo": ["name": _mergedServerInfo.name, "version": _mergedServerInfo.version], - ] - return JsonRpcResponse(id, result: result) - - case "notifications/initialized": - return nil - - case "tools/list": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/list before initialize") - } - return JsonRpcResponse(id, result: ["tools": toolsListUnion()]) - - case "tools/call": - if !session.initialized { - return errorResponse(id, jsonRpcErrorInvalidRequest, "tools/call before initialize") - } - guard let params = request.params as? [String: Any] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing params") - } - guard let toolName = params["name"] as? String else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: missing tool name") - } - guard let server = _route[toolName], let entry = _entries[toolName] else { - return errorResponse(id, jsonRpcErrorInvalidParams, "tools/call: unknown tool '\(toolName)'") - } - let argsRaw: Any = params["arguments"] ?? [String: Any]() - let argsJson: String - do { - let data = try JSONSerialization.data(withJSONObject: argsRaw, options: [.sortedKeys, .fragmentsAllowed]) - argsJson = String(data: data, encoding: .utf8)! - } catch { - return errorResponse(id, jsonRpcErrorInternalError, "tools/call: failed to serialize arguments: \(error)") - } + let prepared: PreparedMcpCall<(AnyAsyncRoutableMcpServer, McpToolEntry)> = McpProtocolDispatch.prepare(request, session, info: { self._mergedServerInfo }, tools: { self.toolsListUnion() }, find: { name in + guard let server = self._route[name], let entry = self._entries[name] else { return nil } + return (server, entry) + }) + switch prepared { + case .immediate(let response): return response + case .invoke(let route, let args): do { - let resultStr = try await server.routeToolCall(entry.method, argsJson, ctx, codecCtx) - let result: [String: Any] = [ - "content": [["type": "text", "text": resultStr]], - "isError": false, - ] - return JsonRpcResponse(id, result: result) - } catch let e as BaboonWiringException { - let result: [String: Any] = [ - "content": [["type": "text", "text": describeWiringError(e.error)]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) + let text = try await route.0.routeToolCall(route.1.method, args, ctx, codecCtx) + return McpProtocolDispatch.result(request.id, text, isError: false) } catch { - let result: [String: Any] = [ - "content": [["type": "text", "text": "\(error)"]], - "isError": true, - ] - return JsonRpcResponse(id, result: result) + return McpProtocolDispatch.failedCall(request.id, error) } - - default: - return errorResponse(id, jsonRpcErrorMethodNotFound, "Method not found: \(request.method)") } } - private func toolsListUnion() -> [[String: Any]] { - var toolsArr: [[String: Any]] = [] - for name in _toolOrder { - guard let t = _entries[name] else { continue } - var entry: [String: Any] = [ - "name": t.name, - "inputSchema": t.inputSchema, - ] - if let d = t.description { - entry["description"] = d - } - toolsArr.append(entry) - } - return toolsArr + _toolOrder.compactMap { _entries[$0] }.map(McpProtocolDispatch.tool) } - private func describeWiringError(_ e: BaboonWiringError) -> String { - return "\(e)" - } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 7cf81586a..9b0e10d99 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -282,6 +282,17 @@ public struct BaboonIndexEntry { } extension BaboonBinCodecIndexed { + public func consumeIndex(_ ctx: BaboonCodecContext, _ reader: BaboonBinReader) throws -> Int { + let header = reader.readU8() + if header & 1 == 0 { return 0 } + let count = indexElementsCount + for _ in 0.. [BaboonIndexEntry] { let header = reader.readU8() let hasIndex = (header & 1) != 0 @@ -477,38 +488,12 @@ public class BaboonBinWriter { } public func writeUuid(_ uuid: UUID) { - let hex = uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased() - assert(hex.count == 32) - - let hexBytes = Array(hex.utf8) - func parseByte(_ offset: Int) -> UInt8 { - func hexVal(_ c: UInt8) -> UInt8 { - if c >= 48 && c <= 57 { return c - 48 } - if c >= 97 && c <= 102 { return c - 87 } - if c >= 65 && c <= 70 { return c - 55 } - fatalError("Invalid hex char") - } - return hexVal(hexBytes[offset]) << 4 | hexVal(hexBytes[offset + 1]) - } - + let bytes = uuid.uuid // .NET mixed-endian GUID format - var bytes = [UInt8](repeating: 0, count: 16) - // First 4 bytes: little-endian - bytes[0] = parseByte(6) - bytes[1] = parseByte(4) - bytes[2] = parseByte(2) - bytes[3] = parseByte(0) - // Next 2 bytes: little-endian - bytes[4] = parseByte(10) - bytes[5] = parseByte(8) - // Next 2 bytes: little-endian - bytes[6] = parseByte(14) - bytes[7] = parseByte(12) - // Remaining 8 bytes: big-endian - for i in 0..<8 { - bytes[8 + i] = parseByte(16 + i * 2) - } - writeAll(Data(bytes)) + writeAll(Data([ + bytes.3, bytes.2, bytes.1, bytes.0, bytes.5, bytes.4, bytes.7, bytes.6, + bytes.8, bytes.9, bytes.10, bytes.11, bytes.12, bytes.13, bytes.14, bytes.15, + ])) } public func writeTsu(_ value: Date) { @@ -743,33 +728,15 @@ public class BaboonBinReader { guard pos + 16 <= data.count else { throw BaboonCodecError.truncated("readUuid: need 16 bytes at pos \(pos), only \(data.count - pos) available") } - var bytes = [UInt8](repeating: 0, count: 16) - for i in 0..<16 { - bytes[i] = data[data.startIndex + pos + i] - } + let start = data.startIndex + pos pos += 16 - // .NET mixed-endian GUID format to standard UUID - func hexByte(_ b: UInt8) -> String { - return String(format: "%02x", b) - } - - var hex = "" - hex += hexByte(bytes[3]) + hexByte(bytes[2]) + hexByte(bytes[1]) + hexByte(bytes[0]) - hex += "-" - hex += hexByte(bytes[5]) + hexByte(bytes[4]) - hex += "-" - hex += hexByte(bytes[7]) + hexByte(bytes[6]) - hex += "-" - hex += hexByte(bytes[8]) + hexByte(bytes[9]) - hex += "-" - for i in 10..<16 { - hex += hexByte(bytes[i]) - } - guard let uuid = UUID(uuidString: hex) else { - throw BaboonCodecError.invalidUuid - } - return uuid + return UUID(uuid: ( + data[start + 3], data[start + 2], data[start + 1], data[start], + data[start + 5], data[start + 4], data[start + 7], data[start + 6], + data[start + 8], data[start + 9], data[start + 10], data[start + 11], + data[start + 12], data[start + 13], data[start + 14], data[start + 15] + )) } public func readTsu() -> Date { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts index 446581609..17ea79bbd 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts @@ -15,9 +15,10 @@ import { BaboonBinWriter, BaboonCodecException, BaboonDecoderFailure, + BaboonEncoderFailure, BinTools, } from "./BaboonSharedRuntime"; -import type { BaboonEither } from "./BaboonSharedRuntime"; +import type { BaboonEither, BaboonCodecContext } from "./BaboonSharedRuntime"; // --- AnyMeta ----------------------------------------------------------------------------------- @@ -302,3 +303,132 @@ function readOptString( ), }; } + +export function encodeAnyJsonField( + ctx: BaboonCodecContext, + expectedKind: number, + staticDomain: string | undefined, + staticVersion: string | undefined, + staticTypeid: string | undefined, + value: AnyOpaque, +): unknown { + if (value.meta.kind !== expectedKind) { + throw new BaboonEncoderFailure( + `any: meta-kind 0x${(value.meta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` + ); + } + let anyInner: unknown; + if (value.tag === "Json") { + anyInner = value.json; + } else { + const anyFacade = ctx.facade; + if (anyFacade === undefined) { + throw new BaboonEncoderFailure( + "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueJson directly." + ); + } + const anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid); + if (anyConvResult.tag === "Left") { + throw anyConvResult.value; + } + anyInner = anyConvResult.value; + } + const anyEnvelope = AnyMetaCodec.writeJson(value.meta); + anyEnvelope[AnyMetaCodec.ANY_CONTENT_KEY] = anyInner; + return anyEnvelope; +} + +export function decodeAnyJsonField(expectedKind: number, wire: unknown): AnyOpaque { + if (wire === null || typeof wire !== "object" || Array.isArray(wire)) { + throw new BaboonDecoderFailure("any: JSON envelope must be an object"); + } + const anyMetaResult = AnyMetaCodec.readJson(wire); + if (anyMetaResult.tag === "Left") { + throw anyMetaResult.value; + } + const anyMeta = anyMetaResult.value; + if (anyMeta.kind !== expectedKind) { + throw new BaboonDecoderFailure( + `any: wire kind 0x${(anyMeta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` + ); + } + const anyObj = wire as Record; + const anyContent = anyObj[AnyMetaCodec.ANY_CONTENT_KEY]; + if (anyContent === undefined) { + throw new BaboonDecoderFailure(`any: JSON envelope missing '${AnyMetaCodec.ANY_CONTENT_KEY}' content key`); + } + return anyOpaqueJson(anyMeta, anyContent); +} + +export function encodeAnyUebaField( + ctx: BaboonCodecContext, + writer: BaboonBinWriter, + expectedKind: number, + staticDomain: string | undefined, + staticVersion: string | undefined, + staticTypeid: string | undefined, + value: AnyOpaque, +): void { + if (value.meta.kind !== expectedKind) { + throw new BaboonEncoderFailure( + `any: meta-kind 0x${(value.meta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` + ); + } + let anyBlob: Uint8Array; + if (value.tag === "Ueba") { + anyBlob = value.bytes; + } else { + const anyFacade = ctx.facade; + if (anyFacade === undefined) { + throw new BaboonEncoderFailure( + "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueUeba directly." + ); + } + const anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid); + if (anyConvResult.tag === "Left") { + throw anyConvResult.value; + } + anyBlob = anyConvResult.value; + } + // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). + const anyMetaBuf = new BaboonBinWriter(); + AnyMetaCodec.writeBin(value.meta, anyMetaBuf); + const anyMetaBytes = anyMetaBuf.toBytes(); + const anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; + BinTools.writeI32(writer, anyTotalLength); + BinTools.writeI32(writer, anyMetaBytes.length); + writer.writeBytes(anyMetaBytes); + writer.writeBytes(anyBlob); +} + +export function decodeAnyUebaField(reader: BaboonBinReader, expectedKind: number): AnyOpaque { + const anyTotalLength = BinTools.readI32(reader); + if (anyTotalLength < 0) { + throw new BaboonDecoderFailure(`any: negative total-length ${anyTotalLength}`); + } + const anyMetaLength = BinTools.readI32(reader); + if (anyMetaLength < 0) { + throw new BaboonDecoderFailure(`any: negative meta-length ${anyMetaLength}`); + } + if (anyTotalLength < 4 + anyMetaLength) { + throw new BaboonDecoderFailure(`any: total-length ${anyTotalLength} smaller than 4 + meta-length ${anyMetaLength}`); + } + const anyReadResult = AnyMetaCodec.readBinWithLength(reader); + const anyMeta = anyReadResult.meta; + const anyBytesRead = anyReadResult.bytesRead; + if (anyBytesRead > anyMetaLength) { + throw new BaboonDecoderFailure(`any: meta bytes-read ${anyBytesRead} exceeded meta-length window ${anyMetaLength}`); + } + if (anyBytesRead < anyMetaLength) { + // Forward-compat: skip future meta-extension bytes within the meta-length window. + reader.readBytes(anyMetaLength - anyBytesRead); + } + if (anyMeta.kind !== expectedKind) { + throw new BaboonDecoderFailure( + `any: wire kind 0x${(anyMeta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` + ); + } + const anyBlobLen = anyTotalLength - 4 - anyMetaLength; + const anyBlob = reader.readBytes(anyBlobLen); + return anyOpaqueUeba(anyMeta, anyBlob); +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonMcpRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonMcpRuntime.ts index c45b66678..2f4b024ff 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonMcpRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonMcpRuntime.ts @@ -172,9 +172,61 @@ export type BaboonEitherResult = // All JSON-RPC method strings ("tools/list" …) and result keys ("protocolVersion", // "inputSchema" …) are literal lowercase strings, NOT subject to any per-language // symbol casing. +type PreparedMcpCall = + | { readonly tag: "Response"; readonly response: JsonRpcResponse | undefined } + | { readonly tag: "Call"; readonly id: JsonRpcId | null; readonly target: T; readonly data: string }; + +function describeMcpTool(tool: McpToolEntry): { name: string; inputSchema: unknown; description?: string } { + const entry: { name: string; inputSchema: unknown; description?: string } = { name: tool.name, inputSchema: tool.inputSchema }; + if (tool.description !== undefined) entry.description = tool.description; + return entry; +} + +function prepareMcpCall( + request: JsonRpcRequest, + session: McpSession, + serverInfo: () => McpServerInfo, + tools: () => ReadonlyArray<{ name: string; inputSchema: unknown; description?: string }>, + lookup: (name: string) => T | undefined, + error: (id: JsonRpcId | null, code: number, message: string) => JsonRpcResponse, +): PreparedMcpCall { + const id = request.id ?? null; + const response = (value: JsonRpcResponse | undefined): PreparedMcpCall => ({ tag: "Response", response: value }); + switch (request.method) { + case "initialize": { + const params = request.params as { protocolVersion?: unknown } | undefined; + if (params === undefined || params === null || params.protocolVersion === undefined) { + return response(error(id, JsonRpcErrorCodes.InvalidParams, "initialize: missing protocolVersion")); + } + session.initialized = true; + return response({ id, result: { protocolVersion: McpProtocolVersion, capabilities: { tools: {} }, serverInfo: { name: serverInfo().name, version: serverInfo().version } } }); + } + case "notifications/initialized": + return response(undefined); + case "tools/list": + if (!session.initialized) return response(error(id, JsonRpcErrorCodes.InvalidRequest, "tools/list before initialize")); + return response({ id, result: { tools: tools() } }); + case "tools/call": { + if (!session.initialized) return response(error(id, JsonRpcErrorCodes.InvalidRequest, "tools/call before initialize")); + const params = request.params as { name?: unknown; arguments?: unknown } | undefined; + const name = params?.name; + if (typeof name !== "string") return response(error(id, JsonRpcErrorCodes.InvalidParams, "tools/call: missing tool name")); + const target = lookup(name); + if (target === undefined) return response(error(id, JsonRpcErrorCodes.InvalidParams, `tools/call: unknown tool '${name}'`)); + return { tag: "Call", id, target, data: JSON.stringify(params?.arguments ?? {}) }; + } + default: + return response(error(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`)); + } +} + +function mcpCallResponse(id: JsonRpcId | null, result: BaboonEitherResult, describe: (error: BaboonWiringError) => string): JsonRpcResponse { + return { id, result: { content: [{ type: "text", text: result.tag === "Right" ? result.value : describe(result.value) }], isError: result.tag === "Left" } }; +} + export abstract class AbstractBaboonMcpServer implements IBaboonMcpServer, IBaboonRoutableMcpServer { // PUBLIC routable-server surface (tasks:T114): the muxer reads serverInfo / - // tools and routes via routeToolCall, never via the (private) byName() and + // tools and routes via routeToolCall, never via the protected findTool() and // never via handle(). public abstract readonly serverInfo: McpServerInfo; public abstract readonly tools: readonly McpToolEntry[]; @@ -186,68 +238,18 @@ export abstract class AbstractBaboonMcpServer implements IBaboonMcpServer { - const m = new Map(); - for (const t of this.tools) m.set(t.name, t); - return m; + protected findTool(name: string): McpToolEntry | undefined { + let result: McpToolEntry | undefined; + for (const tool of this.tools) if (tool.name === name) result = tool; + return result; } handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse | undefined { - const id: JsonRpcId | null = request.id ?? null; - switch (request.method) { - case 'initialize': { - const params = request.params as { protocolVersion?: unknown } | undefined; - if (params === undefined || params === null || params.protocolVersion === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'initialize: missing protocolVersion'); - } - session.initialized = true; - return { - id, - result: { - protocolVersion: McpProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: this.serverInfo.name, version: this.serverInfo.version }, - }, - }; - } - case 'notifications/initialized': - return undefined; - case 'tools/list': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/list before initialize'); - } - const tools = this.tools.map(t => { - const entry: { name: string; inputSchema: unknown; description?: string } = { name: t.name, inputSchema: t.inputSchema }; - if (t.description !== undefined) entry.description = t.description; - return entry; - }); - return { id, result: { tools } }; - } - case 'tools/call': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/call before initialize'); - } - const params = request.params as { name?: unknown; arguments?: unknown } | undefined; - const name = params?.name; - if (typeof name !== 'string') { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'tools/call: missing tool name'); - } - const entry = this.byName().get(name); - if (entry === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, `tools/call: unknown tool '${name}'`); - } - const argsJson = JSON.stringify(params?.arguments ?? {}); - const result = this.invokeJson(entry.method, argsJson, ctx, codecCtx); - if (result.tag === 'Right') { - return { id, result: { content: [{ type: 'text', text: result.value }], isError: false } }; - } else { - // Channel B: a valid protocol call whose domain payload failed. - return { id, result: { content: [{ type: 'text', text: this.describeWiringError(result.value) }], isError: true } }; - } - } - default: - return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`); - } + const prepared = prepareMcpCall(request, session, () => this.serverInfo, () => this.tools.map(describeMcpTool), name => this.findTool(name), + (id, code, message) => this.errorResponse(id, code, message)); + if (prepared.tag === "Response") return prepared.response; + const result = this.invokeJson(prepared.target.method, prepared.data, ctx, codecCtx); + return mcpCallResponse(prepared.id, result, error => this.describeWiringError(error)); } protected errorResponse(id: JsonRpcId | null, code: number, message: string): JsonRpcResponse { @@ -281,13 +283,28 @@ export abstract class AbstractBaboonMcpServer implements IBaboonMcpServer { + private readonly entries = new Map(); + + register(server: S): void { + for (const entry of server.tools) { + if (this.entries.has(entry.name)) throw new BaboonMcpWiringException({ tag: 'DuplicateTool', toolName: entry.name }); + this.entries.set(entry.name, { server, entry }); + } + } + + lookup(name: string): { server: S; entry: McpToolEntry } | undefined { return this.entries.get(name); } + + describeTools(): Array<{ name: string; inputSchema: unknown; description?: string }> { + return Array.from(this.entries.values(), target => describeMcpTool(target.entry)); + } +} + export class AbstractMcpMuxer implements IBaboonMcpServer { // Registration order preserved (insertion-ordered Map / array — JsonMuxer - // LinkedHashMap precedent). `route`/`entries` are built at registration + // LinkedHashMap precedent). The registry is built at registration // (contract §2), never per request. - private readonly servers: IBaboonRoutableMcpServer[] = []; - private readonly route = new Map>(); - private readonly entries = new Map(); + private readonly registry = new McpRegistry>(); private readonly mergedServerInfo: McpServerInfo; // varargs ctor mirrors `JsonMuxer(...services)`. `mergedServerInfo` is the @@ -300,86 +317,16 @@ export class AbstractMcpMuxer implements IBaboonMcpServer { // Folds the server's declaration-ordered `tools()` into the union table; // throws DuplicateTool on a tool-name collision across servers (the exact // MCP-tier analogue of JsonMuxer.register throwing DuplicateService). - register(server: IBaboonRoutableMcpServer): void { - for (const t of server.tools) { - if (this.route.has(t.name)) { - throw new BaboonMcpWiringException({ tag: 'DuplicateTool', toolName: t.name }); - } - this.route.set(t.name, server); - this.entries.set(t.name, t); - } - this.servers.push(server); - } + register(server: IBaboonRoutableMcpServer): void { this.registry.register(server); } handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): JsonRpcResponse | undefined { - const id: JsonRpcId | null = request.id ?? null; - switch (request.method) { - case 'initialize': { - const params = request.params as { protocolVersion?: unknown } | undefined; - if (params === undefined || params === null || params.protocolVersion === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'initialize: missing protocolVersion'); - } - session.initialized = true; - return { - id, - result: { - protocolVersion: McpProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: this.mergedServerInfo.name, version: this.mergedServerInfo.version }, - }, - }; - } - case 'notifications/initialized': - return undefined; - case 'tools/list': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/list before initialize'); - } - return { id, result: { tools: this.toolsListUnion() } }; - } - case 'tools/call': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/call before initialize'); - } - const params = request.params as { name?: unknown; arguments?: unknown } | undefined; - const name = params?.name; - if (typeof name !== 'string') { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'tools/call: missing tool name'); - } - const server = this.route.get(name); - if (server === undefined) { - // NoMatchingTool: surfaced as the SAME wire response the per-service - // base uses for an unknown tool (-32602, "unknown tool ''"), - // so the bytes are identical whether one server or the muxer rejects. - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, `tools/call: unknown tool '${name}'`); - } - const entry = this.entries.get(name)!; - const argsJson = JSON.stringify(params?.arguments ?? {}); - const result = server.routeToolCall(entry.method, argsJson, ctx, codecCtx); - if (result.tag === 'Right') { - return { id, result: { content: [{ type: 'text', text: result.value }], isError: false } }; - } else { - // Channel B: a valid protocol call whose domain payload failed. - return { id, result: { content: [{ type: 'text', text: this.describeWiringError(result.value) }], isError: true } }; - } - } - default: - return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`); - } + const prepared = prepareMcpCall(request, session, () => this.mergedServerInfo, () => this.registry.describeTools(), name => this.registry.lookup(name), + (id, code, message) => this.errorResponse(id, code, message)); + if (prepared.tag === "Response") return prepared.response; + const result = prepared.target.server.routeToolCall(prepared.target.entry.method, prepared.data, ctx, codecCtx); + return mcpCallResponse(prepared.id, result, error => this.describeWiringError(error)); } - // Backs tools/list (§3.2): the union of all registered servers' tool entries - // in registration-then-declaration order (the insertion order of `entries`), - // each in the same shape the per-service base emits. - private toolsListUnion(): Array<{ name: string; inputSchema: unknown; description?: string }> { - const out: Array<{ name: string; inputSchema: unknown; description?: string }> = []; - for (const t of this.entries.values()) { - const entry: { name: string; inputSchema: unknown; description?: string } = { name: t.name, inputSchema: t.inputSchema }; - if (t.description !== undefined) entry.description = t.description; - out.push(entry); - } - return out; - } protected errorResponse(id: JsonRpcId | null, code: number, message: string): JsonRpcResponse { return { id, error: { code, message } }; @@ -432,68 +379,18 @@ export abstract class AbstractAsyncBaboonMcpServer implements IBaboonAsyncM return this.invokeJson(method, data, ctx, codecCtx); } - private byName(): Map { - const m = new Map(); - for (const t of this.tools) m.set(t.name, t); - return m; + protected findTool(name: string): McpToolEntry | undefined { + let result: McpToolEntry | undefined; + for (const tool of this.tools) if (tool.name === name) result = tool; + return result; } async handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Promise { - const id: JsonRpcId | null = request.id ?? null; - switch (request.method) { - case 'initialize': { - const params = request.params as { protocolVersion?: unknown } | undefined; - if (params === undefined || params === null || params.protocolVersion === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'initialize: missing protocolVersion'); - } - session.initialized = true; - return { - id, - result: { - protocolVersion: McpProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: this.serverInfo.name, version: this.serverInfo.version }, - }, - }; - } - case 'notifications/initialized': - return undefined; - case 'tools/list': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/list before initialize'); - } - const tools = this.tools.map(t => { - const entry: { name: string; inputSchema: unknown; description?: string } = { name: t.name, inputSchema: t.inputSchema }; - if (t.description !== undefined) entry.description = t.description; - return entry; - }); - return { id, result: { tools } }; - } - case 'tools/call': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/call before initialize'); - } - const params = request.params as { name?: unknown; arguments?: unknown } | undefined; - const name = params?.name; - if (typeof name !== 'string') { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'tools/call: missing tool name'); - } - const entry = this.byName().get(name); - if (entry === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, `tools/call: unknown tool '${name}'`); - } - const argsJson = JSON.stringify(params?.arguments ?? {}); - const result = await this.invokeJson(entry.method, argsJson, ctx, codecCtx); - if (result.tag === 'Right') { - return { id, result: { content: [{ type: 'text', text: result.value }], isError: false } }; - } else { - // Channel B: a valid protocol call whose domain payload failed. - return { id, result: { content: [{ type: 'text', text: this.describeWiringError(result.value) }], isError: true } }; - } - } - default: - return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`); - } + const prepared = prepareMcpCall(request, session, () => this.serverInfo, () => this.tools.map(describeMcpTool), name => this.findTool(name), + (id, code, message) => this.errorResponse(id, code, message)); + if (prepared.tag === "Response") return prepared.response; + const result = await this.invokeJson(prepared.target.method, prepared.data, ctx, codecCtx); + return mcpCallResponse(prepared.id, result, error => this.describeWiringError(error)); } protected errorResponse(id: JsonRpcId | null, code: number, message: string): JsonRpcResponse { @@ -515,9 +412,7 @@ export abstract class AbstractAsyncBaboonMcpServer implements IBaboonAsyncM // `initialize`, the ordering rule, and the `NoMatchingTool` wire mapping are // identical to the sync muxer; only the `tools/call` hop awaits. export class AbstractAsyncMcpMuxer implements IBaboonAsyncMcpServer { - private readonly servers: IBaboonRoutableAsyncMcpServer[] = []; - private readonly route = new Map>(); - private readonly entries = new Map(); + private readonly registry = new McpRegistry>(); private readonly mergedServerInfo: McpServerInfo; constructor(mergedServerInfo: McpServerInfo, ...servers: IBaboonRoutableAsyncMcpServer[]) { @@ -525,79 +420,16 @@ export class AbstractAsyncMcpMuxer implements IBaboonAsyncMcpServer { for (const s of servers) this.register(s); } - register(server: IBaboonRoutableAsyncMcpServer): void { - for (const t of server.tools) { - if (this.route.has(t.name)) { - throw new BaboonMcpWiringException({ tag: 'DuplicateTool', toolName: t.name }); - } - this.route.set(t.name, server); - this.entries.set(t.name, t); - } - this.servers.push(server); - } + register(server: IBaboonRoutableAsyncMcpServer): void { this.registry.register(server); } async handle(request: JsonRpcRequest, session: McpSession, ctx: Ctx, codecCtx: BaboonCodecContext): Promise { - const id: JsonRpcId | null = request.id ?? null; - switch (request.method) { - case 'initialize': { - const params = request.params as { protocolVersion?: unknown } | undefined; - if (params === undefined || params === null || params.protocolVersion === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'initialize: missing protocolVersion'); - } - session.initialized = true; - return { - id, - result: { - protocolVersion: McpProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: this.mergedServerInfo.name, version: this.mergedServerInfo.version }, - }, - }; - } - case 'notifications/initialized': - return undefined; - case 'tools/list': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/list before initialize'); - } - return { id, result: { tools: this.toolsListUnion() } }; - } - case 'tools/call': { - if (!session.initialized) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'tools/call before initialize'); - } - const params = request.params as { name?: unknown; arguments?: unknown } | undefined; - const name = params?.name; - if (typeof name !== 'string') { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, 'tools/call: missing tool name'); - } - const server = this.route.get(name); - if (server === undefined) { - return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, `tools/call: unknown tool '${name}'`); - } - const entry = this.entries.get(name)!; - const argsJson = JSON.stringify(params?.arguments ?? {}); - const result = await server.routeToolCall(entry.method, argsJson, ctx, codecCtx); - if (result.tag === 'Right') { - return { id, result: { content: [{ type: 'text', text: result.value }], isError: false } }; - } else { - return { id, result: { content: [{ type: 'text', text: this.describeWiringError(result.value) }], isError: true } }; - } - } - default: - return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`); - } + const prepared = prepareMcpCall(request, session, () => this.mergedServerInfo, () => this.registry.describeTools(), name => this.registry.lookup(name), + (id, code, message) => this.errorResponse(id, code, message)); + if (prepared.tag === "Response") return prepared.response; + const result = await prepared.target.server.routeToolCall(prepared.target.entry.method, prepared.data, ctx, codecCtx); + return mcpCallResponse(prepared.id, result, error => this.describeWiringError(error)); } - private toolsListUnion(): Array<{ name: string; inputSchema: unknown; description?: string }> { - const out: Array<{ name: string; inputSchema: unknown; description?: string }> = []; - for (const t of this.entries.values()) { - const entry: { name: string; inputSchema: unknown; description?: string } = { name: t.name, inputSchema: t.inputSchema }; - if (t.description !== undefined) entry.description = t.description; - out.push(entry); - } - return out; - } protected errorResponse(id: JsonRpcId | null, code: number, message: string): JsonRpcResponse { return { id, error: { code, message } }; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 7f1c8996e..f005d1ba6 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -92,10 +92,12 @@ export class BaboonCodecContext { export class BaboonBinWriter { private buf: Uint8Array; + private view: DataView; private pos: number; constructor(initialCapacity: number = 256) { this.buf = new Uint8Array(initialCapacity); + this.view = new DataView(this.buf.buffer); this.pos = 0; } @@ -108,6 +110,7 @@ export class BaboonBinWriter { const newBuf = new Uint8Array(newCap); newBuf.set(this.buf.subarray(0, this.pos)); this.buf = newBuf; + this.view = new DataView(newBuf.buffer); } } @@ -116,6 +119,60 @@ export class BaboonBinWriter { this.buf[this.pos++] = value & 0xFF; } + writeI8(value: number): void { + this.ensureCapacity(1); + this.view.setInt8(this.pos, value); + this.pos += 1; + } + + writeI16(value: number): void { + this.ensureCapacity(2); + this.view.setInt16(this.pos, value, true); + this.pos += 2; + } + + writeI32(value: number): void { + this.ensureCapacity(4); + this.view.setInt32(this.pos, value, true); + this.pos += 4; + } + + writeI64(value: bigint): void { + this.ensureCapacity(8); + this.view.setBigInt64(this.pos, value, true); + this.pos += 8; + } + + writeU16(value: number): void { + this.ensureCapacity(2); + this.view.setUint16(this.pos, value, true); + this.pos += 2; + } + + writeU32(value: number): void { + this.ensureCapacity(4); + this.view.setUint32(this.pos, value, true); + this.pos += 4; + } + + writeU64(value: bigint): void { + this.ensureCapacity(8); + this.view.setBigUint64(this.pos, value, true); + this.pos += 8; + } + + writeF32(value: number): void { + this.ensureCapacity(4); + this.view.setFloat32(this.pos, value, true); + this.pos += 4; + } + + writeF64(value: number): void { + this.ensureCapacity(8); + this.view.setFloat64(this.pos, value, true); + this.pos += 8; + } + writeBytes(data: Uint8Array): void { this.ensureCapacity(data.length); this.buf.set(data, this.pos); @@ -160,6 +217,9 @@ export class BaboonBinReader { readBytes(length: number): Uint8Array { const slice = this.buf.slice(this.pos, this.pos + length); this.pos += length; + if (Object.getPrototypeOf(this.buf) === Uint8Array.prototype && this.buf.slice === Uint8Array.prototype.slice) { + return slice; + } return new Uint8Array(slice); } @@ -382,27 +442,19 @@ export class BinTools { } static writeI8(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(1); - new DataView(buf).setInt8(0, value); - writer.writeBytes(new Uint8Array(buf)); + writer.writeI8(value); } static writeI16(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(2); - new DataView(buf).setInt16(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeI16(value); } static writeI32(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(4); - new DataView(buf).setInt32(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeI32(value); } static writeI64(writer: BaboonBinWriter, value: bigint): void { - const buf = new ArrayBuffer(8); - new DataView(buf).setBigInt64(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeI64(value); } static writeU8(writer: BaboonBinWriter, value: number): void { @@ -410,33 +462,23 @@ export class BinTools { } static writeU16(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(2); - new DataView(buf).setUint16(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeU16(value); } static writeU32(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(4); - new DataView(buf).setUint32(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeU32(value); } static writeU64(writer: BaboonBinWriter, value: bigint): void { - const buf = new ArrayBuffer(8); - new DataView(buf).setBigUint64(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeU64(value); } static writeF32(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(4); - new DataView(buf).setFloat32(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeF32(value); } static writeF64(writer: BaboonBinWriter, value: number): void { - const buf = new ArrayBuffer(8); - new DataView(buf).setFloat64(0, value, true); - writer.writeBytes(new Uint8Array(buf)); + writer.writeF64(value); } static writeDecimal(writer: BaboonBinWriter, value: BaboonDecimal): void { @@ -756,49 +798,52 @@ export interface IBaboonUebaService> { invoke(method: BaboonMethodId, data: Uint8Array, ctx: BaboonCodecContext): R; } -export class JsonMuxer> { - private readonly table = new Map>(); - constructor(...services: IBaboonJsonService[]) { - for (const s of services) this.register(s); - } - register(service: IBaboonJsonService): void { +class ServiceRegistry { + private readonly table = new Map(); + + register(service: S): void { if (this.table.has(service.serviceName)) { throw new BaboonWiringException({ tag: 'DuplicateService', serviceName: service.serviceName }); } this.table.set(service.serviceName, service); } - invoke(method: BaboonMethodId, data: string, ctx: BaboonCodecContext): R { + + resolve(method: BaboonMethodId): S { const service = this.table.get(method.serviceName); - if (service === undefined) { - throw new BaboonWiringException({ tag: 'NoMatchingService', method }); - } + if (service === undefined) throw new BaboonWiringException({ tag: 'NoMatchingService', method }); + return service; + } + + names(): readonly string[] { return Array.from(this.table.keys()); } +} + +export class JsonMuxer> { + private readonly table = new ServiceRegistry>(); + constructor(...services: IBaboonJsonService[]) { + for (const s of services) this.register(s); + } + register(service: IBaboonJsonService): void { this.table.register(service); } + invoke(method: BaboonMethodId, data: string, ctx: BaboonCodecContext): R { + const service = this.table.resolve(method); return service.invoke(method, data, ctx); } serviceNames(): readonly string[] { - return Array.from(this.table.keys()); + return this.table.names(); } } export class UebaMuxer> { - private readonly table = new Map>(); + private readonly table = new ServiceRegistry>(); constructor(...services: IBaboonUebaService[]) { for (const s of services) this.register(s); } - register(service: IBaboonUebaService): void { - if (this.table.has(service.serviceName)) { - throw new BaboonWiringException({ tag: 'DuplicateService', serviceName: service.serviceName }); - } - this.table.set(service.serviceName, service); - } + register(service: IBaboonUebaService): void { this.table.register(service); } invoke(method: BaboonMethodId, data: Uint8Array, ctx: BaboonCodecContext): R { - const service = this.table.get(method.serviceName); - if (service === undefined) { - throw new BaboonWiringException({ tag: 'NoMatchingService', method }); - } + const service = this.table.resolve(method); return service.invoke(method, data, ctx); } serviceNames(): readonly string[] { - return Array.from(this.table.keys()); + return this.table.names(); } } @@ -819,48 +864,32 @@ export interface IBaboonUebaServiceCtx> { } export class JsonMuxerCtx> { - private readonly table = new Map>(); + private readonly table = new ServiceRegistry>(); constructor(...services: IBaboonJsonServiceCtx[]) { for (const s of services) this.register(s); } - register(service: IBaboonJsonServiceCtx): void { - if (this.table.has(service.serviceName)) { - throw new BaboonWiringException({ tag: 'DuplicateService', serviceName: service.serviceName }); - } - this.table.set(service.serviceName, service); - } + register(service: IBaboonJsonServiceCtx): void { this.table.register(service); } invoke(method: BaboonMethodId, data: string, ctx: Ctx, codecCtx: BaboonCodecContext): R { - const service = this.table.get(method.serviceName); - if (service === undefined) { - throw new BaboonWiringException({ tag: 'NoMatchingService', method }); - } + const service = this.table.resolve(method); return service.invoke(method, data, ctx, codecCtx); } serviceNames(): readonly string[] { - return Array.from(this.table.keys()); + return this.table.names(); } } export class UebaMuxerCtx> { - private readonly table = new Map>(); + private readonly table = new ServiceRegistry>(); constructor(...services: IBaboonUebaServiceCtx[]) { for (const s of services) this.register(s); } - register(service: IBaboonUebaServiceCtx): void { - if (this.table.has(service.serviceName)) { - throw new BaboonWiringException({ tag: 'DuplicateService', serviceName: service.serviceName }); - } - this.table.set(service.serviceName, service); - } + register(service: IBaboonUebaServiceCtx): void { this.table.register(service); } invoke(method: BaboonMethodId, data: Uint8Array, ctx: Ctx, codecCtx: BaboonCodecContext): R { - const service = this.table.get(method.serviceName); - if (service === undefined) { - throw new BaboonWiringException({ tag: 'NoMatchingService', method }); - } + const service = this.table.resolve(method); return service.invoke(method, data, ctx, codecCtx); } serviceNames(): readonly string[] { - return Array.from(this.table.keys()); + return this.table.names(); } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/AnyFieldPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/AnyFieldPlan.scala new file mode 100644 index 000000000..85cb6c235 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/AnyFieldPlan.scala @@ -0,0 +1,26 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.{Domain, TypeRef} + +final case class AnyFieldPlan( + kind: Byte, + staticDomain: Option[String], + staticVersion: Option[String], + staticTypeId: Option[String], +) + +object AnyFieldPlan { + def forField(field: TypeRef.Any, domain: Domain): AnyFieldPlan = { + val (staticDomain, staticVersion) = field.variant match { + case TypeRef.AnyVariant.Global => (None, None) + case TypeRef.AnyVariant.ThisDom => (Some(domain.id.toString), None) + case TypeRef.AnyVariant.Current => (Some(domain.id.toString), Some(domain.version.v.toString)) + } + AnyFieldPlan( + TypeRef.AnyVariant.metaKindByte(field.variant, field.underlying.isDefined), + staticDomain, + staticVersion, + field.underlying.map(_.id.toString), + ) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/BaboonRuntimeResources.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/BaboonRuntimeResources.scala index 6b612f194..d3865db62 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/BaboonRuntimeResources.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/BaboonRuntimeResources.scala @@ -7,6 +7,7 @@ object BaboonRuntimeResources { // added under `baboon-compiler/src/main/resources/baboon-runtime//`, // touch this comment to force a recompile of this unit (sbt won't otherwise // detect resource-tree changes and the macro's embedded map will be stale). + // Last touched: 2026-09-16 — add shared Scala/Kotlin any-envelope codecs and kotlin-common resources. // Last touched: 2026-06-16 — add async MCP dispatch surface (IBaboonAsyncMcpServer) to baboon-runtime/swift/baboon_mcp_runtime.swift (D24/T67) // Last touched: 2026-06-04 — add baboon-runtime/typescript/BaboonMcpRuntime.ts (T8 MCP server runtime) // Last touched: 2026-06-02 — split swift service-wiring runtime out of baboon_runtime.swift diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DocumentationRenderer.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DocumentationRenderer.scala new file mode 100644 index 000000000..d689267b9 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DocumentationRenderer.scala @@ -0,0 +1,36 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.Docs + +object DocumentationRenderer { + def javadoc(docs: Docs, indent: String): String = { + val prefixLines = docs.prefix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) + val suffixLines = docs.suffix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) + + if (prefixLines.isEmpty && suffixLines.isEmpty) return "" + + val mergedLines: List[String] = + if (suffixLines.isEmpty) prefixLines + else if (prefixLines.isEmpty) suffixLines + else prefixLines ++ List("") ++ suffixLines + + // D35: renderDocs introduces no backslashes (plain `/** */`), so the + // backslash-escape is applied at the renderDocs boundary. See + // DocCommentEscaping.escapeBackslashForQInterpolation. + val allLines: List[String] = + mergedLines.map(DocCommentEscaping.escapeBackslashForQInterpolation) + + // Single-line compact form: /** text */ + val rendered = + if (allLines.size == 1) { + s"$indent/** ${allLines.head} */\n" + } else { + val middle = allLines.map { + case "" => s"$indent *" + case l => s"$indent * $l" + }.mkString("\n") + s"$indent/**\n$middle\n$indent */\n" + } + rendered + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainProductTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainProductTranslator.scala new file mode 100644 index 000000000..12b835181 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainProductTranslator.scala @@ -0,0 +1,25 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.CompilerProduct +import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.typer.model.{Domain, DomainMember} +import izumi.functional.bio.{Error2, F} +import izumi.fundamentals.collections.nonempty.NEList + +object DomainProductTranslator { + def translate[F[+_, +_]: Error2, A]( + domain: Domain, + products: Set[CompilerProduct], + product: CompilerProduct, + translateMember: DomainMember.User => F[NEList[BaboonIssue], List[A]], + ): F[NEList[BaboonIssue], List[A]] = { + if (products.contains(product)) { + F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { + case (_, member: DomainMember.User) => translateMember(member) + case _ => F.pure(List.empty[A]) + } + } else { + F.pure(List.empty[A]) + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/IdentifierFieldKind.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/IdentifierFieldKind.scala new file mode 100644 index 000000000..ad4cfe9ce --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/IdentifierFieldKind.scala @@ -0,0 +1,47 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.{TypeId, TypeRef} + +/** Identifier-repr categories after alias resolution. Target renderers retain + * their own integer representations and canonical parsing/range checks. + */ +sealed trait IdentifierFieldKind + +object IdentifierFieldKind { + case object Bit extends IdentifierFieldKind + case object SignedInt extends IdentifierFieldKind /* i08/i16/i32 */ + case object SignedLong extends IdentifierFieldKind /* i64 */ + case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ + case object UnsignedLong extends IdentifierFieldKind /* u64 */ + case object Str extends IdentifierFieldKind + case object Uid extends IdentifierFieldKind + case object Tsu extends IdentifierFieldKind + case object Tso extends IdentifierFieldKind + case object Bytes extends IdentifierFieldKind + final case class NestedId(id: TypeId.User) extends IdentifierFieldKind + + def classify(tpe: TypeRef): IdentifierFieldKind = { + tpe match { + case TypeRef.Scalar(b: TypeId.BuiltinScalar) => + import TypeId.Builtins.* + b match { + case `bit` => IdentifierFieldKind.Bit + case `i08` | `i16` | `i32` => IdentifierFieldKind.SignedInt + case `i64` => IdentifierFieldKind.SignedLong + case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt + case `u64` => IdentifierFieldKind.UnsignedLong + case `str` => IdentifierFieldKind.Str + case `uid` => IdentifierFieldKind.Uid + case `tsu` => IdentifierFieldKind.Tsu + case `tso` => IdentifierFieldKind.Tso + case `bytes` => IdentifierFieldKind.Bytes + case other => + throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") + } + case TypeRef.Scalar(uid: TypeId.User) => + IdentifierFieldKind.NestedId(uid) + case other => + throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/McpServerGeneratorHook.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/McpServerGeneratorHook.scala index 0b93e87cb..bc1a02456 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/McpServerGeneratorHook.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/McpServerGeneratorHook.scala @@ -2,7 +2,6 @@ package io.septimalmind.baboon.translator import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.typer.model.BaboonFamily -import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.nonempty.NEList /** Per-language MCP server generator seam (T6 dispatch hook). @@ -11,24 +10,7 @@ import izumi.fundamentals.collections.nonempty.NEList * instance of this trait and calls [[generateMcpServer]] when its * `generateMcpServer` language option is `true`. The returned [[Sources]] are * merged into the translator's normal output. - * - * Downstream generator tasks (T8/T10/T12-T18) replace [[McpServerGeneratorHookStub]] - * with their language-specific implementations; they do NOT need to re-wire the - * dispatch: the call site in the translator is already present. */ trait McpServerGeneratorHook[F[+_, +_]] { def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] } - -object McpServerGeneratorHook { - - /** No-op stub: emits nothing. Used for all languages until the real per-language - * generator is implemented. With this stub in place, toggling `---generate-mcp-server` - * is accepted by the CLI but produces no additional output — the compiler - * behaves identically to the pre-T6 baseline. - */ - class McpServerGeneratorHookStub[F[+_, +_]: Error2] extends McpServerGeneratorHook[F] { - override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = - F.pure(Sources(Map.empty)) - } -} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala index ae432240c..786c9f3d3 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala @@ -207,28 +207,29 @@ class CSBaboonTranslator[F[+_, +_]: Error2]( val className = s"Domain${pascalDomainId}Facade" // Emit one Register call per known version, in ascending order. - val registers = lineage.versions.toSeq.sortBy(_._1).map { - case (version, domain) => - val versionPkg = trans.toCsPkg(domain.id, version, evo) - val codecsJson = CSValue.CSType(versionPkg, "BaboonCodecsJson", fq = false, CSValue.CSTypeOrigin.Other) - val codecsUeba = CSValue.CSType(versionPkg, "BaboonCodecsUeba", fq = false, CSValue.CSTypeOrigin.Other) - val metaFac = CSValue.CSType(versionPkg, "BaboonMeta", fq = false, CSValue.CSTypeOrigin.Other) - val domainId = domain.id.toString - val versionStr = version.v.toString - - if (withMeta) { - q"""Register( - | new $baboonDomainVersion("$domainId", "$versionStr"), - | () => $codecsJson.Instance, - | () => $codecsUeba.Instance, - | () => $metaFac.Instance);""".stripMargin - } else { - q"""Register( - | new $baboonDomainVersion("$domainId", "$versionStr"), - | () => $codecsJson.Instance, - | () => $codecsUeba.Instance);""".stripMargin - } - }.toList + val registers = lineage.versions.toSeq + .sortBy(_._1).map { + case (version, domain) => + val versionPkg = trans.toCsPkg(domain.id, version, evo) + val codecsJson = CSValue.CSType(versionPkg, "BaboonCodecsJson", fq = false, CSValue.CSTypeOrigin.Other) + val codecsUeba = CSValue.CSType(versionPkg, "BaboonCodecsUeba", fq = false, CSValue.CSTypeOrigin.Other) + val metaFac = CSValue.CSType(versionPkg, "BaboonMeta", fq = false, CSValue.CSTypeOrigin.Other) + val domainId = domain.id.toString + val versionStr = version.v.toString + + if (withMeta) { + q"""Register( + | new $baboonDomainVersion("$domainId", "$versionStr"), + | () => $codecsJson.Instance, + | () => $codecsUeba.Instance, + | () => $metaFac.Instance);""".stripMargin + } else { + q"""Register( + | new $baboonDomainVersion("$domainId", "$versionStr"), + | () => $codecsJson.Instance, + | () => $codecsUeba.Instance);""".stripMargin + } + }.toList val classTree = q"""public sealed class $className : $baboonCodecsFacade @@ -493,6 +494,8 @@ class CSBaboonTranslator[F[+_, +_]: Error2]( rt(s"BaboonExceptions.cs", "baboon-runtime/cs/BaboonExceptions.cs"), rt(s"BaboonTypeMeta.cs", "baboon-runtime/cs/BaboonTypeMeta.cs"), rt(s"AnyOpaque.cs", "baboon-runtime/cs/AnyOpaque.cs"), + rt(s"BaboonAnyJsonCodec.cs", "baboon-runtime/cs/BaboonAnyJsonCodec.cs"), + rt(s"BaboonAnyBinCodec.cs", "baboon-runtime/cs/BaboonAnyBinCodec.cs"), rt(s"BaboonTools.cs", "baboon-runtime/cs/BaboonTools.cs"), rt(s"BaboonTime.cs", "baboon-runtime/cs/BaboonTime.cs"), rt(s"BaboonByteString.cs", "baboon-runtime/cs/BaboonByteString.cs"), diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala index b27513a21..de954a975 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala @@ -85,13 +85,22 @@ class CSConversionTranslator[F[+_, +_]: Error2]( transfer(TypeRef.Scalar(tpe), ref, 0, maybeOldTpe = maybeOldTpe.map(TypeRef.Scalar.apply)) } + val targetMeta = if (conv.sourceTpe != conv.targetTpe) { + q"""protected override $csString TargetTypeId() + |{ + | return "${conv.targetTpe.toString}"; + |}""".stripMargin + } else q"" + val fullMeta = q"""|$versionsMeta | |public override $csString TypeId() |{ | return "${conv.sourceTpe.toString}"; - |}""".stripMargin.shift(4).trim + |} + | + |$targetMeta""".stripMargin.shift(4).trim conv match { case _: Conversion.CustomConversionRequired => @@ -206,8 +215,8 @@ class CSConversionTranslator[F[+_, +_]: Error2]( // keyword-escaped; the local-variable stem must NOT (a `@` inside // `_@default` is not a legal identifier — `@` only legalises a // bare keyword at the identifier head). - val base = op.targetField.name.name.capitalize - val baseRef = escapeCsKeyword(base) + val base = op.targetField.name.name.capitalize + val baseRef = escapeCsKeyword(base) val fieldRef = q"_from.$baseRef" val initExpr = op match { case o: FieldOp.Transfer => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala index 2fc834742..dc7df5e12 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.csharp import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.CSTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.{CSPackageId, CSType, CSTypeOrigin} @@ -285,7 +286,7 @@ object CSDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[CSValue]): TextTree[CSValue] = { val block = csTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } /** D38: prepend the type `` block followed by the `` block as @@ -295,7 +296,7 @@ object CSDefnTranslator { private def prependDocs(docs: Docs, paramDocs: String, tree: TextTree[CSValue]): TextTree[CSValue] = { val summaryBlock = csTrees.renderDocs(docs, "") val block = summaryBlock + paramDocs - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } private def makeRepr(defn: DomainMember.User, name: CSValue.CSType, isLatestVersion: Boolean): DefnRepr = { @@ -506,21 +507,22 @@ object CSDefnTranslator { } val methods = service.methods.map { m => - val out = m.out.map(r => trans.asCsRef(r, domain, evo)) - val err = m.err.map(r => trans.asCsRef(r, domain, evo)) + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val out = plan.output + val err = plan.error val csFqName: CSValue => String = { case t: CSValue.CSType => (t.pkg.parts :+ t.name).mkString(".") case t: CSValue.CSTypeName => t.name } - val outStr = out.map(_.mapRender(csFqName)).getOrElse("") - val errStr = err.map(_.mapRender(csFqName)) - val syncRet = resolved.renderReturnType(outStr, errStr, "void") + val outStr = out.map(_.mapRender(csFqName)).getOrElse("") + val errStr = err.map(_.mapRender(csFqName)) + val syncRet = resolved.renderReturnType(outStr, errStr, "void") val retStr = if (target.language.asyncServices) { if (syncRet == "void") "System.Threading.Tasks.Task" else s"System.Threading.Tasks.Task<$syncRet>" } else syncRet - val methodEx = q"""public $retStr ${escapeCsKeyword(m.name.name.capitalize)}($ctxParam${trans.asCsRef(m.sig, domain, evo)} arg);""" + val methodEx = q"""public $retStr ${plan.methodName}($ctxParam${plan.input} arg);""" prependDocs(m.docs, methodEx) }.join("\n") @@ -593,7 +595,8 @@ object CSDefnTranslator { * service-method I/O types nest in `static partial class { * static partial class { … } }` (the interface-companion layout); * everything else lives in a flat namespace. Shared by the definition, - * fixture, and test emitters so all three agree on the type's location. */ + * fixture, and test emitters so all three agree on the type's location. + */ private def wrapInContainer(defn: DomainMember.User, srcRef: CSValue.CSType, tree: TextTree[CSValue]): TextTree[CSValue] = { trans.serviceMethodContainers(defn.id, domain, evo) match { case Some((nsPrefix, classes)) => @@ -613,7 +616,8 @@ object CSDefnTranslator { * `serviceMethodFixtureNs` namespace (NOT the static-class companion): the * fixture/test classes are siblings referencing the type by FQN, so the * separate test assembly compiles (no cross-assembly `partial`). Other - * types keep the fixture beside the type's own namespace. */ + * types keep the fixture beside the type's own namespace. + */ private def wrapFixtureNs(defn: DomainMember.User, srcRef: CSValue.CSType, tree: TextTree[CSValue]): TextTree[CSValue] = { trans.serviceMethodFixtureNs(defn.id, domain, evo) match { case Some(ns) => csTrees.inNs(ns, tree) @@ -645,9 +649,9 @@ object CSDefnTranslator { ): List[TextTree[CSValue]] = { fields.map { f => - val tpe = trans.asCsRef(f.tpe, domain, evo) - val mname = escapeCsKeyword(s"${f.name.name.capitalize}") - val fieldEx = q"public $tpe $mname { get; }" + val tpe = trans.asCsRef(f.tpe, domain, evo) + val mname = escapeCsKeyword(s"${f.name.name.capitalize}") + val fieldEx = q"public $tpe $mname { get; }" prependDocs(f.docs, fieldEx) } } @@ -719,49 +723,11 @@ object CSDefnTranslator { // Spec contract: docs/spec/identifier-repr.md. Mirrors ScDefnTranslator // section but uses C# stdlib + BaboonIdentifierRepr runtime helper. - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def renderFieldValueExpr(csFieldName: String, kind: IdentifierFieldKind): TextTree[CSValue] = { kind match { - case IdentifierFieldKind.Bit => q"$baboonIdRepr.BitToString(this.$csFieldName)" - case IdentifierFieldKind.SignedInt => q"this.$csFieldName.ToString($csInvariantCulture.InvariantCulture)" - case IdentifierFieldKind.UnsignedSmallInt => + case IdentifierFieldKind.Bit => q"$baboonIdRepr.BitToString(this.$csFieldName)" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => q"this.$csFieldName.ToString($csInvariantCulture.InvariantCulture)" + case IdentifierFieldKind.UnsignedSmallInt => // u08/u16/u32 already map to C# unsigned types; ToString(InvariantCulture) is // unsigned-correct unlike Java/Scala where the same value is signed. q"this.$csFieldName.ToString($csInvariantCulture.InvariantCulture)" @@ -787,7 +753,7 @@ object CSDefnTranslator { f => val srcFieldName = f.name.name val csFieldName = escapeCsKeyword(srcFieldName.capitalize) - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderFieldValueExpr(csFieldName, kind) // The repr field name is the source name per spec §2.1. q""""$srcFieldName:" + ($valueExpr)""" @@ -873,7 +839,7 @@ object CSDefnTranslator { val rawVar = s"${srcFieldName}_raw" val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asCsRef(f.tpe, domain, evo) val parseHead = @@ -891,7 +857,7 @@ object CSDefnTranslator { | if (__r is $either.Left __l) return $either.Left(__l.Value); | $valVar = (($either.Right)__r).Value; |}""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val rangeCheck = signedRangeCheck(f.tpe) val typeName = signedTypeName(f.tpe) val cast = signedNarrowCast(f.tpe) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala index 62189207e..cf90d13df 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSTypeOrigin import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -120,8 +120,7 @@ class CSJsonCodecGenerator( } } - val anyHelpers: List[TextTree[CSValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val methods = encoderMethods ++ decoderMethods ++ anyHelpers + val methods = encoderMethods ++ decoderMethods q"""public class ${cName.asName} : $cParent |{ @@ -237,7 +236,7 @@ class CSJsonCodecGenerator( val enc = mkEncoder(f.tpe, fieldRef) // The string literals below are on-wire JSON keys — they MUST stay the // original model field name (no `@` escaping; `@` is source-only). - val dec = mkDecoder(f.tpe, q"""asObject["${f.name.name}"]""") + val dec = mkDecoder(f.tpe, q"""asObject["${f.name.name}"]""") ( q"""new $nsJProperty("${f.name.name}", $enc)""", q"${escapeCsKeyword(f.name.name.capitalize)}: $dec", @@ -332,17 +331,8 @@ class CSJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bytes => - q"new $nsJValue($ref.Encode())" - case TypeId.Builtins.uid => - q"new $nsJValue($ref.ToString())" - // PR-28.3 (M28): tso always renders ±HH:MM (UTC = "+00:00"); tsu renders trailing 'Z'. - case TypeId.Builtins.tsu => - q"new $nsJValue($baboonTimeFormats.TsuToString($ref))" - case TypeId.Builtins.tso => - q"new $nsJValue($baboonTimeFormats.TsoToString($ref))" - case _: TypeId.BuiltinScalar => - q"new $nsJValue($ref)" + case b: TypeId.BuiltinScalar => + CSScalarCodecEmitter.jsonEncode(b, ref, CSScalarCodecEmitter.JsonBooleanFormat.BooleanValue) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -386,28 +376,7 @@ class CSJsonCodecGenerator( } private def mkDecoder(tpe: TypeRef, ref: TextTree[CSValue], codecArgs: CodecArguments = CodecArguments.empty): TextTree[CSValue] = { - def mkReader(bs: TypeId.BuiltinScalar): TextTree[CSValue] = { - val fref = q"$ref!" - bs match { - case TypeId.Builtins.bit => q"$fref.Value<$csBoolean>()!" - case TypeId.Builtins.i08 => q"$fref.Value<$csSByte>()!" - case TypeId.Builtins.i16 => q"$fref.Value<$csInt16>()!" - case TypeId.Builtins.i32 => q"$fref.Value<$csInt32>()!" - case TypeId.Builtins.i64 => q"$fref.Value<$csInt64>()!" - case TypeId.Builtins.u08 => q"$fref.Value<$csByte>()!" - case TypeId.Builtins.u16 => q"$fref.Value<$csUInt16>()!" - case TypeId.Builtins.u32 => q"$fref.Value<$csUInt32>()!" - case TypeId.Builtins.u64 => q"$fref.Value<$csUInt64>()!" - case TypeId.Builtins.f32 => q"$fref.Value<$csSingle>()!" - case TypeId.Builtins.f64 => q"$fref.Value<$csDouble>()!" - case TypeId.Builtins.f128 => q"$BaboonTools.ReadDecimalLenient($fref)" - case TypeId.Builtins.str => q"$fref.Value<$csString>()!" - case TypeId.Builtins.bytes => q"$csByteString.Parse($fref.Value<$csString>()!)" - case TypeId.Builtins.uid => q"$csGuid.Parse($fref.Value<$csString>()!)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.FromString($fref.Value<$csString>()!)" - case other => throw new RuntimeException(s"BUG: Unexpected type: $other") - } - } + def mkReader(bs: TypeId.BuiltinScalar): TextTree[CSValue] = CSScalarCodecEmitter.jsonDecode(bs, ref) def decodeKey(tpe: TypeRef, ref: TextTree[CSValue]): TextTree[CSValue] = { tpe.id match { @@ -525,135 +494,16 @@ class CSJsonCodecGenerator( } - // Per-codec-object helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec object that has any any-bearing fields. Mirrors PR 2.3 - // (`ScJsonCodecGenerator.anyFieldHelpers`) and PR 3.2's UEBA helper shape. - private def anyFieldHelpers: TextTree[CSValue] = { - q"""private const string AnyEnvelopeContentKey = "$$c"; - | - |private $nsJToken EncodeAnyField( - | $baboonCodecContext ctx, - | byte expectedKind, - | $csString? staticDomain, - | $csString? staticVersion, - | $csString? staticTypeid, - | $baboonAnyOpaque value) - |{ - | if (value.Meta.Kind != expectedKind) - | { - | throw new $baboonCodecException.EncoderFailure( - | $$"any: meta-kind 0x{value.Meta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); - | } - | $nsJToken anyInner; - | if (value is $baboonAnyOpaqueJson anyJson) - | { - | anyInner = anyJson.Json; - | } - | else if (value is $baboonAnyOpaqueUeba anyUeba) - | { - | var f = ctx.Facade ?? throw new $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueJson directly."); - | var anyConvResult = f.UebaToJson(anyUeba.Meta, anyUeba.Bytes, staticDomain, staticVersion, staticTypeid); - | if (anyConvResult is $either<$baboonCodecException, $nsJToken>.Left anyConvL) - | { - | throw anyConvL.Value; - | } - | anyInner = (($either<$baboonCodecException, $nsJToken>.Right)anyConvResult).Value; - | } - | else - | { - | throw new $baboonCodecException.EncoderFailure( - | $$"unexpected AnyOpaque subclass: {value.GetType()}"); - | } - | var anyEnvelope = ($nsJObject)$baboonAnyMetaCodec.WriteJson(value.Meta); - | anyEnvelope.Add(AnyEnvelopeContentKey, anyInner); - | return anyEnvelope; - |} - | - |private $baboonAnyOpaqueJson DecodeAnyField(byte expectedKind, $nsJToken? wire) - |{ - | if (wire is null) - | { - | throw new $baboonCodecException.DecoderFailure( - | "any: missing JSON envelope (null token)"); - | } - | var anyMetaResult = $baboonAnyMetaCodec.ReadJson(wire); - | if (anyMetaResult is $either<$baboonCodecException, $baboonAnyMeta>.Left anyMetaL) - | { - | throw anyMetaL.Value; - | } - | var anyMeta = (($either<$baboonCodecException, $baboonAnyMeta>.Right)anyMetaResult).Value; - | if (anyMeta.Kind != expectedKind) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: wire kind 0x{anyMeta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); - | } - | var anyContent = wire[AnyEnvelopeContentKey]; - | if (anyContent is null) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: JSON envelope missing '{AnyEnvelopeContentKey}' content key"); - | } - | return new $baboonAnyOpaqueJson(anyMeta, anyContent); - |}""".stripMargin - } - - // Deep walk (mirrors PR 3.2's UEBA `hasAnyField` and PR 2.3's Scala helper): a codec object needs - // the any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-object `EncodeAnyField` helper; this site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` below. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[CSValue]): TextTree[CSValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"EncodeAnyField(ctx, ($csByte)$expectedHex, $staticDom, $staticVer, $staticTid, $ref)" + val plan = AnyFieldPlan.forField(a, domain) + val expectedHex = "0x%02x".format(plan.kind & 0xFF) + def fallback(value: Option[String]): TextTree[CSValue] = value.fold(q"($csString?)null")(v => q"""($csString?)"$v"""") + q"$baboonAnyJsonCodec.Encode(ctx, ($csByte)$expectedHex, ${fallback(plan.staticDomain)}, ${fallback(plan.staticVersion)}, ${fallback(plan.staticTypeId)}, $ref)" } - // Decode delegates to the per-codec-object `DecodeAnyField` helper. JSON decode never cross- - // converts (always returns `AnyOpaqueJson` from JSON wire); user calls `facade.DecodeAny(opaque)` - // for typed resolution. No `ctx` / no static fallbacks needed at the decode site. private def mkAnyDecoder(a: TypeRef.Any, ref: TextTree[CSValue]): TextTree[CSValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"DecodeAnyField(($csByte)$expectedHex, $ref)" - } - - // Static fallbacks for the cross-format facade helper (`UebaToJson`). The wire `meta` may omit - // components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. See `BaboonCodecsFacade.BuildSyntheticTypeMeta` - // for the merge semantics. Per spec table: - // A=(None,None,None), B=(currentDomain,None,None), C=(currentDomain,currentVersion,None), - // D1=(None,None,underlyingFqid), D2=(currentDomain,None,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[CSValue], TextTree[CSValue], TextTree[CSValue]) = { - val none = q"($csString?)null" - def some(s: String) = q"""($csString?)"$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) + val expectedHex = "0x%02x".format(AnyFieldPlan.forField(a, domain).kind & 0xFF) + q"$baboonAnyJsonCodec.Decode(($csByte)$expectedHex, $ref)" } def codecName(id: TypeId.User): CSValue.CSType = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala new file mode 100644 index 000000000..ddd6cbcc6 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala @@ -0,0 +1,73 @@ +package io.septimalmind.baboon.translator.csharp + +import io.septimalmind.baboon.translator.csharp.CSTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +object CSScalarCodecEmitter { + sealed trait JsonBooleanFormat + object JsonBooleanFormat { + case object BooleanValue extends JsonBooleanFormat + case object LowercaseString extends JsonBooleanFormat + } + + def jsonDecode(b: TypeId.BuiltinScalar, wire: TextTree[CSValue]): TextTree[CSValue] = { + val fref = q"$wire!" + b match { + case TypeId.Builtins.bit => q"$fref.Value<$csBoolean>()!" + case TypeId.Builtins.i08 => q"$fref.Value<$csSByte>()!" + case TypeId.Builtins.i16 => q"$fref.Value<$csInt16>()!" + case TypeId.Builtins.i32 => q"$fref.Value<$csInt32>()!" + case TypeId.Builtins.i64 => q"$fref.Value<$csInt64>()!" + case TypeId.Builtins.u08 => q"$fref.Value<$csByte>()!" + case TypeId.Builtins.u16 => q"$fref.Value<$csUInt16>()!" + case TypeId.Builtins.u32 => q"$fref.Value<$csUInt32>()!" + case TypeId.Builtins.u64 => q"$fref.Value<$csUInt64>()!" + case TypeId.Builtins.f32 => q"$fref.Value<$csSingle>()!" + case TypeId.Builtins.f64 => q"$fref.Value<$csDouble>()!" + case TypeId.Builtins.f128 => q"$BaboonTools.ReadDecimalLenient($fref)" + case TypeId.Builtins.str => q"$fref.Value<$csString>()!" + case TypeId.Builtins.bytes => q"$csByteString.Parse($fref.Value<$csString>()!)" + case TypeId.Builtins.uid => q"$csGuid.Parse($fref.Value<$csString>()!)" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.FromString($fref.Value<$csString>()!)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + } + + def jsonEncode(b: TypeId.BuiltinScalar, value: TextTree[CSValue], booleanFormat: JsonBooleanFormat): TextTree[CSValue] = b match { + case TypeId.Builtins.bytes => q"new $nsJValue($value.Encode())" + case TypeId.Builtins.uid => q"new $nsJValue($value.ToString())" + case TypeId.Builtins.tsu => q"new $nsJValue($baboonTimeFormats.TsuToString($value))" + case TypeId.Builtins.tso => q"new $nsJValue($baboonTimeFormats.TsoToString($value))" + case TypeId.Builtins.bit if booleanFormat == JsonBooleanFormat.LowercaseString => q"new $nsJValue($value.ToString().ToLowerInvariant())" + case _: TypeId.BuiltinScalar => q"new $nsJValue($value)" + } + + def uebaDecode(b: TypeId.BuiltinScalar, br: TextTree[CSValue]): TextTree[CSValue] = b match { + case TypeId.Builtins.bit => q"$br.ReadBoolean()" + case TypeId.Builtins.i08 => q"$br.ReadSByte()" + case TypeId.Builtins.i16 => q"$br.ReadInt16()" + case TypeId.Builtins.i32 => q"$br.ReadInt32()" + case TypeId.Builtins.i64 => q"$br.ReadInt64()" + case TypeId.Builtins.u08 => q"$br.ReadByte()" + case TypeId.Builtins.u16 => q"$br.ReadUInt16()" + case TypeId.Builtins.u32 => q"$br.ReadUInt32()" + case TypeId.Builtins.u64 => q"$br.ReadUInt64()" + case TypeId.Builtins.f32 => q"$br.ReadSingle()" + case TypeId.Builtins.f64 => q"$br.ReadDouble()" + case TypeId.Builtins.f128 => q"$br.ReadDecimal()" + case TypeId.Builtins.str => q"$br.ReadString()" + case TypeId.Builtins.bytes => q"$csByteString.ReadBytes($br)" + case TypeId.Builtins.uid => q"new $csGuid($br.ReadBytes(16))" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.DecodeFromBin($br)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaEncode(b: TypeId.BuiltinScalar, bw: TextTree[CSValue], value: TextTree[CSValue]): TextTree[CSValue] = b match { + case TypeId.Builtins.bytes => q"$csByteString.WriteBytes($value, $bw)" + case TypeId.Builtins.uid => q"$bw.Write($value.ToByteArray())" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.EncodeToBin($value, $bw)" + case _: TypeId.BuiltinScalar => q"$bw.Write($value)" + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala new file mode 100644 index 000000000..a2a6223f3 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala @@ -0,0 +1,17 @@ +package io.septimalmind.baboon.translator.csharp + +import io.septimalmind.baboon.translator.ResolvedServiceResult +import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} +import izumi.fundamentals.platform.strings.TextTree + +final class CSServiceMethodPlan( + val method: Typedef.MethodDef, + resolveType: TypeRef => TextTree[CSValue], + result: ResolvedServiceResult, +) { + val methodName: String = CSTypes.escapeCsKeyword(method.name.name.capitalize) + val hasError: Boolean = method.err.isDefined && !result.noErrors + lazy val input: TextTree[CSValue] = resolveType(method.sig) + lazy val output: Option[TextTree[CSValue]] = method.out.map(resolveType) + lazy val error: Option[TextTree[CSValue]] = method.err.map(resolveType) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala index 089dd8f5d..b7069655a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala @@ -78,80 +78,27 @@ object CSServiceWiringTranslator { // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). private def jsonDecodeExpr(id: TypeId, wire: TextTree[CSValue]): TextTree[CSValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.Instance.Decode($codecCtxName, $wire)" - case b: TypeId.BuiltinScalar => - val fref = q"$wire!" - b match { - case TypeId.Builtins.bit => q"$fref.Value<$csBoolean>()!" - case TypeId.Builtins.i08 => q"$fref.Value<$csSByte>()!" - case TypeId.Builtins.i16 => q"$fref.Value<$csInt16>()!" - case TypeId.Builtins.i32 => q"$fref.Value<$csInt32>()!" - case TypeId.Builtins.i64 => q"$fref.Value<$csInt64>()!" - case TypeId.Builtins.u08 => q"$fref.Value<$csByte>()!" - case TypeId.Builtins.u16 => q"$fref.Value<$csUInt16>()!" - case TypeId.Builtins.u32 => q"$fref.Value<$csUInt32>()!" - case TypeId.Builtins.u64 => q"$fref.Value<$csUInt64>()!" - case TypeId.Builtins.f32 => q"$fref.Value<$csSingle>()!" - case TypeId.Builtins.f64 => q"$fref.Value<$csDouble>()!" - case TypeId.Builtins.f128 => q"$BaboonTools.ReadDecimalLenient($fref)" - case TypeId.Builtins.str => q"$fref.Value<$csString>()!" - case TypeId.Builtins.bytes => q"$csByteString.Parse($fref.Value<$csString>()!)" - case TypeId.Builtins.uid => q"$csGuid.Parse($fref.Value<$csString>()!)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.FromString($fref.Value<$csString>()!)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.Instance.Decode($codecCtxName, $wire)" + case b: TypeId.BuiltinScalar => CSScalarCodecEmitter.jsonDecode(b, wire) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[CSValue]): TextTree[CSValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.Instance.Encode($codecCtxName, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bytes => q"new $nsJValue($value.Encode())" - case TypeId.Builtins.uid => q"new $nsJValue($value.ToString())" - case TypeId.Builtins.tsu => q"new $nsJValue($baboonTimeFormats.TsuToString($value))" - case TypeId.Builtins.tso => q"new $nsJValue($baboonTimeFormats.TsoToString($value))" - case TypeId.Builtins.bit => q"new $nsJValue($value.ToString().ToLowerInvariant())" - case _: TypeId.BuiltinScalar => q"new $nsJValue($value)" - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.Instance.Encode($codecCtxName, $value)" + case b: TypeId.BuiltinScalar => CSScalarCodecEmitter.jsonEncode(b, value, CSScalarCodecEmitter.JsonBooleanFormat.LowercaseString) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, br: TextTree[CSValue]): TextTree[CSValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.Instance.Decode($codecCtxName, $br)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$br.ReadBoolean()" - case TypeId.Builtins.i08 => q"$br.ReadSByte()" - case TypeId.Builtins.i16 => q"$br.ReadInt16()" - case TypeId.Builtins.i32 => q"$br.ReadInt32()" - case TypeId.Builtins.i64 => q"$br.ReadInt64()" - case TypeId.Builtins.u08 => q"$br.ReadByte()" - case TypeId.Builtins.u16 => q"$br.ReadUInt16()" - case TypeId.Builtins.u32 => q"$br.ReadUInt32()" - case TypeId.Builtins.u64 => q"$br.ReadUInt64()" - case TypeId.Builtins.f32 => q"$br.ReadSingle()" - case TypeId.Builtins.f64 => q"$br.ReadDouble()" - case TypeId.Builtins.f128 => q"$br.ReadDecimal()" - case TypeId.Builtins.str => q"$br.ReadString()" - case TypeId.Builtins.bytes => q"$csByteString.ReadBytes($br)" - case TypeId.Builtins.uid => q"new $csGuid($br.ReadBytes(16))" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.DecodeFromBin($br)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.Instance.Decode($codecCtxName, $br)" + case b: TypeId.BuiltinScalar => CSScalarCodecEmitter.uebaDecode(b, br) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, bw: TextTree[CSValue], value: TextTree[CSValue]): TextTree[CSValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.Instance.Encode($codecCtxName, $bw, $value);" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bytes => q"$csByteString.WriteBytes($value, $bw);" - case TypeId.Builtins.uid => q"$bw.Write($value.ToByteArray());" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.EncodeToBin($value, $bw);" - case _: TypeId.BuiltinScalar => q"$bw.Write($value);" - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.Instance.Encode($codecCtxName, $bw, $value);" + case b: TypeId.BuiltinScalar => q"${CSScalarCodecEmitter.uebaEncode(b, bw, value)};" + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def renderContainer(error: String, success: String): String = { @@ -273,15 +220,16 @@ object CSServiceWiringTranslator { // under async. The transport callback becomes a `Task`-returning // delegate so the client `await`s it. val awaitTransport = if (isAsync) "await " else "" - def clientRet(out: Option[TypeRef]): TextTree[CSValue] = out match { - case Some(o) => taskWrap(trans.asCsRef(o, domain, evo)) + def clientRet(out: Option[TextTree[CSValue]]): TextTree[CSValue] = out match { + case Some(o) => taskWrap(o) case None => if (isAsync) q"System.Threading.Tasks.Task" else q"void" } val clientMethods = service.methods.flatMap { m => - val inRef = trans.asCsRef(m.sig, domain, evo) - val retType = clientRet(m.out) + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val inRef = plan.input + val retType = clientRet(plan.output) val uebaMethod = if (hasUeba) { val encodeIn = uebaEncodeStmt(m.sig.id.asInstanceOf[TypeId.Scalar], q"bw", q"arg") @@ -294,13 +242,13 @@ object CSServiceWiringTranslator { case None => q"return;" } Some( - q"""public $asyncKw$retType ${escapeCsKeyword(m.name.name.capitalize)}($ctxParamDecl$inRef arg, $baboonCodecContext $codecCtxName) + q"""public $asyncKw$retType ${plan.methodName}($ctxParamDecl$inRef arg, $baboonCodecContext $codecCtxName) |{ | var oms = new $memoryStream(); | var bw = new $binaryWriter(oms); | $encodeIn | bw.Flush(); - | var resp = ${awaitTransport}_transportUeba(${ctxArgPass}"$svcName", "${m.name.name}", oms.ToArray()); + | var resp = ${awaitTransport}_transportUeba($ctxArgPass"$svcName", "${m.name.name}", oms.ToArray()); | ${decodeOut.shift(4).trim} |}""".stripMargin ) @@ -315,10 +263,10 @@ object CSServiceWiringTranslator { case None => q"return;" } Some( - q"""public $asyncKw$retType ${escapeCsKeyword(m.name.name.capitalize)}Json($ctxParamDecl$inRef arg, $baboonCodecContext $codecCtxName) + q"""public $asyncKw$retType ${plan.methodName}Json($ctxParamDecl$inRef arg, $baboonCodecContext $codecCtxName) |{ | var encoded = $encodeIn; - | var resp = ${awaitTransport}_transportJson(${ctxArgPass}"$svcName", "${m.name.name}", encoded.ToString($nsFormatting.None)); + | var resp = ${awaitTransport}_transportJson($ctxArgPass"$svcName", "${m.name.name}", encoded.ToString($nsFormatting.None)); | ${decodeOut.shift(4).trim} |}""".stripMargin ) @@ -394,14 +342,14 @@ object CSServiceWiringTranslator { // to avoid the duplicate-parameter collision; in `none` mode it stays // `ctx`, keeping that output byte-identical. private def svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } private def svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } private def codecCtxName: String = resolvedCtx match { case ResolvedServiceContext.NoContext => "ctx" @@ -449,10 +397,10 @@ object CSServiceWiringTranslator { isJson: Boolean, retType: TextTree[CSValue], ): TextTree[CSValue] = { - val svcName = service.id.name.name - val wireType = if (isJson) q"$csString" else q"byte[]" - val invokerName = if (isJson) "InvokeJson" else "InvokeUeba" - val wrapperName = s"${svcName}${if (isJson) "JsonService" else "UebaService"}" + val svcName = service.id.name.name + val wireType = if (isJson) q"$csString" else q"byte[]" + val invokerName = if (isJson) "InvokeJson" else "InvokeUeba" + val wrapperName = s"$svcName${if (isJson) "JsonService" else "UebaService"}" val svcType: TextTree[CSValue] = q"I${svcName.capitalize}$genericParam" // The implemented runtime contract follows the context mode: `none` keeps @@ -470,7 +418,7 @@ object CSServiceWiringTranslator { val fieldDecls: List[TextTree[CSValue]] = q"private readonly $svcType _impl;" :: - rtField.toList.map { case (n, t) => q"private readonly $t _$n;" } + rtField.toList.map { case (n, t) => q"private readonly $t _$n;" } val ctorParams: TextTree[CSValue] = { val all = q"$svcType impl" :: rtField.toList.map { case (n, t) => q"$t $n" } @@ -503,6 +451,130 @@ object CSServiceWiringTranslator { |}""".stripMargin } + private def noErrorsInvocation(plan: CSServiceMethodPlan): TextTree[CSValue] = + plan.method.out match { + case Some(_) => q"var result = ${awaitKw}impl.${plan.methodName}(${ctxArgPass}decoded);" + case None => q"${awaitKw}impl.${plan.methodName}(${ctxArgPass}decoded);" + } + + private def errorsInvocation(plan: CSServiceMethodPlan): TextTree[CSValue] = plan.output match { + case Some(outType) => + if (plan.hasError) { + val errType = plan.error.get + q"""try + |{ + | var callResult = impl.${plan.methodName}(${ctxArgPass}v); + | return rt.LeftMap<$errType, $outType, $baboonWiringError>( + | callResult, err => new $baboonWiringError.CallFailed(method, err)); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else { + q"""try + |{ + | return rt.Pure<$baboonWiringError, $outType>(impl.${plan.methodName}(${ctxArgPass}v)); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } + case None => + if (plan.hasError) { + val errType = plan.error.get + q"""try + |{ + | var callResult = impl.${plan.methodName}(${ctxArgPass}v); + | return rt.LeftMap<$errType, $unit, $baboonWiringError>( + | callResult, err => new $baboonWiringError.CallFailed(method, err)); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else { + q"""try + |{ + | impl.${plan.methodName}(${ctxArgPass}v); + | return rt.Pure<$baboonWiringError, $unit>($unit.Default); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } + } + + private def errorsAsyncInvocation( + plan: CSServiceMethodPlan, + wireType: TextTree[CSValue], + emptyWire: TextTree[CSValue], + encodeFlatMap: TextTree[CSValue], + ): TextTree[CSValue] = { + val hasErrType = plan.hasError + plan.method.out match { + case Some(_) => + val outType = plan.output.get + + val callStep = if (hasErrType) { + val errType = plan.error.get + q"""${ct("BaboonWiringError", renderFq(outType))} output; + |try + |{ + | var callResult = await impl.${plan.methodName}(${ctxArgPass}decoded); + | output = rt.LeftMap<$errType, $outType, $baboonWiringError>( + | callResult, err => new $baboonWiringError.CallFailed(method, err)); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $wireType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else { + q"""${ct("BaboonWiringError", renderFq(outType))} output; + |try + |{ + | var callResultValue = await impl.${plan.methodName}(${ctxArgPass}decoded); + | output = rt.Pure<$baboonWiringError, $outType>(callResultValue); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $wireType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } + + q"""$callStep + |${encodeFlatMap.trim}""".stripMargin + + case None => + if (hasErrType) { + val errType = plan.error.get + q"""try + |{ + | var callResult = await impl.${plan.methodName}(${ctxArgPass}decoded); + | var mapped = rt.LeftMap<$errType, $unit, $baboonWiringError>( + | callResult, err => new $baboonWiringError.CallFailed(method, err)); + | return rt.FlatMap<$baboonWiringError, $unit, $wireType>(mapped, v => rt.Pure<$baboonWiringError, $wireType>($emptyWire)); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $wireType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else { + q"""try + |{ + | await impl.${plan.methodName}(${ctxArgPass}decoded); + | return rt.Pure<$baboonWiringError, $wireType>($emptyWire); + |} + |catch ($csException ex) + |{ + | return rt.Fail<$baboonWiringError, $wireType>(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } + } + } + // ========== noErrors mode ========== private def generateNoErrorsWiring(service: Typedef.Service): TextTree[CSValue] = { @@ -539,6 +611,7 @@ object CSServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -550,10 +623,7 @@ object CSServiceWiringTranslator { q"""return "null";""" } - val callExpr = m.out match { - case Some(_) => q"var result = ${awaitKw}impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded);" - case None => q"${awaitKw}impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded);" - } + val callExpr = noErrorsInvocation(plan) q"""case "${m.name.name}": |{ @@ -583,6 +653,7 @@ object CSServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -597,10 +668,7 @@ object CSServiceWiringTranslator { q"""return new byte[0];""" } - val callExpr = m.out match { - case Some(_) => q"var result = ${awaitKw}impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded);" - case None => q"${awaitKw}impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded);" - } + val callExpr = noErrorsInvocation(plan) q"""case "${m.name.name}": |{ @@ -679,107 +747,63 @@ object CSServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asCsRef(m.sig, domain, evo) + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") - if (isAsync) generateErrorsJsonCaseAsync(m, inRef, decodeIn) + if (isAsync) generateErrorsJsonCaseAsync(plan, inRef, decodeIn) else { - val decodeStep = - q"""${ct("BaboonWiringError", renderFq(inRef))} input; - |try - |{ - | var wire = $BaboonTools.ParseWireJson(data); - | input = rt.Pure<$baboonWiringError, $inRef>($decodeIn); - |} - |catch ($csException ex) - |{ - | input = rt.Fail<$baboonWiringError, $inRef>(new $baboonWiringError.DecoderFailed(method, ex)); - |}""".stripMargin - - val hasErrType = m.err.isDefined && !resolved.noErrors - - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) - val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") - - val callBody = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.LeftMap<$errType, $outType, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try - |{ - | return rt.Pure<$baboonWiringError, $outType>(impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - - q"""var output = rt.FlatMap<$baboonWiringError, $inRef, $outType>(input, v => + val decodeStep = + q"""${ct("BaboonWiringError", renderFq(inRef))} input; + |try |{ - | ${callBody.shift(4).trim} - |}); - |return rt.FlatMap<$baboonWiringError, $outType, $csString>(output, v => + | var wire = $BaboonTools.ParseWireJson(data); + | input = rt.Pure<$baboonWiringError, $inRef>($decodeIn); + |} + |catch ($csException ex) |{ - | try - | { - | var encoded = $encodeOut; - | return rt.Pure<$baboonWiringError, $csString>(encoded.ToString($nsFormatting.None)); - | } - | catch ($csException ex) - | { - | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.EncoderFailed(method, ex)); - | } - |});""".stripMargin + | input = rt.Fail<$baboonWiringError, $inRef>(new $baboonWiringError.DecoderFailed(method, ex)); + |}""".stripMargin - case None => - val callBody = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.LeftMap<$errType, $unit, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) + val callAndEncodeStep = m.out match { + case Some(outRef) => + val outType = plan.output.get + val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") + + val callBody = errorsInvocation(plan) + + q"""var output = rt.FlatMap<$baboonWiringError, $inRef, $outType>(input, v => |{ - | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try + | ${callBody.shift(4).trim} + |}); + |return rt.FlatMap<$baboonWiringError, $outType, $csString>(output, v => |{ - | impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.Pure<$baboonWiringError, $unit>($unit.Default); - |} - |catch ($csException ex) + | try + | { + | var encoded = $encodeOut; + | return rt.Pure<$baboonWiringError, $csString>(encoded.ToString($nsFormatting.None)); + | } + | catch ($csException ex) + | { + | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.EncoderFailed(method, ex)); + | } + |});""".stripMargin + + case None => + val callBody = errorsInvocation(plan) + + q"""return rt.FlatMap<$baboonWiringError, $inRef, $csString>(input, v => |{ - | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + | ${callBody.shift(4).trim} + | return rt.Pure<$baboonWiringError, $csString>("null"); + |});""".stripMargin + } - q"""return rt.FlatMap<$baboonWiringError, $inRef, $csString>(input, v => - |{ - | ${callBody.shift(4).trim} - | return rt.Pure<$baboonWiringError, $csString>("null"); - |});""".stripMargin - } - - q"""case "${m.name.name}": - |{ - | ${decodeStep.shift(4).trim} - | ${callAndEncodeStep.shift(4).trim} - |}""".stripMargin + q"""case "${m.name.name}": + |{ + | ${decodeStep.shift(4).trim} + | ${callAndEncodeStep.shift(4).trim} + |}""".stripMargin } }.join("\n") @@ -808,11 +832,11 @@ object CSServiceWiringTranslator { * `rt.Pure` / `rt.Fail` boundaries, identical to the sync arm's results. */ private def generateErrorsJsonCaseAsync( - m: Typedef.MethodDef, + plan: CSServiceMethodPlan, inRef: TextTree[CSValue], decodeIn: TextTree[CSValue], ): TextTree[CSValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val m = plan.method val decodeStep = q"""$inRef decoded; @@ -831,7 +855,7 @@ object CSServiceWiringTranslator { // first, then the already-resolved container is threaded synchronously. val encodeFlatMap = m.out match { case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) + val outType = plan.output.get val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") q"""return rt.FlatMap<$baboonWiringError, $outType, $csString>(output, v => |{ @@ -848,65 +872,7 @@ object CSServiceWiringTranslator { case None => q"" } - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) - - val callStep = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""${ct("BaboonWiringError", renderFq(outType))} output; - |try - |{ - | var callResult = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | output = rt.LeftMap<$errType, $outType, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""${ct("BaboonWiringError", renderFq(outType))} output; - |try - |{ - | var callResultValue = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | output = rt.Pure<$baboonWiringError, $outType>(callResultValue); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - - q"""$callStep - |${encodeFlatMap.trim}""".stripMargin - - case None => - if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | var mapped = rt.LeftMap<$errType, $unit, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - | return rt.FlatMap<$baboonWiringError, $unit, $csString>(mapped, v => rt.Pure<$baboonWiringError, $csString>("null")); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try - |{ - | await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | return rt.Pure<$baboonWiringError, $csString>("null"); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $csString>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - } + val callAndEncodeStep = errorsAsyncInvocation(plan, q"$csString", q""""null"""", encodeFlatMap) q"""case "${m.name.name}": |{ @@ -921,111 +887,67 @@ object CSServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asCsRef(m.sig, domain, evo) + val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") - if (isAsync) generateErrorsUebaCaseAsync(m, inRef, decodeIn) + if (isAsync) generateErrorsUebaCaseAsync(plan, inRef, decodeIn) else { - val decodeStep = - q"""${ct("BaboonWiringError", renderFq(inRef))} input; - |try - |{ - | var ms = new $memoryStream(data); - | var br = new $binaryReader(ms); - | input = rt.Pure<$baboonWiringError, $inRef>($decodeIn); - |} - |catch ($csException ex) - |{ - | input = rt.Fail<$baboonWiringError, $inRef>(new $baboonWiringError.DecoderFailed(method, ex)); - |}""".stripMargin - - val hasErrType = m.err.isDefined && !resolved.noErrors - - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) - val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") - - val callBody = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.LeftMap<$errType, $outType, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try - |{ - | return rt.Pure<$baboonWiringError, $outType>(impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, $outType>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - - q"""var output = rt.FlatMap<$baboonWiringError, $inRef, $outType>(input, v => + val decodeStep = + q"""${ct("BaboonWiringError", renderFq(inRef))} input; + |try |{ - | ${callBody.shift(4).trim} - |}); - |return rt.FlatMap<$baboonWiringError, $outType, byte[]>(output, v => + | var ms = new $memoryStream(data); + | var br = new $binaryReader(ms); + | input = rt.Pure<$baboonWiringError, $inRef>($decodeIn); + |} + |catch ($csException ex) |{ - | try - | { - | var oms = new $memoryStream(); - | var bw = new $binaryWriter(oms); - | $encStmt - | bw.Flush(); - | return rt.Pure<$baboonWiringError, byte[]>(oms.ToArray()); - | } - | catch ($csException ex) - | { - | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.EncoderFailed(method, ex)); - | } - |});""".stripMargin + | input = rt.Fail<$baboonWiringError, $inRef>(new $baboonWiringError.DecoderFailed(method, ex)); + |}""".stripMargin - case None => - val callBody = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.LeftMap<$errType, $unit, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) + val callAndEncodeStep = m.out match { + case Some(outRef) => + val outType = plan.output.get + val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") + + val callBody = errorsInvocation(plan) + + q"""var output = rt.FlatMap<$baboonWiringError, $inRef, $outType>(input, v => |{ - | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try + | ${callBody.shift(4).trim} + |}); + |return rt.FlatMap<$baboonWiringError, $outType, byte[]>(output, v => |{ - | impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}v); - | return rt.Pure<$baboonWiringError, $unit>($unit.Default); - |} - |catch ($csException ex) + | try + | { + | var oms = new $memoryStream(); + | var bw = new $binaryWriter(oms); + | $encStmt + | bw.Flush(); + | return rt.Pure<$baboonWiringError, byte[]>(oms.ToArray()); + | } + | catch ($csException ex) + | { + | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.EncoderFailed(method, ex)); + | } + |});""".stripMargin + + case None => + val callBody = errorsInvocation(plan) + + q"""return rt.FlatMap<$baboonWiringError, $inRef, byte[]>(input, v => |{ - | return rt.Fail<$baboonWiringError, $unit>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + | ${callBody.shift(4).trim} + | return rt.Pure<$baboonWiringError, byte[]>(new byte[0]); + |});""".stripMargin + } - q"""return rt.FlatMap<$baboonWiringError, $inRef, byte[]>(input, v => - |{ - | ${callBody.shift(4).trim} - | return rt.Pure<$baboonWiringError, byte[]>(new byte[0]); - |});""".stripMargin - } - - q"""case "${m.name.name}": - |{ - | ${decodeStep.shift(4).trim} - | ${callAndEncodeStep.shift(4).trim} - |}""".stripMargin + q"""case "${m.name.name}": + |{ + | ${decodeStep.shift(4).trim} + | ${callAndEncodeStep.shift(4).trim} + |}""".stripMargin } }.join("\n") @@ -1049,11 +971,11 @@ object CSServiceWiringTranslator { * [[generateErrorsJsonCaseAsync]] for the await-then-thread rationale. */ private def generateErrorsUebaCaseAsync( - m: Typedef.MethodDef, + plan: CSServiceMethodPlan, inRef: TextTree[CSValue], decodeIn: TextTree[CSValue], ): TextTree[CSValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val m = plan.method val decodeStep = q"""$inRef decoded; @@ -1070,7 +992,7 @@ object CSServiceWiringTranslator { val encodeFlatMap = m.out match { case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) + val outType = plan.output.get val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") q"""return rt.FlatMap<$baboonWiringError, $outType, byte[]>(output, v => |{ @@ -1090,65 +1012,7 @@ object CSServiceWiringTranslator { case None => q"" } - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asCsRef(outRef, domain, evo) - - val callStep = if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""${ct("BaboonWiringError", renderFq(outType))} output; - |try - |{ - | var callResult = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | output = rt.LeftMap<$errType, $outType, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""${ct("BaboonWiringError", renderFq(outType))} output; - |try - |{ - | var callResultValue = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | output = rt.Pure<$baboonWiringError, $outType>(callResultValue); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - - q"""$callStep - |${encodeFlatMap.trim}""".stripMargin - - case None => - if (hasErrType) { - val errType = trans.asCsRef(m.err.get, domain, evo) - q"""try - |{ - | var callResult = await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | var mapped = rt.LeftMap<$errType, $unit, $baboonWiringError>( - | callResult, err => new $baboonWiringError.CallFailed(method, err)); - | return rt.FlatMap<$baboonWiringError, $unit, byte[]>(mapped, v => rt.Pure<$baboonWiringError, byte[]>(new byte[0])); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try - |{ - | await impl.${escapeCsKeyword(m.name.name.capitalize)}(${ctxArgPass}decoded); - | return rt.Pure<$baboonWiringError, byte[]>(new byte[0]); - |} - |catch ($csException ex) - |{ - | return rt.Fail<$baboonWiringError, byte[]>(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } - } + val callAndEncodeStep = errorsAsyncInvocation(plan, q"byte[]", q"new byte[0]", encodeFlatMap) q"""case "${m.name.name}": |{ diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeInfo.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeInfo.scala index 46a7d572a..d7cde4363 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeInfo.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeInfo.scala @@ -78,11 +78,11 @@ class CSTypeInfo(target: CSTarget, enquiries: BaboonEnquiries) { val ownerAdt = dom.defs.meta.nodes(o.id).asInstanceOf[DomainMember.User].defn.asInstanceOf[Typedef.Adt] val allowedUpgrades = possibleAdtUpgrades(ownerAdt, version, lineage, higher) + lazy val upgradeVersions = possibleUpgrades(o.id, version, lineage).toSet allowedUpgrades.filter { higherTwinVersion => - val upgradeVersions = possibleUpgrades(o.id, version, lineage) - val hardCriterion = upgradeVersions.contains(higherTwinVersion) + val hardCriterion = upgradeVersions.contains(higherTwinVersion) hardCriterion } } else { @@ -102,7 +102,8 @@ class CSTypeInfo(target: CSTarget, enquiries: BaboonEnquiries) { private def possibleAdtUpgrades(adt: Typedef.Adt, version: Version, lineage: BaboonLineage, higher: List[Version]): List[Version] = { // maximum version to which EVERY branch can be upgraded - val maxUpgrade = higher.filter(mh => adt.members.map(m => possibleUpgrades(m, version, lineage, checkOwner = false)).forall(_.contains(mh))) + lazy val branchUpgrades = adt.members.map(m => possibleUpgrades(m, version, lineage, checkOwner = false).toSet) + val maxUpgrade = higher.filter(mh => branchUpgrades.forall(_.contains(mh))) maxUpgrade } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypes.scala index f2806ab90..2252791df 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypes.scala @@ -95,6 +95,8 @@ object CSTypes { val baboonAnyOpaqueJson: CSType = CSType(baboonRuntimePkg, "AnyOpaqueJson", fq = false, CSTypeOrigin.Other) val baboonAnyMeta: CSType = CSType(baboonRuntimePkg, "AnyMeta", fq = false, CSTypeOrigin.Other) val baboonAnyMetaCodec: CSType = CSType(baboonRuntimePkg, "AnyMetaCodec", fq = false, CSTypeOrigin.Other) + val baboonAnyJsonCodec: CSType = CSType(baboonRuntimePkg, "BaboonAnyJsonCodec", fq = false, CSTypeOrigin.Other) + val baboonAnyBinCodec: CSType = CSType(baboonRuntimePkg, "BaboonAnyBinCodec", fq = false, CSTypeOrigin.Other) val baboonCodecException: CSType = CSType(baboonRuntimePkg, "BaboonCodecException", fq = false, CSTypeOrigin.Other) // Baboon type @@ -194,15 +196,83 @@ object CSTypes { * verbatim-identifier `@` prefix. Source: C# language specification §6.4.4 (Keywords). */ val csReservedKeywords: Set[String] = Set( - "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", - "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", - "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", - "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", - "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", - "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", - "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", - "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", - "void", "volatile", "while", + "abstract", + "as", + "base", + "bool", + "break", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "do", + "double", + "else", + "enum", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "goto", + "if", + "implicit", + "in", + "int", + "interface", + "internal", + "is", + "lock", + "long", + "namespace", + "new", + "null", + "object", + "operator", + "out", + "override", + "params", + "private", + "protected", + "public", + "readonly", + "ref", + "return", + "sbyte", + "sealed", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unsafe", + "ushort", + "using", + "virtual", + "void", + "volatile", + "while", ) /** Escape a C# identifier with a verbatim-identifier `@` prefix when it collides with a reserved diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala index 288cb6b3f..d4dd79d26 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSTypeOrigin import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* import izumi.fundamentals.platform.strings.TextTree.style.c.* @@ -148,8 +148,7 @@ class CSUEBACodecGenerator( |}""".stripMargin }.toList - val anyHelpers: List[TextTree[CSValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val methods = indexMethods ++ encoderMethods ++ decoderMethods ++ anyHelpers + val methods = indexMethods ++ encoderMethods ++ decoderMethods val cName = codecName(srcRef, CSTypeOrigin(defn.id, domain)) val cParent = if (isEncoderEnabled) { @@ -358,11 +357,11 @@ class CSUEBACodecGenerator( } private def dtoDec(name: CSValue.CSType, fields: List[(TextTree[CSValue], TextTree[CSValue])]) = { - q"""var index = ((IBaboonBinCodecIndexed)this).ReadIndex(ctx, wire); + q"""var indexCount = ((IBaboonBinCodecIndexed)this).ConsumeIndex(ctx, wire); | |if (ctx.UseIndices) |{ - | $debug.Assert(index.Count == IndexElementsCount(ctx)); + | $debug.Assert(indexCount == IndexElementsCount(ctx)); |} | |return new $name( @@ -417,158 +416,16 @@ class CSUEBACodecGenerator( } } - // Per-codec-object helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec object that has any - // any-bearing fields. Mirrors `ScUEBACodecGenerator.anyFieldHelpers`. See spec §172 + PR-05-D02. - private def anyFieldHelpers: TextTree[CSValue] = { - q"""private void EncodeAnyField( - | $baboonCodecContext ctx, - | $binaryWriter writer, - | byte expectedKind, - | $csString? staticDomain, - | $csString? staticVersion, - | $csString? staticTypeid, - | $baboonAnyOpaque value) - |{ - | if (value.Meta.Kind != expectedKind) - | { - | throw new $baboonCodecException.EncoderFailure( - | $$"any: meta-kind 0x{value.Meta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); - | } - | byte[] anyBlob; - | if (value is $baboonAnyOpaqueUeba anyUeba) - | { - | anyBlob = anyUeba.Bytes; - | } - | else if (value is $baboonAnyOpaqueJson anyJson) - | { - | var f = ctx.Facade ?? throw new $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueUeba directly."); - | var anyConvResult = f.JsonToUebaBytes(anyJson.Meta, anyJson.Json, staticDomain, staticVersion, staticTypeid); - | if (anyConvResult is $either<$baboonCodecException, byte[]>.Left anyConvL) - | { - | throw anyConvL.Value; - | } - | anyBlob = (($either<$baboonCodecException, byte[]>.Right)anyConvResult).Value; - | } - | else - | { - | throw new $baboonCodecException.EncoderFailure( - | $$"unexpected AnyOpaque subclass: {value.GetType()}"); - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | using var anyMetaBuf = new $memoryStream(); - | using (var anyMetaWriter = new $binaryWriter(anyMetaBuf)) - | { - | $baboonAnyMetaCodec.WriteBin(value.Meta, anyMetaWriter); - | anyMetaWriter.Flush(); - | } - | var anyMetaBytes = anyMetaBuf.ToArray(); - | var anyTotalLength = 4 + anyMetaBytes.Length + anyBlob.Length; - | writer.Write(anyTotalLength); - | writer.Write(anyMetaBytes.Length); - | writer.Write(anyMetaBytes); - | writer.Write(anyBlob); - |} - | - |private $baboonAnyOpaqueUeba DecodeAnyField($binaryReader wire, byte expectedKind) - |{ - | var anyTotalLength = wire.ReadInt32(); - | var anyMetaLength = wire.ReadInt32(); - | if (anyMetaLength < 0) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: invalid meta-length {anyMetaLength}"); - | } - | if (anyTotalLength < 4 + anyMetaLength) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: total-length {anyTotalLength} smaller than 4 + meta-length {anyMetaLength}"); - | } - | var (anyMeta, anyBytesRead) = $baboonAnyMetaCodec.ReadBinWithLength(wire); - | if (anyBytesRead > anyMetaLength) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: meta-bytes-read {anyBytesRead} exceeded meta-length window {anyMetaLength}"); - | } - | if (anyBytesRead < anyMetaLength) - | { - | // Skip future meta extension bytes within the meta-length window (forward-compat). - | var anySkip = anyMetaLength - anyBytesRead; - | wire.ReadBytes(anySkip); - | } - | if (anyMeta.Kind != expectedKind) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: wire kind 0x{anyMeta.Kind & 0xFF:x2} does not match field-declared 0x{expectedKind & 0xFF:x2}"); - | } - | var anyBlobLen = anyTotalLength - 4 - anyMetaLength; - | var anyBlob = wire.ReadBytes(anyBlobLen); - | if (anyBlob.Length != anyBlobLen) - | { - | throw new $baboonCodecException.DecoderFailure( - | $$"any: short read on blob, expected {anyBlobLen} got {anyBlob.Length}"); - | } - | return new $baboonAnyOpaqueUeba(anyMeta, anyBlob); - |}""".stripMargin - } - - // Deep walk (mirrors Scala's hasAnyField): a codec object needs the any-field helpers if any - // direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Wire layout (locked, see docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // The actual framing/kind-check/skip-extension logic lives in the per-codec-object helpers - // emitted by `anyFieldHelpers`; this just wires the expected kind and static fallbacks in. private def mkAnyDecoder(a: TypeRef.Any, wref: TextTree[CSValue]): TextTree[CSValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"DecodeAnyField($wref, ($csByte)$expectedHex)" + val expectedHex = "0x%02x".format(AnyFieldPlan.forField(a, domain).kind & 0xFF) + q"$baboonAnyBinCodec.Decode($wref, ($csByte)$expectedHex)" } - // Encode delegates to the per-codec-object `EncodeAnyField` helper; this site wires the - // expected kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta - // resolution. See `anyStaticFallbacks` below. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[CSValue], wref: TextTree[CSValue]): TextTree[CSValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"EncodeAnyField(ctx, $wref, ($csByte)$expectedHex, $staticDom, $staticVer, $staticTid, $ref)" - } - - // Static fallbacks for the cross-format facade helpers (`JsonToUebaBytes`/`UebaToJson`). The wire - // `meta` may omit components that are pinned by the field's static declaration; the codec emits - // whatever is statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade.BuildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(None,None,None), B=(currentDomain,None,None), C=(currentDomain,currentVersion,None), - // D1=(None,None,underlyingFqid), D2=(currentDomain,None,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[CSValue], TextTree[CSValue], TextTree[CSValue]) = { - val none = q"($csString?)null" - def some(s: String) = q"""($csString?)"$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) + val plan = AnyFieldPlan.forField(a, domain) + val expectedHex = "0x%02x".format(plan.kind & 0xFF) + def fallback(value: Option[String]): TextTree[CSValue] = value.fold(q"($csString?)null")(v => q"""($csString?)"$v"""") + q"$baboonAnyBinCodec.Encode(ctx, $wref, ($csByte)$expectedHex, ${fallback(plan.staticDomain)}, ${fallback(plan.staticVersion)}, ${fallback(plan.staticTypeId)}, $ref)" } private def mkDecoder(tpe: TypeRef, wref: TextTree[CSValue], codecArgs: CodecArguments = CodecArguments.empty): TextTree[CSValue] = { @@ -576,25 +433,7 @@ class CSUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.ReadBoolean()" - case TypeId.Builtins.i08 => q"$wref.ReadSByte()" - case TypeId.Builtins.i16 => q"$wref.ReadInt16()" - case TypeId.Builtins.i32 => q"$wref.ReadInt32()" - case TypeId.Builtins.i64 => q"$wref.ReadInt64()" - case TypeId.Builtins.u08 => q"$wref.ReadByte()" - case TypeId.Builtins.u16 => q"$wref.ReadUInt16()" - case TypeId.Builtins.u32 => q"$wref.ReadUInt32()" - case TypeId.Builtins.u64 => q"$wref.ReadUInt64()" - case TypeId.Builtins.f32 => q"$wref.ReadSingle()" - case TypeId.Builtins.f64 => q"$wref.ReadDouble()" - case TypeId.Builtins.f128 => q"$wref.ReadDecimal()" - case TypeId.Builtins.str => q"$wref.ReadString()" - case TypeId.Builtins.bytes => q"$csByteString.ReadBytes($wref)" - case TypeId.Builtins.uid => q"new $csGuid($wref.ReadBytes(16))" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.DecodeFromBin($wref)" - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + CSScalarCodecEmitter.uebaDecode(s, wref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -643,26 +482,7 @@ class CSUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.Write($ref)" - case TypeId.Builtins.i08 => q"$wref.Write($ref)" - case TypeId.Builtins.i16 => q"$wref.Write($ref)" - case TypeId.Builtins.i32 => q"$wref.Write($ref)" - case TypeId.Builtins.i64 => q"$wref.Write($ref)" - case TypeId.Builtins.u08 => q"$wref.Write($ref)" - case TypeId.Builtins.u16 => q"$wref.Write($ref)" - case TypeId.Builtins.u32 => q"$wref.Write($ref)" - case TypeId.Builtins.u64 => q"$wref.Write($ref)" - case TypeId.Builtins.f32 => q"$wref.Write($ref)" - case TypeId.Builtins.f64 => q"$wref.Write($ref)" - case TypeId.Builtins.f128 => q"$wref.Write($ref)" - case TypeId.Builtins.str => q"$wref.Write($ref)" - case TypeId.Builtins.bytes => q"$csByteString.WriteBytes($ref, $wref)" - case TypeId.Builtins.uid => q"$wref.Write($ref.ToByteArray())" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonTimeFormats.EncodeToBin($ref, $wref)" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + CSScalarCodecEmitter.uebaEncode(s, wref, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CsMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CsMcpServerGenerator.scala index c6c6854db..ee75f06da 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CsMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CsMcpServerGenerator.scala @@ -54,9 +54,10 @@ class CsMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -85,23 +86,26 @@ class CsMcpServerGenerator[F[+_, +_]: Error2]( } } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name - val className = s"${serviceName.capitalize}McpServer" - val modelVer = domain.version.v.toString - val ns = typeTranslator.toCsPkg(domain.id, domain.version, evo).parts.mkString(".") + val className = s"${serviceName.capitalize}McpServer" + val modelVer = domain.version.v.toString + val ns = typeTranslator.toCsPkg(domain.id, domain.version, evo).parts.mkString(".") // Declaration-ordered tool entries (K4 §2.3): one per method. The wire // tool.name and the BaboonMethodId service/method strings stay verbatim // lowercase model names; only C# symbols are PascalCase. val toolEntries: List[String] = svc.methods.toList.map { m => - val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) - val descArg = McpDocs.flatten(m.docs).map(d => s", ${jsonString(d)}").getOrElse("") - s""" new Baboon.Runtime.Shared.McpToolEntry(${csString(toolName)}, new Baboon.Runtime.Shared.BaboonMethodId(${csString(serviceName)}, ${csString(m.name.name)}), Newtonsoft.Json.Linq.JToken.Parse(${csVerbatimJson(schema)})$descArg),""" + val toolName = s"${serviceName}_${m.name.name}" + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) + val descArg = McpDocs.flatten(m.docs).map(d => s", ${jsonString(d)}").getOrElse("") + s""" new Baboon.Runtime.Shared.McpToolEntry(${csString(toolName)}, new Baboon.Runtime.Shared.BaboonMethodId(${csString(serviceName)}, ${csString( + m.name.name + )}), Newtonsoft.Json.Linq.JToken.Parse(${csVerbatimJson(schema)})$descArg),""" } // Async axis (`--cs-async-services=true`): the errors-mode wiring entry @@ -132,12 +136,18 @@ class CsMcpServerGenerator[F[+_, +_]: Error2]( |// per-request `Ctx`. |public sealed class $className : Baboon.Runtime.Shared.$baseClass |{ - | public override Baboon.Runtime.Shared.McpServerInfo ServerInfo { get; } = new Baboon.Runtime.Shared.McpServerInfo(${csString(serviceName)}, ${csString(modelVer)}); + | public override Baboon.Runtime.Shared.McpServerInfo ServerInfo { get; } = new Baboon.Runtime.Shared.McpServerInfo(${csString(serviceName)}, ${csString( + modelVer + )}); | - | public override System.Collections.Generic.IReadOnlyList Tools { get; } = new System.Collections.Generic.List + | private readonly Baboon.Runtime.Shared.McpToolRegistry _tools = new Baboon.Runtime.Shared.McpToolRegistry(new System.Collections.Generic.List | { |${toolEntries.mkString("\n")} - | }; + | }); + | + | public override System.Collections.Generic.IReadOnlyList Tools => _tools.Snapshot(); + | + | protected override bool TryFindTool(string name, out Baboon.Runtime.Shared.BaboonMethodId method) => _tools.TryFind(name, out method); | | private readonly Baboon.Runtime.Shared.$delegateType _invokeJson; | diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtAnyFieldPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtAnyFieldPlan.scala new file mode 100644 index 000000000..a7ccdb576 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtAnyFieldPlan.scala @@ -0,0 +1,17 @@ +package io.septimalmind.baboon.translator.dart + +import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.typer.model.{Domain, TypeRef} +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +private[dart] object DtAnyFieldPlan { + def fallbacks(field: TypeRef.Any, domain: Domain): (TextTree[DtValue], TextTree[DtValue], TextTree[DtValue]) = { + val plan = AnyFieldPlan.forField(field, domain) + def render(value: Option[String]): TextTree[DtValue] = value match { + case Some(v) => q"\"$v\"" + case None => q"null" + } + (render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala index 8587f87e7..ccca0478f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala @@ -101,11 +101,9 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( } } facade <- { - if ( - target.language.generateDomainFacade && + if (target.language.generateDomainFacade && target.output.products.contains(CompilerProduct.Conversion) && - domain.version == evo.latest - ) { + domain.version == evo.latest) { generateDomainFacade(domain, lineage) } else { F.pure(List.empty) @@ -309,7 +307,7 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( val rendered = o.tree.mapRender { case t: DtValue.DtTypeName => trans.escapeDartKeyword(t.name) case t: DtValue.DtType if t.fq => - fqPrefixMap.get(fqFileKey(t)) match { + fqPrefixMap.get(dtFileKey(t)) match { case Some(prefix) => s"$prefix.${trans.escapeDartKeyword(t.name)}" case None => trans.escapeDartKeyword(t.name) } @@ -336,20 +334,35 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( } } - private def dtFileKey(t: DtValue.DtType): String = { - val fileName = t.importAs.getOrElse(trans.toSnakeCase(t.name)) - s"${t.pkg.parts.toList.mkString("/")}/$fileName" + private case class LibraryRef(fileName: String, packageParts: List[String]) { + def physicalKey: String = s"${packageParts.mkString("/")}/$fileName" } - private def fqFileKey(t: DtValue.DtType): String = { - val fileName = t.importAs.getOrElse(trans.toSnakeCase(t.name)) - s"${t.pkg.parts.toList.mkString("/")}/$fileName" + private case class ResolvedLibrary(uri: String, alias: Option[String]) { + def render: String = s"import '$uri'${alias.fold("")(a => s" as $a")};" + } + + private def libraryRef(t: DtValue.DtType): LibraryRef = + LibraryRef(t.importAs.getOrElse(trans.toSnakeCase(t.name)), t.pkg.parts.toList) + + private def dtFileKey(t: DtValue.DtType): String = libraryRef(t).physicalKey + + private def runtimeLibrary(t: DtValue.DtType): Option[ResolvedLibrary] = { + val file = t.pkg match { + case p if p == baboonRuntimePkg => Some("baboon_runtime") + case p if p == baboonAnyOpaquePkg => Some("baboon_any_opaque") + case p if p == baboonCodecsFacadePkg => Some("baboon_codecs_facade") + case p if p == baboonFixturePkg => Some("baboon_fixture") + case p if p == baboonIdReprPkg => Some("baboon_identifier_repr") + case _ => None + } + file.map(name => ResolvedLibrary(s"package:baboon_runtime/$name.dart", None)) } private def buildFqPrefixMap(fqTypes: Seq[DtValue.DtType]): Map[String, String] = { fqTypes.map { t => - val key = fqFileKey(t) + val key = dtFileKey(t) val fileName = t.importAs.getOrElse(trans.toSnakeCase(t.name)) val pkgParts = t.pkg.parts.toList val versionIdx = pkgParts.indexWhere(p => p.startsWith("v") && p.length > 1 && p.lift(1).exists(_.isDigit)) @@ -371,26 +384,20 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( currentDir: String, fqPrefixMap: Map[String, String], ): Option[String] = { + val runtime = runtimeLibrary(t) if (t.pkg == dartCorePkg) { None // dart:core is implicitly imported - } else if (t.pkg == baboonRuntimePkg) { - Some("import 'package:baboon_runtime/baboon_runtime.dart';") - } else if (t.pkg == baboonAnyOpaquePkg) { - Some("import 'package:baboon_runtime/baboon_any_opaque.dart';") - } else if (t.pkg == baboonCodecsFacadePkg) { - Some("import 'package:baboon_runtime/baboon_codecs_facade.dart';") - } else if (t.pkg == baboonFixturePkg) { - Some("import 'package:baboon_runtime/baboon_fixture.dart';") - } else if (t.pkg == baboonIdReprPkg) { - Some("import 'package:baboon_runtime/baboon_identifier_repr.dart';") + } else if (runtime.isDefined) { + runtime.map(_.render) } else { - val fileName = t.importAs.getOrElse(trans.toSnakeCase(t.name)) - val typePath = t.pkg.parts.toList.map(moduleSegmentToFilesystem).mkString("/") + val library = libraryRef(t) + val fileName = library.fileName + val typePath = library.packageParts.map(moduleSegmentToFilesystem).mkString("/") val fullFilePath = s"$typePath/$fileName.dart" val relativePath = makeRelativePath(currentDir, fullFilePath) - val key = fqFileKey(t) + val key = dtFileKey(t) val prefix = fqPrefixMap.getOrElse(key, "fq") - Some(s"import '$relativePath' as $prefix;") + Some(ResolvedLibrary(relativePath, Some(prefix)).render) } } @@ -419,18 +426,11 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( currentFileName: String, filePrefixMap: Map[String, String], ): Option[String] = { + val runtime = runtimeLibrary(t) if (t.pkg == dartCorePkg) { None // dart:core is implicitly imported - } else if (t.pkg == baboonRuntimePkg) { - Some("import 'package:baboon_runtime/baboon_runtime.dart';") - } else if (t.pkg == baboonAnyOpaquePkg) { - Some("import 'package:baboon_runtime/baboon_any_opaque.dart';") - } else if (t.pkg == baboonCodecsFacadePkg) { - Some("import 'package:baboon_runtime/baboon_codecs_facade.dart';") - } else if (t.pkg == baboonFixturePkg) { - Some("import 'package:baboon_runtime/baboon_fixture.dart';") - } else if (t.pkg == baboonIdReprPkg) { - Some("import 'package:baboon_runtime/baboon_identifier_repr.dart';") + } else if (runtime.isDefined) { + runtime.map(_.render) } else if (t.pkg == dartTypedDataPkg) { Some("import 'dart:typed_data';") } else if (t.pkg == dartConvertPkg) { @@ -438,19 +438,20 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( } else if (t.pkg == dartIoPkg) { Some("import 'dart:io';") } else { - val typePath = t.pkg.parts.toList.mkString("/") - val fileName = t.importAs.getOrElse(trans.toSnakeCase(t.name)) + val library = libraryRef(t) + val typePath = library.packageParts.mkString("/") + val fileName = library.fileName val currentPath = currentModule.parts.toList.mkString("/") - val asClause = filePrefixMap.get(dtFileKey(t)).map(p => s" as $p").getOrElse("") + val alias = filePrefixMap.get(library.physicalKey) if (typePath == currentPath) { if (fileName == currentFileName) { None // Skip self-import } else { - Some(s"import '$fileName.dart'$asClause;") + Some(ResolvedLibrary(s"$fileName.dart", alias).render) } } else { val relativePath = makeRelativePath(currentPath, s"$typePath/$fileName.dart") - Some(s"import '$relativePath'$asClause;") + Some(ResolvedLibrary(relativePath, alias).render) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala index a4cf97b13..3feb2ba36 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.dart import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.dart.DtBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.dart.DtTypes.* import io.septimalmind.baboon.translator.dart.DtValue.DtPackageId @@ -339,10 +339,11 @@ class DtConversionTranslator[F[+_, +_]: Error2]( } q"final $localVar = $expr;" } - val ctorArgs = dto.fields.map { f => - val dartName = trans.escapeDartKeyword(f.name.name) - val localVar = trans.escapeDartKeyword(f.name.name.toLowerCase) - q"$dartName: $localVar" + val ctorArgs = dto.fields.map { + f => + val dartName = trans.escapeDartKeyword(f.name.name) + val localVar = trans.escapeDartKeyword(f.name.name.toLowerCase) + q"$dartName: $localVar" } val classDef = q"""class $className | extends $baboonAbstractConversion<$tin, $tout> { @@ -365,11 +366,7 @@ class DtConversionTranslator[F[+_, +_]: Error2]( List(RenderedConversion(fname, tools.inLib(classDef), Some(regtree), None)) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RenderedConversion]] } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala index d37a8e1b0..0203e6d99 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.dart import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.DtTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.dart.DtValue.DtType import io.septimalmind.baboon.typer.EnumWireStyle import io.septimalmind.baboon.typer.model.* @@ -61,7 +62,7 @@ object DtDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[DtValue]): TextTree[DtValue] = { val block = dtTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } override def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { @@ -383,9 +384,10 @@ object DtDefnTranslator { } else q"" val equalsBody = if (hasFields) { - val fieldComparisons = dto.fields.map { f => - val dartName = trans.escapeDartKeyword(f.name.name) - q"baboonDeepEquals($dartName, other.$dartName)" + val fieldComparisons = dto.fields.map { + f => + val dartName = trans.escapeDartKeyword(f.name.name) + q"baboonDeepEquals($dartName, other.$dartName)" } q"""@override |bool operator ==(Object other) => @@ -398,9 +400,10 @@ object DtDefnTranslator { } val hashCodeBody = if (hasFields) { - val hashParts = dto.fields.map { f => - val dartName = trans.escapeDartKeyword(f.name.name) - q"baboonDeepHashCode($dartName)" + val hashParts = dto.fields.map { + f => + val dartName = trans.escapeDartKeyword(f.name.name) + q"baboonDeepHashCode($dartName)" } q"""@override |int get hashCode => Object.hashAll([${hashParts.join(", ")}]);""".stripMargin @@ -413,9 +416,10 @@ object DtDefnTranslator { // Identifier toString — spec docs/spec/identifier-repr.md, PR-57d. renderIdentifierToString(dto, name) } else if (hasFields) { - val fieldStrings = dto.fields.map { f => - val dartName = trans.escapeDartKeyword(f.name.name) - q"${f.name.name}: $$$dartName" + val fieldStrings = dto.fields.map { + f => + val dartName = trans.escapeDartKeyword(f.name.name) + q"${f.name.name}: $$$dartName" } q"""@override |String toString() => '${name.asName}(${fieldStrings.join(", ")})';""".stripMargin @@ -512,7 +516,7 @@ object DtDefnTranslator { mainMeta: List[DtDomainTreeTools.MetaField], codecMeta: Iterable[TextTree[DtValue]], ): DefnRepr = { - val contractParents = adt.contracts.map(c => trans.toDtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = adt.contracts.map(c => trans.toDtTypeRefKeepForeigns(c, domain, evo)) // MFACADE-PR-F: include BaboonMetaProvider on the sealed parent so polymorphic // dispatch sees the interface; concrete branch DTOs implement it via renderDto's // own conformance + instance getters. @@ -583,9 +587,9 @@ object DtDefnTranslator { val ctxTypeParam = wiringTranslator.serviceInterfaceTypeParam val methods = service.methods.map { m => - val in = trans.asDtRef(m.sig, domain, evo) - val out = m.out.map(trans.asDtRef(_, domain, evo)) - val baseRet = out.map(o => q"$o").getOrElse(q"void") + val in = trans.asDtRef(m.sig, domain, evo) + val out = m.out.map(trans.asDtRef(_, domain, evo)) + val baseRet = out.map(o => q"$o").getOrElse(q"void") // Under `--dt-async-services` the interface method returns `Future` // (`Future` for void), aligning with the always-async Dart // client; when off the bare `T`/`void` keeps output byte-identical. @@ -618,46 +622,6 @@ object DtDefnTranslator { // ----- Identifier toString + parseRepr emission (PR-57d) ----- // Spec: docs/spec/identifier-repr.md. - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32 */ - case object SignedLong extends IdentifierFieldKind /* i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` => IdentifierFieldKind.SignedInt - case `i64` => IdentifierFieldKind.SignedLong - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def signedTypeName(tpe: TypeRef): String = tpe match { case TypeRef.Scalar(TypeId.Builtins.i08) => "i08" case TypeRef.Scalar(TypeId.Builtins.i16) => "i16" @@ -711,7 +675,7 @@ object DtDefnTranslator { f => val srcFieldName = f.name.name val dartFieldName = trans.escapeDartKeyword(srcFieldName) - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderIdentifierFieldValueExpr(dartFieldName, kind) q""""$srcFieldName:" + ($valueExpr)""" } @@ -738,7 +702,7 @@ object DtDefnTranslator { val valVar = s"${srcFieldName}_v" val resVar = s"${srcFieldName}_r" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asDtRef(f.tpe, domain, evo) val parseHead = diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtForeignWirePlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtForeignWirePlan.scala new file mode 100644 index 000000000..39a414abd --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtForeignWirePlan.scala @@ -0,0 +1,21 @@ +package io.septimalmind.baboon.translator.dart + +import io.septimalmind.baboon.typer.model.{BaboonLang, TypeId, TypeRef, Typedef} + +private[dart] sealed trait DtForeignWirePlan + +private[dart] object DtForeignWirePlan { + final case class Inline(ref: TypeRef) extends DtForeignWirePlan + case object Codec extends DtForeignWirePlan + + def ueba(foreign: Typedef.Foreign): DtForeignWirePlan = { + foreign.bindings.get(BaboonLang.Dart) match { + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(ref))) => Inline(ref) + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) if decl == "dart.core.String" || decl == "String" => + Inline(TypeRef.Scalar(TypeId.Builtins.str)) + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => + foreign.runtimeMapping.fold[DtForeignWirePlan](Codec)(Inline.apply) + case _ => Codec + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala index e34588d77..50fae82ff 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala @@ -249,18 +249,7 @@ class DtJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"$ref" - case TypeId.Builtins.i64 => q"$ref.toString()" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"$ref" - case TypeId.Builtins.u64 => q"BigInt.from($ref).toUnsigned(64).toString()" - case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"$ref" - case TypeId.Builtins.f128 => q"$ref.value" - case TypeId.Builtins.str => q"$ref" - case TypeId.Builtins.uid => q"$ref" - case TypeId.Builtins.bytes => q"$ref.toHexString()" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($ref)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($ref)" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.encodeJson(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -309,18 +298,7 @@ class DtJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref as bool" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"($ref as num).toInt()" - case TypeId.Builtins.i64 => q"($ref is String ? int.parse($ref as String) : ($ref as num).toInt())" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"($ref as num).toInt()" - case TypeId.Builtins.u64 => q"($ref is String ? BigInt.parse($ref as String).toSigned(64).toInt() : ($ref as num).toInt())" - case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"($ref as num).toDouble()" - case TypeId.Builtins.f128 => q"$baboonDecimal($ref is String ? $ref as String : $ref.toString())" - case TypeId.Builtins.str => q"$ref as String" - case TypeId.Builtins.uid => q"$ref as String" - case TypeId.Builtins.bytes => q"$baboonByteStringTools.fromHexString($ref as String)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseUtc($ref as String)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseOffset($ref as String)" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.decodeJson(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -447,11 +425,10 @@ class DtJsonCodecGenerator( // Encode delegates to the per-codec-class `encodeAnyField` helper. This site wires the expected // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[DtValue]): TextTree[DtValue] = { val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) + val (staticDom, staticVer, staticTid) = DtAnyFieldPlan.fallbacks(a, domain) q"encodeAnyField(ctx, $expectedHex, $staticDom, $staticVer, $staticTid, $ref)" } @@ -464,42 +441,7 @@ class DtJsonCodecGenerator( q"decodeAnyField($expectedHex, $ref)" } - // Static fallbacks for the cross-format facade helper (`uebaToJson`). The wire `meta` may omit - // components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade._buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), - // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS/Dart — extraction deferred (textual emission - // diverges by language flavor; see PR 4.2 ledger entry's DRY analysis). 11th instance. - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[DtValue], TextTree[DtValue], TextTree[DtValue]) = { - val none = q"null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec class that has any any-bearing field. Mirrors PR 6.3's Java - // helper shape (closest precedent: JsonNode-style API) and PR 7.3's TS shape. Kind-check on - // encode runs before any envelope construction. - // - // Cast-via-`as`: `ctx.facade` returns the `BaboonCodecsFacadeBase` opaque base (chosen by PR 8.1 - // to break the `baboon_runtime.dart` <-> `baboon_codecs_facade.dart` import cycle). The concrete - // facade class carries `uebaToJson`; the cast is safe because `BaboonCodecContext.withFacade` - // is the only construction path and accepts the same hierarchy. + // Public generated helpers remain forwarding methods for source compatibility. private def anyFieldHelpers: TextTree[DtValue] = { q"""Object? encodeAnyField( | $baboonCodecContext ctx, @@ -508,75 +450,10 @@ class DtJsonCodecGenerator( | String? staticVersion, | String? staticTypeid, | $baboonAnyOpaque value, - |) { - | if (value.meta.kind != expectedKind) { - | throw $baboonEncoderFailure( - | 'any: meta-kind 0x' + (value.meta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + - | ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), - | ); - | } - | Object? anyInner; - | switch (value) { - | case $baboonAnyOpaqueJson(:final json): - | anyInner = json; - | case $baboonAnyOpaqueUeba(:final meta, :final bytes): - | final anyFacadeBase = ctx.facade; - | if (anyFacadeBase == null) { - | throw $baboonEncoderFailure( - | 'Cannot encode AnyOpaqueUeba into JSON without a facade reference. ' - | 'Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), ' - | 'or supply AnyOpaqueJson directly.', - | ); - | } - | // Downcast to the concrete facade — the marker base is empty by design (PR 8.1 - | // import-cycle break). Construction goes through BaboonCodecContext.withFacade - | // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. - | final anyFacade = anyFacadeBase as $baboonCodecsFacade; - | final anyConvResult = anyFacade.uebaToJson( - | meta, - | bytes, - | staticDomain: staticDomain, - | staticVersion: staticVersion, - | staticTypeid: staticTypeid, - | ); - | switch (anyConvResult) { - | case $baboonLeft(:final value): - | throw value; - | case $baboonRight(:final value): - | anyInner = value; - | } - | } - | final anyEnvelope = $baboonAnyMetaCodec.writeJson(value.meta); - | anyEnvelope[$baboonAnyMetaCodec.ANY_CONTENT_KEY] = anyInner; - | return anyEnvelope; - |} + |) => $baboonEncodeAnyJsonField(ctx, expectedKind, staticDomain, staticVersion, staticTypeid, value); | - |$baboonAnyOpaque decodeAnyField(int expectedKind, Object? wire) { - | if (wire is! Map) { - | throw $baboonDecoderFailure('any: JSON envelope must be an object'); - | } - | final anyMetaResult = $baboonAnyMetaCodec.readJson(wire); - | final $baboonAnyMeta anyMeta; - | switch (anyMetaResult) { - | case $baboonLeft(:final value): - | throw value; - | case $baboonRight(:final value): - | anyMeta = value; - | } - | if (anyMeta.kind != expectedKind) { - | throw $baboonDecoderFailure( - | 'any: wire kind 0x' + (anyMeta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + - | ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), - | ); - | } - | if (!wire.containsKey($baboonAnyMetaCodec.ANY_CONTENT_KEY)) { - | throw $baboonDecoderFailure( - | "any: JSON envelope missing '" + $baboonAnyMetaCodec.ANY_CONTENT_KEY + "' content key", - | ); - | } - | final anyContent = wire[$baboonAnyMetaCodec.ANY_CONTENT_KEY]; - | return $baboonAnyOpaqueJson(anyMeta, anyContent); - |}""".stripMargin + |$baboonAnyOpaque decodeAnyField(int expectedKind, Object? wire) => + | $baboonDecodeAnyJsonField(expectedKind, wire);""".stripMargin } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[DtValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtMcpServerGenerator.scala index 225f561de..b888171cc 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtMcpServerGenerator.scala @@ -47,9 +47,10 @@ class DtMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -75,7 +76,8 @@ class DtMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/$fname" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -90,14 +92,14 @@ class DtMcpServerGenerator[F[+_, +_]: Error2]( val toolEntries: List[String] = svc.methods.toList.map { m => val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) // Embed schema as a Dart single-quoted string literal. Escape rules: // `\` → `\\`, `'` → `\'`, `$` → `\$` (Dart interpolation guard). val schemaLiteral = schema.noSpaces .replace("\\", "\\\\") .replace("'", "\\'") .replace("$", "\\$") - val descArg = McpDocs.flatten(m.docs).map(d => s", ${dartDescString(d)}").getOrElse("") + val descArg = McpDocs.flatten(m.docs).map(d => s", ${dartDescString(d)}").getOrElse("") s" McpToolEntry(${dartString(toolName)}, const BaboonMethodId(${dartString(serviceName)}, ${dartString(m.name.name)}), jsonDecode('$schemaLiteral') as Map$descArg)," } @@ -181,14 +183,14 @@ object DtMcpServerGenerator { def dartString(s: String): String = { val sb = new StringBuilder("'") s.foreach { - case '\\' => sb.append("\\\\") - case '\'' => sb.append("\\'") - case '$' => sb.append("\\$") - case '\n' => sb.append("\\n") - case '\r' => sb.append("\\r") - case '\t' => sb.append("\\t") + case '\\' => sb.append("\\\\") + case '\'' => sb.append("\\'") + case '$' => sb.append("\\$") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") case c if c < 0x20 => sb.append(f"\\u${c.toInt}%04x") - case c => sb.append(c) + case c => sb.append(c) } sb.append('\'').toString() } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtScalarCodecOps.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtScalarCodecOps.scala new file mode 100644 index 000000000..d8450c34f --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtScalarCodecOps.scala @@ -0,0 +1,83 @@ +package io.septimalmind.baboon.translator.dart + +import io.septimalmind.baboon.translator.dart.DtTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +private[dart] object DtScalarCodecOps { + def decodeJson(id: TypeId.BuiltinScalar, wire: TextTree[DtValue]): TextTree[DtValue] = id match { + case TypeId.Builtins.bit => q"$wire as bool" + case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"($wire as num).toInt()" + case TypeId.Builtins.i64 => q"($wire is String ? int.parse($wire as String) : ($wire as num).toInt())" + case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"($wire as num).toInt()" + case TypeId.Builtins.u64 => q"($wire is String ? BigInt.parse($wire as String).toSigned(64).toInt() : ($wire as num).toInt())" + case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"($wire as num).toDouble()" + case TypeId.Builtins.f128 => q"$baboonDecimal($wire is String ? $wire as String : $wire.toString())" + case TypeId.Builtins.str => q"$wire as String" + case TypeId.Builtins.uid => q"$wire as String" + case TypeId.Builtins.bytes => q"$baboonByteStringTools.fromHexString($wire as String)" + case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseUtc($wire as String)" + case TypeId.Builtins.tso => q"$baboonTimeFormats.parseOffset($wire as String)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in Dart scalar codec: $other") + } + + def encodeJson(id: TypeId.BuiltinScalar, value: TextTree[DtValue]): TextTree[DtValue] = id match { + case TypeId.Builtins.bit => q"$value" + case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"$value" + case TypeId.Builtins.i64 => q"$value.toString()" + case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"$value" + case TypeId.Builtins.u64 => q"BigInt.from($value).toUnsigned(64).toString()" + case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"$value" + case TypeId.Builtins.f128 => q"$value.value" + case TypeId.Builtins.str => q"$value" + case TypeId.Builtins.uid => q"$value" + case TypeId.Builtins.bytes => q"$value.toHexString()" + case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($value)" + case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($value)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in Dart scalar codec: $other") + } + + def decodeUeba(id: TypeId.BuiltinScalar, reader: TextTree[DtValue]): TextTree[DtValue] = id match { + case TypeId.Builtins.bit => q"$reader.readBool()" + case TypeId.Builtins.i08 => q"$reader.readI8()" + case TypeId.Builtins.i16 => q"$reader.readI16()" + case TypeId.Builtins.i32 => q"$reader.readI32()" + case TypeId.Builtins.i64 => q"$reader.readI64()" + case TypeId.Builtins.u08 => q"$reader.readU8()" + case TypeId.Builtins.u16 => q"$reader.readU16()" + case TypeId.Builtins.u32 => q"$reader.readU32()" + case TypeId.Builtins.u64 => q"$reader.readU64()" + case TypeId.Builtins.f32 => q"$reader.readF32()" + case TypeId.Builtins.f64 => q"$reader.readF64()" + case TypeId.Builtins.f128 => q"$reader.readDecimal()" + case TypeId.Builtins.str => q"$reader.readString()" + case TypeId.Builtins.bytes => q"$reader.readBytes()" + case TypeId.Builtins.uid => q"$reader.readUuid()" + case TypeId.Builtins.tsu => q"$reader.readTsu()" + case TypeId.Builtins.tso => q"$reader.readTso()" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in Dart scalar codec: $other") + } + + def encodeUeba(id: TypeId.BuiltinScalar, writer: TextTree[DtValue], value: TextTree[DtValue]): TextTree[DtValue] = id match { + case TypeId.Builtins.bit => q"$writer.writeBool($value);" + case TypeId.Builtins.i08 => q"$writer.writeI8($value);" + case TypeId.Builtins.i16 => q"$writer.writeI16($value);" + case TypeId.Builtins.i32 => q"$writer.writeI32($value);" + case TypeId.Builtins.i64 => q"$writer.writeI64($value);" + case TypeId.Builtins.u08 => q"$writer.writeU8($value);" + case TypeId.Builtins.u16 => q"$writer.writeU16($value);" + case TypeId.Builtins.u32 => q"$writer.writeU32($value);" + case TypeId.Builtins.u64 => q"$writer.writeU64($value);" + case TypeId.Builtins.f32 => q"$writer.writeF32($value);" + case TypeId.Builtins.f64 => q"$writer.writeF64($value);" + case TypeId.Builtins.f128 => q"$writer.writeDecimal($value);" + case TypeId.Builtins.str => q"$writer.writeString($value);" + case TypeId.Builtins.bytes => q"$writer.writeBytes($value);" + case TypeId.Builtins.uid => q"$writer.writeUuid($value);" + case TypeId.Builtins.tsu => q"$writer.writeTsu($value);" + case TypeId.Builtins.tso => q"$writer.writeTso($value);" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in Dart scalar codec: $other") + } + +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala index 15fe28313..44bac4a02 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala @@ -14,11 +14,13 @@ trait DtServiceWiringTranslator { /** Generic clause (``) appended to the service-interface class name when * an `abstract` service-context mode is active; empty otherwise (incl. `none` - * and concrete `type` mode). */ + * and concrete `type` mode). + */ def serviceInterfaceTypeParam: String /** Leading service-context method parameter declaration (e.g. `Ctx ctx, `) - * spliced before each interface method's `arg`; empty in `none` mode. */ + * spliced before each interface method's `arg`; empty in `none` mode. + */ def serviceMethodCtxParam: String } @@ -43,8 +45,8 @@ object DtServiceWiringTranslator { // when the flag is off, keeping that output byte-identical to HEAD. private val isAsync: Boolean = target.language.asyncServices - // `async` modifier on the static dispatcher and on each per-method handler - // closure; `await` prefix on the impl call. Empty in sync mode. + // `async` modifier on the static dispatcher; `await` prefix on the impl call. + // Empty in sync mode. private val dispatcherAsyncKw: String = if (isAsync) " async" else "" private val awaitKw: String = if (isAsync) "await " else "" @@ -60,25 +62,23 @@ object DtServiceWiringTranslator { private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "dart", target.language.serviceContext, target.language.pragmas) - private def hasActiveJsonCodecs(service: Typedef.Service): Boolean = { - codecs.exists { - c => - c.id == "Json" && service.methods.forall { - m => - c.isActive(m.sig.id) && m.out.forall(o => c.isActive(o.id)) - } - } - } + private case class EndpointPlan(method: Typedef.MethodDef, dartName: String, inputType: TextTree[DtValue], resultType: TextTree[DtValue]) + private case class ServicePlan(endpoints: List[EndpointPlan], jsonActive: Boolean, uebaActive: Boolean) - private def hasActiveUebaCodecs(service: Typedef.Service): Boolean = { - codecs.exists { - c => - c.id == "Ueba" && service.methods.forall { - m => - c.isActive(m.sig.id) && m.out.forall(o => c.isActive(o.id)) - } - } - } + private def codecActive(service: Typedef.Service, codecId: String): Boolean = + codecs.exists(c => c.id == codecId && service.methods.forall(m => c.isActive(m.sig.id) && m.out.forall(o => c.isActive(o.id)))) + + private lazy val servicePlans: Map[TypeId.User, ServicePlan] = domain.defs.meta.nodes.values.collect { + case DomainMember.User(_, service: Typedef.Service, _, _) => + val endpoints = service.methods.map { + m => + EndpointPlan(m, trans.escapeDartKeyword(m.name.name), trans.asDtRef(m.sig, domain, evo), m.out.map(o => trans.asDtRef(o, domain, evo)).getOrElse(q"void")) + } + service.id -> ServicePlan(endpoints, codecActive(service, "Json"), codecActive(service, "Ueba")) + }.toMap + + private def hasActiveJsonCodecs(service: Typedef.Service): Boolean = servicePlans(service.id).jsonActive + private def hasActiveUebaCodecs(service: Typedef.Service): Boolean = servicePlans(service.id).uebaActive private def jsonCodecName(typeId: TypeId.User): DtValue.DtType = { val srcRef = trans.toDtTypeRefKeepForeigns(typeId, domain, evo) @@ -95,97 +95,27 @@ object DtServiceWiringTranslator { // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). // wire is a Dart dynamic (json-decoded), encode returns a Dart dynamic. private def jsonDecodeExpr(id: TypeId, wire: TextTree[DtValue]): TextTree[DtValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.instance.decode($codecCtxRef, $wire)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$wire as bool" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"($wire as num).toInt()" - case TypeId.Builtins.i64 => q"($wire is String ? int.parse($wire as String) : ($wire as num).toInt())" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"($wire as num).toInt()" - case TypeId.Builtins.u64 => q"($wire is String ? BigInt.parse($wire as String).toSigned(64).toInt() : ($wire as num).toInt())" - case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"($wire as num).toDouble()" - case TypeId.Builtins.f128 => q"$baboonDecimal($wire is String ? $wire as String : $wire.toString())" - case TypeId.Builtins.str => q"$wire as String" - case TypeId.Builtins.uid => q"$wire as String" - case TypeId.Builtins.bytes => q"$baboonByteStringTools.fromHexString($wire as String)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseUtc($wire as String)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseOffset($wire as String)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.instance.decode($codecCtxRef, $wire)" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.decodeJson(b, wire) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[DtValue]): TextTree[DtValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.instance.encode($codecCtxRef, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$value" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"$value" - case TypeId.Builtins.i64 => q"$value.toString()" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"$value" - case TypeId.Builtins.u64 => q"BigInt.from($value).toUnsigned(64).toString()" - case TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"$value" - case TypeId.Builtins.f128 => q"$value.value" - case TypeId.Builtins.str => q"$value" - case TypeId.Builtins.uid => q"$value" - case TypeId.Builtins.bytes => q"$value.toHexString()" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($value)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.instance.encode($codecCtxRef, $value)" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.encodeJson(b, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, reader: TextTree[DtValue]): TextTree[DtValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.instance.decode($codecCtxRef, $reader)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$reader.readBool()" - case TypeId.Builtins.i08 => q"$reader.readI8()" - case TypeId.Builtins.i16 => q"$reader.readI16()" - case TypeId.Builtins.i32 => q"$reader.readI32()" - case TypeId.Builtins.i64 => q"$reader.readI64()" - case TypeId.Builtins.u08 => q"$reader.readU8()" - case TypeId.Builtins.u16 => q"$reader.readU16()" - case TypeId.Builtins.u32 => q"$reader.readU32()" - case TypeId.Builtins.u64 => q"$reader.readU64()" - case TypeId.Builtins.f32 => q"$reader.readF32()" - case TypeId.Builtins.f64 => q"$reader.readF64()" - case TypeId.Builtins.f128 => q"$reader.readDecimal()" - case TypeId.Builtins.str => q"$reader.readString()" - case TypeId.Builtins.bytes => q"$reader.readBytes()" - case TypeId.Builtins.uid => q"$reader.readUuid()" - case TypeId.Builtins.tsu => q"$reader.readTsu()" - case TypeId.Builtins.tso => q"$reader.readTso()" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.instance.decode($codecCtxRef, $reader)" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.decodeUeba(b, reader) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, writer: TextTree[DtValue], value: TextTree[DtValue]): TextTree[DtValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $writer, $value);" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$writer.writeBool($value);" - case TypeId.Builtins.i08 => q"$writer.writeI8($value);" - case TypeId.Builtins.i16 => q"$writer.writeI16($value);" - case TypeId.Builtins.i32 => q"$writer.writeI32($value);" - case TypeId.Builtins.i64 => q"$writer.writeI64($value);" - case TypeId.Builtins.u08 => q"$writer.writeU8($value);" - case TypeId.Builtins.u16 => q"$writer.writeU16($value);" - case TypeId.Builtins.u32 => q"$writer.writeU32($value);" - case TypeId.Builtins.u64 => q"$writer.writeU64($value);" - case TypeId.Builtins.f32 => q"$writer.writeF32($value);" - case TypeId.Builtins.f64 => q"$writer.writeF64($value);" - case TypeId.Builtins.f128 => q"$writer.writeDecimal($value);" - case TypeId.Builtins.str => q"$writer.writeString($value);" - case TypeId.Builtins.bytes => q"$writer.writeBytes($value);" - case TypeId.Builtins.uid => q"$writer.writeUuid($value);" - case TypeId.Builtins.tsu => q"$writer.writeTsu($value);" - case TypeId.Builtins.tso => q"$writer.writeTso($value);" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $writer, $value);" + case b: TypeId.BuiltinScalar => DtScalarCodecOps.encodeUeba(b, writer, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def renderContainer(error: String, success: String): String = { @@ -194,11 +124,6 @@ object DtServiceWiringTranslator { s"$typeName$p" } - private def renderConcreteContainer(error: String, success: String): String = { - val p = resolved.pattern.get.replace("$error", error).replace("$success", success) - s"${resolved.resultType.get}$p" - } - override def translateServiceRt(domain: Domain): Option[TextTree[DtValue]] = { if (resolved.noErrors) return None @@ -234,13 +159,8 @@ object DtServiceWiringTranslator { // would be empty — skip emission entirely so the service file stays // a clean abstract-interface declaration. if (!hasActiveJsonCodecs(service) && !hasActiveUebaCodecs(service)) None - // Errors-mode wiring emission for Dart relies on `IBaboonServiceRt` - // and FQ wire-error type names that the existing renderer does not - // import-trigger; the dispatcher in this mode pre-dates the muxer - // and is currently dead code (not invoked from any DefnTranslator - // path). Keep it dead in errors mode so we don't introduce a - // compile-time regression while shipping the no-errors muxer - // wrappers. The errors-mode dispatcher fix is a separate PR. + // Errors-mode dispatch requires symbol-tracked result/error types and + // muxer wrappers; it remains disabled until those contracts are implemented. else if (!resolved.noErrors) None else Some(generateNoErrorsWiring(service)) case _ => None @@ -264,11 +184,12 @@ object DtServiceWiringTranslator { val hasJson = hasActiveJsonCodecs(service) if (!hasUeba && !hasJson) return None - val clientMethods = service.methods.flatMap { - m => - val dartMethodName = trans.escapeDartKeyword(m.name.name) - val inTypeRef = trans.asDtRef(m.sig, domain, evo) - val retType = m.out.map(o => trans.asDtRef(o, domain, evo)).getOrElse(q"void") + val clientMethods = servicePlans(service.id).endpoints.flatMap { + endpoint => + val m = endpoint.method + val dartMethodName = endpoint.dartName + val inTypeRef = endpoint.inputType + val retType = endpoint.resultType val uebaMethod = if (hasUeba) { val encodeIn = uebaEncodeStmt(m.sig.id, q"writer", q"arg") @@ -294,8 +215,9 @@ object DtServiceWiringTranslator { val encodeIn = jsonEncodeExpr(m.sig.id, q"arg") val decodeOut = m.out match { case Some(outRef) => - val decodeExpr = jsonDecodeExpr(outRef.id, q"$dtJsonDecode(resp)") - q"return $decodeExpr;" + val decodeExpr = jsonDecodeExpr(outRef.id, q"wire") + q"""final wire = $dtJsonDecode(resp); + |return $decodeExpr;""".stripMargin case None => q"return;" } Some( @@ -317,9 +239,9 @@ object DtServiceWiringTranslator { // parameter. In `none`/concrete mode this is empty and the transport // signature is unchanged (byte-identical for `none`). val transportCtxParam: String = resolvedCtx match { - case ResolvedServiceContext.NoContext => "" - case ResolvedServiceContext.AbstractContext(tn, _) => s"$tn, " - case ResolvedServiceContext.ConcreteContext(tn, _) => s"$tn, " + case ResolvedServiceContext.NoContext => "" + case ResolvedServiceContext.AbstractContext(tn, _) => s"$tn, " + case ResolvedServiceContext.ConcreteContext(tn, _) => s"$tn, " } val transportFields = List( @@ -379,11 +301,6 @@ object DtServiceWiringTranslator { } private def codecCtxRef: TextTree[DtValue] = TextTree.text[DtValue](codecCtxName) - private def renderFq(tree: TextTree[DtValue]): String = tree.mapRender { - case t: DtValue.DtType => if (t.predef) trans.escapeDartKeyword(t.name) else (t.pkg.parts :+ trans.escapeDartKeyword(t.name)).mkString(".") - case t: DtValue.DtTypeName => trans.escapeDartKeyword(t.name) - } - private def generateNoErrorsWiring(service: Typedef.Service): TextTree[DtValue] = { val svcName = service.id.name.name @@ -419,9 +336,10 @@ object DtServiceWiringTranslator { private def generateNoErrorsJsonMethod(service: Typedef.Service): TextTree[DtValue] = { val svcName = service.id.name.name - val cases = service.methods.map { - m => - val dartMethodName = trans.escapeDartKeyword(m.name.name) + val cases = servicePlans(service.id).endpoints.map { + endpoint => + val m = endpoint.method + val dartMethodName = endpoint.dartName val decodeIn = jsonDecodeExpr(m.sig.id, q"wire") val encodeOutput = m.out match { @@ -438,37 +356,33 @@ object DtServiceWiringTranslator { case None => q"${awaitKw}impl.$dartMethodName(${ctxArgPass}decoded);" } - q"""'${m.name.name}': ()$dispatcherAsyncKw { + q"""case '${m.name.name}': { | final wire = $dtJsonDecode(data); | final decoded = $decodeIn; | $callExpr | ${encodeOutput.shift(2).trim} - |},""".stripMargin + |}""".stripMargin }.join("\n") - val retType = asyncRetType(q"String") - val handlerType = asyncRetType(q"String") + val retType = asyncRetType(q"String") q"""static $retType invokeJson$ctxTypeParamDecl( | $baboonMethodId method, | String data, | $svcName$ctxTypeParamDecl impl, | $ctxParamDecl$baboonCodecContext $codecCtxName)$dispatcherAsyncKw { - | final handlers = { + | switch (method.methodName) { | ${cases.shift(4).trim} - | }; - | final handler = handlers[method.methodName]; - | if (handler == null) { - | throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)); + | default: throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)); | } - | return ${awaitKw}handler(); |}""".stripMargin } private def generateNoErrorsUebaMethod(service: Typedef.Service): TextTree[DtValue] = { val svcName = service.id.name.name - val cases = service.methods.map { - m => - val dartMethodName = trans.escapeDartKeyword(m.name.name) + val cases = servicePlans(service.id).endpoints.map { + endpoint => + val m = endpoint.method + val dartMethodName = endpoint.dartName val decodeIn = uebaDecodeExpr(m.sig.id, q"reader") val encodeOutput = m.out match { @@ -486,93 +400,27 @@ object DtServiceWiringTranslator { case None => q"${awaitKw}impl.$dartMethodName(${ctxArgPass}decoded);" } - q"""'${m.name.name}': ()$dispatcherAsyncKw { + q"""case '${m.name.name}': { | final reader = $baboonBinTools.createReader(data); | final decoded = $decodeIn; | $callExpr | ${encodeOutput.shift(2).trim} - |},""".stripMargin + |}""".stripMargin }.join("\n") - val retType = asyncRetType(q"$dtUint8List") - val handlerType = asyncRetType(q"$dtUint8List") + val retType = asyncRetType(q"$dtUint8List") q"""static $retType invokeUeba$ctxTypeParamDecl( | $baboonMethodId method, | $dtUint8List data, | $svcName$ctxTypeParamDecl impl, | $ctxParamDecl$baboonCodecContext $codecCtxName)$dispatcherAsyncKw { - | final handlers = { + | switch (method.methodName) { | ${cases.shift(4).trim} - | }; - | final handler = handlers[method.methodName]; - | if (handler == null) { - | throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)); + | default: throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)); | } - | return ${awaitKw}handler(); |}""".stripMargin } - private def generateErrorsWiring(service: Typedef.Service): TextTree[DtValue] = { - val svcName = service.id.name.name - - val jsonMethod = - if (hasActiveJsonCodecs(service)) - Some(generateErrorsJsonMethod(service)) - else None - - val uebaMethod = - if (hasActiveUebaCodecs(service)) - Some(generateErrorsUebaMethod(service)) - else None - - val methods = Seq(jsonMethod, uebaMethod).flatten.join("\n\n") - - val wiringClass = - q"""class ${svcName}Wiring { - | ${methods.shift(2).trim} - |}""".stripMargin - - val wrappers = generateServiceWrappers(service, errorsJsonRetTree, errorsUebaRetTree) - - Seq(wiringClass, wrappers).filterNot(_.isEmpty).join("\n\n") - } - - // TextTree-form errors-mode return type for the muxer-wrapper signatures. - // We need a TextTree (not a String) so the wrapper's - // `IBaboon*Service` parameterisation can reference `BaboonWiringError` - // through the symbol-tracked DtType — `renderFq` would render it as - // `baboon.runtime.shared.BaboonWiringError`, which is not a valid Dart - // identifier path (Dart imports the type bare). - // - // These helpers are kept for forward-compat: the errors-mode dispatcher in - // Dart is currently disabled at the `translate` entry point pending its - // own fix (see comment there), so they're presently unreachable. - private def errorsJsonRetTree: TextTree[DtValue] = errorsRetTreeFor(q"String") - private def errorsUebaRetTree: TextTree[DtValue] = errorsRetTreeFor(q"$dtUint8List") - - private def errorsRetTreeFor(successType: TextTree[DtValue]): TextTree[DtValue] = { - // Split `resolved.pattern` (e.g. `<$error, $success>`) around the two - // placeholders so we can splice the symbol-tracked `BaboonWiringError` - // (`$baboonWiringError`) and the success-type TextTree. - val p = resolved.pattern.get - val typeName = resolved.hkt.map(_.name).getOrElse(resolved.resultType.get) - // Order-sensitive: pattern always references $error then $success in the - // pattern shipped with the brief example. If reversed in user pragmas - // we fall back to a literal substitution that keeps the order-of-args - // intact. - val errorIdx = p.indexOf("$error") - val successIdx = p.indexOf("$success") - if (errorIdx >= 0 && successIdx > errorIdx) { - val pre = p.substring(0, errorIdx) - val mid = p.substring(errorIdx + "$error".length, successIdx) - val post = p.substring(successIdx + "$success".length) - q"$typeName$pre$baboonWiringError$mid$successType$post" - } else { - // Defensive fallback — render via the existing string path. - q"${ct(bweFq, successType.mapRender { case _ => "" })}" - } - } - /** Emits the cross-domain Muxer-entry wrapper classes for a service. * * Each wrapper implements `IBaboonJsonService` / `IBaboonUebaService` @@ -609,8 +457,8 @@ object DtServiceWiringTranslator { retType: TextTree[DtValue], ): TextTree[DtValue] = { val svcName = service.id.name.name - val wireType = if (isJson) q"String" else q"$dtUint8List" - val invokerFn = if (isJson) "invokeJson" else "invokeUeba" + val wireType = if (isJson) q"String" else q"$dtUint8List" + val invokerFn = if (isJson) "invokeJson" else "invokeUeba" val wrapperName = s"$svcName${if (isJson) "JsonService" else "UebaService"}" // The service context (when active) is supplied PER-INVOKE rather than @@ -620,14 +468,14 @@ object DtServiceWiringTranslator { // unchanged: it implements the context-free IBaboon*Service and takes // `invoke(method, data, ctx)` — keeping that output byte-identical. val svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } val svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } val implementsClause: TextTree[DtValue] = svcCtxTypeName match { @@ -640,7 +488,7 @@ object DtServiceWiringTranslator { val rtField: Option[(String, TextTree[DtValue])] = if (resolved.noErrors) None else Some(("rt", q"IBaboonServiceRt")) - val implField: TextTree[DtValue] = q"final $svcName$ctxTypeParamDecl _impl;" + val implField: TextTree[DtValue] = q"final $svcName$ctxTypeParamDecl _impl;" val extraFields: List[TextTree[DtValue]] = rtField.toList.map { case (name, tpe) => q"final $tpe _$name;" } @@ -690,209 +538,5 @@ object DtServiceWiringTranslator { |}""".stripMargin } - private val bweFq: String = renderFq(q"$baboonWiringError") - - private def ct(error: String, success: String): String = renderContainer(error, success) - - private def generateErrorsJsonMethod(service: Typedef.Service): TextTree[DtValue] = { - val svcName = service.id.name.name - val wiringRetType = ct(bweFq, "String") - - val cases = service.methods.map { - m => - val dartMethodName = trans.escapeDartKeyword(m.name.name) - val inRef = trans.asDtRef(m.sig, domain, evo) - val decodeIn = jsonDecodeExpr(m.sig.id, q"wire") - - val decodeStep = - q"""${ct(bweFq, renderFq(inRef))} input; - |try { - | final wire = $dtJsonDecode(data); - | input = rt.pure($decodeIn); - |} catch (ex) { - | input = rt.fail($baboonWiringError.decoderFailed(method, ex)); - |}""".stripMargin - - val hasErrType = m.err.isDefined && !resolved.noErrors - - val callAndEncodeStep = m.out match { - case Some(outRef) => - val encodeOut = jsonEncodeExpr(outRef.id, q"v") - - val callBody = if (hasErrType) { - q"""try { - | final callResult = impl.$dartMethodName(${ctxArgPass}v); - | return rt.leftMap( - | callResult, (err) => $baboonWiringError.callFailed(method, err)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | return rt.pure(impl.$dartMethodName(${ctxArgPass}v)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } - - q"""final output = rt.flatMap(input, (v) { - | ${callBody.shift(2).trim} - |}); - |return rt.flatMap(output, (v) { - | try { - | final encoded = $encodeOut; - | return rt.pure($dtJsonEncode(encoded)); - | } catch (ex) { - | return rt.fail($baboonWiringError.encoderFailed(method, ex)); - | } - |});""".stripMargin - - case None => - val callBody = if (hasErrType) { - q"""try { - | final callResult = impl.$dartMethodName(${ctxArgPass}v); - | return rt.leftMap( - | callResult, (err) => $baboonWiringError.callFailed(method, err)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | impl.$dartMethodName(${ctxArgPass}v); - | return rt.pure(null); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } - - q"""return rt.flatMap(input, (v) { - | ${callBody.shift(2).trim} - | return rt.pure('null'); - |});""".stripMargin - } - - q"""'${m.name.name}': () { - | ${decodeStep.shift(2).trim} - | ${callAndEncodeStep.shift(2).trim} - |},""".stripMargin - }.join("\n") - - q"""static $wiringRetType invokeJson$ctxTypeParamDecl( - | $baboonMethodId method, - | String data, - | $svcName$ctxTypeParamDecl impl, - | IBaboonServiceRt rt, - | $ctxParamDecl$baboonCodecContext $codecCtxName) { - | final handlers = { - | ${cases.shift(4).trim} - | }; - | final handler = handlers[method.methodName]; - | if (handler == null) { - | return rt.fail($baboonWiringError.noMatchingMethod(method)); - | } - | return handler(); - |}""".stripMargin - } - - private def generateErrorsUebaMethod(service: Typedef.Service): TextTree[DtValue] = { - val svcName = service.id.name.name - val wiringRetType = ct(bweFq, "Uint8List") - - val cases = service.methods.map { - m => - val dartMethodName = trans.escapeDartKeyword(m.name.name) - val inRef = trans.asDtRef(m.sig, domain, evo) - val decodeIn = uebaDecodeExpr(m.sig.id, q"reader") - - val decodeStep = - q"""${ct(bweFq, renderFq(inRef))} input; - |try { - | final reader = $baboonBinTools.createReader(data); - | input = rt.pure($decodeIn); - |} catch (ex) { - | input = rt.fail($baboonWiringError.decoderFailed(method, ex)); - |}""".stripMargin - - val hasErrType = m.err.isDefined && !resolved.noErrors - - val callAndEncodeStep = m.out match { - case Some(outRef) => - val encStmt = uebaEncodeStmt(outRef.id, q"writer", q"v") - - val callBody = if (hasErrType) { - q"""try { - | final callResult = impl.$dartMethodName(${ctxArgPass}v); - | return rt.leftMap( - | callResult, (err) => $baboonWiringError.callFailed(method, err)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | return rt.pure(impl.$dartMethodName(${ctxArgPass}v)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } - - q"""final output = rt.flatMap(input, (v) { - | ${callBody.shift(2).trim} - |}); - |return rt.flatMap(output, (v) { - | try { - | final writer = $baboonBinTools.createWriter(); - | $encStmt - | return rt.pure(writer.toBytes()); - | } catch (ex) { - | return rt.fail($baboonWiringError.encoderFailed(method, ex)); - | } - |});""".stripMargin - - case None => - val callBody = if (hasErrType) { - q"""try { - | final callResult = impl.$dartMethodName(${ctxArgPass}v); - | return rt.leftMap( - | callResult, (err) => $baboonWiringError.callFailed(method, err)); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | impl.$dartMethodName(${ctxArgPass}v); - | return rt.pure(null); - |} catch (ex) { - | return rt.fail($baboonWiringError.callFailed(method, ex)); - |}""".stripMargin - } - - q"""return rt.flatMap(input, (v) { - | ${callBody.shift(2).trim} - | return rt.pure($dtUint8List(0)); - |});""".stripMargin - } - - q"""'${m.name.name}': () { - | ${decodeStep.shift(2).trim} - | ${callAndEncodeStep.shift(2).trim} - |},""".stripMargin - }.join("\n") - - q"""static $wiringRetType invokeUeba$ctxTypeParamDecl( - | $baboonMethodId method, - | $dtUint8List data, - | $svcName$ctxTypeParamDecl impl, - | IBaboonServiceRt rt, - | $ctxParamDecl$baboonCodecContext $codecCtxName) { - | final handlers = { - | ${cases.shift(4).trim} - | }; - | final handler = handlers[method.methodName]; - | if (handler == null) { - | return rt.fail($baboonWiringError.noMatchingMethod(method)); - | } - | return handler(); - |}""".stripMargin - } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtTypes.scala index 7c094278f..c1efa7f1b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtTypes.scala @@ -9,8 +9,8 @@ object DtTypes { val baboonFixturePkg: DtPackageId = parseDtPkg("baboon.fixture") // baboon metadata - val iBaboonGenerated: DtType = DtType(baboonRuntimePkg, "BaboonGenerated") - val iBaboonAdtMemberMeta: DtType = DtType(baboonRuntimePkg, "BaboonAdtMemberMeta") + val iBaboonGenerated: DtType = DtType(baboonRuntimePkg, "BaboonGenerated") + val iBaboonAdtMemberMeta: DtType = DtType(baboonRuntimePkg, "BaboonAdtMemberMeta") // PR-F: BaboonAdtMember is the runtime-active interface with an instance getter // (`String get baboonAdtTypeIdentifier`) used by `BaboonTypeMeta.from(_, useAdtIdentifier: true)`. // BaboonAdtMemberMeta is the static-only marker kept for back-compat; new ADT branches @@ -43,12 +43,16 @@ object DtTypes { // `uebaToJson`). The base abstract type `BaboonCodecsFacadeBase` lives in `baboon_runtime.dart` // (already imported) so `ctx.facade` typechecks; the codec downcasts to the concrete facade // when invoking the cross-format helpers. - val baboonAnyOpaquePkg: DtPackageId = parseDtPkg("baboon.any.opaque") - val baboonAnyOpaque: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaque") - val baboonAnyOpaqueUeba: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaqueUeba") - val baboonAnyOpaqueJson: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaqueJson") - val baboonAnyMeta: DtType = DtType(baboonAnyOpaquePkg, "AnyMeta") - val baboonAnyMetaCodec: DtType = DtType(baboonAnyOpaquePkg, "AnyMetaCodec") + val baboonAnyOpaquePkg: DtPackageId = parseDtPkg("baboon.any.opaque") + val baboonAnyOpaque: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaque") + val baboonAnyOpaqueUeba: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaqueUeba") + val baboonAnyOpaqueJson: DtType = DtType(baboonAnyOpaquePkg, "AnyOpaqueJson") + val baboonAnyMeta: DtType = DtType(baboonAnyOpaquePkg, "AnyMeta") + val baboonAnyMetaCodec: DtType = DtType(baboonAnyOpaquePkg, "AnyMetaCodec") + val baboonEncodeAnyJsonField: DtType = DtType(baboonAnyOpaquePkg, "encodeAnyJsonField") + val baboonDecodeAnyJsonField: DtType = DtType(baboonAnyOpaquePkg, "decodeAnyJsonField") + val baboonEncodeAnyUebaField: DtType = DtType(baboonAnyOpaquePkg, "encodeAnyUebaField") + val baboonDecodeAnyUebaField: DtType = DtType(baboonAnyOpaquePkg, "decodeAnyUebaField") val baboonCodecsFacadePkg: DtPackageId = parseDtPkg("baboon.codecs.facade") val baboonCodecsFacade: DtType = DtType(baboonCodecsFacadePkg, "BaboonCodecsFacade") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala index 8e8da819a..f5f1f27fd 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala @@ -6,7 +6,7 @@ import io.septimalmind.baboon.translator.dart.DtCodecTranslator.CodecMeta import io.septimalmind.baboon.translator.dart.DtDomainTreeTools.MetaField import io.septimalmind.baboon.translator.dart.DtTypes.* import io.septimalmind.baboon.translator.dart.DtValue.DtType -import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} +import io.septimalmind.baboon.typer.EnumWireStyle import io.septimalmind.baboon.typer.model.* import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree @@ -41,13 +41,9 @@ class DtUEBACodecGenerator( // throwing-stub codec class — no underlying primitive is declared, and the value- // position call site references this class. Closes PR-I.1d-N03 / PR-26.7-D01. case f: Typedef.Foreign => - f.bindings.get(BaboonLang.Dart) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(_))) => None - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) if decl == "dart.core.String" || decl == "String" => - None - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) if f.runtimeMapping.isDefined => - None - case _ => Some(genForeignBodies(dtRef)) + DtForeignWirePlan.ueba(f) match { + case _: DtForeignWirePlan.Inline => None + case DtForeignWirePlan.Codec => Some(genForeignBodies(dtRef)) } case _: Typedef.Contract => None case _: Typedef.Service => None @@ -289,7 +285,7 @@ class DtUEBACodecGenerator( | writer.writeU8(header); | final buffer = $baboonBinWriter(); | ${fields.map(_._3).joinN().shift(2).trim} - | writer.writeAll(buffer.toBytes()); + | writer.writeBuffer(buffer); |} else { | ${noIndex.shift(2).trim} |} @@ -401,50 +397,13 @@ class DtUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"reader.readBool()" - case TypeId.Builtins.i08 => q"reader.readI8()" - case TypeId.Builtins.i16 => q"reader.readI16()" - case TypeId.Builtins.i32 => q"reader.readI32()" - case TypeId.Builtins.i64 => q"reader.readI64()" - case TypeId.Builtins.u08 => q"reader.readU8()" - case TypeId.Builtins.u16 => q"reader.readU16()" - case TypeId.Builtins.u32 => q"reader.readU32()" - case TypeId.Builtins.u64 => q"reader.readU64()" - case TypeId.Builtins.f32 => q"reader.readF32()" - case TypeId.Builtins.f64 => q"reader.readF64()" - - case TypeId.Builtins.f128 => q"reader.readDecimal()" - case TypeId.Builtins.str => q"reader.readString()" - case TypeId.Builtins.bytes => q"reader.readBytes()" - - case TypeId.Builtins.uid => q"reader.readUuid()" - case TypeId.Builtins.tsu => q"reader.readTsu()" - case TypeId.Builtins.tso => q"reader.readTso()" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => DtScalarCodecOps.decodeUeba(s, q"reader") case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => - f.bindings.get(BaboonLang.Dart) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => - mkDecoder(aliasedRef) - // PR-26.7 (M26): stringy Custom-mapped foreigns in value position — read as - // a UEBA string. The `_UebaCodec` class is no longer emitted for stringy - // customs (closes PR-I.1d-N03 stringy case). - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) if decl == "dart.core.String" || decl == "String" => - q"reader.readString()" - // PR-26.7 round-2 (M26): non-stringy Custom-mapped foreigns with a declared - // `runtimeMapping` (e.g. `ObscureInt` with `rt = i32`) deref to the - // underlying primitive's UEBA decoder. The `_UebaCodec` class is no - // longer emitted for these customs (closes PR-26.7-D01). - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) if f.runtimeMapping.isDefined => - mkDecoder(f.runtimeMapping.get) - // Non-stringy Custom foreigns without `runtimeMapping` retain the throwing- - // stub `_UebaCodec` — see `translate` foreign branch. - case _ => + DtForeignWirePlan.ueba(f) match { + case DtForeignWirePlan.Inline(ref) => mkDecoder(ref) + case DtForeignWirePlan.Codec => val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) q"""$targetTpe.instance.decode(ctx, reader)""" } @@ -477,41 +436,13 @@ class DtUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.writeBool($ref);" - case TypeId.Builtins.i08 => q"$wref.writeI8($ref);" - case TypeId.Builtins.i16 => q"$wref.writeI16($ref);" - case TypeId.Builtins.i32 => q"$wref.writeI32($ref);" - case TypeId.Builtins.i64 => q"$wref.writeI64($ref);" - case TypeId.Builtins.u08 => q"$wref.writeU8($ref);" - case TypeId.Builtins.u16 => q"$wref.writeU16($ref);" - case TypeId.Builtins.u32 => q"$wref.writeU32($ref);" - case TypeId.Builtins.u64 => q"$wref.writeU64($ref);" - case TypeId.Builtins.f32 => q"$wref.writeF32($ref);" - case TypeId.Builtins.f64 => q"$wref.writeF64($ref);" - case TypeId.Builtins.f128 => q"$wref.writeDecimal($ref);" - case TypeId.Builtins.str => q"$wref.writeString($ref);" - case TypeId.Builtins.bytes => q"$wref.writeBytes($ref);" - case TypeId.Builtins.uid => q"$wref.writeUuid($ref);" - case TypeId.Builtins.tsu => q"$wref.writeTsu($ref);" - case TypeId.Builtins.tso => q"$wref.writeTso($ref);" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => DtScalarCodecOps.encodeUeba(s, wref, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => - f.bindings.get(BaboonLang.Dart) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => - mkEncoder(aliasedRef, ref, wref, depth) - // PR-26.7 (M26): see `mkDecoder` analog. - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) if decl == "dart.core.String" || decl == "String" => - q"$wref.writeString($ref);" - // PR-26.7 round-2 (M26): see `mkDecoder` analog. - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) if f.runtimeMapping.isDefined => - mkEncoder(f.runtimeMapping.get, ref, wref, depth) - case _ => + DtForeignWirePlan.ueba(f) match { + case DtForeignWirePlan.Inline(wireRef) => mkEncoder(wireRef, ref, wref, depth) + case DtForeignWirePlan.Codec => val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) q"""$targetTpe.instance.encode(ctx, $wref, $ref);""" } @@ -573,11 +504,10 @@ class DtUEBACodecGenerator( // Encode delegates to the per-codec-class `encodeAnyField` helper. This site wires the expected // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[DtValue], wref: TextTree[DtValue]): TextTree[DtValue] = { val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) + val (staticDom, staticVer, staticTid) = DtAnyFieldPlan.fallbacks(a, domain) q"encodeAnyField(ctx, $wref, $expectedHex, $staticDom, $staticVer, $staticTid, $ref);" } @@ -590,47 +520,7 @@ class DtUEBACodecGenerator( q"decodeAnyField(reader, $expectedHex)" } - // Static fallbacks for the cross-format facade helpers (`jsonToUebaBytes`/`uebaToJson`). The - // wire `meta` may omit components that are pinned by the field's static declaration; the codec - // emits whatever is statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade._buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), - // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS/Dart — extraction deferred (textual emission - // diverges by language flavor; see PR 4.2 ledger entry's DRY analysis). - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[DtValue], TextTree[DtValue], TextTree[DtValue]) = { - val none = q"null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec class that has any - // any-bearing field. Mirrors `JvUEBACodecGenerator.anyFieldHelpers` and - // `TsUEBACodecGenerator.anyFieldHelpers`. Wire layout (locked, see - // docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // - // PR-12-D01 lesson applied (Dart): Dart `int` is 64-bit so the failure mode differs from JVM - // wraparound, but explicit negative-i32 sanity guards on the on-wire lengths keep the error - // message specific (rather than letting the sublist call throw a generic RangeError). - // - // Cast-via-`as`: `ctx.facade` returns the `BaboonCodecsFacadeBase` opaque base (chosen by PR 8.1 - // to break the `baboon_runtime.dart` <-> `baboon_codecs_facade.dart` import cycle). The concrete - // facade class carries `jsonToUebaBytes`; the cast is safe because `BaboonCodecContext.withFacade` - // is the only construction path and accepts the same hierarchy. + // Public generated helpers remain forwarding methods for source compatibility. private def anyFieldHelpers: TextTree[DtValue] = { q"""void encodeAnyField( | $baboonCodecContext ctx, @@ -640,95 +530,10 @@ class DtUEBACodecGenerator( | String? staticVersion, | String? staticTypeid, | $baboonAnyOpaque value, - |) { - | if (value.meta.kind != expectedKind) { - | throw $baboonEncoderFailure( - | 'any: meta-kind 0x' + (value.meta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + - | ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), - | ); - | } - | $dtUint8List anyBlob; - | switch (value) { - | case $baboonAnyOpaqueUeba(:final bytes): - | anyBlob = bytes; - | case $baboonAnyOpaqueJson(:final meta, :final json): - | final anyFacadeBase = ctx.facade; - | if (anyFacadeBase == null) { - | throw $baboonEncoderFailure( - | 'Cannot encode AnyOpaqueJson into UEBA without a facade reference. ' - | 'Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), ' - | 'or supply AnyOpaqueUeba directly.', - | ); - | } - | // Downcast to the concrete facade — the marker base is empty by design (PR 8.1 - | // import-cycle break). Construction goes through BaboonCodecContext.withFacade - | // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. - | final anyFacade = anyFacadeBase as $baboonCodecsFacade; - | final anyConvResult = anyFacade.jsonToUebaBytes( - | meta, - | json, - | staticDomain: staticDomain, - | staticVersion: staticVersion, - | staticTypeid: staticTypeid, - | ); - | switch (anyConvResult) { - | case $baboonLeft(:final value): - | throw value; - | case $baboonRight(:final value): - | anyBlob = value; - | } - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | final anyMetaBuf = $baboonBinWriter(); - | $baboonAnyMetaCodec.writeBin(value.meta, anyMetaBuf); - | final anyMetaBytes = anyMetaBuf.toBytes(); - | final anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; - | writer.writeI32(anyTotalLength); - | writer.writeI32(anyMetaBytes.length); - | writer.writeAll(anyMetaBytes); - | writer.writeAll(anyBlob); - |} + |) => $baboonEncodeAnyUebaField(ctx, writer, expectedKind, staticDomain, staticVersion, staticTypeid, value); | - |$baboonAnyOpaque decodeAnyField($baboonBinReader wire, int expectedKind) { - | final anyTotalLength = wire.readI32(); - | if (anyTotalLength < 0) { - | throw $baboonDecoderFailure( - | 'any: negative total-length $$anyTotalLength', - | ); - | } - | final anyMetaLength = wire.readI32(); - | if (anyMetaLength < 0) { - | throw $baboonDecoderFailure( - | 'any: negative meta-length $$anyMetaLength', - | ); - | } - | if (anyTotalLength < 4 + anyMetaLength) { - | throw $baboonDecoderFailure( - | 'any: total-length $$anyTotalLength smaller than 4 + meta-length $$anyMetaLength', - | ); - | } - | final anyReadResult = $baboonAnyMetaCodec.readBinWithLength(wire); - | final anyMeta = anyReadResult.$$1; - | final anyBytesRead = anyReadResult.$$2; - | if (anyBytesRead > anyMetaLength) { - | throw $baboonDecoderFailure( - | 'any: meta bytes-read $$anyBytesRead exceeded meta-length window $$anyMetaLength', - | ); - | } - | if (anyBytesRead < anyMetaLength) { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | wire.skipBytes(anyMetaLength - anyBytesRead); - | } - | if (anyMeta.kind != expectedKind) { - | throw $baboonDecoderFailure( - | 'any: wire kind 0x' + (anyMeta.kind & 0xFF).toRadixString(16).padLeft(2, '0') + - | ' does not match field-declared 0x' + (expectedKind & 0xFF).toRadixString(16).padLeft(2, '0'), - | ); - | } - | final anyBlobLen = anyTotalLength - 4 - anyMetaLength; - | final anyBlob = wire.readNBytes(anyBlobLen); - | return $baboonAnyOpaqueUeba(anyMeta, anyBlob); - |}""".stripMargin + |$baboonAnyOpaque decodeAnyField($baboonBinReader wire, int expectedKind) => + | $baboonDecodeAnyUebaField(wire, expectedKind);""".stripMargin } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[DtValue]] = { @@ -768,17 +573,11 @@ class DtUEBACodecGenerator( // round-2 (closes PR-26.7-D01). // BaboonRef-aliased foreigns are also suppressed via `isBaboonRefForeign`. Non-stringy // Custom WITHOUT `runtimeMapping` still register a throwing-stub class. - val isInlineableCustomForeign = domain.defs.meta.nodes.get(id).exists { - case DomainMember.User(_, f: Typedef.Foreign, _, _) => - f.bindings.get(BaboonLang.Dart) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - decl == "dart.core.String" || decl == "String" || f.runtimeMapping.isDefined - case _ => false - } - case _ => false + val isInlineForeign = domain.defs.meta.nodes.get(id).exists { + case DomainMember.User(_, f: Typedef.Foreign, _, _) => DtForeignWirePlan.ueba(f).isInstanceOf[DtForeignWirePlan.Inline] + case _ => false } - !isInlineableCustomForeign && - !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Dart) && + !isInlineForeign && target.language.generateUebaCodecs && (target.language.generateUebaCodecsByDefault || domain.derivationRequests .getOrElse(RawMemberMeta.Derived("ueba"), Set.empty[TypeId]).contains(id)) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala index 56954b758..394230f51 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlBaboonTranslator.scala @@ -96,17 +96,22 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( case u: DomainMember.User => u }.toList.sortBy(_.id.toString) - // Resolve all field type refs through foreign type mappings - def resolvedFields(fields: List[Field]): List[Field] = { - fields.map(f => f.copy(tpe = typeTranslator.resolveTypeRef(f.tpe, foreignResolutions))) - } + val resolvedFields = members.map { + m => + val fields = m.defn match { + case dto: Typedef.Dto => dto.fields + case adt: Typedef.Adt => adt.fields + case _ => Nil + } + m.id -> fields.map(f => f.copy(tpe = typeTranslator.resolveTypeRef(f.tpe, foreignResolutions))) + }.toMap // Collect all map types used in fields (after resolution) so we can emit helper types val allMapTypes = members.flatMap { m => m.defn match { case dto: Typedef.Dto => - resolvedFields(dto.fields).flatMap(f => typeTranslator.collectMapTypes(f.tpe)) + resolvedFields(dto.id).flatMap(f => typeTranslator.collectMapTypes(f.tpe)) case _ => Nil } }.toSet @@ -135,7 +140,7 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( "BaboonAny", ) - val usedScalars = collectUsedCustomScalars(members, foreignResolutions) + val usedScalars = resolvedFields.valuesIterator.flatten.flatMap(f => collectScalarsFromRef(f.tpe)).toSet val scalarsToEmit = (builtinCustomScalars.intersect(usedScalars).toList ++ foreignScalars).sorted val scalars: List[GqlTree] = scalarsToEmit.map { @@ -163,7 +168,7 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( case _: Typedef.NonDataTypedef => None // skip services, contracts case _: Typedef.Foreign => None // skip — handled as scalars or resolved via runtimeMapping case _ if m.ownedByAdt => None // skip — emitted by parent ADT - case defn => renderTypedef(defn, m, domain, foreignResolutions) + case defn => renderTypedef(defn, m, domain, resolvedFields) } } @@ -181,39 +186,35 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( defn: Typedef.User, member: DomainMember.User, domain: Domain, - foreignResolutions: Map[TypeId.User, Option[TypeRef]], + resolvedFields: Map[TypeId.User, List[Field]], ): Option[GqlTree] = { defn match { case dto: Typedef.Dto => - Some(renderDto(dto, member.docs, foreignResolutions)) + Some(renderDto(dto, member.docs, resolvedFields)) case e: Typedef.Enum => Some(renderEnum(e, member.docs)) case adt: Typedef.Adt => - Some(renderAdt(adt, member.docs, domain, foreignResolutions)) + Some(renderAdt(adt, member.docs, domain, resolvedFields)) case _ => None } } - private def resolveFieldType(ref: TypeRef, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): String = { - typeTranslator.fieldTypeStr(typeTranslator.resolveTypeRef(ref, foreignResolutions)) - } - /** Doc comments are user text: interpolated verbatim so backslashes survive and `|` margins are never stripped. */ private def description(docs: Docs, indent: String): GqlTree = TextTree.verbatim[Nothing](typeTranslator.renderGqlDescription(docs, indent)) - private def renderDto(dto: Typedef.Dto, docs: Docs, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): GqlTree = { + private def renderDto(dto: Typedef.Dto, docs: Docs, resolvedFields: Map[TypeId.User, List[Field]]): GqlTree = { val name = typeTranslator.typeName(dto.id) // GraphQL forbids empty object types: emit a placeholder field val fields: List[GqlTree] = if (dto.fields.isEmpty) List(q" _empty: Boolean") else - dto.fields.map { + resolvedFields(dto.id).map { f => - q"${description(f.docs, " ")} ${typeTranslator.sanitizeName(f.name.name)}: ${resolveFieldType(f.tpe, foreignResolutions)}" + q"${description(f.docs, " ")} ${typeTranslator.sanitizeName(f.name.name)}: ${typeTranslator.fieldTypeStr(f.tpe)}" } val body: GqlTree = q"""type $name { @@ -236,7 +237,7 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( adt: Typedef.Adt, docs: Docs, domain: Domain, - foreignResolutions: Map[TypeId.User, Option[TypeRef]], + resolvedFields: Map[TypeId.User, List[Field]], ): GqlTree = { import Typedef.Adt.AdtSyntax val name = typeTranslator.typeName(adt.id) @@ -250,7 +251,7 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( domain.defs.meta.nodes.get(memberId).toList.flatMap { case u: DomainMember.User => u.defn match { - case dto: Typedef.Dto => List(renderDto(dto, u.docs, foreignResolutions)) + case dto: Typedef.Dto => List(renderDto(dto, u.docs, resolvedFields)) case _ => Nil // skip non-DTO members (shouldn't happen per grammar) } case _ => Nil @@ -262,22 +263,6 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( (branches :+ union).joinNN() } - private def collectUsedCustomScalars( - members: List[DomainMember.User], - foreignResolutions: Map[TypeId.User, Option[TypeRef]], - ): Set[String] = { - members.flatMap { - m => - m.defn match { - case dto: Typedef.Dto => - dto.fields.flatMap(f => collectScalarsFromRef(typeTranslator.resolveTypeRef(f.tpe, foreignResolutions))) - case adt: Typedef.Adt => - adt.fields.flatMap(f => collectScalarsFromRef(typeTranslator.resolveTypeRef(f.tpe, foreignResolutions))) - case _ => Nil - } - }.toSet - } - /** GraphQL block-string description for a custom scalar. * * Returns a complete `"""..."""` block (with trailing newline) ready to be @@ -287,9 +272,6 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( private def scalarDescription(name: String): Option[String] = { name match { case "BaboonAny" => - // Triple-quote sequences are produced via concatenation since Scala - // triple-quoted strings cannot contain `"""` directly. - val tq = "\"\"\"" val body = """Opaque any-envelope. JSON serialization of a baboon AnyOpaque value. | @@ -307,7 +289,7 @@ class GqlBaboonTranslator[F[+_, +_]: Error2]( | 0x06 — variant D1 (`any[T]`) — domain + version (typeid static) | 0x02 — variant D2 (`any[domain:this, T]`) — version (typeid static) | 0x00 — variant D3 (`any[domain:current, T]`) — none on the wire""".stripMargin - Some(s"$tq\n$body\n$tq\n") + Some(typeTranslator.descriptionLiteral(body.split("\n", -1).toList, "")) case _ => None } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlTypeTranslator.scala index 9cf62f554..c4530f44e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/GqlTypeTranslator.scala @@ -1,6 +1,7 @@ package io.septimalmind.baboon.translator.graphql import io.septimalmind.baboon.typer.model.* +import io.septimalmind.baboon.translator.schema.SchemaReferences class GqlTypeTranslator { @@ -26,8 +27,12 @@ class GqlTypeTranslator { else if (prefixLines.isEmpty) suffixLines else prefixLines ++ List("") ++ suffixLines - val tq = "\"\"\"" - val escaped = allLines.map(_.replace(tq, "\\\"\\\"\\\"")) + descriptionLiteral(allLines, indent) + } + + def descriptionLiteral(lines: List[String], indent: String): String = { + val tq = "\"\"\"" + val escaped = lines.map(_.replace(tq, "\\\"\\\"\\\"")) if (escaped.size == 1) { s"""$indent"${escaped.head}"\n""" @@ -38,26 +43,11 @@ class GqlTypeTranslator { } def foreignTypeResolution(domain: Domain): Map[TypeId.User, Option[TypeRef]] = { - domain.defs.meta.nodes.values.collect { - case u: DomainMember.User => - u.defn match { - case f: Typedef.Foreign => Some(f.id -> f.runtimeMapping) - case _ => None - } - }.flatten.toMap + SchemaReferences.prepare(domain).resolutions } def resolveTypeRef(ref: TypeRef, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): TypeRef = { - ref match { - case TypeRef.Scalar(id: TypeId.User) => - foreignResolutions.get(id) match { - case Some(Some(resolved)) => resolveTypeRef(resolved, foreignResolutions) - case _ => ref - } - case TypeRef.Constructor(id, args) => - TypeRef.Constructor(id, args.map(a => resolveTypeRef(a, foreignResolutions))) - case _ => ref - } + SchemaReferences.resolve(ref, foreignResolutions) } def scalarName(id: TypeId.BuiltinScalar): String = { @@ -122,12 +112,11 @@ class GqlTypeTranslator { } def typeName(id: TypeId.User): String = { - val parts = id.pkg.path.toList ++ id.owner.asPseudoPkg :+ id.name.name - parts.map(sanitize).mkString("_") + SchemaReferences.name(id) } def sanitize(s: String): String = { - s.replace("-", "_").replace(".", "_") + SchemaReferences.sanitize(s) } /** Ensure a name is a valid GraphQL identifier: `[_A-Za-z][_0-9A-Za-z]*`. diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala index e4a81f57f..0ed1241ad 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.JvTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.java.JvTypes.* -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -120,14 +120,7 @@ class JvBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[JvDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[JvDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[JvDefnTranslator.Output]] = { @@ -320,6 +313,8 @@ class JvBaboonTranslator[F[+_, +_]: Error2]( rt("BaboonDomainVersion.java", "baboon-runtime/java/BaboonDomainVersion.java"), rt("BaboonTypeMeta.java", "baboon-runtime/java/BaboonTypeMeta.java"), rt("BaboonAnyOpaque.java", "baboon-runtime/java/BaboonAnyOpaque.java"), + rt("BaboonAnyJsonCodec.java", "baboon-runtime/java/BaboonAnyJsonCodec.java"), + rt("BaboonAnyBinCodec.java", "baboon-runtime/java/BaboonAnyBinCodec.java"), rt("BaboonCodecsFacade.java", "baboon-runtime/java/BaboonCodecsFacade.java"), rt("BaboonMethodId.java", "baboon-runtime/java/BaboonMethodId.java"), rt("BaboonWiringError.java", "baboon-runtime/java/BaboonWiringError.java"), @@ -334,6 +329,7 @@ class JvBaboonTranslator[F[+_, +_]: Error2]( rt("JsonMuxerCtx.java", "baboon-runtime/java/JsonMuxerCtx.java"), rt("UebaMuxerCtx.java", "baboon-runtime/java/UebaMuxerCtx.java"), rt("BaboonExt.java", "baboon-runtime/java/BaboonExt.java"), + rt("BaboonMetadataAccess.java", "baboon-runtime/java/BaboonMetadataAccess.java"), ) ) } else { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala index 028264367..b356ba295 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.java import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.java.JvBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.translator.java.JvValue.JvPackageId @@ -344,11 +344,7 @@ class JvConversionTranslator[F[+_, +_]: Error2]( List(RenderedConversion(fname, tools.inPkg(pkg.parts.toSeq, classDef), Some(regtree), None)) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RenderedConversion]] } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala index 45ed621e4..925f4f782 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.java import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.JvTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.java.JvValue.JvType import io.septimalmind.baboon.typer.EnumWireStyle @@ -303,7 +304,7 @@ object JvDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[JvValue]): TextTree[JvValue] = { val block = jvTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } /** PR-I.1b (M24 Phase 3.1) — emit a `_KeyCodec` extension hook for @@ -594,17 +595,18 @@ object JvDefnTranslator { } val methods = service.methods.map { m => - val in = trans.asJvRef(m.sig, domain, evo) - val out = m.out.map(trans.asJvRef(_, domain, evo)) - val err = m.err.map(trans.asJvRef(_, domain, evo)) + val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val in = plan.input + val out = plan.output + val err = plan.error val jvFqName: JvValue => String = { case t: JvValue.JvType => if (t.predef) t.name else (t.pkg.parts :+ t.name).mkString(".") case t: JvValue.JvTypeName => t.name } - val outStr = out.map(_.mapRender(jvFqName)).getOrElse("void") - val errStr = err.map(_.mapRender(jvFqName)) - val retStr = resolved.renderReturnType(outStr, errStr, "void") - val javaMethodName = JvTypeTranslator.escapeJvKeyword(m.name.name) + val outStr = out.map(_.mapRender(jvFqName)).getOrElse("void") + val errStr = err.map(_.mapRender(jvFqName)) + val retStr = resolved.renderReturnType(outStr, errStr, "void") + val javaMethodName = plan.declarationName val retTree: TextTree[JvValue] = if (target.language.asyncServices) { val inner = if (retStr == "void") "Void" else retStr @@ -612,7 +614,7 @@ object JvDefnTranslator { } else { q"$retStr" } - val methodTree = q"$retTree $javaMethodName($ctxParam$in arg);" + val methodTree = q"$retTree $javaMethodName($ctxParam$in arg);" prependDocs(m.docs, methodTree) } val typeParams = Seq( @@ -633,17 +635,6 @@ object JvDefnTranslator { ) } - private def collectContractFieldNames(contracts: List[TypeId.User]): Set[String] = { - contracts.flatMap { - contractId => - domain.defs.meta.nodes.get(contractId) match { - case Some(DomainMember.User(_, ct: Typedef.Contract, _, _)) => - ct.fields.map(_.name.name) ++ collectContractFieldNames(ct.contracts) - case _ => Seq.empty - } - }.toSet - } - private def ownerSubdir(owner: Owner): String = owner match { case Owner.Toplevel => "" case Owner.Ns(path) => "/" + path.map(_.name.toLowerCase).mkString("/") @@ -666,48 +657,10 @@ object JvDefnTranslator { // Spec contract: docs/spec/identifier-repr.md. Mirrors ScDefnTranslator // section but uses Java stdlib + BaboonIdentifierRepr runtime helper. - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def renderFieldValueExpr(jvFieldName: String, kind: IdentifierFieldKind, f: Field): TextTree[JvValue] = { kind match { - case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(this.$jvFieldName())" - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(this.$jvFieldName())" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => // Java's primitive toString is locale-independent for integers. q"$jvBoxedLong.toString(this.$jvFieldName())" case IdentifierFieldKind.UnsignedSmallInt => @@ -739,9 +692,9 @@ object JvDefnTranslator { val fieldExprs: List[TextTree[JvValue]] = dto.fields.map { f => - val srcFieldName = f.name.name // original model name (used in repr key) + val srcFieldName = f.name.name // original model name (used in repr key) val javaName = JvTypeTranslator.escapeJvKeyword(srcFieldName) // Java accessor name - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderFieldValueExpr(javaName, kind, f) // The repr field name is the source name per spec §2.1. q""""$srcFieldName:" + ($valueExpr)""" @@ -824,7 +777,7 @@ object JvDefnTranslator { val rawVar = s"${srcFieldName}_raw" val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asJvRef(f.tpe, domain, evo) val parseHead = @@ -842,7 +795,7 @@ object JvDefnTranslator { | if (__r instanceof $baboonEither.Left __l) return $baboonEither.left(__l.value()); | $valVar = (($baboonEither.Right) __r).value(); |}""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val rangeCheck = signedRangeCheck(f.tpe) val typeName = signedTypeName(f.tpe) val cast = signedNarrow(f.tpe) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala index 03330fc4e..42977278a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.java.JvDomainTreeTools.MetaField import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -65,10 +65,9 @@ class JvJsonCodecGenerator( |}""".stripMargin.trim ) - val anyHelpers: List[TextTree[JvValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encodeMethod ++ decodeMethod ++ anyHelpers - val cName = codecName(srcRef, defn.defn.id.owner) - val meta = renderMeta(defn, jvDomainTreeTools.makeCodecMeta(defn)) + val baseMethods = encodeMethod ++ decodeMethod + val cName = codecName(srcRef, defn.defn.id.owner) + val meta = renderMeta(defn, jvDomainTreeTools.makeCodecMeta(defn)) val cParent = if (isEncoderEnabled) { defn match { @@ -265,23 +264,7 @@ class JvJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.uid => q"new $textNode($ref.toString())" - case TypeId.Builtins.tsu => q"new $textNode($baboonTimeFormats.formatTsu($ref))" - case TypeId.Builtins.tso => q"new $textNode($baboonTimeFormats.formatTso($ref))" - case TypeId.Builtins.bit => q"$booleanNode.valueOf($ref)" - case TypeId.Builtins.i08 => q"$shortNode.valueOf((short) $ref)" - case TypeId.Builtins.i16 => q"$shortNode.valueOf($ref)" - case TypeId.Builtins.i32 => q"$intNode.valueOf($ref)" - case TypeId.Builtins.i64 => q"$longNode.valueOf($ref)" - case TypeId.Builtins.u08 => q"$shortNode.valueOf($ref)" - case TypeId.Builtins.u16 => q"$intNode.valueOf($ref)" - case TypeId.Builtins.u32 => q"$longNode.valueOf($ref)" - case TypeId.Builtins.u64 => q"new $textNode(Long.toUnsignedString($ref))" - case TypeId.Builtins.f32 => q"$floatNode.valueOf($ref)" - case TypeId.Builtins.f64 => q"$doubleNode.valueOf($ref)" - case TypeId.Builtins.f128 => q"new $textNode($ref.toPlainString())" - case TypeId.Builtins.str => q"new $textNode($ref)" - case TypeId.Builtins.bytes => q"new $textNode($ref.toHex())" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.jsonEncode(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -336,23 +319,7 @@ class JvJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref.booleanValue()" - case TypeId.Builtins.i08 => q"(byte) $ref.intValue()" - case TypeId.Builtins.i16 => q"(short) $ref.intValue()" - case TypeId.Builtins.i32 => q"$ref.intValue()" - case TypeId.Builtins.i64 => q"($ref.isTextual() ? Long.parseLong($ref.textValue()) : $ref.longValue())" - case TypeId.Builtins.u08 => q"(short) $ref.intValue()" - case TypeId.Builtins.u16 => q"$ref.intValue()" - case TypeId.Builtins.u32 => q"$ref.longValue()" - case TypeId.Builtins.u64 => q"($ref.isTextual() ? Long.parseUnsignedLong($ref.textValue()) : $ref.longValue())" - case TypeId.Builtins.f32 => q"(float) $ref.doubleValue()" - case TypeId.Builtins.f64 => q"$ref.doubleValue()" - case TypeId.Builtins.f128 => q"($ref.isTextual() ? new $jvBigDecimal($ref.textValue()) : $ref.decimalValue())" - case TypeId.Builtins.str => q"$ref.textValue()" - case TypeId.Builtins.bytes => q"$jvByteString.fromHex($ref.textValue())" - case TypeId.Builtins.uid => q"$jvUid.fromString($ref.textValue())" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($ref.textValue())" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($ref.textValue())" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.jsonDecode(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -491,131 +458,16 @@ class JvJsonCodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java-UEBA `hasAnyField`): a codec object needs the - // any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-object `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[JvValue]): TextTree[JvValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, (byte)$expectedHex, $staticDom, $staticVer, $staticTid, $ref)" + val plan = AnyFieldPlan.forField(a, domain) + val expectedHex = "0x%02x".format(plan.kind & 0xFF) + def fallback(value: Option[String]): TextTree[JvValue] = value.fold(q"(String) null")(v => q"""(String) "$v"""") + q"$baboonAnyJsonCodec.encode(ctx, (byte) $expectedHex, ${fallback(plan.staticDomain)}, ${fallback(plan.staticVersion)}, ${fallback(plan.staticTypeId)}, $ref)" } - // Decode delegates to the per-codec-object `decodeAnyField` helper. JSON decode never cross- - // converts (always returns `AnyOpaqueJson` from JSON wire); user calls `facade.decodeAny(opaque)` - // for typed resolution. No `ctx` / no static fallbacks needed at the decode site. private def mkAnyDecoder(a: TypeRef.Any, ref: TextTree[JvValue]): TextTree[JvValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField((byte)$expectedHex, $ref)" - } - - // Static fallbacks for the cross-format facade helper (`uebaToJson`). The wire `meta` may omit - // components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), - // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java — extraction deferred (textual emission diverges - // by language flavor; see PR 4.2 ledger entry's DRY analysis). 7th instance. - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[JvValue], TextTree[JvValue], TextTree[JvValue]) = { - val none = q"(String)null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-object helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec object that has any any-bearing fields. Mirrors PR 3.3's C# - // `EncodeAnyField`/`DecodeAnyField` helpers and PR 5.3's Kotlin shape. PR-08-D06 lesson: - // `AnyMetaCodec.writeJson` returns `JsonNode` typed but always produces an `ObjectNode` — we - // cast to `ObjectNode` to add the `$c` envelope key. Kind-check on encode runs before any - // envelope construction. - private def anyFieldHelpers: TextTree[JvValue] = { - q"""private $jsonNode encodeAnyField( - | $baboonCodecContext ctx, - | byte expectedKind, - | String staticDomain, - | String staticVersion, - | String staticTypeid, - | $baboonAnyOpaque value - |) { - | if (value.meta().kind() != expectedKind) { - | throw new $baboonCodecException.EncoderFailure( - | "any: meta-kind 0x" + String.format("%02x", value.meta().kind() & 0xFF) + - | " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); - | } - | $jsonNode anyInner; - | if (value instanceof $baboonAnyOpaqueJson anyJson) { - | anyInner = anyJson.json(); - | } else if (value instanceof $baboonAnyOpaqueUeba anyUeba) { - | var anyFacade = ctx.facade(); - | if (anyFacade == null) { - | throw new $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly."); - | } - | var anyConvResult = anyFacade.uebaToJson(anyUeba.meta(), anyUeba.bytes(), staticDomain, staticVersion, staticTypeid); - | if (anyConvResult instanceof $baboonEither.Left<$baboonCodecException, $jsonNode> anyConvL) { - | throw anyConvL.value(); - | } - | anyInner = (($baboonEither.Right<$baboonCodecException, $jsonNode>) anyConvResult).value(); - | } else { - | throw new $baboonCodecException.EncoderFailure( - | "unexpected AnyOpaque subclass: " + value.getClass().getName()); - | } - | var anyEnvelope = ($objectNode) $baboonAnyMetaCodec.writeJson(value.meta()); - | anyEnvelope.set($baboonAnyMetaCodec.ANY_CONTENT_KEY, anyInner); - | return anyEnvelope; - |} - | - |private $baboonAnyOpaqueJson decodeAnyField(byte expectedKind, $jsonNode wire) { - | if (wire == null) { - | throw new $baboonCodecException.DecoderFailure( - | "any: missing JSON envelope (null token)"); - | } - | var anyMetaResult = $baboonAnyMetaCodec.readJson(wire); - | if (anyMetaResult instanceof $baboonEither.Left<$baboonCodecException, $baboonAnyMeta> anyMetaL) { - | throw anyMetaL.value(); - | } - | var anyMeta = (($baboonEither.Right<$baboonCodecException, $baboonAnyMeta>) anyMetaResult).value(); - | if (anyMeta.kind() != expectedKind) { - | throw new $baboonCodecException.DecoderFailure( - | "any: wire kind 0x" + String.format("%02x", anyMeta.kind() & 0xFF) + - | " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); - | } - | var anyContent = wire.get($baboonAnyMetaCodec.ANY_CONTENT_KEY); - | if (anyContent == null) { - | throw new $baboonCodecException.DecoderFailure( - | "any: JSON envelope missing '" + $baboonAnyMetaCodec.ANY_CONTENT_KEY + "' content key"); - | } - | return new $baboonAnyOpaqueJson(anyMeta, anyContent); - |}""".stripMargin + val expectedHex = "0x%02x".format(AnyFieldPlan.forField(a, domain).kind & 0xFF) + q"$baboonAnyJsonCodec.decode((byte) $expectedHex, $ref)" } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[JvValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvMcpServerGenerator.scala index 168c86fc8..f8f7a3ca3 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvMcpServerGenerator.scala @@ -53,9 +53,10 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } // Additive MCP runtime files (one class per file, all in baboon.runtime.shared). @@ -74,11 +75,12 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( "BaboonMcpWiringError.java", "BaboonMcpWiringException.java", "AbstractMcpMuxer.java", - ).map { fname => - fname -> OutputFile( - BaboonRuntimeResources.read(s"baboon-runtime/java/$fname"), - io.septimalmind.baboon.CompilerProduct.Runtime, - ) + ).map { + fname => + fname -> OutputFile( + BaboonRuntimeResources.read(s"baboon-runtime/java/$fname"), + io.septimalmind.baboon.CompilerProduct.Runtime, + ) } F.pure(Sources((mcpRuntimeFiles ++ perService).toMap)) } @@ -97,7 +99,8 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/$fname" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -118,13 +121,15 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( // Backslashes and double-quotes are escaped as `\\` and `\"` respectively. val toolEntries: List[String] = svc.methods.toList.map { m => - val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val toolName = s"${serviceName}_${m.name.name}" + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) // Embed schema as a Java string literal via javaString which handles // all escaping (`\` → `\\`, `"` → `\"`). `$` is literal in Java. - val schemaJson = schema.noSpaces - val descArg = McpDocs.flatten(m.docs).map(d => s", ${jsonString(d)}").getOrElse("") - s""" new McpToolEntry(${javaString(toolName)}, new BaboonMethodId(${javaString(serviceName)}, ${javaString(m.name.name)}), parseSchema(${javaString(schemaJson)})$descArg)""" + val schemaJson = schema.noSpaces + val descArg = McpDocs.flatten(m.docs).map(d => s", ${jsonString(d)}").getOrElse("") + s""" new McpToolEntry(${javaString(toolName)}, new BaboonMethodId(${javaString(serviceName)}, ${javaString(m.name.name)}), parseSchema(${javaString( + schemaJson + )})$descArg)""" } val content = @@ -142,6 +147,9 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( |import com.fasterxml.jackson.databind.JsonNode; | |import java.util.List; + |import java.util.Map; + |import java.util.HashMap; + |import java.util.Collections; | |// Generated MCP server for service `$serviceName` (model `${domain.id.path.mkString(".")}` v$modelVer). |// Transport-abstract: `handle` is inherited from AbstractBaboonMcpServer and @@ -151,9 +159,18 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( |// per-request `Ctx`. |public final class $className extends AbstractBaboonMcpServer { | private final McpJsonInvoke _invokeJson; + | private final List _tools; + | private final Map _toolMethods; + | private final McpServerInfo _serverInfo = new McpServerInfo(${javaString(serviceName)}, ${javaString(modelVer)}); | | public $className(McpJsonInvoke invokeJson) { | this._invokeJson = invokeJson; + | this._tools = List.of( + |${toolEntries.mkString(",\n")} + | ); + | Map methods = new HashMap<>(); + | for (McpToolEntry tool : _tools) methods.put(tool.name, tool.method); + | this._toolMethods = Collections.unmodifiableMap(methods); | } | | private static JsonNode parseSchema(String json) { @@ -166,14 +183,19 @@ class JvMcpServerGenerator[F[+_, +_]: Error2]( | | @Override | public McpServerInfo serverInfo() { - | return new McpServerInfo(${javaString(serviceName)}, ${javaString(modelVer)}); + | return _serverInfo; | } | | @Override | public List tools() { - | return List.of( - |${toolEntries.mkString(",\n")} - | ); + | return _tools.stream().map(tool -> new McpToolEntry( + | tool.name, tool.method, tool.inputSchema.deepCopy(), tool.description + | )).toList(); + | } + | + | @Override + | protected Map toolMethods() { + | return _toolMethods; | } | | @Override diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala new file mode 100644 index 000000000..d25b6fbf9 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala @@ -0,0 +1,91 @@ +package io.septimalmind.baboon.translator.java + +import io.septimalmind.baboon.translator.java.JvTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +object JvScalarCodecEmitter { + + def jsonDecode(b: TypeId.BuiltinScalar, wire: TextTree[JvValue]): TextTree[JvValue] = b match { + case TypeId.Builtins.bit => q"$wire.booleanValue()" + case TypeId.Builtins.i08 => q"(byte) $wire.intValue()" + case TypeId.Builtins.i16 => q"(short) $wire.intValue()" + case TypeId.Builtins.i32 => q"$wire.intValue()" + case TypeId.Builtins.i64 => q"($wire.isTextual() ? Long.parseLong($wire.textValue()) : $wire.longValue())" + case TypeId.Builtins.u08 => q"(short) $wire.intValue()" + case TypeId.Builtins.u16 => q"$wire.intValue()" + case TypeId.Builtins.u32 => q"$wire.longValue()" + case TypeId.Builtins.u64 => q"($wire.isTextual() ? Long.parseUnsignedLong($wire.textValue()) : $wire.longValue())" + case TypeId.Builtins.f32 => q"(float) $wire.doubleValue()" + case TypeId.Builtins.f64 => q"$wire.doubleValue()" + case TypeId.Builtins.f128 => q"($wire.isTextual() ? new $jvBigDecimal($wire.textValue()) : $wire.decimalValue())" + case TypeId.Builtins.str => q"$wire.textValue()" + case TypeId.Builtins.bytes => q"$jvByteString.fromHex($wire.textValue())" + case TypeId.Builtins.uid => q"$jvUid.fromString($wire.textValue())" + case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($wire.textValue())" + case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($wire.textValue())" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def jsonEncode(b: TypeId.BuiltinScalar, value: TextTree[JvValue]): TextTree[JvValue] = b match { + case TypeId.Builtins.uid => q"new $textNode($value.toString())" + case TypeId.Builtins.tsu => q"new $textNode($baboonTimeFormats.formatTsu($value))" + case TypeId.Builtins.tso => q"new $textNode($baboonTimeFormats.formatTso($value))" + case TypeId.Builtins.bit => q"$booleanNode.valueOf($value)" + case TypeId.Builtins.i08 => q"$shortNode.valueOf((short) $value)" + case TypeId.Builtins.i16 => q"$shortNode.valueOf($value)" + case TypeId.Builtins.i32 => q"$intNode.valueOf($value)" + case TypeId.Builtins.i64 => q"$longNode.valueOf($value)" + case TypeId.Builtins.u08 => q"$shortNode.valueOf($value)" + case TypeId.Builtins.u16 => q"$intNode.valueOf($value)" + case TypeId.Builtins.u32 => q"$longNode.valueOf($value)" + case TypeId.Builtins.u64 => q"new $textNode(Long.toUnsignedString($value))" + case TypeId.Builtins.f32 => q"$floatNode.valueOf($value)" + case TypeId.Builtins.f64 => q"$doubleNode.valueOf($value)" + case TypeId.Builtins.f128 => q"new $textNode($value.toPlainString())" + case TypeId.Builtins.str => q"new $textNode($value)" + case TypeId.Builtins.bytes => q"new $textNode($value.toHex())" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaDecode(b: TypeId.BuiltinScalar, br: TextTree[JvValue]): TextTree[JvValue] = b match { + case TypeId.Builtins.bit => q"$br.readByte() != 0" + case TypeId.Builtins.i08 => q"$br.readByte()" + case TypeId.Builtins.i16 => q"$br.readShort()" + case TypeId.Builtins.i32 => q"$br.readInt()" + case TypeId.Builtins.i64 => q"$br.readLong()" + case TypeId.Builtins.u08 => q"(short) ($br.readByte() & 0xFF)" + case TypeId.Builtins.u16 => q"($br.readShort() & 0xFFFF)" + case TypeId.Builtins.u32 => q"($br.readInt() & 0xFFFFFFFFL)" + case TypeId.Builtins.u64 => q"$br.readLong()" + case TypeId.Builtins.f32 => q"$br.readFloat()" + case TypeId.Builtins.f64 => q"$br.readDouble()" + case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal($br)" + case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" + case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" + case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.readTimestamp($br)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaEncode(b: TypeId.BuiltinScalar, bw: TextTree[JvValue], value: TextTree[JvValue]): TextTree[JvValue] = b match { + case TypeId.Builtins.bit => q"$bw.writeByte($value ? 1 : 0);" + case TypeId.Builtins.i08 => q"$bw.writeByte($value);" + case TypeId.Builtins.i16 => q"$bw.writeShort($value);" + case TypeId.Builtins.i32 => q"$bw.writeInt($value);" + case TypeId.Builtins.i64 => q"$bw.writeLong($value);" + case TypeId.Builtins.u08 => q"$bw.writeByte((byte) ($value & 0xFF));" + case TypeId.Builtins.u16 => q"$bw.writeShort((short) ($value & 0xFFFF));" + case TypeId.Builtins.u32 => q"$bw.writeInt((int) ($value & 0xFFFFFFFFL));" + case TypeId.Builtins.u64 => q"$bw.writeLong($value);" + case TypeId.Builtins.f32 => q"$bw.writeFloat($value);" + case TypeId.Builtins.f64 => q"$bw.writeDouble($value);" + case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($bw, $value);" + case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value);" + case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value);" + case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value);" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.writeTimestamp($bw, $value);" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala new file mode 100644 index 000000000..bd02013b6 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala @@ -0,0 +1,18 @@ +package io.septimalmind.baboon.translator.java + +import io.septimalmind.baboon.translator.ResolvedServiceResult +import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} +import izumi.fundamentals.platform.strings.TextTree + +final class JvServiceMethodPlan( + val method: Typedef.MethodDef, + resolveType: TypeRef => TextTree[JvValue], + result: ResolvedServiceResult, +) { + val hasError: Boolean = method.err.isDefined && !result.noErrors + val hasOutput: Boolean = method.out.isDefined + val declarationName: String = JvTypeTranslator.escapeJvKeyword(method.name.name) + lazy val input: TextTree[JvValue] = resolveType(method.sig) + lazy val output: Option[TextTree[JvValue]] = method.out.map(resolveType) + lazy val error: Option[TextTree[JvValue]] = method.err.map(resolveType) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala index d626d4e65..566c3e32b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala @@ -73,105 +73,27 @@ object JvServiceWiringTranslator { // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). private def jsonDecodeExpr(id: TypeId, wire: TextTree[JvValue]): TextTree[JvValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.INSTANCE.decode($codecCtxName, $wire)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$wire.booleanValue()" - case TypeId.Builtins.i08 => q"(byte) $wire.intValue()" - case TypeId.Builtins.i16 => q"(short) $wire.intValue()" - case TypeId.Builtins.i32 => q"$wire.intValue()" - case TypeId.Builtins.i64 => q"($wire.isTextual() ? Long.parseLong($wire.textValue()) : $wire.longValue())" - case TypeId.Builtins.u08 => q"(short) $wire.intValue()" - case TypeId.Builtins.u16 => q"$wire.intValue()" - case TypeId.Builtins.u32 => q"$wire.longValue()" - case TypeId.Builtins.u64 => q"($wire.isTextual() ? Long.parseUnsignedLong($wire.textValue()) : $wire.longValue())" - case TypeId.Builtins.f32 => q"(float) $wire.doubleValue()" - case TypeId.Builtins.f64 => q"$wire.doubleValue()" - case TypeId.Builtins.f128 => q"($wire.isTextual() ? new $jvBigDecimal($wire.textValue()) : $wire.decimalValue())" - case TypeId.Builtins.str => q"$wire.textValue()" - case TypeId.Builtins.bytes => q"$jvByteString.fromHex($wire.textValue())" - case TypeId.Builtins.uid => q"$jvUid.fromString($wire.textValue())" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($wire.textValue())" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($wire.textValue())" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.INSTANCE.decode($codecCtxName, $wire)" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.jsonDecode(b, wire) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[JvValue]): TextTree[JvValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.INSTANCE.encode($codecCtxName, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.uid => q"new $textNode($value.toString())" - case TypeId.Builtins.tsu => q"new $textNode($baboonTimeFormats.formatTsu($value))" - case TypeId.Builtins.tso => q"new $textNode($baboonTimeFormats.formatTso($value))" - case TypeId.Builtins.bit => q"$booleanNode.valueOf($value)" - case TypeId.Builtins.i08 => q"$shortNode.valueOf((short) $value)" - case TypeId.Builtins.i16 => q"$shortNode.valueOf($value)" - case TypeId.Builtins.i32 => q"$intNode.valueOf($value)" - case TypeId.Builtins.i64 => q"$longNode.valueOf($value)" - case TypeId.Builtins.u08 => q"$shortNode.valueOf($value)" - case TypeId.Builtins.u16 => q"$intNode.valueOf($value)" - case TypeId.Builtins.u32 => q"$longNode.valueOf($value)" - case TypeId.Builtins.u64 => q"new $textNode(Long.toUnsignedString($value))" - case TypeId.Builtins.f32 => q"$floatNode.valueOf($value)" - case TypeId.Builtins.f64 => q"$doubleNode.valueOf($value)" - case TypeId.Builtins.f128 => q"new $textNode($value.toPlainString())" - case TypeId.Builtins.str => q"new $textNode($value)" - case TypeId.Builtins.bytes => q"new $textNode($value.toHex())" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.INSTANCE.encode($codecCtxName, $value)" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.jsonEncode(b, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, br: TextTree[JvValue]): TextTree[JvValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.INSTANCE.decode($codecCtxName, $br)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$br.readByte() != 0" - case TypeId.Builtins.i08 => q"$br.readByte()" - case TypeId.Builtins.i16 => q"$br.readShort()" - case TypeId.Builtins.i32 => q"$br.readInt()" - case TypeId.Builtins.i64 => q"$br.readLong()" - case TypeId.Builtins.u08 => q"(short) ($br.readByte() & 0xFF)" - case TypeId.Builtins.u16 => q"($br.readShort() & 0xFFFF)" - case TypeId.Builtins.u32 => q"($br.readInt() & 0xFFFFFFFFL)" - case TypeId.Builtins.u64 => q"$br.readLong()" - case TypeId.Builtins.f32 => q"$br.readFloat()" - case TypeId.Builtins.f64 => q"$br.readDouble()" - case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal($br)" - case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" - case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.readTimestamp($br)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.INSTANCE.decode($codecCtxName, $br)" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.uebaDecode(b, br) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, bw: TextTree[JvValue], value: TextTree[JvValue]): TextTree[JvValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.INSTANCE.encode($codecCtxName, $bw, $value);" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$bw.writeByte($value ? 1 : 0);" - case TypeId.Builtins.i08 => q"$bw.writeByte($value);" - case TypeId.Builtins.i16 => q"$bw.writeShort($value);" - case TypeId.Builtins.i32 => q"$bw.writeInt($value);" - case TypeId.Builtins.i64 => q"$bw.writeLong($value);" - case TypeId.Builtins.u08 => q"$bw.writeByte((byte) ($value & 0xFF));" - case TypeId.Builtins.u16 => q"$bw.writeShort((short) ($value & 0xFFFF));" - case TypeId.Builtins.u32 => q"$bw.writeInt((int) ($value & 0xFFFFFFFFL));" - case TypeId.Builtins.u64 => q"$bw.writeLong($value);" - case TypeId.Builtins.f32 => q"$bw.writeFloat($value);" - case TypeId.Builtins.f64 => q"$bw.writeDouble($value);" - case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($bw, $value);" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value);" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value);" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value);" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.writeTimestamp($bw, $value);" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.INSTANCE.encode($codecCtxName, $bw, $value);" + case b: TypeId.BuiltinScalar => JvScalarCodecEmitter.uebaEncode(b, bw, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def renderContainer(error: String, success: String): String = { @@ -257,10 +179,11 @@ object JvServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val inRef = trans.asJvRef(m.sig, domain, evo) + val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val inRef = plan.input - val uebaMethod = if (hasUeba) Some(generateClientUebaMethod(svcName, m, inRef)) else None - val jsonMethod = if (hasJson) Some(generateClientJsonMethod(svcName, m, inRef)) else None + val uebaMethod = if (hasUeba) Some(generateClientUebaMethod(svcName, plan, inRef)) else None + val jsonMethod = if (hasJson) Some(generateClientJsonMethod(svcName, plan, inRef)) else None uebaMethod.toList ++ jsonMethod.toList } @@ -329,22 +252,23 @@ object JvServiceWiringTranslator { // historical context-free transports are emitted byte-for-byte. private def ctxTransportTypeArg: String = svcCtxTypeName.fold("")(tn => s"<$tn>") private def uebaTransportType: TextTree[JvValue] = - if (ctxOrConcreteActive) (if (isAsync) q"$baboonClientTransportUebaAsyncCtx$ctxTransportTypeArg" else q"$baboonClientTransportUebaSyncCtx$ctxTransportTypeArg") - else if (isAsync) q"$baboonClientTransportUebaAsync" else q"$baboonClientTransportUebaSync" + if (ctxOrConcreteActive) if (isAsync) q"$baboonClientTransportUebaAsyncCtx$ctxTransportTypeArg" else q"$baboonClientTransportUebaSyncCtx$ctxTransportTypeArg" + else if (isAsync) q"$baboonClientTransportUebaAsync" + else q"$baboonClientTransportUebaSync" private def jsonTransportType: TextTree[JvValue] = - if (ctxOrConcreteActive) (if (isAsync) q"$baboonClientTransportJsonAsyncCtx$ctxTransportTypeArg" else q"$baboonClientTransportJsonSyncCtx$ctxTransportTypeArg") - else if (isAsync) q"$baboonClientTransportJsonAsync" else q"$baboonClientTransportJsonSync" - private def uebaTransportFieldDecl: TextTree[JvValue] = q"private final $uebaTransportType transportUeba;" - private def jsonTransportFieldDecl: TextTree[JvValue] = q"private final $jsonTransportType transportJson;" + if (ctxOrConcreteActive) if (isAsync) q"$baboonClientTransportJsonAsyncCtx$ctxTransportTypeArg" else q"$baboonClientTransportJsonSyncCtx$ctxTransportTypeArg" + else if (isAsync) q"$baboonClientTransportJsonAsync" + else q"$baboonClientTransportJsonSync" + private def uebaTransportFieldDecl: TextTree[JvValue] = q"private final $uebaTransportType transportUeba;" + private def jsonTransportFieldDecl: TextTree[JvValue] = q"private final $jsonTransportType transportJson;" private def uebaTransportParam(name: String): TextTree[JvValue] = q"$uebaTransportType $name" private def jsonTransportParam(name: String): TextTree[JvValue] = q"$jsonTransportType $name" // Boxed return element type for a method (used inside CompletableFuture<...> // and as the sync return type). void output maps to Void/void respectively. - private def clientReturnType(out: Option[TypeRef]): TextTree[JvValue] = { + private def clientReturnType(out: Option[TextTree[JvValue]]): TextTree[JvValue] = { out match { - case Some(outRef) => - val rendered = trans.asJvRef(outRef, domain, evo) + case Some(rendered) => if (isAsync) q"$completableFuture<$rendered>" else rendered case None => if (isAsync) q"$completableFuture" else q"void" @@ -353,10 +277,11 @@ object JvServiceWiringTranslator { private def generateClientUebaMethod( svcName: String, - m: Typedef.MethodDef, + plan: JvServiceMethodPlan, inRef: TextTree[JvValue], ): TextTree[JvValue] = { - val retType = clientReturnType(m.out) + val m = plan.method + val retType = clientReturnType(plan.output) val methodName = m.name.name val encodeIn = uebaEncodeStmt(m.sig.id.asInstanceOf[TypeId.Scalar], q"bw", q"arg") @@ -387,7 +312,7 @@ object JvServiceWiringTranslator { // synchronously before the returned CompletableFuture). q"""public $retType $methodName($ctxParamDecl$inRef arg) throws Exception { | ${encodeBlock.shift(2).trim} - | return transportUeba.apply(${ctxArgPass}"$svcName", "$methodName", payload).thenApply(resp -> { + | return transportUeba.apply($ctxArgPass"$svcName", "$methodName", payload).thenApply(resp -> { | ${decodeBody.shift(4).trim} | }); |}""".stripMargin @@ -402,7 +327,7 @@ object JvServiceWiringTranslator { } q"""public $retType $methodName($ctxParamDecl$inRef arg) throws Exception { | ${encodeBlock.shift(2).trim} - | byte[] resp = transportUeba.apply(${ctxArgPass}"$svcName", "$methodName", payload); + | byte[] resp = transportUeba.apply($ctxArgPass"$svcName", "$methodName", payload); | ${decodeBody.shift(2).trim} |}""".stripMargin } @@ -410,10 +335,11 @@ object JvServiceWiringTranslator { private def generateClientJsonMethod( svcName: String, - m: Typedef.MethodDef, + plan: JvServiceMethodPlan, inRef: TextTree[JvValue], ): TextTree[JvValue] = { - val retType = clientReturnType(m.out) + val m = plan.method + val retType = clientReturnType(plan.output) val methodName = s"${m.name.name}Json" val routeName = m.name.name val encodeIn = jsonEncodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"arg") @@ -425,8 +351,7 @@ object JvServiceWiringTranslator { case Some(outRef) => val decodeExpr = jsonDecodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"wire") q"""try { - | var mapper = new $objectMapper(); - | $jsonNode wire = mapper.readTree(resp); + | $jsonNode wire = $codecCtxName.parseJson(resp); | return $decodeExpr; |} catch (Exception ex) { | throw new RuntimeException(ex); @@ -435,7 +360,7 @@ object JvServiceWiringTranslator { } q"""public $retType $methodName($ctxParamDecl$inRef arg) { | $encodeBlock - | return transportJson.apply(${ctxArgPass}"$svcName", "$routeName", payload).thenApply(resp -> { + | return transportJson.apply($ctxArgPass"$svcName", "$routeName", payload).thenApply(resp -> { | ${decodeBody.shift(4).trim} | }); |}""".stripMargin @@ -443,14 +368,13 @@ object JvServiceWiringTranslator { val decodeBody = m.out match { case Some(outRef) => val decodeExpr = jsonDecodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"wire") - q"""var mapper = new $objectMapper(); - |$jsonNode wire = mapper.readTree(resp); + q"""$jsonNode wire = $codecCtxName.parseJson(resp); |return $decodeExpr;""".stripMargin case None => q"" } q"""public $retType $methodName($ctxParamDecl$inRef arg) throws Exception { | $encodeBlock - | String resp = transportJson.apply(${ctxArgPass}"$svcName", "$routeName", payload); + | String resp = transportJson.apply($ctxArgPass"$svcName", "$routeName", payload); | ${decodeBody.shift(2).trim} |}""".stripMargin } @@ -474,14 +398,14 @@ object JvServiceWiringTranslator { // avoid the duplicate-parameter collision; in `none` mode it stays `ctx`, // keeping that output byte-identical. private def svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } private def svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } // The codec-context parameter name. Renamed only when a service context is // present (it occupies the default `ctx` slot); `none` mode keeps `ctx`. @@ -541,7 +465,7 @@ object JvServiceWiringTranslator { // output is preserved byte-for-byte: the leading clause stays `svcTypeArg` // (just the HKT name) and the declaration form continues to appear after the // method name via [[staticGenericAfterName]]. - private def staticGenericDecl: String = if (ctxActive) genericParam else svcTypeArg + private def staticGenericDecl: String = if (ctxActive) genericParam else svcTypeArg private def staticGenericAfterName: String = if (ctxActive) "" else genericParam private def renderFq(tree: TextTree[JvValue]): String = tree.mapRender { @@ -565,8 +489,8 @@ object JvServiceWiringTranslator { val wrappers = generateServiceWrappers( service, isErrorsMode = false, - jsonActive = hasActiveJsonCodecs(service), - uebaActive = hasActiveUebaCodecs(service), + jsonActive = hasActiveJsonCodecs(service), + uebaActive = hasActiveUebaCodecs(service), ) val parts = Seq(jsonMethod, uebaMethod).flatten ++ wrappers.toSeq @@ -601,8 +525,7 @@ object JvServiceWiringTranslator { } q"""case "${m.name.name}" -> { - | var mapper = new $objectMapper(); - | $jsonNode wire = mapper.readTree(data); + | $jsonNode wire = $codecCtxName.parseJson(data); | var decoded = $decodeIn; | ${composed.shift(2).trim} |}""".stripMargin @@ -622,8 +545,7 @@ object JvServiceWiringTranslator { } q"""case "${m.name.name}" -> { - | var mapper = new $objectMapper(); - | $jsonNode wire = mapper.readTree(data); + | $jsonNode wire = $codecCtxName.parseJson(data); | var decoded = $decodeIn; | $callExpr | ${encodeOutput.shift(2).trim} @@ -739,8 +661,8 @@ object JvServiceWiringTranslator { val wrappers = generateServiceWrappers( service, isErrorsMode = true, - jsonActive = hasActiveJsonCodecs(service), - uebaActive = hasActiveUebaCodecs(service), + jsonActive = hasActiveJsonCodecs(service), + uebaActive = hasActiveUebaCodecs(service), ) val parts = Seq(jsonMethod, uebaMethod).flatten ++ wrappers.toSeq @@ -755,49 +677,59 @@ object JvServiceWiringTranslator { private def ct(error: String, success: String): String = renderContainer(error, success) + private def synchronousInvocation(plan: JvServiceMethodPlan): TextTree[JvValue] = { + val m = plan.method + if (plan.hasError) { + q"""try { + | var callResult = impl.${m.name.name}(${ctxArgPass}v); + | return rt.leftMap( + | callResult, err -> new $baboonWiringError.CallFailed(method, err)); + |} catch (Throwable ex) { + | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else if (plan.hasOutput) { + q"""try { + | return rt.pure(impl.${m.name.name}(${ctxArgPass}v)); + |} catch (Throwable ex) { + | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } else { + q"""try { + | impl.${m.name.name}(${ctxArgPass}v); + | return rt.pure(null); + |} catch (Throwable ex) { + | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); + |}""".stripMargin + } + } + private def generateErrorsJsonMethod(service: Typedef.Service): TextTree[JvValue] = { val svcName = service.id.name.name val wiringRetType = ct(bweFq, "String") val cases = service.methods.map { m => - val inRef = trans.asJvRef(m.sig, domain, evo) + val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") - if (isAsync) generateErrorsJsonCaseAsync(m, inRef, decodeIn) + if (isAsync) generateErrorsJsonCaseAsync(plan, inRef, decodeIn) else { val decodeStep = q"""${ct(bweFq, renderFq(inRef))} input; |try { - | var mapper = new $objectMapper(); - | $jsonNode wire = mapper.readTree(data); + | $jsonNode wire = $codecCtxName.parseJson(data); | input = rt.pure($decodeIn); |} catch (Throwable ex) { | input = rt.fail(new $baboonWiringError.DecoderFailed(method, ex)); |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors - val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asJvRef(outRef, domain, evo) + val outType = plan.output.get val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") - val callBody = if (hasErrType) { - q"""try { - | var callResult = impl.${m.name.name}(${ctxArgPass}v); - | return rt.leftMap( - | callResult, err -> new $baboonWiringError.CallFailed(method, err)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | return rt.pure(impl.${m.name.name}(${ctxArgPass}v)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + val callBody = synchronousInvocation(plan) val containerExpr = q"""rt.flatMap(output, v -> { @@ -814,22 +746,7 @@ object JvServiceWiringTranslator { |yield ${errorsFutureWrap(containerExpr)};""".stripMargin case None => - val callBody = if (hasErrType) { - q"""try { - | var callResult = impl.${m.name.name}(${ctxArgPass}v); - | return rt.leftMap( - | callResult, err -> new $baboonWiringError.CallFailed(method, err)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | impl.${m.name.name}(${ctxArgPass}v); - | return rt.pure(null); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + val callBody = synchronousInvocation(plan) val containerExpr = q"""rt.flatMap(input, v -> { @@ -869,17 +786,17 @@ object JvServiceWiringTranslator { // CompletableFuture-around-F double-wrap. Decode failures short-circuit with // a single completedFuture(rt.fail(…)). private def generateErrorsJsonCaseAsync( - m: Typedef.MethodDef, + plan: JvServiceMethodPlan, inRef: TextTree[JvValue], decodeIn: TextTree[JvValue], ): TextTree[JvValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val m = plan.method + val hasErrType = plan.hasError val decodeStep = q"""$inRef decoded; |try { - | var mapper = new $objectMapper(); - | $jsonNode wire = mapper.readTree(data); + | $jsonNode wire = $codecCtxName.parseJson(data); | decoded = $decodeIn; |} catch (Throwable ex) { | yield $completableFuture.completedFuture(rt.fail(new $baboonWiringError.DecoderFailed(method, ex))); @@ -887,7 +804,7 @@ object JvServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asJvRef(outRef, domain, evo) + val outType = plan.output.get val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") // Resolve the impl future first, bind `output` (the F container) from @@ -897,31 +814,23 @@ object JvServiceWiringTranslator { // error type-arg pins to BaboonWiringError, not the CallFailed subtype. val bindOutput = bindOutputStep(hasErrType, renderFq(outType)) - q"""yield impl.${m.name.name}(${ctxArgPass}decoded).handle((callResult, ex) -> { - | if (ex != null) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - | } - | ${bindOutput.shift(2).trim} - | return rt.flatMap(output, v -> { - | try { - | var encoded = $encodeOut; - | return rt.pure(encoded.toString()); - | } catch (Throwable encEx) { - | return rt.fail(new $baboonWiringError.EncoderFailed(method, encEx)); - | } - | }); - |});""".stripMargin + asynchronousInvocation( + plan, + bindOutput, + q"""return rt.flatMap(output, v -> { + | try { + | var encoded = $encodeOut; + | return rt.pure(encoded.toString()); + | } catch (Throwable encEx) { + | return rt.fail(new $baboonWiringError.EncoderFailed(method, encEx)); + | } + |});""".stripMargin, + ) case None => val bindOutput = bindOutputStep(hasErrType, "Void") - q"""yield impl.${m.name.name}(${ctxArgPass}decoded).handle((callResult, ex) -> { - | if (ex != null) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - | } - | ${bindOutput.shift(2).trim} - | return rt.flatMap(output, v -> rt.pure("null")); - |});""".stripMargin + asynchronousInvocation(plan, bindOutput, q"""return rt.flatMap(output, v -> rt.pure("null"));""".stripMargin) } q"""case "${m.name.name}" -> { @@ -930,6 +839,20 @@ object JvServiceWiringTranslator { |}""".stripMargin } + private def asynchronousInvocation( + plan: JvServiceMethodPlan, + bindOutput: TextTree[JvValue], + encode: TextTree[JvValue], + ): TextTree[JvValue] = { + q"""yield impl.${plan.method.name.name}(${ctxArgPass}decoded).handle((callResult, ex) -> { + | if (ex != null) { + | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); + | } + | ${bindOutput.shift(2).trim} + | ${encode.shift(2).trim} + |});""".stripMargin + } + // Binds the resolved impl-future value into the explicitly-typed F container // `output`. The explicit `F` declaration (not `var`) // pins the error type-arg so leftMap's `err -> CallFailed(…)` does not infer @@ -951,10 +874,11 @@ object JvServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asJvRef(m.sig, domain, evo) + val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") - if (isAsync) generateErrorsUebaCaseAsync(m, inRef, decodeIn) + if (isAsync) generateErrorsUebaCaseAsync(plan, inRef, decodeIn) else { val decodeStep = q"""${ct(bweFq, renderFq(inRef))} input; @@ -966,28 +890,12 @@ object JvServiceWiringTranslator { | input = rt.fail(new $baboonWiringError.DecoderFailed(method, ex)); |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors - val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asJvRef(outRef, domain, evo) + val outType = plan.output.get val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") - val callBody = if (hasErrType) { - q"""try { - | var callResult = impl.${m.name.name}(${ctxArgPass}v); - | return rt.leftMap( - | callResult, err -> new $baboonWiringError.CallFailed(method, err)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | return rt.pure(impl.${m.name.name}(${ctxArgPass}v)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + val callBody = synchronousInvocation(plan) val containerExpr = q"""rt.flatMap(output, v -> { @@ -1007,22 +915,7 @@ object JvServiceWiringTranslator { |yield ${errorsFutureWrap(containerExpr)};""".stripMargin case None => - val callBody = if (hasErrType) { - q"""try { - | var callResult = impl.${m.name.name}(${ctxArgPass}v); - | return rt.leftMap( - | callResult, err -> new $baboonWiringError.CallFailed(method, err)); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } else { - q"""try { - | impl.${m.name.name}(${ctxArgPass}v); - | return rt.pure(null); - |} catch (Throwable ex) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - |}""".stripMargin - } + val callBody = synchronousInvocation(plan) val containerExpr = q"""rt.flatMap(input, v -> { @@ -1055,11 +948,12 @@ object JvServiceWiringTranslator { // Async errors-mode UEBA case (one `switch` arm). See // [[generateErrorsJsonCaseAsync]] for the chain-then-thread rationale. private def generateErrorsUebaCaseAsync( - m: Typedef.MethodDef, + plan: JvServiceMethodPlan, inRef: TextTree[JvValue], decodeIn: TextTree[JvValue], ): TextTree[JvValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val m = plan.method + val hasErrType = plan.hasError val decodeStep = q"""$inRef decoded; @@ -1073,39 +967,31 @@ object JvServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asJvRef(outRef, domain, evo) + val outType = plan.output.get val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") val bindOutput = bindOutputStep(hasErrType, renderFq(outType)) - q"""yield impl.${m.name.name}(${ctxArgPass}decoded).handle((callResult, ex) -> { - | if (ex != null) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - | } - | ${bindOutput.shift(2).trim} - | return rt.flatMap(output, v -> { - | try { - | var oms = new $byteArrayOutputStream(); - | var bw = new $binaryOutput(oms); - | $encStmt - | bw.flush(); - | return rt.pure(oms.toByteArray()); - | } catch (Throwable encEx) { - | return rt.fail(new $baboonWiringError.EncoderFailed(method, encEx)); - | } - | }); - |});""".stripMargin + asynchronousInvocation( + plan, + bindOutput, + q"""return rt.flatMap(output, v -> { + | try { + | var oms = new $byteArrayOutputStream(); + | var bw = new $binaryOutput(oms); + | $encStmt + | bw.flush(); + | return rt.pure(oms.toByteArray()); + | } catch (Throwable encEx) { + | return rt.fail(new $baboonWiringError.EncoderFailed(method, encEx)); + | } + |});""".stripMargin, + ) case None => val bindOutput = bindOutputStep(hasErrType, "Void") - q"""yield impl.${m.name.name}(${ctxArgPass}decoded).handle((callResult, ex) -> { - | if (ex != null) { - | return rt.fail(new $baboonWiringError.CallFailed(method, ex)); - | } - | ${bindOutput.shift(2).trim} - | return rt.flatMap(output, v -> rt.pure(new byte[0])); - |});""".stripMargin + asynchronousInvocation(plan, bindOutput, q"""return rt.flatMap(output, v -> rt.pure(new byte[0]));""".stripMargin) } q"""case "${m.name.name}" -> { @@ -1141,9 +1027,9 @@ object JvServiceWiringTranslator { uebaActive: Boolean, ): Option[TextTree[JvValue]] = { if (!jsonActive && !uebaActive) return None - val jsonWrapper = if (jsonActive) Some(generateOneWrapper(service, isJson = true, isErrorsMode = isErrorsMode)) else None + val jsonWrapper = if (jsonActive) Some(generateOneWrapper(service, isJson = true, isErrorsMode = isErrorsMode)) else None val uebaWrapper = if (uebaActive) Some(generateOneWrapper(service, isJson = false, isErrorsMode = isErrorsMode)) else None - val joined = Seq(jsonWrapper, uebaWrapper).flatten.join("\n\n") + val joined = Seq(jsonWrapper, uebaWrapper).flatten.join("\n\n") Some(joined) } @@ -1152,16 +1038,17 @@ object JvServiceWiringTranslator { isJson: Boolean, isErrorsMode: Boolean, ): TextTree[JvValue] = { - val svcName = service.id.name.name - val wireType = if (isJson) "String" else "byte[]" - val invokerFn = if (isJson) "invokeJson" else "invokeUeba" + val svcName = service.id.name.name + val wireType = if (isJson) "String" else "byte[]" + val invokerFn = if (isJson) "invokeJson" else "invokeUeba" // When a service context is active the wrapper implements the // context-carrying contract and receives the service context PER-INVOKE // (not via the constructor). In `none` mode the historical context-free // contract is used, byte-for-byte. - val ifaceType = - if (ctxOrConcreteActive) (if (isJson) iBaboonJsonServiceCtx else iBaboonUebaServiceCtx) - else (if (isJson) iBaboonJsonService else iBaboonUebaService) + val ifaceType = + if (ctxOrConcreteActive) if (isJson) iBaboonJsonServiceCtx else iBaboonUebaServiceCtx + else if (isJson) iBaboonJsonService + else iBaboonUebaService val wrapperName = if (isJson) "JsonService" else "UebaService" // Type parameters for the wrapper class. Mirrors `genericParam` from @@ -1219,7 +1106,7 @@ object JvServiceWiringTranslator { // supplied per `invoke`. `rt` (errors mode) remains a constructor field. val extras: List[(String, String)] = List(rtField).flatten - val implFieldDecl: TextTree[JvValue] = q"private final $svcName$svcImplTypeArg impl;" + val implFieldDecl: TextTree[JvValue] = q"private final $svcName$svcImplTypeArg impl;" val extraFieldDecls: Seq[TextTree[JvValue]] = extras.map { case (n, t) => q"private final $t $n;" } val allFieldDecls: TextTree[JvValue] = (implFieldDecl +: extraFieldDecls).join("\n") @@ -1264,7 +1151,7 @@ object JvServiceWiringTranslator { // The wrapper is a nested class inside ${Svc}Wiring; call the enclosing // class's static method directly. Type inference picks F/C from impl. - q"""public static final class $wrapperName$classTypeParams implements ${ifaceType}$ifaceTypeArgs { + q"""public static final class $wrapperName$classTypeParams implements $ifaceType$ifaceTypeArgs { | ${allFieldDecls.shift(2).trim} | | public $wrapperName($ctorParams) { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvTypes.scala index 61c317888..ff89a9449 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvTypes.scala @@ -46,8 +46,8 @@ object JvTypes { val baboonRandomFactory: JvType = JvType(baboonFixturePkg, "BaboonRandomFactory") // baboon facade types - val baboonCodecsFacade: JvType = JvType(baboonRuntimePkg, "BaboonCodecsFacade") - val baboonDomainVersion: JvType = JvType(baboonRuntimePkg, "BaboonDomainVersion") + val baboonCodecsFacade: JvType = JvType(baboonRuntimePkg, "BaboonCodecsFacade") + val baboonDomainVersion: JvType = JvType(baboonRuntimePkg, "BaboonDomainVersion") // baboon service wiring types val baboonMethodId: JvType = JvType(baboonRuntimePkg, "BaboonMethodId") @@ -68,7 +68,7 @@ object JvTypes { val baboonClientTransportUebaSyncCtx: JvType = JvType(baboonRuntimePkg, "BaboonClientTransport.UebaSyncCtx") val baboonClientTransportJsonAsyncCtx: JvType = JvType(baboonRuntimePkg, "BaboonClientTransport.JsonAsyncCtx") val baboonClientTransportUebaAsyncCtx: JvType = JvType(baboonRuntimePkg, "BaboonClientTransport.UebaAsyncCtx") - val completableFuture: JvType = JvType(parseJvPkg("java.util.concurrent"), "CompletableFuture") + val completableFuture: JvType = JvType(parseJvPkg("java.util.concurrent"), "CompletableFuture") // baboon conversions val baboonAbstractConversion: JvType = JvType(baboonRuntimePkg, "AbstractConversion") @@ -81,6 +81,8 @@ object JvTypes { val baboonAnyOpaqueJson: JvType = JvType(baboonRuntimePkg, "BaboonAnyOpaque.AnyOpaqueJson") val baboonAnyMeta: JvType = JvType(baboonRuntimePkg, "BaboonAnyOpaque.AnyMeta") val baboonAnyMetaCodec: JvType = JvType(baboonRuntimePkg, "BaboonAnyOpaque.AnyMetaCodec") + val baboonAnyJsonCodec: JvType = JvType(baboonRuntimePkg, "BaboonAnyJsonCodec") + val baboonAnyBinCodec: JvType = JvType(baboonRuntimePkg, "BaboonAnyBinCodec") val baboonCodecException: JvType = JvType(baboonRuntimePkg, "BaboonCodecException") val baboonEither: JvType = JvType(baboonRuntimePkg, "BaboonEither") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala index 69fac343c..b19d214c5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.java.JvDomainTreeTools.MetaField import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -122,15 +122,13 @@ class JvUEBACodecGenerator( |}""".stripMargin ) - val anyHelpers: List[TextTree[JvValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encoderMethods ++ decoderMethods ++ branchDecoder.map { body => q"""public $name decodeBranch($baboonCodecContext ctx, $binaryInput input) throws Exception { | ${body.shift(2).trim} |}""".stripMargin - }.toList ++ indexMethods ++ anyHelpers + }.toList ++ indexMethods val codecIface = q"$baboonBinCodec<$name>" val cName = codecName(srcRef, defn.defn.id.owner) @@ -341,8 +339,8 @@ class JvUEBACodecGenerator( } private def dtoDec(name: JvValue.JvType, fields: List[TextTree[JvValue]]): TextTree[JvValue] = { - q"""var index = this.readIndex(ctx, input); - |if (ctx.useIndices()) assert index.size() == (int) indexElementsCount(ctx); + q"""var indexCount = $baboonBinCodec.consumeIndex(ctx, input, (int) indexElementsCount(ctx)); + |if (ctx.useIndices()) assert indexCount == (int) indexElementsCount(ctx); |return new $name( | ${fields.join(",\n").shift(2).trim} |); @@ -406,29 +404,7 @@ class JvUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"input.readByte() != 0" - case TypeId.Builtins.i08 => q"input.readByte()" - case TypeId.Builtins.i16 => q"input.readShort()" - case TypeId.Builtins.i32 => q"input.readInt()" - case TypeId.Builtins.i64 => q"input.readLong()" - case TypeId.Builtins.u08 => q"(short) (input.readByte() & 0xFF)" - case TypeId.Builtins.u16 => q"(input.readShort() & 0xFFFF)" - case TypeId.Builtins.u32 => q"(input.readInt() & 0xFFFFFFFFL)" - case TypeId.Builtins.u64 => q"input.readLong()" - case TypeId.Builtins.f32 => q"input.readFloat()" - case TypeId.Builtins.f64 => q"input.readDouble()" - - case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal(input)" - case TypeId.Builtins.str => q"$baboonBinTools.readString(input)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString(input)" - - case TypeId.Builtins.uid => q"$baboonBinTools.readUid(input)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => - q"$baboonBinTools.readTimestamp(input)" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + JvScalarCodecEmitter.uebaDecode(s, q"input") case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -469,27 +445,7 @@ class JvUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.writeByte($ref ? 1 : 0);" - case TypeId.Builtins.i08 => q"$wref.writeByte($ref);" - case TypeId.Builtins.i16 => q"$wref.writeShort($ref);" - case TypeId.Builtins.i32 => q"$wref.writeInt($ref);" - case TypeId.Builtins.i64 => q"$wref.writeLong($ref);" - case TypeId.Builtins.u08 => q"$wref.writeByte((byte) ($ref & 0xFF));" - case TypeId.Builtins.u16 => q"$wref.writeShort((short) ($ref & 0xFFFF));" - case TypeId.Builtins.u32 => q"$wref.writeInt((int) ($ref & 0xFFFFFFFFL));" - case TypeId.Builtins.u64 => q"$wref.writeLong($ref);" - case TypeId.Builtins.f32 => q"$wref.writeFloat($ref);" - case TypeId.Builtins.f64 => q"$wref.writeDouble($ref);" - case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($wref, $ref);" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($wref, $ref);" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($wref, $ref);" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($wref, $ref);" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => - q"$baboonBinTools.writeTimestamp($wref, $ref);" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + JvScalarCodecEmitter.uebaEncode(s, wref, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -541,165 +497,16 @@ class JvUEBACodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin hasAnyField): a codec object needs the any-field - // helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-object `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[JvValue], wref: TextTree[JvValue]): TextTree[JvValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, $wref, (byte)$expectedHex, $staticDom, $staticVer, $staticTid, $ref);" + val plan = AnyFieldPlan.forField(a, domain) + val expectedHex = "0x%02x".format(plan.kind & 0xFF) + def fallback(value: Option[String]): TextTree[JvValue] = value.fold(q"(String) null")(v => q"""(String) "$v"""") + q"$baboonAnyBinCodec.encode(ctx, $wref, (byte) $expectedHex, ${fallback(plan.staticDomain)}, ${fallback(plan.staticVersion)}, ${fallback(plan.staticTypeId)}, $ref);" } - // Decode delegates to the per-codec-object `decodeAnyField` helper, returning an - // `AnyOpaqueUeba` (the helper's narrow return type — `mkDecoder`'s field type position is - // `AnyOpaque`, the supertype, so this widens implicitly via Java sealed-interface assignment). private def mkAnyDecoder(a: TypeRef.Any): TextTree[JvValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField(input, (byte)$expectedHex)" - } - - // Static fallbacks for the cross-format facade helpers (`jsonToUebaBytes`/`uebaToJson`). The - // wire `meta` may omit components that are pinned by the field's static declaration; the codec - // emits whatever is statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), - // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java — extraction deferred (textual emission diverges - // by language flavor; see PR 4.2 ledger entry's DRY analysis). - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[JvValue], TextTree[JvValue], TextTree[JvValue]) = { - val none = q"(String)null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-object helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec object that has any - // any-bearing field. Mirrors `CSUEBACodecGenerator.anyFieldHelpers`, - // `RsUEBACodecGenerator.anyFieldHelpers`, and `KtUEBACodecGenerator.anyFieldHelpers`. Wire - // layout (locked, see docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // - // Negative-i32 sanity guards on the on-wire lengths run before any size arithmetic — Java `int` - // is 32-bit signed and `4 + Integer.MIN_VALUE` overflows silently otherwise (PR-12-D01 lesson - // applied to Java). - private def anyFieldHelpers: TextTree[JvValue] = { - q"""private void encodeAnyField( - | $baboonCodecContext ctx, - | $binaryOutput writer, - | byte expectedKind, - | String staticDomain, - | String staticVersion, - | String staticTypeid, - | $baboonAnyOpaque value - |) throws Exception { - | if (value.meta().kind() != expectedKind) { - | throw new $baboonCodecException.EncoderFailure( - | "any: meta-kind 0x" + String.format("%02x", value.meta().kind() & 0xFF) + - | " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); - | } - | byte[] anyBlob; - | if (value instanceof $baboonAnyOpaqueUeba anyUeba) { - | anyBlob = anyUeba.bytes(); - | } else if (value instanceof $baboonAnyOpaqueJson anyJson) { - | var anyFacade = ctx.facade(); - | if (anyFacade == null) { - | throw new $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly."); - | } - | var anyConvResult = anyFacade.jsonToUebaBytes(anyJson.meta(), anyJson.json(), staticDomain, staticVersion, staticTypeid); - | if (anyConvResult instanceof $baboonEither.Left<$baboonCodecException, byte[]> anyConvL) { - | throw anyConvL.value(); - | } - | anyBlob = (($baboonEither.Right<$baboonCodecException, byte[]>) anyConvResult).value(); - | } else { - | throw new $baboonCodecException.EncoderFailure( - | "unexpected AnyOpaque subclass: " + value.getClass().getName()); - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | var anyMetaBuf = new $byteArrayOutputStream(); - | var anyMetaWriter = new $binaryOutput(anyMetaBuf); - | $baboonAnyMetaCodec.writeBin(value.meta(), anyMetaWriter); - | anyMetaWriter.flush(); - | byte[] anyMetaBytes = anyMetaBuf.toByteArray(); - | int anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; - | writer.writeInt(anyTotalLength); - | writer.writeInt(anyMetaBytes.length); - | writer.write(anyMetaBytes); - | writer.write(anyBlob); - |} - | - |private $baboonAnyOpaqueUeba decodeAnyField($binaryInput wire, byte expectedKind) throws Exception { - | int anyTotalLength = wire.readInt(); - | if (anyTotalLength < 0) { - | throw new $baboonCodecException.DecoderFailure( - | "any: negative total-length " + anyTotalLength); - | } - | int anyMetaLength = wire.readInt(); - | if (anyMetaLength < 0) { - | throw new $baboonCodecException.DecoderFailure( - | "any: negative meta-length " + anyMetaLength); - | } - | if (anyTotalLength < 4 + anyMetaLength) { - | throw new $baboonCodecException.DecoderFailure( - | "any: total-length " + anyTotalLength + " smaller than 4 + meta-length " + anyMetaLength); - | } - | var anyReadResult = $baboonAnyMetaCodec.readBinWithLength(wire); - | var anyMeta = anyReadResult.meta(); - | int anyBytesRead = anyReadResult.bytesRead(); - | if (anyBytesRead > anyMetaLength) { - | throw new $baboonCodecException.DecoderFailure( - | "any: meta bytes-read " + anyBytesRead + " exceeded meta-length window " + anyMetaLength); - | } - | if (anyBytesRead < anyMetaLength) { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | int anySkip = anyMetaLength - anyBytesRead; - | byte[] anySkipBuf = wire.readNBytes(anySkip); - | if (anySkipBuf.length != anySkip) { - | throw new $baboonCodecException.DecoderFailure( - | "any: short read while skipping meta-extension bytes, expected " + anySkip + " got " + anySkipBuf.length); - | } - | } - | if (anyMeta.kind() != expectedKind) { - | throw new $baboonCodecException.DecoderFailure( - | "any: wire kind 0x" + String.format("%02x", anyMeta.kind() & 0xFF) + - | " does not match field-declared 0x" + String.format("%02x", expectedKind & 0xFF)); - | } - | int anyBlobLen = anyTotalLength - 4 - anyMetaLength; - | byte[] anyBlob = wire.readNBytes(anyBlobLen); - | if (anyBlob.length != anyBlobLen) { - | throw new $baboonCodecException.DecoderFailure( - | "any: short read on blob, expected " + anyBlobLen + " got " + anyBlob.length); - | } - | return new $baboonAnyOpaqueUeba(anyMeta, anyBlob); - |}""".stripMargin + val expectedHex = "0x%02x".format(AnyFieldPlan.forField(a, domain).kind & 0xFF) + q"$baboonAnyBinCodec.decode(input, (byte) $expectedHex)" } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[JvValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala index b4df2399b..8a01a6263 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala @@ -11,7 +11,7 @@ import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* import izumi.fundamentals.collections.nonempty.NEList -import io.septimalmind.baboon.translator.BaboonRuntimeResources +import io.septimalmind.baboon.translator.{BaboonRuntimeResources, DomainProductTranslator} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -80,13 +80,13 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( val registerCalls = lineage.versions.toSeq.sortBy(_._1).map { case (version, domain) => - val verPkg = trans.toKtPkg(domain.id, version, lineage.evolution) - val verStr = version.v.toString - val codecsJson = KtValue.KtType(verPkg, "BaboonCodecsJson") - val codecsUeba = KtValue.KtType(verPkg, "BaboonCodecsUeba") - val metadata = KtValue.KtType(verPkg, "BaboonMetadata") - val hasMeta = target.language.writeEvolutionDict - val hasJson = target.language.generateJsonCodecs + val verPkg = trans.toKtPkg(domain.id, version, lineage.evolution) + val verStr = version.v.toString + val codecsJson = KtValue.KtType(verPkg, "BaboonCodecsJson") + val codecsUeba = KtValue.KtType(verPkg, "BaboonCodecsUeba") + val metadata = KtValue.KtType(verPkg, "BaboonMetadata") + val hasMeta = target.language.writeEvolutionDict + val hasJson = target.language.generateJsonCodecs (hasJson, hasMeta) match { case (true, true) => q"""register( @@ -122,7 +122,7 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( | } |}""".stripMargin - val tree = ktTreeTools.inPkg(lineagePkg.parts.toSeq, facadeTree) + val tree = ktTreeTools.inPkg(lineagePkg.parts.toSeq, facadeTree) val output = KtDefnTranslator.Output( s"${lineage.pkg.path.map(_.toLowerCase).mkString("/")}/$classNameStr.kt", tree, @@ -137,16 +137,8 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( domain: Domain, p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[KtDefnTranslator.Output]], - ): F[NEList[BaboonIssue], List[KtDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } - } + ): F[NEList[BaboonIssue], List[KtDefnTranslator.Output]] = + DomainProductTranslator.translate(domain, target.output.products, p, translate) private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[KtDefnTranslator.Output]] = { val evo = lineage.evolution @@ -342,16 +334,18 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( val rtDir = if (target.language.multiplatform) "baboon-runtime/kotlin-kmp" else "baboon-runtime/kotlin" val baseFiles = List( + rt("BaboonAnyBinCodec.kt", s"$rtDir/BaboonAnyBinCodec.kt", stripJsonSections), rt("BaboonAnyOpaque.kt", s"$rtDir/BaboonAnyOpaque.kt", stripJsonSections), rt("BaboonByteString.kt", s"$rtDir/BaboonByteString.kt"), rt("BaboonCodecs.kt", s"$rtDir/BaboonCodecs.kt", stripJsonSections), rt("BaboonCodecsFacade.kt", s"$rtDir/BaboonCodecsFacade.kt", stripJsonSections), + rt("BaboonCodecVersionSelection.kt", "baboon-runtime/kotlin-common/BaboonCodecVersionSelection.kt"), rt("BaboonConversions.kt", s"$rtDir/BaboonConversions.kt"), - rt("BaboonEither.kt", s"$rtDir/BaboonEither.kt"), - rt("BaboonExceptions.kt", s"$rtDir/BaboonExceptions.kt"), + rt("BaboonEither.kt", "baboon-runtime/kotlin-common/BaboonEither.kt"), + rt("BaboonExceptions.kt", "baboon-runtime/kotlin-common/BaboonExceptions.kt"), rt("BaboonIdentifierRepr.kt", s"$rtDir/BaboonIdentifierRepr.kt"), rt("BaboonRuntimeShared.kt", s"$rtDir/BaboonRuntimeShared.kt"), - rt("BaboonServiceWiring.kt", s"$rtDir/BaboonServiceWiring.kt"), + rt("BaboonServiceWiring.kt", "baboon-runtime/kotlin-common/BaboonServiceWiring.kt"), rt("BaboonTools.kt", s"$rtDir/BaboonTools.kt"), rt("BaboonTimeFormats.kt", s"$rtDir/BaboonTimeFormats.kt"), ) @@ -363,7 +357,11 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( ) } else Nil - F.pure(baseFiles ++ kmpExtraFiles) + val jsonFiles = if (jsonEnabled) { + List(rt("BaboonAnyJsonCodec.kt", "baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt")) + } else Nil + + F.pure(baseFiles ++ kmpExtraFiles ++ jsonFiles) } else { F.pure(List.empty) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala index 8b85dc34a..75718b852 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.kotlin import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.kotlin.KtBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.translator.kotlin.KtValue.KtPackageId @@ -176,10 +176,12 @@ class KtConversionTranslator[F[+_, +_]: Error2]( val tin = trans.asKtType(conv.sourceTpe, srcDom, evo).fullyQualified def tout = trans.asKtType(conv.targetTpe, domain, evo) - val meta = q"""override val versionFrom: String = "${srcVer.v.toString}" - |override val versionTo: String = "${domain.version.v.toString}" - |override val typeId: String = "${conv.sourceTpe.toString}" + val targetMeta = if (conv.sourceTpe == conv.targetTpe) q"" else q"""override val targetTypeId: String = "${conv.targetTpe.toString}"""" + val sourceMeta = q"""override val versionFrom: String = "${srcVer.v.toString}" + |override val versionTo: String = "${domain.version.v.toString}" + |override val typeId: String = "${conv.sourceTpe.toString}" """.stripMargin.trim + val meta = if (conv.sourceTpe == conv.targetTpe) sourceMeta else Seq(sourceMeta, targetMeta).join("\n") val rendered = conv match { case _: Conversion.CustomConversionRequired => @@ -331,11 +333,7 @@ class KtConversionTranslator[F[+_, +_]: Error2]( List(RenderedConversion(fname, tools.inPkg(pkg.parts.toSeq, classDef), Some(regtree), None)) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RenderedConversion]] } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala index 03bd013be..825473617 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.kotlin import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.KtTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.kotlin.KtValue.KtType import io.septimalmind.baboon.typer.EnumWireStyle @@ -246,7 +247,7 @@ object KtDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[KtValue]): TextTree[KtValue] = { val block = ktTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } private def makeRepr( @@ -265,9 +266,9 @@ object KtDefnTranslator { val contractFieldNames = collectContractFieldNames(dto.contracts) val params = dto.fields.map { f => - val t = trans.asKtNullableRef(f.tpe, domain, evo) - val prefix = if (contractFieldNames.contains(f.name.name)) "override val" else "val" - val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) + val t = trans.asKtNullableRef(f.tpe, domain, evo) + val prefix = if (contractFieldNames.contains(f.name.name)) "override val" else "val" + val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) val fieldTree = q"$prefix $ktName: $t" prependDocs(f.docs, fieldTree) } @@ -406,8 +407,8 @@ object KtDefnTranslator { case contract: Typedef.Contract => val methods = contract.fields.map { f => - val t = trans.asKtNullableRef(f.tpe, domain, evo) - val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) + val t = trans.asKtNullableRef(f.tpe, domain, evo) + val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) val methodTree = q"val $ktName: $t" prependDocs(f.docs, methodTree) } @@ -432,17 +433,18 @@ object KtDefnTranslator { } val methods = service.methods.map { m => - val in = trans.asKtRef(m.sig, domain, evo) - val out = m.out.map(trans.asKtRef(_, domain, evo)) - val err = m.err.map(trans.asKtRef(_, domain, evo)) + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val in = plan.input + val out = plan.output + val err = plan.error val ktFqName: KtValue => String = { case t: KtValue.KtType => if (t.predef) t.name else (t.pkg.parts :+ t.name).mkString(".") case t: KtValue.KtTypeName => t.name } - val outStr = out.map(_.mapRender(ktFqName)).getOrElse("") - val errStr = err.map(_.mapRender(ktFqName)) - val retStr = resolved.renderReturnType(outStr, errStr, "Unit") - val ktMName = KtTypeTranslator.escapeKtKeyword(m.name.name) + val outStr = out.map(_.mapRender(ktFqName)).getOrElse("") + val errStr = err.map(_.mapRender(ktFqName)) + val retStr = resolved.renderReturnType(outStr, errStr, "Unit") + val ktMName = plan.methodName val suspendKw = if (target.language.asyncServices) "suspend " else "" val methodTree = q"${suspendKw}fun $ktMName(${ctxParam}arg: $in): $retStr" prependDocs(m.docs, methodTree) @@ -563,53 +565,15 @@ object KtDefnTranslator { // JvDefnTranslator but uses Kotlin idiom (top-level objects, ULong unsigned // arithmetic, Either with Left/Right subclasses). - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def renderFieldValueExpr(ktFieldName: String, kind: IdentifierFieldKind): TextTree[KtValue] = { kind match { case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(this.$ktFieldName)" // Kotlin's signed and unsigned-small primitive toString already produces // canonical decimal (no locale, unsigned-small types render as unsigned). - case IdentifierFieldKind.SignedInt => q"this.$ktFieldName.toString()" - case IdentifierFieldKind.UnsignedSmallInt => q"this.$ktFieldName.toString()" - case IdentifierFieldKind.UnsignedLong => q"$baboonIdRepr.u64ToString(this.$ktFieldName)" - case IdentifierFieldKind.Str => q"$baboonIdRepr.escapeStr(this.$ktFieldName)" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => q"this.$ktFieldName.toString()" + case IdentifierFieldKind.UnsignedSmallInt => q"this.$ktFieldName.toString()" + case IdentifierFieldKind.UnsignedLong => q"$baboonIdRepr.u64ToString(this.$ktFieldName)" + case IdentifierFieldKind.Str => q"$baboonIdRepr.escapeStr(this.$ktFieldName)" // UUID.toString() (java.util.UUID) and kotlin.uuid.Uuid.toString() both // emit the canonical lowercase 36-char hyphenated form per RFC 4122. case IdentifierFieldKind.Uid => q"this.$ktFieldName.toString()" @@ -629,7 +593,7 @@ object KtDefnTranslator { f => val srcFieldName = f.name.name val ktFieldName = KtTypeTranslator.escapeKtKeyword(srcFieldName) - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderFieldValueExpr(ktFieldName, kind) // The string label in the repr always uses the original model name (wire key). q""""$srcFieldName:" + ($valueExpr)""" @@ -712,7 +676,7 @@ object KtDefnTranslator { val rawVar = s"${srcFieldName}_raw" val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asKtRef(f.tpe, domain, evo) // All locals beyond `valVar` (which the constructor consumes) are scoped @@ -738,7 +702,7 @@ object KtDefnTranslator { |val $rNb = $baboonIdRepr.parseBit($rawVar) |if ($rNb is $baboonEither.Left) return $baboonEither.Left($rNb.value) |val $valVar: $tpe = ($rNb as $baboonEither.Right).value""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val rangeCheck = signedRangeCheck(f.tpe) val typeName = signedTypeName(f.tpe) val narrow = signedNarrow(f.tpe) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala index da2c79327..ab3952d41 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.kotlin.KtDomainTreeTools.MetaField import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -20,6 +20,8 @@ class KtJsonCodecGenerator( ktTypes: KtTypes, ) extends KtCodecTranslator { + private val scalarCodecs = new KtScalarCodecEmitter(ktTypes) + override def translate(defn: DomainMember.User, ktRef: KtValue.KtType, srcRef: KtValue.KtType): Option[TextTree[KtValue]] = { if (isActive(defn.id)) { (defn.defn match { @@ -64,10 +66,9 @@ class KtJsonCodecGenerator( |}""".stripMargin.trim ) - val anyHelpers: List[TextTree[KtValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encodeMethod ++ decodeMethod ++ anyHelpers - val cName = codecName(srcRef) - val meta = renderMeta(defn, ktDomainTreeTools.makeCodecMeta(defn)) + val baseMethods = encodeMethod ++ decodeMethod + val cName = codecName(srcRef) + val meta = renderMeta(defn, ktDomainTreeTools.makeCodecMeta(defn)) val cParent = if (isEncoderEnabled) { defn match { @@ -227,7 +228,7 @@ class KtJsonCodecGenerator( q"$ref.toString()" // M19/PR-60: single-primitive-field wrappers — peel and recurse. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => - val inner = d.fields.head + val inner = d.fields.head val innerKtName = KtTypeTranslator.escapeKtKeyword(inner.name.name) encodeKey(inner.tpe, q"$ref.$innerKtName") case o => @@ -244,23 +245,7 @@ class KtJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.uid => q"$jsonPrimitive($ref.toString())" - case TypeId.Builtins.tsu => q"$jsonPrimitive($baboonTimeFormats.formatTsu($ref))" - case TypeId.Builtins.tso => q"$jsonPrimitive($baboonTimeFormats.formatTso($ref))" - case TypeId.Builtins.bit => q"$jsonPrimitive($ref)" - case TypeId.Builtins.i08 => q"$jsonPrimitive($ref.toInt())" - case TypeId.Builtins.i16 => q"$jsonPrimitive($ref.toInt())" - case TypeId.Builtins.i32 => q"$jsonPrimitive($ref)" - case TypeId.Builtins.i64 => q"$jsonPrimitive($ref)" - case TypeId.Builtins.u08 => q"$jsonPrimitive($ref.toInt())" - case TypeId.Builtins.u16 => q"$jsonPrimitive($ref.toInt())" - case TypeId.Builtins.u32 => q"$jsonPrimitive($ref.toLong())" - case TypeId.Builtins.u64 => q"$jsonPrimitive($ref.toLong())" - case TypeId.Builtins.f32 => q"$jsonPrimitive($ref)" - case TypeId.Builtins.f64 => q"$jsonPrimitive($ref)" - case TypeId.Builtins.f128 => if (ktTypes.multiplatform) q"$jsonPrimitive($ref.toString())" else q"$jsonPrimitive($ref.toPlainString())" - case TypeId.Builtins.str => q"$jsonPrimitive($ref)" - case TypeId.Builtins.bytes => q"$jsonPrimitive($ref.toHexString())" + case b: TypeId.BuiltinScalar => scalarCodecs.jsonEncode(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -301,25 +286,7 @@ class KtJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref.jsonPrimitive.boolean" - case TypeId.Builtins.i08 => q"$ref.jsonPrimitive.int.toByte()" - case TypeId.Builtins.i16 => q"$ref.jsonPrimitive.int.toShort()" - case TypeId.Builtins.i32 => q"$ref.jsonPrimitive.int" - case TypeId.Builtins.i64 => q"$ref.jsonPrimitive.long" - case TypeId.Builtins.u08 => q"$ref.jsonPrimitive.int.toUByte()" - case TypeId.Builtins.u16 => q"$ref.jsonPrimitive.int.toUShort()" - case TypeId.Builtins.u32 => q"$ref.jsonPrimitive.long.toUInt()" - case TypeId.Builtins.u64 => q"$ref.jsonPrimitive.long.toULong()" - case TypeId.Builtins.f32 => q"$ref.jsonPrimitive.float" - case TypeId.Builtins.f64 => q"$ref.jsonPrimitive.double" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"${ktTypes.ktBigDecimal}.fromString($ref.jsonPrimitive.content)" else q"java.math.BigDecimal($ref.jsonPrimitive.content)" - case TypeId.Builtins.str => q"$ref.jsonPrimitive.content" - case TypeId.Builtins.bytes => q"$ktByteString.fromHexString($ref.jsonPrimitive.content)" - case TypeId.Builtins.uid => - if (ktTypes.multiplatform) q"kotlin.uuid.Uuid.parse($ref.jsonPrimitive.content)" else q"java.util.UUID.fromString($ref.jsonPrimitive.content)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($ref.jsonPrimitive.content)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($ref.jsonPrimitive.content)" + case b: TypeId.BuiltinScalar => scalarCodecs.jsonDecode(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -429,36 +396,19 @@ class KtJsonCodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kt-UEBA `hasAnyField`): a codec object needs the any-field - // helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-object `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. + // The runtime helper owns envelope framing and cross-format conversion. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[KtValue]): TextTree[KtValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, $expectedHex.toByte(), $staticDom, $staticVer, $staticTid, $ref)" + q"$baboonAnyJsonCodec.encode(ctx, $expectedHex.toByte(), $staticDom, $staticVer, $staticTid, $ref)" } - // Decode delegates to the per-codec-object `decodeAnyField` helper. JSON decode never cross- - // converts (always returns `AnyOpaqueJson` from JSON wire); user calls `facade.decodeAny(opaque)` - // for typed resolution. No `ctx` / no static fallbacks needed at the decode site. + // Decoding preserves the opaque payload; typed resolution remains facade-owned. private def mkAnyDecoder(a: TypeRef.Any, ref: TextTree[KtValue]): TextTree[KtValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField($expectedHex.toByte(), $ref)" + q"$baboonAnyJsonCodec.decode($expectedHex.toByte(), $ref)" } // Static fallbacks for the cross-format facade helper (`uebaToJson`). The wire `meta` may omit @@ -468,85 +418,10 @@ class KtJsonCodecGenerator( // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kt-UEBA/Kt-JSON — extraction deferred (see PR 4.2 ledger - // entry's DRY analysis): textual emission diverges by language flavor. private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[KtValue], TextTree[KtValue], TextTree[KtValue]) = { - val none = q"null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-object helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec object that has any any-bearing fields. Mirrors PR 5.2's - // `KtUEBACodecGenerator.anyFieldHelpers` and PR 3.3's C# JSON helper shape. PR-06-D08 lesson: - // `AnyMetaCodec.writeJson` returns `JsonElement` typed but always produces a `JsonObject` — - // we cast to `JsonObject` to add the `$c` envelope key. The const `'$c'` source escape uses - // Kotlin's `${'$'}c` form so the literal compiles to `$c` (matching the runtime constant - // contract). - private def anyFieldHelpers: TextTree[KtValue] = { - q"""private val anyEnvelopeContentKey: String = "$${'$$'}c" - | - |private fun encodeAnyField( - | ctx: $baboonCodecContext, - | expectedKind: Byte, - | staticDomain: String?, - | staticVersion: String?, - | staticTypeid: String?, - | value: $baboonAnyOpaque, - |): $jsonElement { - | if (value.meta.kind != expectedKind) { - | throw $baboonCodecException.EncoderFailure( - | "any: meta-kind 0x" + (value.meta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + - | " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') - | ) - | } - | val anyInner: $jsonElement = when (value) { - | is $baboonAnyOpaqueJson -> value.json - | is $baboonAnyOpaqueUeba -> { - | val anyFacade = ctx.facade ?: throw $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." - | ) - | val anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid) - | when (anyConvResult) { - | is $baboonEither.Left -> throw anyConvResult.value - | is $baboonEither.Right -> anyConvResult.value - | } - | } - | } - | val anyMetaJson = $baboonAnyMetaCodec.writeJson(value.meta) as $jsonObject - | return $jsonObject(anyMetaJson.toMutableMap().apply { put(anyEnvelopeContentKey, anyInner) }) - |} - | - |private fun decodeAnyField(expectedKind: Byte, wire: $jsonElement): $baboonAnyOpaqueJson { - | val anyMetaResult = $baboonAnyMetaCodec.readJson(wire) - | val anyMeta = when (anyMetaResult) { - | is $baboonEither.Left -> throw anyMetaResult.value - | is $baboonEither.Right -> anyMetaResult.value - | } - | if (anyMeta.kind != expectedKind) { - | throw $baboonCodecException.DecoderFailure( - | "any: wire kind 0x" + (anyMeta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + - | " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') - | ) - | } - | val anyContent = (wire as? $jsonObject)?.get(anyEnvelopeContentKey) - | ?: throw $baboonCodecException.DecoderFailure( - | "any: JSON envelope missing '" + anyEnvelopeContentKey + "' content key" - | ) - | return $baboonAnyOpaqueJson(anyMeta, anyContent) - |}""".stripMargin + val plan = AnyFieldPlan.forField(a, domain) + def render(value: Option[String]): TextTree[KtValue] = value.fold[TextTree[KtValue]](q"null")(s => q""""$s"""") + (render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[KtValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtMcpServerGenerator.scala index 128522b53..2478e6fc8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtMcpServerGenerator.scala @@ -53,9 +53,10 @@ class KtMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -80,7 +81,8 @@ class KtMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/$fname" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -104,7 +106,7 @@ class KtMcpServerGenerator[F[+_, +_]: Error2]( val toolEntries: List[String] = svc.methods.toList.map { m => val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) // Embed schema as a regular Kotlin string literal. Escape rules for // a Kotlin non-raw string: `\` → `\\`, `"` → `\"`, `$` → `\$`. val schemaLiteral = schema.noSpaces @@ -112,7 +114,9 @@ class KtMcpServerGenerator[F[+_, +_]: Error2]( .replace("\"", "\\\"") .replace("$", "\\$") val descArg = McpDocs.flatten(m.docs).map(d => s", description = ${ktDescString(d)}").getOrElse("") - s""" McpToolEntry(${ktString(toolName)}, BaboonMethodId(${ktString(serviceName)}, ${ktString(m.name.name)}), Json.parseToJsonElement("$schemaLiteral")$descArg),""" + s""" McpToolEntry(${ktString(toolName)}, BaboonMethodId(${ktString(serviceName)}, ${ktString( + m.name.name + )}), Json.parseToJsonElement("$schemaLiteral")$descArg),""" } val content = diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala new file mode 100644 index 000000000..26a4d6084 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala @@ -0,0 +1,107 @@ +package io.septimalmind.baboon.translator.kotlin + +import io.septimalmind.baboon.translator.kotlin.KtTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +final class KtScalarCodecEmitter(ktTypes: KtTypes) { + + def jsonDecode(b: TypeId.BuiltinScalar, wire: TextTree[KtValue]): TextTree[KtValue] = b match { + case TypeId.Builtins.bit => q"$wire.jsonPrimitive.boolean" + case TypeId.Builtins.i08 => q"$wire.jsonPrimitive.int.toByte()" + case TypeId.Builtins.i16 => q"$wire.jsonPrimitive.int.toShort()" + case TypeId.Builtins.i32 => q"$wire.jsonPrimitive.int" + case TypeId.Builtins.i64 => q"$wire.jsonPrimitive.long" + case TypeId.Builtins.u08 => q"$wire.jsonPrimitive.int.toUByte()" + case TypeId.Builtins.u16 => q"$wire.jsonPrimitive.int.toUShort()" + case TypeId.Builtins.u32 => q"$wire.jsonPrimitive.long.toUInt()" + case TypeId.Builtins.u64 => q"$wire.jsonPrimitive.long.toULong()" + case TypeId.Builtins.f32 => q"$wire.jsonPrimitive.float" + case TypeId.Builtins.f64 => q"$wire.jsonPrimitive.double" + case TypeId.Builtins.f128 => + if (ktTypes.multiplatform) q"${ktTypes.ktBigDecimal}.fromString($wire.jsonPrimitive.content)" + else q"java.math.BigDecimal($wire.jsonPrimitive.content)" + case TypeId.Builtins.str => q"$wire.jsonPrimitive.content" + case TypeId.Builtins.bytes => q"$ktByteString.fromHexString($wire.jsonPrimitive.content)" + case TypeId.Builtins.uid => + if (ktTypes.multiplatform) q"kotlin.uuid.Uuid.parse($wire.jsonPrimitive.content)" + else q"java.util.UUID.fromString($wire.jsonPrimitive.content)" + case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($wire.jsonPrimitive.content)" + case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($wire.jsonPrimitive.content)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def jsonEncode(b: TypeId.BuiltinScalar, value: TextTree[KtValue]): TextTree[KtValue] = b match { + case TypeId.Builtins.uid => q"$jsonPrimitive($value.toString())" + case TypeId.Builtins.tsu => q"$jsonPrimitive($baboonTimeFormats.formatTsu($value))" + case TypeId.Builtins.tso => q"$jsonPrimitive($baboonTimeFormats.formatTso($value))" + case TypeId.Builtins.bit => q"$jsonPrimitive($value)" + case TypeId.Builtins.i08 => q"$jsonPrimitive($value.toInt())" + case TypeId.Builtins.i16 => q"$jsonPrimitive($value.toInt())" + case TypeId.Builtins.i32 => q"$jsonPrimitive($value)" + case TypeId.Builtins.i64 => q"$jsonPrimitive($value)" + case TypeId.Builtins.u08 => q"$jsonPrimitive($value.toInt())" + case TypeId.Builtins.u16 => q"$jsonPrimitive($value.toInt())" + case TypeId.Builtins.u32 => q"$jsonPrimitive($value.toLong())" + case TypeId.Builtins.u64 => q"$jsonPrimitive($value.toLong())" + case TypeId.Builtins.f32 => q"$jsonPrimitive($value)" + case TypeId.Builtins.f64 => q"$jsonPrimitive($value)" + case TypeId.Builtins.f128 => + if (ktTypes.multiplatform) q"$jsonPrimitive($value.toString())" + else q"$jsonPrimitive($value.toPlainString())" + case TypeId.Builtins.str => q"$jsonPrimitive($value)" + case TypeId.Builtins.bytes => q"$jsonPrimitive($value.toHexString())" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaDecode(b: TypeId.BuiltinScalar, br: TextTree[KtValue]): TextTree[KtValue] = b match { + case TypeId.Builtins.bit => q"$br.readBoolean()" + case TypeId.Builtins.i08 => q"$br.readByte()" + case TypeId.Builtins.i16 => q"$br.readShort()" + case TypeId.Builtins.i32 => q"$br.readInt()" + case TypeId.Builtins.i64 => q"$br.readLong()" + case TypeId.Builtins.u08 => q"$br.readByte().toUByte()" + case TypeId.Builtins.u16 => q"$br.readShort().toUShort()" + case TypeId.Builtins.u32 => q"$br.readInt().toUInt()" + case TypeId.Builtins.u64 => q"$br.readLong().toULong()" + case TypeId.Builtins.f32 => q"$br.readFloat()" + case TypeId.Builtins.f64 => q"$br.readDouble()" + case TypeId.Builtins.f128 => + if (ktTypes.multiplatform) q"$baboonBinTools.readBaboonDecimal($br)" + else q"$baboonBinTools.readBigDecimal($br)" + case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" + case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" + case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" + case TypeId.Builtins.tsu => q"$baboonBinTools.readTimestamp($br)" + case TypeId.Builtins.tso => + if (ktTypes.multiplatform) q"$baboonBinTools.readTimestampOffset($br)" + else q"$baboonBinTools.readTimestamp($br)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaEncode(b: TypeId.BuiltinScalar, bw: TextTree[KtValue], value: TextTree[KtValue]): TextTree[KtValue] = b match { + case TypeId.Builtins.bit => q"$bw.writeBoolean($value)" + case TypeId.Builtins.i08 => q"$bw.writeByte($value.toInt())" + case TypeId.Builtins.i16 => q"$bw.writeShort($value.toInt())" + case TypeId.Builtins.i32 => q"$bw.writeInt($value)" + case TypeId.Builtins.i64 => q"$bw.writeLong($value)" + case TypeId.Builtins.u08 => q"$bw.writeByte($value.toInt())" + case TypeId.Builtins.u16 => q"$bw.writeShort($value.toInt())" + case TypeId.Builtins.u32 => q"$bw.writeInt($value.toInt())" + case TypeId.Builtins.u64 => q"$bw.writeLong($value.toLong())" + case TypeId.Builtins.f32 => q"$bw.writeFloat($value)" + case TypeId.Builtins.f64 => q"$bw.writeDouble($value)" + case TypeId.Builtins.f128 => + if (ktTypes.multiplatform) q"$baboonBinTools.writeBaboonDecimal($bw, $value)" + else q"$baboonBinTools.writeBigDecimal($bw, $value)" + case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value)" + case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value)" + case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value)" + case TypeId.Builtins.tsu => q"$baboonBinTools.writeTimestamp($bw, $value)" + case TypeId.Builtins.tso => + if (ktTypes.multiplatform) q"$baboonBinTools.writeTimestampOffset($bw, $value)" + else q"$baboonBinTools.writeTimestamp($bw, $value)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala new file mode 100644 index 000000000..14f050127 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala @@ -0,0 +1,20 @@ +package io.septimalmind.baboon.translator.kotlin + +import io.septimalmind.baboon.translator.ResolvedServiceResult +import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} +import izumi.fundamentals.platform.strings.TextTree + +final class KtServiceMethodPlan( + method: Typedef.MethodDef, + resolveType: TypeRef => TextTree[KtValue], + result: ResolvedServiceResult, +) { + val methodName: String = KtTypeTranslator.escapeKtKeyword(method.name.name) + val hasError: Boolean = method.err.isDefined && !result.noErrors + + // Keep type resolution demand-driven: a disabled transport or error mode + // must not start resolving types that its renderer never used. + lazy val input: TextTree[KtValue] = resolveType(method.sig) + lazy val output: Option[TextTree[KtValue]] = method.out.map(resolveType) + lazy val error: Option[TextTree[KtValue]] = method.err.map(resolveType) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala index 933b45ec5..23d667348 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala @@ -26,6 +26,8 @@ object KtServiceWiringTranslator { ) extends KtServiceWiringTranslator { import ktTypes.* + private val scalarCodecs = new KtScalarCodecEmitter(ktTypes) + // When the Kotlin `asyncServices` flag is on, the service interface methods // are `suspend fun` (KtDefnTranslator), so the server dispatchers // `invokeJson`/`invokeUeba` must call the impl in a suspend context and @@ -123,118 +125,28 @@ object KtServiceWiringTranslator { private def jsonDecodeExpr(id: TypeId, wire: TextTree[KtValue]): TextTree[KtValue] = id match { case u: TypeId.User => q"${jsonCodecName(u)}.decode($codecCtxRef, $wire)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$wire.jsonPrimitive.boolean" - case TypeId.Builtins.i08 => q"$wire.jsonPrimitive.int.toByte()" - case TypeId.Builtins.i16 => q"$wire.jsonPrimitive.int.toShort()" - case TypeId.Builtins.i32 => q"$wire.jsonPrimitive.int" - case TypeId.Builtins.i64 => q"$wire.jsonPrimitive.long" - case TypeId.Builtins.u08 => q"$wire.jsonPrimitive.int.toUByte()" - case TypeId.Builtins.u16 => q"$wire.jsonPrimitive.int.toUShort()" - case TypeId.Builtins.u32 => q"$wire.jsonPrimitive.long.toUInt()" - case TypeId.Builtins.u64 => q"$wire.jsonPrimitive.long.toULong()" - case TypeId.Builtins.f32 => q"$wire.jsonPrimitive.float" - case TypeId.Builtins.f64 => q"$wire.jsonPrimitive.double" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"${ktTypes.ktBigDecimal}.fromString($wire.jsonPrimitive.content)" - else q"java.math.BigDecimal($wire.jsonPrimitive.content)" - case TypeId.Builtins.str => q"$wire.jsonPrimitive.content" - case TypeId.Builtins.bytes => q"$ktByteString.fromHexString($wire.jsonPrimitive.content)" - case TypeId.Builtins.uid => - if (ktTypes.multiplatform) q"kotlin.uuid.Uuid.parse($wire.jsonPrimitive.content)" - else q"java.util.UUID.fromString($wire.jsonPrimitive.content)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseTsu($wire.jsonPrimitive.content)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseTso($wire.jsonPrimitive.content)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + scalarCodecs.jsonDecode(b, wire) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[KtValue]): TextTree[KtValue] = id match { case u: TypeId.User => q"${jsonCodecName(u)}.encode($codecCtxRef, $value)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.uid => q"$jsonPrimitive($value.toString())" - case TypeId.Builtins.tsu => q"$jsonPrimitive($baboonTimeFormats.formatTsu($value))" - case TypeId.Builtins.tso => q"$jsonPrimitive($baboonTimeFormats.formatTso($value))" - case TypeId.Builtins.bit => q"$jsonPrimitive($value)" - case TypeId.Builtins.i08 => q"$jsonPrimitive($value.toInt())" - case TypeId.Builtins.i16 => q"$jsonPrimitive($value.toInt())" - case TypeId.Builtins.i32 => q"$jsonPrimitive($value)" - case TypeId.Builtins.i64 => q"$jsonPrimitive($value)" - case TypeId.Builtins.u08 => q"$jsonPrimitive($value.toInt())" - case TypeId.Builtins.u16 => q"$jsonPrimitive($value.toInt())" - case TypeId.Builtins.u32 => q"$jsonPrimitive($value.toLong())" - case TypeId.Builtins.u64 => q"$jsonPrimitive($value.toLong())" - case TypeId.Builtins.f32 => q"$jsonPrimitive($value)" - case TypeId.Builtins.f64 => q"$jsonPrimitive($value)" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"$jsonPrimitive($value.toString())" - else q"$jsonPrimitive($value.toPlainString())" - case TypeId.Builtins.str => q"$jsonPrimitive($value)" - case TypeId.Builtins.bytes => q"$jsonPrimitive($value.toHexString())" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + scalarCodecs.jsonEncode(b, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, br: TextTree[KtValue]): TextTree[KtValue] = id match { case u: TypeId.User => q"${uebaCodecName(u)}.instance.decode($codecCtxRef, $br)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$br.readBoolean()" - case TypeId.Builtins.i08 => q"$br.readByte()" - case TypeId.Builtins.i16 => q"$br.readShort()" - case TypeId.Builtins.i32 => q"$br.readInt()" - case TypeId.Builtins.i64 => q"$br.readLong()" - case TypeId.Builtins.u08 => q"$br.readByte().toUByte()" - case TypeId.Builtins.u16 => q"$br.readShort().toUShort()" - case TypeId.Builtins.u32 => q"$br.readInt().toUInt()" - case TypeId.Builtins.u64 => q"$br.readLong().toULong()" - case TypeId.Builtins.f32 => q"$br.readFloat()" - case TypeId.Builtins.f64 => q"$br.readDouble()" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"$baboonBinTools.readBaboonDecimal($br)" - else q"$baboonBinTools.readBigDecimal($br)" - case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" - case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" - case TypeId.Builtins.tsu => q"$baboonBinTools.readTimestamp($br)" - case TypeId.Builtins.tso => - if (ktTypes.multiplatform) q"$baboonBinTools.readTimestampOffset($br)" - else q"$baboonBinTools.readTimestamp($br)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + scalarCodecs.uebaDecode(b, br) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, bw: TextTree[KtValue], value: TextTree[KtValue]): TextTree[KtValue] = id match { case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $bw, $value)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$bw.writeBoolean($value)" - case TypeId.Builtins.i08 => q"$bw.writeByte($value.toInt())" - case TypeId.Builtins.i16 => q"$bw.writeShort($value.toInt())" - case TypeId.Builtins.i32 => q"$bw.writeInt($value)" - case TypeId.Builtins.i64 => q"$bw.writeLong($value)" - case TypeId.Builtins.u08 => q"$bw.writeByte($value.toInt())" - case TypeId.Builtins.u16 => q"$bw.writeShort($value.toInt())" - case TypeId.Builtins.u32 => q"$bw.writeInt($value.toInt())" - case TypeId.Builtins.u64 => q"$bw.writeLong($value.toLong())" - case TypeId.Builtins.f32 => q"$bw.writeFloat($value)" - case TypeId.Builtins.f64 => q"$bw.writeDouble($value)" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"$baboonBinTools.writeBaboonDecimal($bw, $value)" - else q"$baboonBinTools.writeBigDecimal($bw, $value)" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value)" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value)" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value)" - case TypeId.Builtins.tsu => q"$baboonBinTools.writeTimestamp($bw, $value)" - case TypeId.Builtins.tso => - if (ktTypes.multiplatform) q"$baboonBinTools.writeTimestampOffset($bw, $value)" - else q"$baboonBinTools.writeTimestamp($bw, $value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + scalarCodecs.uebaEncode(b, bw, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } @@ -343,14 +255,14 @@ object KtServiceWiringTranslator { // Service-context type/param names regardless of abstract vs concrete; None for `none`. private def svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } private def svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } // The codec-context parameter is always present in wiring/client/wrapper @@ -453,6 +365,7 @@ object KtServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -465,8 +378,8 @@ object KtServiceWiringTranslator { } val callExpr = m.out match { - case Some(_) => q"val result = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded)" - case None => q"impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded)" + case Some(_) => q"val result = impl.${plan.methodName}(${ctxArgPass}decoded)" + case None => q"impl.${plan.methodName}(${ctxArgPass}decoded)" } q""""${m.name.name}" -> { @@ -477,11 +390,11 @@ object KtServiceWiringTranslator { |}""".stripMargin }.join("\n") - q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeJson${funGenericSuffix}( + q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeJson$funGenericSuffix( | method: $baboonMethodId, | data: String, | impl: $svcName$svcTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): String { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): String { | return when (method.methodName) { | ${cases.shift(4).trim} | else -> @@ -494,6 +407,7 @@ object KtServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -507,8 +421,8 @@ object KtServiceWiringTranslator { } val callExpr = m.out match { - case Some(_) => q"val result = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded)" - case None => q"impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded)" + case Some(_) => q"val result = impl.${plan.methodName}(${ctxArgPass}decoded)" + case None => q"impl.${plan.methodName}(${ctxArgPass}decoded)" } q""""${m.name.name}" -> { @@ -519,11 +433,11 @@ object KtServiceWiringTranslator { |}""".stripMargin }.join("\n") - q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeUeba${funGenericSuffix}( + q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeUeba$funGenericSuffix( | method: $baboonMethodId, | data: ByteArray, | impl: $svcName$svcTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): ByteArray { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): ByteArray { | return when (method.methodName) { | ${cases.shift(4).trim} | else -> @@ -554,8 +468,8 @@ object KtServiceWiringTranslator { | ${methods.shift(2).trim} |}""".stripMargin - val jsonRet = q"${ct(bweFq, "String")}" - val uebaRet = q"${ct(bweFq, "ByteArray")}" + val jsonRet = q"${ct(bweFq, "String")}" + val uebaRet = q"${ct(bweFq, "ByteArray")}" val wrappers = generateServiceWrappers(service, wrapperRetType(jsonRet), wrapperRetType(uebaRet)) Seq(Some(wiringObj), wrappers).flatten.join("\n\n") } @@ -589,10 +503,10 @@ object KtServiceWiringTranslator { isJson: Boolean, retType: TextTree[KtValue], ): TextTree[KtValue] = { - val svcName = service.id.name.name + val svcName = service.id.name.name val wireType: TextTree[KtValue] = if (isJson) q"String" else q"ByteArray" - val wrapperName = s"$svcName${if (isJson) "JsonService" else "UebaService"}" - val invokerName = if (isJson) "invokeJson" else "invokeUeba" + val wrapperName = s"$svcName${if (isJson) "JsonService" else "UebaService"}" + val invokerName = if (isJson) "invokeJson" else "invokeUeba" // The implemented runtime contract follows the context mode: `none` keeps // the historical context-free IBaboon*Service; `abstract`/`type` use the @@ -633,8 +547,8 @@ object KtServiceWiringTranslator { val invokerArgs: List[TextTree[KtValue]] = { val base: List[TextTree[KtValue]] = List(q"method", q"data", q"impl") - val withRt = rtField.fold(base)(_ => base :+ q"rt") - val withSvcCtx = svcCtxArgName.fold(withRt)(pn => withRt :+ TextTree.text[KtValue](pn)) + val withRt = rtField.fold(base)(_ => base :+ q"rt") + val withSvcCtx = svcCtxArgName.fold(withRt)(pn => withRt :+ TextTree.text[KtValue](pn)) withSvcCtx :+ codecCtxRef } @@ -673,93 +587,94 @@ object KtServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asKtRef(m.sig, domain, evo) + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") if (isAsync) generateErrorsJsonCaseAsync(m, inRef, decodeIn) else { - val decodeStep = - q"""val input: ${ct(bweFq, renderFq(inRef))} = try { - | val wire = $kotlinxJson.parseToJsonElement(data) - | rt.pure<$bweFq, $inRef>($decodeIn) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $inRef>($bweFq.DecoderFailed(method, ex)) - |}""".stripMargin + val decodeStep = + q"""val input: ${ct(bweFq, renderFq(inRef))} = try { + | val wire = $kotlinxJson.parseToJsonElement(data) + | rt.pure<$bweFq, $inRef>($decodeIn) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $inRef>($bweFq.DecoderFailed(method, ex)) + |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors + val hasErrType = plan.hasError + + val callAndEncodeStep = m.out match { + case Some(outRef) => + val outType = plan.output.get + val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") + + val callBody = if (hasErrType) { + val errType = plan.error.get + q"""try { + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) + | rt.leftMap<$errType, $outType, $bweFq>( + | callResult) { err -> $bweFq.CallFailed(method, err) } + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } else { + q"""try { + | rt.pure<$bweFq, $outType>(impl.${plan.methodName}(${ctxArgPass}v)) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asKtRef(outRef, domain, evo) - val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") - - val callBody = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) - q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.leftMap<$errType, $outType, $bweFq>( - | callResult) { err -> $bweFq.CallFailed(method, err) } - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } else { - q"""try { - | rt.pure<$bweFq, $outType>(impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v)) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + q"""val output = rt.flatMap<$bweFq, $inRef, $outType>(input) { v -> + | ${callBody.shift(2).trim} + |} + |rt.flatMap<$bweFq, $outType, String>(output) { v -> + | try { + | val encoded = $encodeOut + | rt.pure<$bweFq, String>(encoded.toString()) + | } catch (ex: Throwable) { + | rt.fail<$bweFq, String>($bweFq.EncoderFailed(method, ex)) + | } |}""".stripMargin - } - - q"""val output = rt.flatMap<$bweFq, $inRef, $outType>(input) { v -> - | ${callBody.shift(2).trim} - |} - |rt.flatMap<$bweFq, $outType, String>(output) { v -> - | try { - | val encoded = $encodeOut - | rt.pure<$bweFq, String>(encoded.toString()) - | } catch (ex: Throwable) { - | rt.fail<$bweFq, String>($bweFq.EncoderFailed(method, ex)) - | } - |}""".stripMargin - case None => - val callBody = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) - q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.leftMap<$errType, Unit, $bweFq>( - | callResult) { err -> $bweFq.CallFailed(method, err) } - |} catch (ex: Throwable) { - | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } else { - q"""try { - | impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.pure<$bweFq, Unit>(Unit) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } + case None => + val callBody = if (hasErrType) { + val errType = plan.error.get + q"""try { + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) + | rt.leftMap<$errType, Unit, $bweFq>( + | callResult) { err -> $bweFq.CallFailed(method, err) } + |} catch (ex: Throwable) { + | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } else { + q"""try { + | impl.${plan.methodName}(${ctxArgPass}v) + | rt.pure<$bweFq, Unit>(Unit) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } - q"""rt.flatMap<$bweFq, $inRef, String>(input) { v -> - | ${callBody.shift(2).trim} - | rt.pure<$bweFq, String>("null") - |}""".stripMargin - } + q"""rt.flatMap<$bweFq, $inRef, String>(input) { v -> + | ${callBody.shift(2).trim} + | rt.pure<$bweFq, String>("null") + |}""".stripMargin + } - q""""${m.name.name}" -> { - | ${decodeStep.shift(2).trim} - | ${callAndEncodeStep.shift(2).trim} - |}""".stripMargin + q""""${m.name.name}" -> { + | ${decodeStep.shift(2).trim} + | ${callAndEncodeStep.shift(2).trim} + |}""".stripMargin } }.join("\n") - q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeJson${funGenericSuffix}( + q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeJson$funGenericSuffix( | method: $baboonMethodId, | data: String, | impl: $svcName$svcTypeArg, | rt: $iBaboonServiceRtFq$rtTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): $wiringRetType { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): $wiringRetType { | return when (method.methodName) { | ${cases.shift(4).trim} | else -> @@ -781,7 +696,8 @@ object KtServiceWiringTranslator { inRef: TextTree[KtValue], decodeIn: TextTree[KtValue], ): TextTree[KtValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val hasErrType = plan.hasError val decodeStep = q"""val decoded: $inRef = try { @@ -793,13 +709,13 @@ object KtServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asKtRef(outRef, domain, evo) + val outType = plan.output.get val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") val callStep = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) + val errType = plan.error.get q"""val output: ${ct(bweFq, renderFq(outType))} = try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResult = impl.${plan.methodName}(${ctxArgPass}decoded) | rt.leftMap<$errType, $outType, $bweFq>( | callResult) { err -> $bweFq.CallFailed(method, err) } |} catch (ex: Throwable) { @@ -807,7 +723,7 @@ object KtServiceWiringTranslator { |}""".stripMargin } else { q"""val output: ${ct(bweFq, renderFq(outType))} = try { - | val callResultValue = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResultValue = impl.${plan.methodName}(${ctxArgPass}decoded) | rt.pure<$bweFq, $outType>(callResultValue) |} catch (ex: Throwable) { | return rt.fail<$bweFq, String>($bweFq.CallFailed(method, ex)) @@ -826,9 +742,9 @@ object KtServiceWiringTranslator { case None => if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResult = impl.${plan.methodName}(${ctxArgPass}decoded) | val mapped = rt.leftMap<$errType, Unit, $bweFq>( | callResult) { err -> $bweFq.CallFailed(method, err) } | rt.flatMap<$bweFq, Unit, String>(mapped) { v -> rt.pure<$bweFq, String>("null") } @@ -837,7 +753,7 @@ object KtServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | impl.${plan.methodName}(${ctxArgPass}decoded) | rt.pure<$bweFq, String>("null") |} catch (ex: Throwable) { | rt.fail<$bweFq, String>($bweFq.CallFailed(method, ex)) @@ -857,94 +773,95 @@ object KtServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asKtRef(m.sig, domain, evo) + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") if (isAsync) generateErrorsUebaCaseAsync(m, inRef, decodeIn) else { - val decodeStep = - q"""val input: ${ct(bweFq, renderFq(inRef))} = try { - | ${mkReaderSetup("br", "data")} - | rt.pure<$bweFq, $inRef>($decodeIn) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $inRef>($bweFq.DecoderFailed(method, ex)) - |}""".stripMargin + val decodeStep = + q"""val input: ${ct(bweFq, renderFq(inRef))} = try { + | ${mkReaderSetup("br", "data")} + | rt.pure<$bweFq, $inRef>($decodeIn) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $inRef>($bweFq.DecoderFailed(method, ex)) + |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors + val hasErrType = plan.hasError + + val callAndEncodeStep = m.out match { + case Some(outRef) => + val outType = plan.output.get + val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") + + val callBody = if (hasErrType) { + val errType = plan.error.get + q"""try { + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) + | rt.leftMap<$errType, $outType, $bweFq>( + | callResult) { err -> $bweFq.CallFailed(method, err) } + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } else { + q"""try { + | rt.pure<$bweFq, $outType>(impl.${plan.methodName}(${ctxArgPass}v)) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } - val callAndEncodeStep = m.out match { - case Some(outRef) => - val outType = trans.asKtRef(outRef, domain, evo) - val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") - - val callBody = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) - q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.leftMap<$errType, $outType, $bweFq>( - | callResult) { err -> $bweFq.CallFailed(method, err) } - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } else { - q"""try { - | rt.pure<$bweFq, $outType>(impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v)) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, $outType>($bweFq.CallFailed(method, ex)) + q"""val output = rt.flatMap<$bweFq, $inRef, $outType>(input) { v -> + | ${callBody.shift(2).trim} + |} + |rt.flatMap<$bweFq, $outType, ByteArray>(output) { v -> + | try { + | ${mkWriterSetup("bw").shift(4).trim} + | $encStmt + | rt.pure<$bweFq, ByteArray>(${mkWriterGetBytes("bw")}) + | } catch (ex: Throwable) { + | rt.fail<$bweFq, ByteArray>($bweFq.EncoderFailed(method, ex)) + | } |}""".stripMargin - } - - q"""val output = rt.flatMap<$bweFq, $inRef, $outType>(input) { v -> - | ${callBody.shift(2).trim} - |} - |rt.flatMap<$bweFq, $outType, ByteArray>(output) { v -> - | try { - | ${mkWriterSetup("bw").shift(4).trim} - | $encStmt - | rt.pure<$bweFq, ByteArray>(${mkWriterGetBytes("bw")}) - | } catch (ex: Throwable) { - | rt.fail<$bweFq, ByteArray>($bweFq.EncoderFailed(method, ex)) - | } - |}""".stripMargin - case None => - val callBody = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) - q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.leftMap<$errType, Unit, $bweFq>( - | callResult) { err -> $bweFq.CallFailed(method, err) } - |} catch (ex: Throwable) { - | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } else { - q"""try { - | impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}v) - | rt.pure<$bweFq, Unit>(Unit) - |} catch (ex: Throwable) { - | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) - |}""".stripMargin - } + case None => + val callBody = if (hasErrType) { + val errType = plan.error.get + q"""try { + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) + | rt.leftMap<$errType, Unit, $bweFq>( + | callResult) { err -> $bweFq.CallFailed(method, err) } + |} catch (ex: Throwable) { + | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } else { + q"""try { + | impl.${plan.methodName}(${ctxArgPass}v) + | rt.pure<$bweFq, Unit>(Unit) + |} catch (ex: Throwable) { + | rt.fail<$bweFq, Unit>($bweFq.CallFailed(method, ex)) + |}""".stripMargin + } - q"""rt.flatMap<$bweFq, $inRef, ByteArray>(input) { v -> - | ${callBody.shift(2).trim} - | rt.pure<$bweFq, ByteArray>(ByteArray(0)) - |}""".stripMargin - } + q"""rt.flatMap<$bweFq, $inRef, ByteArray>(input) { v -> + | ${callBody.shift(2).trim} + | rt.pure<$bweFq, ByteArray>(ByteArray(0)) + |}""".stripMargin + } - q""""${m.name.name}" -> { - | ${decodeStep.shift(2).trim} - | ${callAndEncodeStep.shift(2).trim} - |}""".stripMargin + q""""${m.name.name}" -> { + | ${decodeStep.shift(2).trim} + | ${callAndEncodeStep.shift(2).trim} + |}""".stripMargin } }.join("\n") - q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeUeba${funGenericSuffix}( + q"""${dispatcherSuspendKw}fun ${funGenericPrefix}invokeUeba$funGenericSuffix( | method: $baboonMethodId, | data: ByteArray, | impl: $svcName$svcTypeArg, | rt: $iBaboonServiceRtFq$rtTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): $wiringRetType { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): $wiringRetType { | return when (method.methodName) { | ${cases.shift(4).trim} | else -> @@ -961,7 +878,8 @@ object KtServiceWiringTranslator { inRef: TextTree[KtValue], decodeIn: TextTree[KtValue], ): TextTree[KtValue] = { - val hasErrType = m.err.isDefined && !resolved.noErrors + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val hasErrType = plan.hasError val decodeStep = q"""val decoded: $inRef = try { @@ -973,13 +891,13 @@ object KtServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asKtRef(outRef, domain, evo) + val outType = plan.output.get val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") val callStep = if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) + val errType = plan.error.get q"""val output: ${ct(bweFq, renderFq(outType))} = try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResult = impl.${plan.methodName}(${ctxArgPass}decoded) | rt.leftMap<$errType, $outType, $bweFq>( | callResult) { err -> $bweFq.CallFailed(method, err) } |} catch (ex: Throwable) { @@ -987,7 +905,7 @@ object KtServiceWiringTranslator { |}""".stripMargin } else { q"""val output: ${ct(bweFq, renderFq(outType))} = try { - | val callResultValue = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResultValue = impl.${plan.methodName}(${ctxArgPass}decoded) | rt.pure<$bweFq, $outType>(callResultValue) |} catch (ex: Throwable) { | return rt.fail<$bweFq, ByteArray>($bweFq.CallFailed(method, ex)) @@ -1007,9 +925,9 @@ object KtServiceWiringTranslator { case None => if (hasErrType) { - val errType = trans.asKtRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | val callResult = impl.${plan.methodName}(${ctxArgPass}decoded) | val mapped = rt.leftMap<$errType, Unit, $bweFq>( | callResult) { err -> $bweFq.CallFailed(method, err) } | rt.flatMap<$bweFq, Unit, ByteArray>(mapped) { v -> rt.pure<$bweFq, ByteArray>(ByteArray(0)) } @@ -1018,7 +936,7 @@ object KtServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | impl.${KtTypeTranslator.escapeKtKeyword(m.name.name)}(${ctxArgPass}decoded) + | impl.${plan.methodName}(${ctxArgPass}decoded) | rt.pure<$bweFq, ByteArray>(ByteArray(0)) |} catch (ex: Throwable) { | rt.fail<$bweFq, ByteArray>($bweFq.CallFailed(method, ex)) @@ -1044,12 +962,13 @@ object KtServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val inTypeRef = trans.asKtRef(m.sig, domain, evo) - val outTypeRef = m.out.map(t => trans.asKtRef(t, domain, evo)) + val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val inTypeRef = plan.input + val outTypeRef = plan.output val retType = outTypeRef.getOrElse(q"Unit") val rawMethodName = m.name.name - val escapedMethodName = KtTypeTranslator.escapeKtKeyword(rawMethodName) + val escapedMethodName = plan.methodName // For the Json-suffixed variant, backtick-quoting must encompass the // whole token: `` `objectJson` `` — not `` `object` ``Json (invalid). val escapedJsonMethodName = diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTreeTools.scala index eba3b8fc0..1eccdad90 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTreeTools.scala @@ -1,6 +1,6 @@ package io.septimalmind.baboon.translator.kotlin -import io.septimalmind.baboon.translator.DocCommentEscaping +import io.septimalmind.baboon.translator.DocumentationRenderer import io.septimalmind.baboon.typer.model.Docs import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -40,35 +40,6 @@ object KtTreeTools { } } - def renderDocs(docs: Docs, indent: String): String = { - val prefixLines = docs.prefix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - val suffixLines = docs.suffix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - - if (prefixLines.isEmpty && suffixLines.isEmpty) return "" - - val mergedLines: List[String] = - if (suffixLines.isEmpty) prefixLines - else if (prefixLines.isEmpty) suffixLines - else prefixLines ++ List("") ++ suffixLines - - // D35: renderDocs introduces no backslashes (plain `/** */`), so the - // backslash-escape is applied at the renderDocs boundary. See - // DocCommentEscaping.escapeBackslashForQInterpolation. - val allLines: List[String] = - mergedLines.map(DocCommentEscaping.escapeBackslashForQInterpolation) - - // Single-line compact form: /** text */ - val rendered = - if (allLines.size == 1) { - s"$indent/** ${allLines.head} */\n" - } else { - val middle = allLines.map { - case "" => s"$indent *" - case l => s"$indent * $l" - }.mkString("\n") - s"$indent/**\n$middle\n$indent */\n" - } - rendered - } + def renderDocs(docs: Docs, indent: String): String = DocumentationRenderer.javadoc(docs, indent) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTypes.scala index a2faf483c..fadba881d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtTypes.scala @@ -105,6 +105,8 @@ object KtTypes { val baboonAnyOpaqueJson: KtType = KtType(baboonRuntimePkg, "AnyOpaqueJson") val baboonAnyMeta: KtType = KtType(baboonRuntimePkg, "AnyMeta") val baboonAnyMetaCodec: KtType = KtType(baboonRuntimePkg, "AnyMetaCodec") + val baboonAnyJsonCodec: KtType = KtType(baboonRuntimePkg, "BaboonAnyJsonCodec") + val baboonAnyBinCodec: KtType = KtType(baboonRuntimePkg, "BaboonAnyBinCodec") val baboonCodecException: KtType = KtType(baboonRuntimePkg, "BaboonCodecException") val baboonEither: KtType = KtType(baboonRuntimePkg, "Either") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala index 4898a7b82..35ef1b008 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.kotlin.KtDomainTreeTools.MetaField import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -21,6 +21,8 @@ class KtUEBACodecGenerator( ) extends KtCodecTranslator { import ktTypes.* + private val scalarCodecs = new KtScalarCodecEmitter(ktTypes) + // In KMP mode, the buffer IS the BaboonBinaryWriter; in JVM mode, it's the ByteArrayOutputStream private val bufferSizeExpr: String = if (ktTypes.multiplatform) "fakeWriter.size()" else "writeMemoryStream.size()" @@ -124,15 +126,13 @@ class KtUEBACodecGenerator( |}""".stripMargin ) - val anyHelpers: List[TextTree[KtValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encoderMethods ++ decoderMethods ++ branchDecoder.map { body => q"""fun decodeBranch(ctx: $baboonCodecContext, wire: $binaryInput): $name { | ${body.shift(2).trim} |}""".stripMargin - }.toList ++ indexMethods ++ anyHelpers + }.toList ++ indexMethods val codecIface = q"$baboonBinCodec<$name>" val cName = codecName(srcRef) @@ -275,7 +275,7 @@ class KtUEBACodecGenerator( |writer.writeByte(header.toInt()) |val fakeWriter = $binaryOutput() |${fields.map(_._3).joinN().trim} - |writer.write(fakeWriter.toByteArray())""".stripMargin + |fakeWriter.writeTo(writer)""".stripMargin } else { q"""header = (header.toInt() or 1).toByte() |writer.writeByte(header.toInt()) @@ -404,32 +404,7 @@ class KtUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"wire.readBoolean()" - case TypeId.Builtins.i08 => q"wire.readByte()" - case TypeId.Builtins.i16 => q"wire.readShort()" - case TypeId.Builtins.i32 => q"wire.readInt()" - case TypeId.Builtins.i64 => q"wire.readLong()" - case TypeId.Builtins.u08 => q"wire.readByte().toUByte()" - case TypeId.Builtins.u16 => q"wire.readShort().toUShort()" - case TypeId.Builtins.u32 => q"wire.readInt().toUInt()" - case TypeId.Builtins.u64 => q"wire.readLong().toULong()" - case TypeId.Builtins.f32 => q"wire.readFloat()" - case TypeId.Builtins.f64 => q"wire.readDouble()" - - case TypeId.Builtins.f128 => if (ktTypes.multiplatform) q"$baboonBinTools.readBaboonDecimal(wire)" else q"$baboonBinTools.readBigDecimal(wire)" - case TypeId.Builtins.str => q"$baboonBinTools.readString(wire)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString(wire)" - - case TypeId.Builtins.uid => q"$baboonBinTools.readUid(wire)" - case TypeId.Builtins.tsu => - if (ktTypes.multiplatform) q"$baboonBinTools.readTimestamp(wire)" else q"$baboonBinTools.readTimestamp(wire)" - case TypeId.Builtins.tso => - if (ktTypes.multiplatform) q"$baboonBinTools.readTimestampOffset(wire)" else q"$baboonBinTools.readTimestamp(wire)" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => scalarCodecs.uebaDecode(s, q"wire") case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -469,31 +444,7 @@ class KtUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.writeBoolean($ref)" - case TypeId.Builtins.i08 => q"$wref.writeByte($ref.toInt())" - case TypeId.Builtins.i16 => q"$wref.writeShort($ref.toInt())" - case TypeId.Builtins.i32 => q"$wref.writeInt($ref)" - case TypeId.Builtins.i64 => q"$wref.writeLong($ref)" - case TypeId.Builtins.u08 => q"$wref.writeByte($ref.toInt())" - case TypeId.Builtins.u16 => q"$wref.writeShort($ref.toInt())" - case TypeId.Builtins.u32 => q"$wref.writeInt($ref.toInt())" - case TypeId.Builtins.u64 => q"$wref.writeLong($ref.toLong())" - case TypeId.Builtins.f32 => q"$wref.writeFloat($ref)" - case TypeId.Builtins.f64 => q"$wref.writeDouble($ref)" - case TypeId.Builtins.f128 => - if (ktTypes.multiplatform) q"$baboonBinTools.writeBaboonDecimal($wref, $ref)" else q"$baboonBinTools.writeBigDecimal($wref, $ref)" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($wref, $ref)" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($wref, $ref)" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($wref, $ref)" - case TypeId.Builtins.tsu => - if (ktTypes.multiplatform) q"$baboonBinTools.writeTimestamp($wref, $ref)" else q"$baboonBinTools.writeTimestamp($wref, $ref)" - case TypeId.Builtins.tso => - if (ktTypes.multiplatform) q"$baboonBinTools.writeTimestampOffset($wref, $ref)" else q"$baboonBinTools.writeTimestamp($wref, $ref)" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => scalarCodecs.uebaEncode(s, wref, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -545,37 +496,19 @@ class KtUEBACodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust hasAnyField): a codec object needs the any-field helpers if - // any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-object `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. + // The runtime helper owns envelope framing and cross-format conversion. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[KtValue], wref: TextTree[KtValue]): TextTree[KtValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, $wref, $expectedHex.toByte(), $staticDom, $staticVer, $staticTid, $ref)" + q"$baboonAnyBinCodec.encode(ctx, $wref, $expectedHex.toByte(), $staticDom, $staticVer, $staticTid, $ref)" } - // Decode delegates to the per-codec-object `decodeAnyField` helper, returning an - // `AnyOpaqueUeba` (the helper's narrow return type — `mkDecoder`'s field type position is - // `AnyOpaque`, the supertype, so this widens implicitly). + // Decoding preserves the opaque payload; typed resolution remains facade-owned. private def mkAnyDecoder(a: TypeRef.Any): TextTree[KtValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField(wire, $expectedHex.toByte())" + q"$baboonAnyBinCodec.decode(wire, $expectedHex.toByte())" } // Static fallbacks for the cross-format facade helpers (`jsonToUebaBytes`/`uebaToJson`). The @@ -585,129 +518,10 @@ class KtUEBACodecGenerator( // A=(null,null,null), B=(currentDomain,null,null), C=(currentDomain,currentVersion,null), // D1=(null,null,underlyingFqid), D2=(currentDomain,null,underlyingFqid), // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin — extraction deferred (see PR 4.2 ledger entry's DRY - // analysis): textual emission diverges by language flavor. private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[KtValue], TextTree[KtValue], TextTree[KtValue]) = { - val none = q"null" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-object helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec object that has any - // any-bearing field. Mirrors `CSUEBACodecGenerator.anyFieldHelpers` and - // `RsUEBACodecGenerator.anyFieldHelpers`. Wire layout (locked, see - // docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // - // Multiplatform fork: JVM uses `ByteArrayOutputStream` + `LEDataOutputStream` to buffer the meta; - // KMP uses `BaboonBinaryWriter()` directly (no nested stream). Both expose `toByteArray()`. - // Negative-i32 sanity guards on the on-wire lengths run before any usize arithmetic — Kotlin - // `Int` is 32-bit signed and `4 + Int.MIN_VALUE` overflows silently otherwise (PR-12-D01 lesson - // applied to Kotlin). - private def anyFieldHelpers: TextTree[KtValue] = { - val metaWriteBlock: TextTree[KtValue] = if (ktTypes.multiplatform) { - q"""val anyMetaWriter = $binaryOutput() - |$baboonAnyMetaCodec.writeBin(value.meta, anyMetaWriter) - |val anyMetaBytes = anyMetaWriter.toByteArray()""".stripMargin - } else { - q"""val anyMetaBuf = $byteArrayOutputStream() - |val anyMetaWriter = $binaryOutput(anyMetaBuf) - |$baboonAnyMetaCodec.writeBin(value.meta, anyMetaWriter) - |anyMetaWriter.flush() - |val anyMetaBytes = anyMetaBuf.toByteArray()""".stripMargin - } - - q"""private fun encodeAnyField( - | ctx: $baboonCodecContext, - | writer: $binaryOutput, - | expectedKind: Byte, - | staticDomain: String?, - | staticVersion: String?, - | staticTypeid: String?, - | value: $baboonAnyOpaque, - |) { - | if (value.meta.kind != expectedKind) { - | throw $baboonCodecException.EncoderFailure( - | "any: meta-kind 0x" + (value.meta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + - | " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') - | ) - | } - | val anyBlob: ByteArray = when (value) { - | is $baboonAnyOpaqueUeba -> value.bytes - | is $baboonAnyOpaqueJson -> { - | val anyFacade = ctx.facade ?: throw $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." - | ) - | val anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid) - | when (anyConvResult) { - | is $baboonEither.Left -> throw anyConvResult.value - | is $baboonEither.Right -> anyConvResult.value - | } - | } - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | ${metaWriteBlock.shift(4).trim} - | val anyTotalLength: Int = 4 + anyMetaBytes.size + anyBlob.size - | writer.writeInt(anyTotalLength) - | writer.writeInt(anyMetaBytes.size) - | writer.write(anyMetaBytes) - | writer.write(anyBlob) - |} - | - |private fun decodeAnyField(wire: $binaryInput, expectedKind: Byte): $baboonAnyOpaqueUeba { - | val anyTotalLength = wire.readInt() - | if (anyTotalLength < 0) { - | throw $baboonCodecException.DecoderFailure( - | "any: negative total-length " + anyTotalLength - | ) - | } - | val anyMetaLength = wire.readInt() - | if (anyMetaLength < 0) { - | throw $baboonCodecException.DecoderFailure( - | "any: negative meta-length " + anyMetaLength - | ) - | } - | if (anyTotalLength < 4 + anyMetaLength) { - | throw $baboonCodecException.DecoderFailure( - | "any: total-length " + anyTotalLength + " smaller than 4 + meta-length " + anyMetaLength - | ) - | } - | val anyReadResult = $baboonAnyMetaCodec.readBinWithLength(wire) - | val anyMeta = anyReadResult.first - | val anyBytesRead = anyReadResult.second - | if (anyBytesRead > anyMetaLength) { - | throw $baboonCodecException.DecoderFailure( - | "any: meta bytes-read " + anyBytesRead + " exceeded meta-length window " + anyMetaLength - | ) - | } - | if (anyBytesRead < anyMetaLength) { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | val anySkip = ByteArray(anyMetaLength - anyBytesRead) - | wire.readFully(anySkip) - | } - | if (anyMeta.kind != expectedKind) { - | throw $baboonCodecException.DecoderFailure( - | "any: wire kind 0x" + (anyMeta.kind.toInt() and 0xFF).toString(16).padStart(2, '0') + - | " does not match field-declared 0x" + (expectedKind.toInt() and 0xFF).toString(16).padStart(2, '0') - | ) - | } - | val anyBlobLen = anyTotalLength - 4 - anyMetaLength - | val anyBlob = ByteArray(anyBlobLen) - | wire.readFully(anyBlob) - | return $baboonAnyOpaqueUeba(anyMeta, anyBlob) - |}""".stripMargin + val plan = AnyFieldPlan.forField(a, domain) + def render(value: Option[String]): TextTree[KtValue] = value.fold[TextTree[KtValue]](q"null")(s => q""""$s"""") + (render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[KtValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/mcp/McpInputSchemaEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/mcp/McpInputSchemaEmitter.scala index 0ff862f16..5e2360e4a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/mcp/McpInputSchemaEmitter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/mcp/McpInputSchemaEmitter.scala @@ -2,6 +2,7 @@ package io.septimalmind.baboon.translator.mcp import io.circe.Json import io.septimalmind.baboon.translator.openapi.OasTypeTranslator +import io.septimalmind.baboon.translator.schema.{JsonSchema, SchemaReferences} import io.septimalmind.baboon.typer.model.* import scala.collection.mutable @@ -45,6 +46,8 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { import McpInputSchemaEmitter.* + def prepare(domain: Domain): PreparedDomain = PreparedDomain(domain, SchemaReferences.prepare(domain)) + /** Emit the standalone `inputSchema` for one service method's request DTO. * * @param requestSig the method's `sig` — a `TypeRef` to the reified `_in` @@ -53,11 +56,12 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * @return a self-contained JSON Schema object (the MCP tool `inputSchema`). */ def emitInputSchema(requestSig: TypeRef, domain: Domain): Json = { - val ctx = ForeignContext( - resolutions = typeTranslator.foreignTypeResolution(domain), - defs = foreignDefsOf(domain), - enums = enumDefsOf(domain), - ) + emitInputSchema(requestSig, prepare(domain)) + } + + def emitInputSchema(requestSig: TypeRef, prepared: PreparedDomain): Json = { + val domain = prepared.domain + val ctx = prepared.references val rootId = requestSig match { case TypeRef.Scalar(id: TypeId.User) => id @@ -107,7 +111,7 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * and excludes foreign types resolvable to a precise scalar (inlined at the * reference site, not bundled). */ - private def reachableClosure(domain: Domain, rootDto: Typedef.Dto, ctx: ForeignContext): Set[TypeId.User] = { + private def reachableClosure(domain: Domain, rootDto: Typedef.Dto, ctx: SchemaReferences): Set[TypeId.User] = { val seen = mutable.LinkedHashSet.empty[TypeId.User] val pending = mutable.Queue.empty[TypeId.User] @@ -145,10 +149,10 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * inline scalar are excluded (not bundled into `$defs`); foreign types with a * Baboon `rt` mapping are resolved through before inspection. */ - private def referencedUserTypes(ref: TypeRef, ctx: ForeignContext): List[TypeId.User] = { + private def referencedUserTypes(ref: TypeRef, ctx: SchemaReferences): List[TypeId.User] = { typeTranslator.resolveTypeRef(ref, ctx.resolutions) match { case TypeRef.Scalar(id: TypeId.User) => - foreignScalar(ctx.defs.get(id)) match { + foreignScalar(ctx.foreigns.get(id)) match { case Some(_) => List.empty // inlined as a scalar, not a $defs entry case None => List(id) } @@ -164,7 +168,7 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * one entry; an ADT contributes its own `oneOf` entry (its branches are * separate closure members emitted independently). */ - private def emitNamedType(m: DomainMember.User, domain: Domain, ctx: ForeignContext): List[(String, Json)] = { + private def emitNamedType(m: DomainMember.User, domain: Domain, ctx: SchemaReferences): List[(String, Json)] = { val name = typeTranslator.schemaName(m.id) m.defn match { case _: Typedef.NonDataTypedef => @@ -198,7 +202,7 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * `{"type":"object"}`; callers prepend it so the same body serves both the * inlined root and a `$defs` entry). */ - private def dtoBody(dto: Typedef.Dto, ctx: ForeignContext): Json = { + private def dtoBody(dto: Typedef.Dto, ctx: SchemaReferences): Json = { if (dto.fields.isEmpty) { Json.obj() } else { @@ -223,7 +227,7 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { val branchRefs = members.map(localRef) val shortNames = members.map(_.name.name) mergeObjects( - Json.obj(oneOfKeyword -> Json.arr(branchRefs*)), + Json.obj(oneOfKeyword -> Json.arr(branchRefs*)), Json.obj(descriptionKeyword -> Json.fromString(typeTranslator.adtWrapperDoc(shortNames))), ) } @@ -240,31 +244,31 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * local `#/$defs/`. Scalars and the `any` envelope reuse the OAS * fragment generator verbatim; collections recurse so element refs are local. */ - private def fieldSchema(ref: TypeRef, ctx: ForeignContext): Json = { + private def fieldSchema(ref: TypeRef, ctx: SchemaReferences): Json = { typeTranslator.resolveTypeRef(ref, ctx.resolutions) match { case TypeRef.Scalar(id: TypeId.BuiltinScalar) => - parseFragment(typeTranslator.scalarSchemaJson(id)) + typeTranslator.scalarSchemaValue(id) case TypeRef.Scalar(id: TypeId.User) => - foreignScalar(ctx.defs.get(id)) match { - case Some(scalar) => parseFragment(typeTranslator.scalarSchemaJson(scalar)) + foreignScalar(ctx.foreigns.get(id)) match { + case Some(scalar) => typeTranslator.scalarSchemaValue(scalar) case None => localRef(id) } case TypeRef.Constructor(TypeId.Builtins.opt, args) => - Json.obj(oneOfKeyword -> Json.arr(fieldSchema(args.head, ctx), Json.obj(typeKeyword -> Json.fromString("null")))) + JsonSchema.nullable(fieldSchema(args.head, ctx)) case TypeRef.Constructor(TypeId.Builtins.lst, args) => - Json.obj(typeKeyword -> Json.fromString("array"), itemsKeyword -> fieldSchema(args.head, ctx)) + JsonSchema.array(fieldSchema(args.head, ctx)) case TypeRef.Constructor(TypeId.Builtins.set, args) => - Json.obj(typeKeyword -> Json.fromString("array"), itemsKeyword -> fieldSchema(args.head, ctx), uniqueItemsKeyword -> Json.True) + JsonSchema.uniqueArray(fieldSchema(args.head, ctx)) case TypeRef.Constructor(TypeId.Builtins.map, args) => mapSchema(args.head, args.tail.head, ctx) case _: TypeRef.Any => - parseFragment(typeTranslator.baboonAnySchema) + typeTranslator.baboonAnySchemaValue case other => throw new IllegalArgumentException(s"Unexpected type reference in MCP inputSchema emitter: ${other.id.name.name}") @@ -280,26 +284,16 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { * entry objects (JSON has no native non-string-keyed map). Mirrors the OAS * map shape but with local element refs. */ - private def mapSchema(keyRef: TypeRef, valRef: TypeRef, ctx: ForeignContext): Json = { + private def mapSchema(keyRef: TypeRef, valRef: TypeRef, ctx: SchemaReferences): Json = { val valSchema = fieldSchema(valRef, ctx) enumKey(keyRef, ctx) match { case Some(e) => - Json.obj( - typeKeyword -> Json.fromString("object"), - additionalPropertiesKeyword -> valSchema, - propertyNamesKeyword -> enumSchema(e), - ) + JsonSchema.objectMap(valSchema, Some(enumSchema(e))) case None => if (isStringKey(keyRef)) { - Json.obj(typeKeyword -> Json.fromString("object"), additionalPropertiesKeyword -> valSchema) + JsonSchema.objectMap(valSchema, None) } else { - val keySchema = fieldSchema(keyRef, ctx) - val entry = Json.obj( - typeKeyword -> Json.fromString("object"), - requiredKeyword -> Json.arr(Json.fromString("key"), Json.fromString("value")), - propertiesKeyword -> Json.obj("key" -> keySchema, "value" -> valSchema), - ) - Json.obj(typeKeyword -> Json.fromString("array"), itemsKeyword -> entry) + JsonSchema.entryMap(fieldSchema(keyRef, ctx), valSchema) } } } @@ -307,7 +301,7 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { /** The enum typedef a map key resolves to, if any (after Baboon `rt` foreign * resolution). `None` for non-enum keys. */ - private def enumKey(ref: TypeRef, ctx: ForeignContext): Option[Typedef.Enum] = + private def enumKey(ref: TypeRef, ctx: SchemaReferences): Option[Typedef.Enum] = typeTranslator.resolveTypeRef(ref, ctx.resolutions) match { case TypeRef.Scalar(id: TypeId.User) => ctx.enums.get(id) case _ => None @@ -357,34 +351,6 @@ class McpInputSchemaEmitter(typeTranslator: OasTypeTranslator) { case _ => false } - private def foreignDefsOf(domain: Domain): Map[TypeId.User, Typedef.Foreign] = - domain.defs.meta.nodes.values.collect { - case u: DomainMember.User => - u.defn match { - case f: Typedef.Foreign => Some(f.id -> f) - case _ => None - } - }.flatten.toMap - - private def enumDefsOf(domain: Domain): Map[TypeId.User, Typedef.Enum] = - domain.defs.meta.nodes.values.collect { - case u: DomainMember.User => - u.defn match { - case e: Typedef.Enum => Some(e.id -> e) - case _ => None - } - }.flatten.toMap - - /** Parse a JSON fragment string produced by the OAS fragment generator into a - * `Json` value. The OAS generator returns well-formed JSON object literals; - * a parse failure indicates an emitter defect and is surfaced eagerly. - */ - private def parseFragment(fragment: String): Json = - io.circe.parser.parse(fragment) match { - case Right(j) => j - case Left(err) => throw new IllegalStateException(s"OAS fragment did not parse as JSON: $fragment ($err)") - } - /** Shallow object merge: `b`'s keys extend / override `a`'s. Both must be JSON * objects (the emitter only ever merges object schemas). */ @@ -413,15 +379,7 @@ object McpInputSchemaEmitter { final val defsKeyword = "$defs" final val defsSegment = "$defs" - /** Foreign-type lookup context threaded through one emission: the OAS - * Baboon→Baboon resolution map plus the foreign typedefs (for per-language - * binding inspection). Kept local to a call so the emitter stays stateless. - */ - private final case class ForeignContext( - resolutions: Map[TypeId.User, Option[TypeRef]], - defs: Map[TypeId.User, Typedef.Foreign], - enums: Map[TypeId.User, Typedef.Enum], - ) + final case class PreparedDomain private[mcp] (domain: Domain, references: SchemaReferences) /** Per-language declaration strings that denote that language's native string * type. A foreign type all of whose bindings are in this set collapses to a diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasBaboonTranslator.scala index 157b9fc5a..8e34c687b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasBaboonTranslator.scala @@ -1,5 +1,7 @@ package io.septimalmind.baboon.translator.openapi +import io.circe.Json +import io.septimalmind.baboon.translator.schema.SchemaReferences import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.OasTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue @@ -93,11 +95,7 @@ class OasBaboonTranslator[F[+_, +_]: Error2]( return List.empty } - val pkg = domain.id - val ver = domain.version - val foreignResolutions = typeTranslator.foreignTypeResolution(domain) - val enumKeys = typeTranslator.enumKeysOf(domain) - + val references = SchemaReferences.prepare(domain) val members = domain.defs.meta.nodes.values.collect { case u: DomainMember.User => u }.toList.sortBy(_.id.toString) @@ -105,168 +103,93 @@ class OasBaboonTranslator[F[+_, +_]: Error2]( val schemas = members.flatMap { m => m.defn match { - case _: Typedef.NonDataTypedef => None - case f: Typedef.Foreign if f.runtimeMapping.nonEmpty => None - case f: Typedef.Foreign => Some(renderForeignSchema(f)) - case _ if m.ownedByAdt => None - case defn => Some(renderTypedef(defn, m.docs, domain, foreignResolutions, enumKeys)) + case _: Typedef.NonDataTypedef => Nil + case f: Typedef.Foreign if f.runtimeMapping.nonEmpty => Nil + case f: Typedef.Foreign => + List( + typeTranslator.schemaName(f.id) -> Json.obj( + "type" -> Json.fromString("object"), + "description" -> Json.fromString(s"Foreign type: ${typeTranslator.schemaName(f.id)}"), + ) + ) + case _ if m.ownedByAdt => Nil + case defn => renderTypedef(defn, m.docs, domain, references) } } - val schemasJson = schemas.mkString(",\n") - - val esc = typeTranslator.escapeJson _ - val doc = - s"""{ - | "openapi": "3.1.0", - | "info": { - | "title": "${esc(pkg.toString)}", - | "version": "${esc(ver.toString)}" - | }, - | "paths": {}, - | "components": { - | "schemas": { - |$schemasJson - | } - | } - |} - |""".stripMargin - - val versionStr = ver.toString.replace(".", "_") - val pkgStr = pkg.path.toList.map(typeTranslator.sanitize).mkString("_") - val filename = s"$pkgStr/v$versionStr/openapi.json" - - List(filename -> OutputFile(doc, CompilerProduct.Definition)) + val doc = Json.obj( + "openapi" -> Json.fromString("3.1.0"), + "info" -> Json.obj("title" -> Json.fromString(domain.id.toString), "version" -> Json.fromString(domain.version.toString)), + "paths" -> Json.obj(), + "components" -> Json.obj("schemas" -> Json.obj(schemas*)), + ) + val versionStr = domain.version.toString.replace(".", "_") + val pkgStr = domain.id.path.toList.map(typeTranslator.sanitize).mkString("_") + List(s"$pkgStr/v$versionStr/openapi.json" -> OutputFile(doc.spaces2 + "\n", CompilerProduct.Definition)) } - private def renderTypedef( - defn: Typedef.User, - docs: Docs, - domain: Domain, - foreignResolutions: Map[TypeId.User, Option[TypeRef]], - enumKeys: Set[TypeId.User], - ): String = { + private def renderTypedef(defn: Typedef.User, docs: Docs, domain: Domain, references: SchemaReferences): List[(String, Json)] = { defn match { - case dto: Typedef.Dto => renderDto(dto, docs, foreignResolutions, enumKeys) - case e: Typedef.Enum => renderEnum(e, docs) - case adt: Typedef.Adt => renderAdt(adt, docs, domain, foreignResolutions, enumKeys) + case dto: Typedef.Dto => List(typeTranslator.schemaName(dto.id) -> renderDto(dto, docs, references)) + case e: Typedef.Enum => List(typeTranslator.schemaName(e.id) -> renderEnum(e, docs)) + case adt: Typedef.Adt => renderAdt(adt, docs, domain, references) case other => throw new IllegalArgumentException(s"Unexpected typedef in OpenAPI renderTypedef: ${other.id}") } } - private def renderDto(dto: Typedef.Dto, docs: Docs, foreignResolutions: Map[TypeId.User, Option[TypeRef]], enumKeys: Set[TypeId.User]): String = { - val name = typeTranslator.schemaName(dto.id) - val esc = typeTranslator.escapeJson _ - val descJson = typeTranslator.renderOasDescription(docs).map(d => s""", "description": "${esc(d)}"""").getOrElse("") - - if (dto.fields.isEmpty) { - s""" "${esc(name)}": {"type": "object"$descJson}""" - } else { - val resolvedFields = dto.fields.map(f => f.copy(tpe = typeTranslator.resolveTypeRef(f.tpe, foreignResolutions))) - - val requiredFields = resolvedFields.filterNot(f => isOptional(f.tpe)) - val requiredJson = - if (requiredFields.isEmpty) "" - else { - val names = requiredFields.map(f => s""""${esc(f.name.name)}"""").mkString(", ") - s""", "required": [$names]""" - } + private def described(schema: Json, docs: Docs): Json = { + typeTranslator.renderOasDescription(docs) match { + // Preserve the built-in envelope description's precedence over field docs. + case Some(description) if !schema.hcursor.downField("description").succeeded => + schema.mapObject(_.add("description", Json.fromString(description))) + case _ => schema + } + } - val propsJson = resolvedFields.map { + private def renderDto(dto: Typedef.Dto, docs: Docs, references: SchemaReferences): Json = { + val base = described(Json.obj("type" -> Json.fromString("object")), docs) + if (dto.fields.isEmpty) base + else { + val fields = dto.fields.map(f => f.copy(tpe = references.resolve(f.tpe))) + val required = fields.filterNot(f => isOptional(f.tpe)).map(f => Json.fromString(f.name.name)) + val properties = fields.map { f => - val schema = typeTranslator.typeRefSchema(f.tpe, enumKeys) - val fieldDesc = typeTranslator.renderOasDescription(f.docs) - fieldDesc match { - case Some(d) => - // Inject "description" into the field's schema object. - // The schema is a JSON object string; insert the description key - // after the opening brace. - val descEntry = s""""description": "${esc(d)}", """ - val augmented = schema match { - case s if s.startsWith("{") => "{" + descEntry + s.drop(1) - case s => s - } - s""" "${esc(f.name.name)}": $augmented""" - case None => - s""" "${esc(f.name.name)}": $schema""" - } - }.mkString(",\n") - - s""" "${esc(name)}": {"type": "object"$descJson$requiredJson, "properties": { - |$propsJson - | }}""".stripMargin + f.name.name -> described(typeTranslator.typeRefSchemaValue(f.tpe, references.enums.keySet), f.docs) + } + val withRequired = if (required.isEmpty) base else base.mapObject(_.add("required", Json.arr(required*))) + withRequired.mapObject(_.add("properties", Json.obj(properties*))) } } - private def renderEnum(e: Typedef.Enum, docs: Docs): String = { - val name = typeTranslator.schemaName(e.id) - val esc = typeTranslator.escapeJson _ - val values = e.members.toList.map(m => s""""${esc(m.name)}"""").mkString(", ") - val descJson = typeTranslator.renderOasDescription(docs).map(d => s""", "description": "${esc(d)}"""").getOrElse("") - s""" "${esc(name)}": {"type": "string"$descJson, "enum": [$values]}""" - } + private def renderEnum(e: Typedef.Enum, docs: Docs): Json = + described(Json.obj("type" -> Json.fromString("string")), docs) + .mapObject(_.add("enum", Json.arr(e.members.toList.map(m => Json.fromString(m.name))*))) - private def renderAdt( - adt: Typedef.Adt, - docs: Docs, - domain: Domain, - foreignResolutions: Map[TypeId.User, Option[TypeRef]], - enumKeys: Set[TypeId.User], - ): String = { + private def renderAdt(adt: Typedef.Adt, docs: Docs, domain: Domain, references: SchemaReferences): List[(String, Json)] = { import Typedef.Adt.AdtSyntax - val name = typeTranslator.schemaName(adt.id) - val esc = typeTranslator.escapeJson _ - val dataMembers = adt.dataMembers(domain) - val branchShortNames = dataMembers.map(_.name.name).toList - val wrapperNote = typeTranslator.adtWrapperDoc(branchShortNames) - val rawDesc = typeTranslator.renderOasDescription(docs) match { - case Some(d) => s"$d\n\n$wrapperNote" - case None => wrapperNote - } - val descJson = s""", "description": "${esc(rawDesc)}"""" - - // Emit each branch schema inline, then the union - val branchSchemas = dataMembers.flatMap { + val dataMembers = adt.dataMembers(domain).toList + val wrapperNote = typeTranslator.adtWrapperDoc(dataMembers.map(_.name.name)) + val description = typeTranslator.renderOasDescription(docs).map(d => s"$d\n\n$wrapperNote").getOrElse(wrapperNote) + val branches = dataMembers.flatMap { memberId => domain.defs.meta.nodes.get(memberId).collect { case u: DomainMember.User => - u.defn match { - case dto: Typedef.Dto => renderDto(dto, u.docs, foreignResolutions, enumKeys) + val schema = u.defn match { + case dto: Typedef.Dto => renderDto(dto, u.docs, references) case e: Typedef.Enum => renderEnum(e, u.docs) case other => throw new IllegalArgumentException(s"Unexpected ADT branch type in OpenAPI backend: ${other.id}") } + typeTranslator.schemaName(memberId) -> schema } } - - val branchRefs = dataMembers.map { - memberId => - val refName = typeTranslator.schemaName(memberId) - s"""{"$$ref": "#/components/schemas/${esc(refName)}"}""" - } - - val refsJson = branchRefs.mkString(", ") - val adtSchema = s""" "${esc(name)}": {"oneOf": [$refsJson]$descJson}""" - - if (branchSchemas.nonEmpty) { - val branchLines = branchSchemas.mkString(",\n") - s"""$branchLines, - |$adtSchema""".stripMargin - } else { - adtSchema - } - } - - private def renderForeignSchema(f: Typedef.Foreign): String = { - val name = typeTranslator.schemaName(f.id) - val esc = typeTranslator.escapeJson _ - s""" "${esc(name)}": {"type": "object", "description": "Foreign type: ${esc(name)}"}""" + branches :+ (typeTranslator.schemaName(adt.id) -> Json.obj( + "oneOf" -> Json.arr(dataMembers.map(typeTranslator.componentRef)*), + "description" -> Json.fromString(description), + )) } - private def isOptional(ref: TypeRef): Boolean = { - ref match { - case TypeRef.Constructor(TypeId.Builtins.opt, _) => true - case _ => false - } + private def isOptional(ref: TypeRef): Boolean = ref match { + case TypeRef.Constructor(TypeId.Builtins.opt, _) => true + case _ => false } - } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala index ced62c5c3..e8baeb449 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala @@ -1,13 +1,14 @@ package io.septimalmind.baboon.translator.openapi +import io.circe.{Json, Printer} +import io.septimalmind.baboon.translator.schema.{JsonSchema, SchemaReferences} import io.septimalmind.baboon.typer.model.* class OasTypeTranslator { /** Return the cleaned description text for a `Docs` value, or `None` for * empty docs. Combines prefix and suffix with a newline separator when both - * are present. The caller is responsible for JSON-escaping the result with - * `escapeJson` before embedding it in a JSON string literal. + * are present. The caller embeds this raw text with `Json.fromString`. */ def renderOasDescription(docs: Docs): Option[String] = { val prefixText = docs.prefix.map(_.cleaned) @@ -21,26 +22,11 @@ class OasTypeTranslator { } def foreignTypeResolution(domain: Domain): Map[TypeId.User, Option[TypeRef]] = { - domain.defs.meta.nodes.values.collect { - case u: DomainMember.User => - u.defn match { - case f: Typedef.Foreign => Some(f.id -> f.runtimeMapping) - case _ => None - } - }.flatten.toMap + SchemaReferences.prepare(domain).resolutions } def resolveTypeRef(ref: TypeRef, foreignResolutions: Map[TypeId.User, Option[TypeRef]]): TypeRef = { - ref match { - case TypeRef.Scalar(id: TypeId.User) => - foreignResolutions.get(id) match { - case Some(Some(resolved)) => resolveTypeRef(resolved, foreignResolutions) - case _ => ref - } - case TypeRef.Constructor(id, args) => - TypeRef.Constructor(id, args.map(a => resolveTypeRef(a, foreignResolutions))) - case _ => ref - } + SchemaReferences.resolve(ref, foreignResolutions) } /** JSON Schema representation of a scalar Baboon type. @@ -48,7 +34,7 @@ class OasTypeTranslator { * Returns `(type, format, extra)` where `extra` may contain additional * properties like `"minimum": 0` for unsigned integers. */ - def scalarSchema(id: TypeId.BuiltinScalar): (String, Option[String], Map[String, String]) = { + private def scalarSchema(id: TypeId.BuiltinScalar): (String, Option[String], Map[String, Json]) = { id match { case TypeId.Builtins.bit => ("boolean", None, Map.empty) case TypeId.Builtins.str => ("string", None, Map.empty) @@ -56,10 +42,10 @@ class OasTypeTranslator { case TypeId.Builtins.i16 => ("integer", Some("int32"), Map.empty) case TypeId.Builtins.i32 => ("integer", Some("int32"), Map.empty) case TypeId.Builtins.i64 => ("integer", Some("int64"), Map.empty) - case TypeId.Builtins.u08 => ("integer", Some("int32"), Map("minimum" -> "0")) - case TypeId.Builtins.u16 => ("integer", Some("int32"), Map("minimum" -> "0")) - case TypeId.Builtins.u32 => ("integer", Some("int32"), Map("minimum" -> "0")) - case TypeId.Builtins.u64 => ("integer", Some("int64"), Map("minimum" -> "0")) + case TypeId.Builtins.u08 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) + case TypeId.Builtins.u16 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) + case TypeId.Builtins.u32 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) + case TypeId.Builtins.u64 => ("integer", Some("int64"), Map("minimum" -> Json.fromInt(0))) case TypeId.Builtins.f32 => ("number", Some("float"), Map.empty) case TypeId.Builtins.f64 => ("number", Some("double"), Map.empty) case TypeId.Builtins.f128 => ("string", Some("decimal"), Map.empty) @@ -78,20 +64,27 @@ class OasTypeTranslator { * as optional (kind-byte conditional `if`/`then` constraints are intentionally * omitted for readability — see the `description` for the kind-byte table). */ - val baboonAnySchema: String = { - """{"type": "object", "title": "BaboonAny", """ + - """"description": "Opaque any-envelope. JSON serialization of a baboon AnyOpaque value: """ + - """{\"$ak\":, \"$ad\"?:str, \"$av\"?:str, \"$at\"?:str, \"$c\":}. """ + - """$ak kind byte: 0x07=A(any), 0x03=B(any[domain:this]), 0x01=C(any[domain:current]), """ + - """0x06=D1(any[T]), 0x02=D2(any[domain:this,T]), 0x00=D3(any[domain:current,T]).", """ + - """"properties": {""" + - """"$ak": {"type": "integer", "minimum": 0, "maximum": 7}, """ + - """"$ad": {"type": "string"}, """ + - """"$av": {"type": "string"}, """ + - """"$at": {"type": "string"}, """ + - """"$c": {}""" + - """}, "required": ["$ak", "$c"]}""" - } + val baboonAnySchemaValue: Json = Json.obj( + "type" -> Json.fromString("object"), + "title" -> Json.fromString("BaboonAny"), + "description" -> Json.fromString( + "Opaque any-envelope. JSON serialization of a baboon AnyOpaque value: " + + """{"$ak":, "$ad"?:str, "$av"?:str, "$at"?:str, "$c":}. """ + + "$ak kind byte: 0x07=A(any), 0x03=B(any[domain:this]), 0x01=C(any[domain:current]), " + + "0x06=D1(any[T]), 0x02=D2(any[domain:this,T]), 0x00=D3(any[domain:current,T])." + ), + "properties" -> Json.obj( + "$ak" -> Json.obj("type" -> Json.fromString("integer"), "minimum" -> Json.fromInt(0), "maximum" -> Json.fromInt(7)), + "$ad" -> Json.obj("type" -> Json.fromString("string")), + "$av" -> Json.obj("type" -> Json.fromString("string")), + "$at" -> Json.obj("type" -> Json.fromString("string")), + "$c" -> Json.obj(), + ), + "required" -> Json.arr(Json.fromString("$ak"), Json.fromString("$c")), + ) + + private val fragmentPrinter = Printer.noSpaces.copy(colonRight = " ", objectCommaRight = " ", arrayCommaRight = " ") + val baboonAnySchema: String = fragmentPrinter.print(baboonAnySchemaValue) /** Inline JSON Schema fragment for a type reference. * @@ -99,25 +92,26 @@ class OasTypeTranslator { * embedded as a property schema or array items schema. */ def typeRefSchema(ref: TypeRef, enumKeys: Set[TypeId.User] = Set.empty): String = { + fragmentPrinter.print(typeRefSchemaValue(ref, enumKeys)) + } + + def typeRefSchemaValue(ref: TypeRef, enumKeys: Set[TypeId.User]): Json = { ref match { case TypeRef.Scalar(id: TypeId.BuiltinScalar) => - scalarSchemaJson(id) + scalarSchemaValue(id) case TypeRef.Scalar(id: TypeId.User) => - s"""{"$$ref": "#/components/schemas/${escapeJson(schemaName(id))}"}""" + componentRef(id) case TypeRef.Constructor(TypeId.Builtins.opt, args) => // nullable via oneOf [schema, null] (OpenAPI 3.1 / JSON Schema 2020-12) - val inner = typeRefSchema(args.head, enumKeys) - s"""{"oneOf": [$inner, {"type": "null"}]}""" + JsonSchema.nullable(typeRefSchemaValue(args.head, enumKeys)) case TypeRef.Constructor(TypeId.Builtins.lst, args) => - val items = typeRefSchema(args.head, enumKeys) - s"""{"type": "array", "items": $items}""" + JsonSchema.array(typeRefSchemaValue(args.head, enumKeys)) case TypeRef.Constructor(TypeId.Builtins.set, args) => - val items = typeRefSchema(args.head, enumKeys) - s"""{"type": "array", "items": $items, "uniqueItems": true}""" + JsonSchema.uniqueArray(typeRefSchemaValue(args.head, enumKeys)) case TypeRef.Constructor(TypeId.Builtins.map, args) => mapSchema(args.head, args.tail.head, enumKeys) @@ -127,7 +121,7 @@ class OasTypeTranslator { // (0x00..0x07, see GraphQL `BaboonAny` description for the kind table). // Inlined directly rather than `$ref`-ed because OpenAPI emission is // schema-only (no shared component registry) and this fragment is small. - baboonAnySchema + baboonAnySchemaValue case other => throw new IllegalArgumentException(s"Unexpected type reference in OpenAPI backend: ${other.id.name.name}") } @@ -143,19 +137,15 @@ class OasTypeTranslator { * to the enum component. Other non-string-keyed maps become arrays of * `{key, value}` entry objects. */ - private def mapSchema(keyRef: TypeRef, valRef: TypeRef, enumKeys: Set[TypeId.User]): String = { - val valSchema = typeRefSchema(valRef, enumKeys) + private def mapSchema(keyRef: TypeRef, valRef: TypeRef, enumKeys: Set[TypeId.User]): Json = { + val valSchema = typeRefSchemaValue(valRef, enumKeys) keyRef match { case TypeRef.Scalar(id: TypeId.User) if enumKeys.contains(id) => - val propertyNames = s"""{"$$ref": "#/components/schemas/${escapeJson(schemaName(id))}"}""" - s"""{"type": "object", "additionalProperties": $valSchema, "propertyNames": $propertyNames}""" + JsonSchema.objectMap(valSchema, Some(componentRef(id))) case _ if isStringKey(keyRef) => - s"""{"type": "object", "additionalProperties": $valSchema}""" + JsonSchema.objectMap(valSchema, None) case _ => - val keySchema = typeRefSchema(keyRef, enumKeys) - val entrySchema = - s"""{"type": "object", "required": ["key", "value"], "properties": {"key": $keySchema, "value": $valSchema}}""" - s"""{"type": "array", "items": $entrySchema}""" + JsonSchema.entryMap(typeRefSchemaValue(keyRef, enumKeys), valSchema) } } @@ -163,13 +153,7 @@ class OasTypeTranslator { * map keys can be reconciled to the string-keyed-object wire form (D6/T30). */ def enumKeysOf(domain: Domain): Set[TypeId.User] = - domain.defs.meta.nodes.values.collect { - case u: DomainMember.User => - u.defn match { - case e: Typedef.Enum => Some(e.id) - case _ => None - } - }.flatten.toSet + SchemaReferences.prepare(domain).enums.keySet private def isStringKey(ref: TypeRef): Boolean = { ref match { @@ -180,36 +164,29 @@ class OasTypeTranslator { } def scalarSchemaJson(id: TypeId.BuiltinScalar): String = { + fragmentPrinter.print(scalarSchemaValue(id)) + } + + def scalarSchemaValue(id: TypeId.BuiltinScalar): Json = { val (tpe, fmt, extra) = scalarSchema(id) - val parts = List(s""""type": "$tpe"""") ++ - fmt.map(f => s""""format": "$f"""").toList ++ - extra.map { case (k, v) => s""""$k": $v""" } - s"{${parts.mkString(", ")}}" + val parts = List("type" -> Json.fromString(tpe)) ++ + fmt.map(f => "format" -> Json.fromString(f)).toList ++ + extra.toList.sortBy(_._1) + Json.obj(parts*) } + def componentRef(id: TypeId.User): Json = Json.obj("$ref" -> Json.fromString(s"#/components/schemas/${schemaName(id)}")) + /** Generate the schema name for a user-defined type, following the same * conventions as the GraphQL backend: package path + owner path + type name, * joined with underscores. */ def schemaName(id: TypeId.User): String = { - val parts = id.pkg.path.toList ++ id.owner.asPseudoPkg :+ id.name.name - parts.map(sanitize).mkString("_") + SchemaReferences.name(id) } def sanitize(s: String): String = { - s.replace("-", "_").replace(".", "_") - } - - def escapeJson(s: String): String = { - s.flatMap { - case '"' => "\\\"" - case '\\' => "\\\\" - case '\n' => "\\n" - case '\r' => "\\r" - case '\t' => "\\t" - case c if c < 0x20 => f"\\u${c.toInt}%04x" - case c => c.toString - } + SchemaReferences.sanitize(s) } /** Return the canonical human-readable description of the ADT @@ -224,8 +201,7 @@ class OasTypeTranslator { * sentence is returned. * * The returned string is RAW (unescaped). Callers are responsible for - * JSON-escaping with `escapeJson` before embedding in a JSON string literal, - * or via `Json.fromString` in circe contexts. + * embedding it via `Json.fromString`. */ def adtWrapperDoc(branchShortNames: List[String]): String = { val generic = diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala index 3e5ed1533..bfd072304 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala @@ -83,10 +83,10 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( val metaAlias = s"BaboonMetadata$suffix" val runtimeMod = s".${vPrefix}baboon_runtime" val metadataMod = s".${vPrefix}baboon_metadata" - val jsonImport = + val jsonImport = if (isLatest) s"from $runtimeMod import BaboonCodecsJson, BaboonCodecsUeba" else s"from $runtimeMod import BaboonCodecsJson as $jsonAlias, BaboonCodecsUeba as $uebaAlias" - val metaImport = + val metaImport = if (isLatest) s"from $metadataMod import BaboonMetadata" else s"from $metadataMod import BaboonMetadata as $metaAlias" (jsonImport, metaImport, jsonAlias, uebaAlias, metaAlias, version.v.toString) @@ -98,9 +98,9 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( case (_, _, jsonAlias, uebaAlias, metaAlias, versionStr) => q"""self.register( | $baboonDomainVersion("$domainIdStr", "$versionStr"), - | codecs_json=lambda: ${jsonAlias}.instance(), - | codecs_bin=lambda: ${uebaAlias}.instance(), - | meta=lambda: ${metaAlias}(), + | codecs_json=lambda: $jsonAlias.instance(), + | codecs_bin=lambda: $uebaAlias.instance(), + | meta=lambda: $metaAlias(), |)""".stripMargin } @@ -449,6 +449,9 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( | def unmodified_since(self, type_id_string: $pyStr) -> $pyList[$pyStr]: | return self.unmodified.get(type_id_string, []) | + | def same_in_versions(self, type_id_string: $pyStr) -> $pyList[$pyStr]: + | return self.unmodified_since(type_id_string) + | | def forward_readable_versions(self, type_id_string: $pyStr) -> $pyDict[$pyStr, $pyStr]: | return self.forward_readable.get(type_id_string, {}) | diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala index 87104e5a2..36e07abe9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala @@ -54,6 +54,13 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( val typeFrom = typeTranslator.asPyTypeVersioned(conversion.sourceTpe, srcDom, evolution, pyFileTools.definitionsBasePkg) def typeTo = typeTranslator.asPyType(conversion.targetTpe, domain, evolution, pyFileTools.definitionsBasePkg) + val targetMeta = if (conversion.sourceTpe != conversion.targetTpe) { + q"""@$pyProperty + |def _target_type_id(self) -> str: + | return "${conversion.targetTpe.toString}" + |""".stripMargin + } else q"" + val meta = q"""@$pyProperty |def version_from(self) -> str: @@ -68,7 +75,9 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( | return "${conversion.sourceTpe.toString}" |""".stripMargin - val convTree = genConversionTree(conversion, typeFrom, typeTo, convType, meta) + val conversionMeta = List(meta, targetMeta).filterNot(_.isEmpty).join("\n") + + val convTree = genConversionTree(conversion, typeFrom, typeTo, convType, conversionMeta) val registerTree = genRegisterTree(conversion, typeFrom, typeTo, convType) val abstractConversion = genAbstractConversion(conversion, convType) @@ -171,11 +180,11 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( val ops = c.ops.map(o => o.targetField -> o).toMap val assigns = dtoDefn.fields.map { field => - val op = ops(field) - val fieldName = field.name.name + val op = ops(field) + val fieldName = field.name.name // Access source object attribute using the keyword-escaped name. - val srcAttrName = escapePyKeyword(fieldName) - val fieldRef = q"_from.$srcAttrName" + val srcAttrName = escapePyKeyword(fieldName) + val fieldRef = q"_from.$srcAttrName" val expr = op match { case o: FieldOp.Transfer => transfer(o.targetField.tpe, fieldRef) @@ -232,16 +241,19 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( } // Local variable references, then constructor kwargs using escaped attribute names. val ctorArgs = dtoDefn.fields.map(f => q"${escapePyKeyword(f.name.name).toLowerCase}") - Some(q"""class ${convType.name}($abstractConversion[$typeFrom, $typeTo]): - | @$pyOverride - | def do_convert(self, ctx, conversions: $baboonAbstractConversions, _from: $typeFrom) -> $typeTo: - | ${assigns.join("\n").shift(8).trim} - | return $typeTo( - | ${ctorArgs.zip(dtoDefn.fields).map { case (a, f) => q"${if (PyKeywords.isKeyword(f.name.name)) s"${f.name.name}_" else f.name.name}=$a" }.join(",\n").shift(12).trim} - | ) - | - | ${meta.shift(4).trim} - |""".stripMargin.trim) + Some( + q"""class ${convType.name}($abstractConversion[$typeFrom, $typeTo]): + | @$pyOverride + | def do_convert(self, ctx, conversions: $baboonAbstractConversions, _from: $typeFrom) -> $typeTo: + | ${assigns.join("\n").shift(8).trim} + | return $typeTo( + | ${ctorArgs + .zip(dtoDefn.fields).map { case (a, f) => q"${if (PyKeywords.isKeyword(f.name.name)) s"${f.name.name}_" else f.name.name}=$a" }.join(",\n").shift(12).trim} + | ) + | + | ${meta.shift(4).trim} + |""".stripMargin.trim + ) case _: Conversion.CustomConversionRequired => Some(q"""class ${convType.name}($abstractConversion[$typeFrom, $typeTo]): @@ -262,19 +274,12 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( case (TypeId.Builtins.opt, TypeId.Builtins.lst) => q"[] if $fieldRef is None else [${transfer(newArgs.head, fieldRef, Some(oldArgs.head))}]" case (TypeId.Builtins.opt, TypeId.Builtins.set) => - q"{} if $fieldRef is None else {${transfer(newArgs.head, fieldRef, Some(oldArgs.head))}}" + q"$pySet() if $fieldRef is None else {${transfer(newArgs.head, fieldRef, Some(oldArgs.head))}}" case (TypeId.Builtins.opt, TypeId.Builtins.opt) => q"None if $fieldRef is None else ${transfer(newArgs.head, fieldRef, Some(oldArgs.head))}" - case (TypeId.Builtins.lst, TypeId.Builtins.lst) => - q"[${transfer(newArgs.head, tmp, Some(oldArgs.head))} for v in range(len($fieldRef))]" - case (TypeId.Builtins.lst, TypeId.Builtins.set) => - q"{${transfer(newArgs.head, tmp, Some(oldArgs.head))} for v in range(len($fieldRef))}" - - case (TypeId.Builtins.set, TypeId.Builtins.lst) => - q"[${transfer(newArgs.head, tmp, Some(oldArgs.head))} for v in range(len($fieldRef))]" - case (TypeId.Builtins.set, TypeId.Builtins.set) => - q"{${transfer(newArgs.head, tmp, Some(oldArgs.head))} for v in range(len($fieldRef))}" + case (TypeId.Builtins.lst | TypeId.Builtins.set, TypeId.Builtins.lst | TypeId.Builtins.set) => + mapCollection(newId, fieldRef, transfer(newArgs.head, tmp, Some(oldArgs.head))) case (TypeId.Builtins.map, TypeId.Builtins.map) => val keyRef = q"k" @@ -339,7 +344,7 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( val tmp = q"v" cn match { case c: TypeRef.Constructor if c.id == TypeId.Builtins.lst => - q"[${transfer(c.args.head, tmp, Some(co.args.head))} for v in $oldRef]" + mapCollection(c.id, oldRef, transfer(c.args.head, tmp, Some(co.args.head))) case c: TypeRef.Constructor if c.id == TypeId.Builtins.map => val keyRef = c.args.head @@ -349,13 +354,19 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( q"{${transfer(keyRef, kv, Some(co.args.head))}: ${transfer(valueRef, vv, Some(co.args.last))} for k,v in $oldRef.items()}" case c: TypeRef.Constructor if c.id == TypeId.Builtins.set => - q"{${transfer(c.args.head, tmp, Some(co.args.head))} for v in $oldRef}" + mapCollection(c.id, oldRef, transfer(c.args.head, tmp, Some(co.args.head))) case c: TypeRef.Constructor if c.id == TypeId.Builtins.opt => q"None if $oldRef is None else ${transfer(c.args.head, oldRef, Some(co.args.head))}" case c => throw new IllegalStateException(s"Unsupported constructor type: ${c.id}") } } + private def mapCollection(id: TypeId, source: TextTree[PyValue], element: TextTree[PyValue]): TextTree[PyValue] = id match { + case TypeId.Builtins.lst => q"[$element for v in $source]" + case TypeId.Builtins.set => q"{$element for v in $source}" + case other => throw new IllegalStateException(s"Unsupported collection mapping: $other") + } + private def transferScalar( oldRef: TextTree[PyValue], newTypeRefTree: TextTree[PyValue], diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala index b49940bbc..c5cb9b846 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.python import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.PyTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.python.PyKeywords.escapePyKeyword import io.septimalmind.baboon.translator.python.PyTypes.* @@ -297,7 +298,7 @@ object PyDefnTranslator { case enum: Typedef.Enum => val branches = enum.members.map { m => - val wireName = EnumWireStyle.wireName(m.name) + val wireName = EnumWireStyle.wireName(m.name) // Escape Python keywords in enum member identifiers. The value (wire-string) stays // as the original wire name; only the Python attribute name gets the trailing `_`. val memberPyIdent = escapePyKeyword(wireName) @@ -405,7 +406,7 @@ object PyDefnTranslator { val contractDocTree: Option[TextTree[PyValue]] = if (contractDocstring.isEmpty) None else Some(q"$contractDocstring") val contractBody = contractDocTree.toList ++ (if (methods.isEmpty) List(q"pass") else methods) - val allMethods = contractBody.joinN() + val allMethods = contractBody.joinN() PyDefnRepr( q"""|class ${contract.id.name.name.capitalize}($parents): | ${allMethods.shift(4).trim} @@ -451,7 +452,7 @@ object PyDefnTranslator { val serviceDocTree: Option[TextTree[PyValue]] = if (serviceDocstring.isEmpty) None else Some(q"$serviceDocstring") val serviceBody = serviceDocTree.toList ++ (if (methods.isEmpty) List(q"pass") else methods) - val allMethods = serviceBody.joinN() + val allMethods = serviceBody.joinN() // Abstract mode: the service interface is `Generic[Ctx]`. `Generic` // and `TypeVar` must be imported (reference them as PyTypes so the // import pass emits `from typing import Generic, TypeVar`) and the @@ -544,49 +545,47 @@ object PyDefnTranslator { private def genDtoFields(dtoFields: List[Field], contractsFields: Set[Field]): List[TextTree[PyValue]] = { val fields = dtoFields.map { field => - val fieldName = field.name.name - val fieldType = typeTranslator.asPyRef(field.tpe, domain, evolution, fileTools.definitionsBasePkg) + val plan = PyFieldPlan(field, contractsFields.contains(field)) + val fieldName = plan.wireName + val fieldType = typeTranslator.asPyRef(field.tpe, domain, evolution, fileTools.definitionsBasePkg) // A trailing `_` suffix is needed when either the field is a Python keyword (PEP 8 // convention) or the field implements a contract abstract method (to avoid a Python // name-clash with the @property accessor). In both cases a pydantic alias preserves the // original model name as the wire key. - val needsAlias = contractsFields.contains(field) || PyKeywords.isKeyword(fieldName) - - if (needsAlias) { - q"${fieldName}_: $fieldType = $pydanticField(alias='$fieldName', serialization_alias='$fieldName')" - } else q"$fieldName: $fieldType" + if (plan.needsAlias) { + q"${plan.attributeName}: $fieldType = $pydanticField(alias='$fieldName', serialization_alias='$fieldName')" + } else q"${plan.attributeName}: $fieldType" } if (fields.isEmpty) List(q"pass") else fields } private def genDtoProperties(contractsFields: List[Field]): Option[List[TextTree[PyValue]]] = { if (contractsFields.nonEmpty) { - val properties = contractsFields - .map { - f => - val fieldName = f.name.name - // The backing pydantic field is always `${fieldName}_` (see genDtoFields). - // The property accessor uses the keyword-escaped name so the method declaration is - // valid Python (e.g. `def class_(self)` instead of `def class(self)`). - val propertyName = escapePyKeyword(fieldName) - q"""@property - |def $propertyName(self) -> ${typeTranslator.asPyRef(f.tpe, domain, evolution, fileTools.definitionsBasePkg)}: - | return self.${fieldName}_ - |""".stripMargin - } + val properties = contractsFields.map { + f => + val plan = PyFieldPlan(f, true) + // The backing pydantic field is always `${fieldName}_` (see genDtoFields). + // The property accessor uses the keyword-escaped name so the method declaration is + // valid Python (e.g. `def class_(self)` instead of `def class(self)`). + val propertyName = plan.accessorName + q"""@property + |def $propertyName(self) -> ${typeTranslator.asPyRef(f.tpe, domain, evolution, fileTools.definitionsBasePkg)}: + | return self.${plan.attributeName} + |""".stripMargin + } Some(properties) } else None } private def genDtoPydanticModelConf(dtoFields: List[Field], hasContracts: Boolean, jsonCodecActive: Boolean): TextTree[PyType] = { - val frozen = Some(q"frozen=True") - val hasKeywordField = dtoFields.exists(f => PyKeywords.isKeyword(f.name.name)) - val serializeByAlias = if (jsonCodecActive && (hasContracts || hasKeywordField)) Some(q"serialize_by_alias=True") else None + val frozen = Some(q"frozen=True") + val hasKeywordField = dtoFields.exists(f => PyKeywords.isKeyword(f.name.name)) + val serializeByAlias = if (jsonCodecActive && (hasContracts || hasKeywordField)) Some(q"serialize_by_alias=True") else None // populate_by_name=True allows constructing instances using the Python attribute name (the // escaped name, e.g. `class_`) instead of the alias (the original model name `class`). This // is required when any field is a Python keyword, because the alias cannot be used as a // constructor keyword argument in generated Python code. - val populateByName = if (hasKeywordField) Some(q"populate_by_name=True") else None + val populateByName = if (hasKeywordField) Some(q"populate_by_name=True") else None val serializeJsonBytesAsHex = if (dtoFields.map(_.tpe.id).contains(TypeId.Builtins.bytes)) { List( @@ -603,13 +602,8 @@ object PyDefnTranslator { // pydantic doesn't know how to validate the runtime `AnyOpaque` ABC; opt into // arbitrary-types when any field carries an `any`-typed payload (direct or nested via a // constructor like `lst[any]` / `opt[any]` / `map[str, any]`). - def hasAnyType(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAnyType) - } val arbitraryTypesAllowed = - if (dtoFields.exists(f => hasAnyType(f.tpe))) Some(q"arbitrary_types_allowed=True") else None + if (dtoFields.exists(f => PyFieldPlan.containsAny(f.tpe))) Some(q"arbitrary_types_allowed=True") else None val configs = List(frozen, serializeByAlias, populateByName, serializeJsonBytesAsHex, serializeDecimalAsJsonNumber, arbitraryTypesAllowed).flatten @@ -628,46 +622,6 @@ object PyDefnTranslator { // - `__repr__(self) -> str` method on the dataclass // - sibling `Codec` class with `@staticmethod parse_repr` (Q-FU-4) // - snake_case method names per PEP 8 - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32 */ - case object SignedLong extends IdentifierFieldKind /* i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKindPy(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` => IdentifierFieldKind.SignedInt - case `i64` => IdentifierFieldKind.SignedLong - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def signedTypeNamePy(tpe: TypeRef): String = tpe match { case TypeRef.Scalar(TypeId.Builtins.i08) => "i08" case TypeRef.Scalar(TypeId.Builtins.i16) => "i16" @@ -722,7 +676,7 @@ object PyDefnTranslator { f => val srcFieldName = f.name.name val escapedFieldName = escapePyKeyword(srcFieldName) - val kind = identifierFieldKindPy(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderIdentifierFieldValueExprPy(escapedFieldName, kind) q""""$srcFieldName:" + ($valueExpr)""" } @@ -751,7 +705,7 @@ object PyDefnTranslator { val valVar = s"${srcFieldName}_v" val resVar = s"${srcFieldName}_r" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKindPy(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val parseHead = q"""${srcFieldName}_fnr = $baboonIdReprParseFieldName(cursor, "$srcFieldName") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyFieldPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyFieldPlan.scala new file mode 100644 index 000000000..a5313ac40 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyFieldPlan.scala @@ -0,0 +1,73 @@ +package io.septimalmind.baboon.translator.python + +import io.septimalmind.baboon.typer.model.{BaboonLang, Domain, DomainMember, Field, TypeId, TypeRef, Typedef} + +private[python] final case class PyFieldPlan(wireName: String, attributeName: String, accessorName: String, constructorName: String) { + def needsAlias: Boolean = wireName != attributeName +} + +private[python] object PyFieldPlan { + final case class SerializationFeatures(hasAny: Boolean, hasAdt: Boolean, hasUserKeyMap: Boolean, hasCustomForeignKey: Boolean) { + def needsExplicitWalk: Boolean = hasAny || hasAdt || hasUserKeyMap + def combine(other: SerializationFeatures): SerializationFeatures = SerializationFeatures( + hasAny || other.hasAny, + hasAdt || other.hasAdt, + hasUserKeyMap || other.hasUserKeyMap, + hasCustomForeignKey || other.hasCustomForeignKey, + ) + } + + private val noFeatures = SerializationFeatures(false, false, false, false) + + def serializationFeatures(tpe: TypeRef, domain: Domain): SerializationFeatures = tpe match { + case _: TypeRef.Any => noFeatures.copy(hasAny = true) + case TypeRef.Scalar(id: TypeId.User) => + val isAdt = domain.defs.meta.nodes.get(id).exists { + case DomainMember.User(_, _: Typedef.Adt, _, _) => true + case _ => false + } + noFeatures.copy(hasAdt = isAdt) + case _: TypeRef.Scalar => noFeatures + case TypeRef.Constructor(id, args) => + val nested = args.foldLeft(noFeatures)((features, ref) => features.combine(serializationFeatures(ref, domain))) + val hasUserKey = id == TypeId.Builtins.map && args.head.isInstanceOf[TypeRef.Scalar] && args.head.id.isInstanceOf[TypeId.User] + nested.combine(noFeatures.copy(hasUserKeyMap = hasUserKey, hasCustomForeignKey = hasUserKey && keyNeedsCustomForeignWrap(args.head, domain))) + } + + private def keyNeedsCustomForeignWrap(tpe: TypeRef, domain: Domain): Boolean = tpe match { + case TypeRef.Scalar(id: TypeId.User) => + domain.defs.meta.nodes.get(id) match { + case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => + f.bindings.get(BaboonLang.Py) match { + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(ref))) => keyNeedsCustomForeignWrap(ref, domain) + case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.Custom)) => true + case _ => false + } + case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.size == 1 && d.contracts.isEmpty => + keyNeedsCustomForeignWrap(d.fields.head.tpe, domain) + case _ => false + } + case _ => false + } + + def apply(field: Field, implementsContract: Boolean): PyFieldPlan = { + val wireName = field.name.name + val escaped = PyKeywords.escapePyKeyword(wireName) + val attribute = if (implementsContract || PyKeywords.isKeyword(wireName)) s"${wireName}_" else wireName + PyFieldPlan(wireName, attribute, escaped, escaped) + } + + def forDto(domain: Domain, dto: Typedef.Dto): Map[Field, PyFieldPlan] = { + val contractFields = dto.contracts + .flatMap(domain.defs.meta.nodes.get).collect { + case DomainMember.User(_, contract: Typedef.Contract, _, _) => contract.fields + }.flatten.toSet + dto.fields.map(field => field -> apply(field, contractFields.contains(field))).toMap + } + + def containsAny(tpe: TypeRef): Boolean = tpe match { + case _: TypeRef.Any => true + case _: TypeRef.Scalar => false + case c: TypeRef.Constructor => c.args.exists(containsAny) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala index 7b6453155..299ffdbc8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala @@ -19,9 +19,13 @@ final class PyJsonCodecGenerator( pyTarget: PyTarget, domain: Domain, ) extends PyCodecTranslator { + private val fieldSerialization: Map[TypeRef, PyFieldPlan.SerializationFeatures] = domain.defs.meta.nodes.valuesIterator.collect { + case DomainMember.User(_, dto: Typedef.Dto, _, _) => dto.fields.map(_.tpe) + }.flatten.toSet.iterator.map((tpe: TypeRef) => tpe -> PyFieldPlan.serializationFeatures(tpe, domain)).toMap + override def translate(defn: DomainMember.User, pyRef: PyType, srcRef: PyType): Option[TextTree[PyValue]] = { (defn.defn match { - case d: Typedef.Dto => Some(genDtoBodies(pyRef, d)) + case d: Typedef.Dto => Some(genDtoBodies(pyRef, d, false)) case _: Typedef.Adt => Some(genAdtBodies(pyRef)) case _: Typedef.Enum => Some(genEnumBodies(pyRef)) case f: Typedef.Foreign => @@ -57,9 +61,25 @@ final class PyJsonCodecGenerator( |""".stripMargin) val anyHelpers: List[TextTree[PyValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil val customKeyHelpers: List[TextTree[PyValue]] = if (hasCustomForeignMapKey(defn)) List(tryDecodeKeyHelper) else Nil - val baseMethods = encodeMethod ++ decodeMethod ++ anyHelpers ++ customKeyHelpers - val cName = q"${srcRef.name}_JsonCodec" - val cType = q"'${codecType(defn.id)}'" + val valueBodies = defn.defn match { + case d: Typedef.Dto if dtoNeedsExplicitWalker(d) => Some(genDtoBodies(name, d, true)) + case _: Typedef.Enum => Some((q"return value.value", q"return $name(wire)")) + case _ => None + } + val valueMethods = valueBodies.toList.flatMap { + case (valueEnc, valueDec) => + val valueEncode = if (isEncoderEnabled) { + List(q"""def encode_value(self, context: $baboonCodecContext, value: $name) -> $pyAny: + | ${valueEnc.shift(4).trim} + |""".stripMargin) + } else Nil + valueEncode ++ List(q"""def decode_value(self, context: $baboonCodecContext, wire: $pyAny) -> $name: + | ${valueDec.shift(4).trim} + |""".stripMargin) + } + val baseMethods = encodeMethod ++ decodeMethod ++ valueMethods ++ anyHelpers ++ customKeyHelpers + val cName = q"${srcRef.name}_JsonCodec" + val cType = q"'${codecType(defn.id)}'" val cParent = if (isEncoderEnabled) { defn match { @@ -108,7 +128,7 @@ final class PyJsonCodecGenerator( (encode, decode) } - private def genDtoBodies(name: PyType, dto: Typedef.Dto): (TextTree[PyValue], TextTree[PyValue]) = { + private def genDtoBodies(name: PyType, dto: Typedef.Dto, nativeValue: Boolean): (TextTree[PyValue], TextTree[PyValue]) = { // For DTOs without `any`-bearing fields AND without `map[user-key, V]` fields, fall through to // pydantic's transparent `model_dump_json` / `model_validate_json` round-trip — pydantic knows // every field type. For DTOs WITH `any`-bearing fields, pydantic cannot round-trip an @@ -129,143 +149,49 @@ final class PyJsonCodecGenerator( val decode = q"""return $name.model_validate_json(wire)""".stripMargin (encode, decode) } else { - // The exclude set for model_dump uses Python attribute names (not aliases). - // Keyword fields have attribute name `fieldName_`, others use `fieldName`. - val walkedFieldAttrNames = dto.fields.filter(f => fieldNeedsExplicitWalk(f.tpe)) - .map(f => if (PyKeywords.isKeyword(f.name.name)) s"${f.name.name}_" else f.name.name) - val excludeSet = walkedFieldAttrNames.map(n => q"'$n'").join(", ") + // Pydantic exclusions use backing attributes, not serialization aliases. + val fieldPlans = PyFieldPlan.forDto(domain, dto) + val walkedFieldAttrNames = dto.fields + .filter(f => fieldNeedsExplicitWalk(f.tpe)) + .map(f => fieldPlans(f).attributeName) + val excludeSet = walkedFieldAttrNames.map(n => q"'$n'").join(", ") val encodePatches = dto.fields.collect { case f if fieldNeedsExplicitWalk(f.tpe) => // Wire key is the ORIGINAL model name; attribute access uses the escaped Python name. - q"obj['${f.name.name}'] = ${mkJsonAnyEncoder(f.tpe, q"value.${escapePyKeyword(f.name.name)}")}" + q"obj['${fieldPlans(f).wireName}'] = ${mkJsonAnyEncoder(f.tpe, q"value.${fieldPlans(f).accessorName}")}" } val decodePatches = dto.fields.collect { case f if fieldNeedsExplicitWalk(f.tpe) => // Wire key is the ORIGINAL model name. q"obj['${f.name.name}'] = ${mkJsonAnyDecoder(f.tpe, q"obj['${f.name.name}']")}" } + val encoded = if (nativeValue) q"obj" else q"$pyJsonDumps(obj)" + val decoded = if (nativeValue) q"dict(wire)" else q"$pyJsonLoads(wire)" val encode = q"""obj = value.model_dump(mode='json', exclude={$excludeSet}) |${encodePatches.joinN()} - |return $pyJsonDumps(obj)""".stripMargin + |return $encoded""".stripMargin val decode = - q"""obj = $pyJsonLoads(wire) + q"""obj = $decoded |${decodePatches.joinN()} |return $name.model_validate(obj)""".stripMargin (encode, decode) } } - // Top-level field test: a codec class needs the any-field helpers if any direct or nested-via- - // Constructor-arg field has type `any`. Mirrors `PyUEBACodecGenerator.hasAnyField`; this 14th - // instance (7 UEBA + 7 JSON generators) is duplicated per-language per-codec — extraction - // deferred per ledger. - private def hasAnyField(defn: DomainMember.User): Boolean = { - defn.defn match { - case d: Typedef.Dto => dtoHasAnyField(d) - case _ => false - } - } - - private def dtoHasAnyField(dto: Typedef.Dto): Boolean = dto.fields.exists(f => fieldHasAny(f.tpe)) - - private def fieldHasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(fieldHasAny) + private def hasAnyField(defn: DomainMember.User): Boolean = defn.defn match { + case d: Typedef.Dto => d.fields.exists(f => fieldSerialization(f.tpe).hasAny) + case _ => false } - // PR-I.2-D01: detect whether the DTO contains any `map[custom-foreign-key, V]` fields. When - // present, the codec class needs the `_try_decode_key` helper that wraps the host call in - // try/except and re-raises as `BaboonCodecException.DecoderFailure("malformed key: ...")` — - // Python expressions cannot contain try/except, so the wrap is factored into a class method - // callable from the dict-comprehension key expression via `self._try_decode_key(lambda: ..., k)`. private def hasCustomForeignMapKey(defn: DomainMember.User): Boolean = defn.defn match { - case d: Typedef.Dto => dtoHasCustomForeignMapKey(d) + case d: Typedef.Dto => d.fields.exists(f => fieldSerialization(f.tpe).hasCustomForeignKey) case _ => false } - private def dtoHasCustomForeignMapKey(dto: Typedef.Dto): Boolean = - dto.fields.exists(f => fieldHasCustomForeignMapKey(f.tpe)) - - // Returns true iff `tpe` (used as a map key) transitively reaches a Custom-foreign via - // `mkJsonKeyDecoder`'s recursion paths: (a) direct Custom-foreign scalar, or (b) single-field - // wrapper DTO whose inner type reaches a Custom-foreign. Mirrors `mkJsonKeyDecoder`'s match arms - // so `_try_decode_key` is emitted exactly when the generated comprehension expression uses it. - private def keyTypeNeedsCustomForeignWrap(tpe: TypeRef): Boolean = tpe match { - case TypeRef.Scalar(u: TypeId.User) => - domain.defs.meta.nodes.get(u) match { - case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => - f.bindings.get(BaboonLang.Py) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => - keyTypeNeedsCustomForeignWrap(aliasedRef) - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => true - case _ => false - } - case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.size == 1 && d.contracts.isEmpty => - keyTypeNeedsCustomForeignWrap(d.fields.head.tpe) - case _ => false - } - case _ => false - } - - private def fieldHasCustomForeignMapKey(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => false - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => - val isCustomForeignKeyMap = c.id == TypeId.Builtins.map && (c.args.head match { - case TypeRef.Scalar(_: TypeId.User) => keyTypeNeedsCustomForeignWrap(c.args.head) - case _ => false - }) - isCustomForeignKeyMap || c.args.exists(fieldHasCustomForeignMapKey) - } - - // PR-60-D02: detect whether the DTO contains any `map[user-key, V]` fields where the key is a - // user type (DTO/id/foreign). Pydantic's transparent path mis-handles such keys; the explicit - // walker uses `mkJsonKeyEncoder`/`mkJsonKeyDecoder` to round-trip them as strings. - private def dtoHasUserKeyMapField(dto: Typedef.Dto): Boolean = dto.fields.exists(f => fieldHasUserKeyMap(f.tpe)) - - private def fieldHasUserKeyMap(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => false - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => - val isMapWithUserKey = c.id == TypeId.Builtins.map && (c.args.head match { - case TypeRef.Scalar(_: TypeId.User) => true - case _ => false - }) - isMapWithUserKey || c.args.exists(fieldHasUserKeyMap) - } - - // PR-29.10-D01: detect whether the DTO contains any field whose type is an ADT user type. - // When a DTO field is declared as an ADT base type (e.g. `okEnvelope: IntStrEnvelope`), - // pydantic's `model_dump_json()` uses the declared base-type schema for the `model_serializer` - // wrap handler — which has no fields — producing `{"VariantName": {}}` instead of - // `{"VariantName": {"field": value}}`. The explicit walker bypasses pydantic's path and - // dispatches directly to the ADT's JSON codec. - private def dtoHasAdtField(dto: Typedef.Dto): Boolean = dto.fields.exists(f => fieldHasAdt(f.tpe)) - - private def fieldHasAdt(tpe: TypeRef): Boolean = tpe match { - case TypeRef.Scalar(u: TypeId.User) => - domain.defs.meta.nodes.get(u) match { - case Some(DomainMember.User(_, _: Typedef.Adt, _, _)) => true - case _ => false - } - case _: TypeRef.Scalar => false - case _: TypeRef.Any => false - case c: TypeRef.Constructor => c.args.exists(fieldHasAdt) - } - - // PR-60-D02: route a DTO through the explicit walker path when EITHER any-bearing OR user-key- - // map-bearing fields are present. The walker handles both cases; non-walked subtrees are passed - // through `pydantic_core.to_jsonable_python` (encode) / left untouched for Pydantic - // `model_validate` to coerce (decode). - // PR-29.10-D01: also route when ADT-bearing fields are present (pydantic's inherited - // model_serializer loses subclass fields when serialized as a declared base-type field). - private def dtoNeedsExplicitWalker(dto: Typedef.Dto): Boolean = - dtoHasAnyField(dto) || dtoHasUserKeyMapField(dto) || dtoHasAdtField(dto) + private def dtoNeedsExplicitWalker(dto: Typedef.Dto): Boolean = dto.fields.exists(f => fieldNeedsExplicitWalk(f.tpe)) - private def fieldNeedsExplicitWalk(tpe: TypeRef): Boolean = - fieldHasAny(tpe) || fieldHasUserKeyMap(tpe) || fieldHasAdt(tpe) + private def fieldNeedsExplicitWalk(tpe: TypeRef): Boolean = fieldSerialization(tpe).needsExplicitWalk // Builds an expression that produces a JSON-friendly Python value (dict/list/scalar) for a // walked field's value. Recurses only into subtrees that need explicit walking (any-bearing, @@ -300,20 +226,20 @@ final class PyJsonCodecGenerator( domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, _: Typedef.Adt, _, _)) => val c = codecType(u) - q"$pyJsonLoads($c.instance().encode(context, $ref))" + q"$c.instance().encode_value(context, $ref)" case _ => // PR-60-D02: a scalar may legitimately appear inside a walked subtree (e.g. the value // side of `map[user-key, V]` where V is itself a non-any-bearing scalar). Defer JSON // conversion to Pydantic's `to_jsonable_python` — equivalent to `model_dump(mode='json')` // for that scalar. - q"$pyToJsonablePython($ref)" + PyScalarCodecOps.jsonFieldValue(ref) } case _: TypeRef.Scalar => // PR-60-D02: a scalar may legitimately appear inside a walked subtree (e.g. the value side // of `map[user-key, V]` where V is itself a non-any-bearing scalar). Defer JSON conversion // to Pydantic's `to_jsonable_python` — equivalent to what `model_dump(mode='json')` would // have produced for that scalar. - q"$pyToJsonablePython($ref)" + PyScalarCodecOps.jsonFieldValue(ref) } // M19/PR-60: produces a JSON-serializable key (Python str/int/etc.) from a typed key. For @@ -388,7 +314,7 @@ final class PyJsonCodecGenerator( domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, _: Typedef.Adt, _, _)) => val c = codecType(u) - q"$c.instance().decode(context, $pyJsonDumps($ref))" + q"$c.instance().decode_value(context, $ref)" case _ => // PR-60-D02: scalar leaves inside walked subtrees pass through unchanged — the // subsequent `model_validate(obj)` call handles primitive/user-type coercion from JSON. diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyMcpServerGenerator.scala index 942151b4a..2899f7e34 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyMcpServerGenerator.scala @@ -52,9 +52,10 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -79,7 +80,8 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/${serviceName}McpServer.py" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -101,12 +103,12 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( // Python dicts (json.loads of the JSON text) so they are constant values. val toolEntries: List[String] = svc.methods.toList.map { m => - val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val toolName = s"${serviceName}_${m.name.name}" + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) val schemaJson = schema.noSpaces .replace("\\", "\\\\") .replace("\"", "\\\"") - val descArg = McpDocs.flatten(m.docs).map(d => s", ${pyString(d)}").getOrElse("") + val descArg = McpDocs.flatten(m.docs).map(d => s", ${pyString(d)}").getOrElse("") s""" McpToolEntry(${pyString(toolName)}, BaboonMethodId(${pyString(serviceName)}, ${pyString(m.name.name)}), json.loads("$schemaJson")$descArg),""" } @@ -121,8 +123,8 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( // the pre-change baseline. val isAsync = target.language.asyncServices - val baseClass = if (isAsync) "AbstractAsyncBaboonMcpServer" else "AbstractBaboonMcpServer" - val asyncPrefix = if (isAsync) "async " else "" + val baseClass = if (isAsync) "AbstractAsyncBaboonMcpServer" else "AbstractBaboonMcpServer" + val asyncPrefix = if (isAsync) "async " else "" val invokeReturn = if (isAsync) "return await self._invoke_json(method, data, ctx, codec_ctx)" else "return self._invoke_json(method, data, ctx, codec_ctx)" @@ -134,7 +136,8 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( |# generated service dispatch; the integrator supplies it (typically the |# errors-mode `$invokeFnName` bound to this service) plus the per-request `ctx`. |import json - |from typing import Callable, Generic, List, TypeVar + |from copy import deepcopy + |from typing import Callable, Dict, Generic, List, TypeVar | |from ${rtPkg}baboon_mcp_runtime import $baseClass, McpServerInfo, McpSession, McpToolEntry |from ${rtPkg}baboon_service_wiring import BaboonLeft, BaboonMethodId, BaboonWiringError @@ -145,6 +148,10 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( |class $className($baseClass[Ctx], Generic[Ctx]): | def __init__(self, invoke_json: Callable[[BaboonMethodId, str, Ctx, object], object]) -> None: | self._invoke_json = invoke_json + | self._tools = [ + |${toolEntries.mkString("\n")} + | ] + | self._tools_by_name = {entry.name: entry for entry in self._tools} | | @property | def server_info(self) -> McpServerInfo: @@ -152,9 +159,10 @@ class PyMcpServerGenerator[F[+_, +_]: Error2]( | | @property | def tools(self) -> List[McpToolEntry]: - | return [ - |${toolEntries.mkString("\n")} - | ] + | return deepcopy(self._tools) + | + | def _by_name(self) -> Dict[str, McpToolEntry]: + | return self._tools_by_name | | ${asyncPrefix}def invoke_json(self, method: BaboonMethodId, data: str, ctx: Ctx, codec_ctx: object) -> object: | $invokeReturn diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyScalarCodecOps.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyScalarCodecOps.scala new file mode 100644 index 000000000..5bf925fbd --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyScalarCodecOps.scala @@ -0,0 +1,62 @@ +package io.septimalmind.baboon.translator.python + +import io.septimalmind.baboon.typer.model.TypeId +import io.septimalmind.baboon.translator.python.PyTypes.* +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.Quote + +private[python] object PyScalarCodecOps { + def jsonFieldValue(value: TextTree[PyValue]): TextTree[PyValue] = q"$pyToJsonablePython($value)" + + def jsonServiceDecode(id: TypeId.BuiltinScalar, data: TextTree[PyValue]): TextTree[PyValue] = { + val value = q"$pyJsonLoads($data)" + id match { + case TypeId.Builtins.bytes => q"$pyBytes.fromhex($value)" + case TypeId.Builtins.uid => q"$pyUuid($value)" + case TypeId.Builtins.f128 => q"$pyDecimal(str($value))" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$pyDateTime.fromisoformat($value)" + case _ => value + } + } + + def jsonServiceEncode(id: TypeId.BuiltinScalar, value: TextTree[PyValue]): TextTree[PyValue] = { + val encoded = id match { + // Direct DTO bytes use hex config; walked Pydantic subtrees retain their own policy. + case TypeId.Builtins.bytes => q"$value.hex()" + case TypeId.Builtins.uid | TypeId.Builtins.f128 | TypeId.Builtins.tsu | TypeId.Builtins.tso => jsonFieldValue(value) + case _ => value + } + q"$pyJsonDumps($encoded)" + } + + private final case class BinaryMethods(read: String, write: String) + + private def binaryMethods(id: TypeId.BuiltinScalar): BinaryMethods = id match { + case TypeId.Builtins.bit => BinaryMethods("read_bool", "write_bool") + case TypeId.Builtins.i08 => BinaryMethods("read_byte", "write_byte") + case TypeId.Builtins.i16 => BinaryMethods("read_i16", "write_i16") + case TypeId.Builtins.i32 => BinaryMethods("read_i32", "write_i32") + case TypeId.Builtins.i64 => BinaryMethods("read_i64", "write_i64") + case TypeId.Builtins.u08 => BinaryMethods("read_ubyte", "write_ubyte") + case TypeId.Builtins.u16 => BinaryMethods("read_u16", "write_u16") + case TypeId.Builtins.u32 => BinaryMethods("read_u32", "write_u32") + case TypeId.Builtins.u64 => BinaryMethods("read_u64", "write_u64") + case TypeId.Builtins.f32 => BinaryMethods("read_f32", "write_f32") + case TypeId.Builtins.f64 => BinaryMethods("read_f64", "write_f64") + case TypeId.Builtins.f128 => BinaryMethods("read_f128", "write_f128") + case TypeId.Builtins.str => BinaryMethods("read_string", "write_str") + case TypeId.Builtins.uid => BinaryMethods("read_uuid", "write_uuid") + case TypeId.Builtins.tsu => BinaryMethods("read_datetime", "write_datetime") + case TypeId.Builtins.tso => BinaryMethods("read_datetime", "write_datetime") + case TypeId.Builtins.bytes => BinaryMethods("read_bytes", "write_bytes") + case other => throw new IllegalStateException(s"Unsupported Python scalar codec: $other") + } + + def decode(id: TypeId.BuiltinScalar, reader: TextTree[PyValue]): TextTree[PyValue] = { + q"$reader.${binaryMethods(id).read}()" + } + + def encode(id: TypeId.BuiltinScalar, writer: TextTree[PyValue], value: TextTree[PyValue]): TextTree[PyValue] = { + q"$writer.${binaryMethods(id).write}($value)" + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala index 1a5e64cda..9da67ef08 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala @@ -71,68 +71,30 @@ object PyServiceWiringTranslator { } // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). - // Python JSON codecs take/return `str` (raw JSON). For builtins, use json.loads/json.dumps. + // Python JSON codecs take/return `str` (raw JSON); scalar operations restore native values. private def jsonDecodeExpr(id: TypeId, data: TextTree[PyValue]): TextTree[PyValue] = id match { case u: TypeId.User => q"${jsonCodecType(u)}.instance().decode($codecCtxRef, $data)" - case _: TypeId.BuiltinScalar => q"$pyJsonLoads($data)" + case b: TypeId.BuiltinScalar => PyScalarCodecOps.jsonServiceDecode(b, data) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[PyValue]): TextTree[PyValue] = id match { case u: TypeId.User => q"${jsonCodecType(u)}.instance().encode($codecCtxRef, $value)" - case _: TypeId.BuiltinScalar => q"$pyJsonDumps($value)" + case b: TypeId.BuiltinScalar => PyScalarCodecOps.jsonServiceEncode(b, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, reader: TextTree[PyValue]): TextTree[PyValue] = id match { case u: TypeId.User => q"${uebaCodecType(u)}.instance().decode($codecCtxRef, $reader)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$reader.read_bool()" - case TypeId.Builtins.i08 => q"$reader.read_byte()" - case TypeId.Builtins.i16 => q"$reader.read_i16()" - case TypeId.Builtins.i32 => q"$reader.read_i32()" - case TypeId.Builtins.i64 => q"$reader.read_i64()" - case TypeId.Builtins.u08 => q"$reader.read_ubyte()" - case TypeId.Builtins.u16 => q"$reader.read_u16()" - case TypeId.Builtins.u32 => q"$reader.read_u32()" - case TypeId.Builtins.u64 => q"$reader.read_u64()" - case TypeId.Builtins.f32 => q"$reader.read_f32()" - case TypeId.Builtins.f64 => q"$reader.read_f64()" - case TypeId.Builtins.f128 => q"$reader.read_f128()" - case TypeId.Builtins.str => q"$reader.read_string()" - case TypeId.Builtins.uid => q"$reader.read_uuid()" - case TypeId.Builtins.tsu => q"$reader.read_datetime()" - case TypeId.Builtins.tso => q"$reader.read_datetime()" - case TypeId.Builtins.bytes => q"$reader.read_bytes()" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + PyScalarCodecOps.decode(b, reader) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, writer: TextTree[PyValue], value: TextTree[PyValue]): TextTree[PyValue] = id match { case u: TypeId.User => q"${uebaCodecType(u)}.instance().encode($codecCtxRef, $writer, $value)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$writer.write_bool($value)" - case TypeId.Builtins.i08 => q"$writer.write_byte($value)" - case TypeId.Builtins.i16 => q"$writer.write_i16($value)" - case TypeId.Builtins.i32 => q"$writer.write_i32($value)" - case TypeId.Builtins.i64 => q"$writer.write_i64($value)" - case TypeId.Builtins.u08 => q"$writer.write_ubyte($value)" - case TypeId.Builtins.u16 => q"$writer.write_u16($value)" - case TypeId.Builtins.u32 => q"$writer.write_u32($value)" - case TypeId.Builtins.u64 => q"$writer.write_u64($value)" - case TypeId.Builtins.f32 => q"$writer.write_f32($value)" - case TypeId.Builtins.f64 => q"$writer.write_f64($value)" - case TypeId.Builtins.f128 => q"$writer.write_f128($value)" - case TypeId.Builtins.str => q"$writer.write_str($value)" - case TypeId.Builtins.uid => q"$writer.write_uuid($value)" - case TypeId.Builtins.tsu => q"$writer.write_datetime($value)" - case TypeId.Builtins.tso => q"$writer.write_datetime($value)" - case TypeId.Builtins.bytes => q"$writer.write_bytes($value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + PyScalarCodecOps.encode(b, writer, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } @@ -240,8 +202,8 @@ object PyServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val inType = typeTranslator.asPyRef(m.sig, domain, evolution, fileTools.definitionsBasePkg) - val outType = m.out.map(o => typeTranslator.asPyRef(o, domain, evolution, fileTools.definitionsBasePkg)) + val inType = typeTranslator.asPyRef(m.sig, domain, evolution, fileTools.definitionsBasePkg) + val outType = m.out.map(o => typeTranslator.asPyRef(o, domain, evolution, fileTools.definitionsBasePkg)) val retAnnot: TextTree[PyValue] = outType.getOrElse(q"None") val uebaMethod = if (hasUeba) { @@ -314,7 +276,7 @@ object PyServiceWiringTranslator { } val clientTree = - q"""class ${svcName}Client${clientGenericBaseClause}: + q"""class ${svcName}Client$clientGenericBaseClause: | \"\"\"RPC client for $svcName. Holds user-supplied transport callbacks | `(service, method, data) -> data` and a $baboonCodecContext, and exposes one | method per endpoint (UEBA: bare name / `bytes`; JSON: `_json` suffix / `str`). @@ -422,10 +384,10 @@ object PyServiceWiringTranslator { } private def generateOneWrapper(service: Typedef.Service, svcType: PyType, isJson: Boolean): TextTree[PyValue] = { - val svcName = service.id.name.name - val wrapperName = s"${svcName}${if (isJson) "_JsonService" else "_UebaService"}" - val invokerFn = s"${if (isJson) "invoke_json_" else "invoke_ueba_"}$svcName" - val wireType = if (isJson) "str" else "bytes" + val svcName = service.id.name.name + val wrapperName = s"$svcName${if (isJson) "_JsonService" else "_UebaService"}" + val invokerFn = s"${if (isJson) "invoke_json_" else "invoke_ueba_"}$svcName" + val wireType = if (isJson) "str" else "bytes" val retAnnot: TextTree[PyValue] = if (resolved.noErrors) q"$wireType" else q"" // `rt` (errors mode) is a per-construction dependency, so it stays a @@ -439,9 +401,9 @@ object PyServiceWiringTranslator { // when a service context is active. In `none` mode `ctx` denotes the // codec context (historical contract) — there is no separate svc ctx. val svcCtxParam: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } val ctorParams: List[TextTree[PyValue]] = { @@ -539,7 +501,7 @@ object PyServiceWiringTranslator { | ${ctorAssigns.join("\n").shift(8).trim} | | ${asyncPrefix}def invoke(self, $invokeParams)$invokeReturnArrow: - | return ${awaitPrefix}$invokerFn(${invokerArgs.join(", ")}) + | return $awaitPrefix$invokerFn(${invokerArgs.join(", ")}) |""".stripMargin } @@ -590,7 +552,9 @@ object PyServiceWiringTranslator { | ${encodeAndReturn.shift(4).trim}""".stripMargin }.joinN() - q"""${asyncPrefix}def invoke_json_$svcName(method: $baboonMethodId, data: str, impl: ${svcTypeRef(svcType)}, ${ctxParamDecl}$codecCtxName: $baboonCodecContext) -> str: + q"""${asyncPrefix}def invoke_json_$svcName(method: $baboonMethodId, data: str, impl: ${svcTypeRef( + svcType + )}, $ctxParamDecl$codecCtxName: $baboonCodecContext) -> str: | ${cases.shift(4).trim} | raise $baboonWiringException($baboonNoMatchingMethod(method)) |""".stripMargin @@ -626,7 +590,9 @@ object PyServiceWiringTranslator { | ${encodeAndReturn.shift(4).trim}""".stripMargin }.joinN() - q"""${asyncPrefix}def invoke_ueba_$svcName(method: $baboonMethodId, data: bytes, impl: ${svcTypeRef(svcType)}, ${ctxParamDecl}$codecCtxName: $baboonCodecContext) -> bytes: + q"""${asyncPrefix}def invoke_ueba_$svcName(method: $baboonMethodId, data: bytes, impl: ${svcTypeRef( + svcType + )}, $ctxParamDecl$codecCtxName: $baboonCodecContext) -> bytes: | ${cases.shift(4).trim} | raise $baboonWiringException($baboonNoMatchingMethod(method)) |""".stripMargin @@ -790,7 +756,9 @@ object PyServiceWiringTranslator { | ${callAndEncodeStep.shift(4).trim}""".stripMargin }.joinN() - q"""${asyncPrefix}def invoke_json_$svcName(method: $baboonMethodId, data: str, impl: ${svcTypeRef(svcType)}, rt: $ibaboonServiceRtType, ${ctxParamDecl}$codecCtxName: $baboonCodecContext): + q"""${asyncPrefix}def invoke_json_$svcName(method: $baboonMethodId, data: str, impl: ${svcTypeRef( + svcType + )}, rt: $ibaboonServiceRtType, $ctxParamDecl$codecCtxName: $baboonCodecContext): | ${cases.shift(4).trim} | return rt.fail($baboonNoMatchingMethod(method)) |""".stripMargin @@ -887,7 +855,9 @@ object PyServiceWiringTranslator { | ${callAndEncodeStep.shift(4).trim}""".stripMargin }.joinN() - q"""${asyncPrefix}def invoke_ueba_$svcName(method: $baboonMethodId, data: bytes, impl: ${svcTypeRef(svcType)}, rt: $ibaboonServiceRtType, ${ctxParamDecl}$codecCtxName: $baboonCodecContext): + q"""${asyncPrefix}def invoke_ueba_$svcName(method: $baboonMethodId, data: bytes, impl: ${svcTypeRef( + svcType + )}, rt: $ibaboonServiceRtType, $ctxParamDecl$codecCtxName: $baboonCodecContext): | ${cases.shift(4).trim} | return rt.fail($baboonNoMatchingMethod(method)) |""".stripMargin diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala index dca838dc5..e3973b710 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala @@ -223,15 +223,16 @@ class PyUEBACodecGenerator( private def genDtoDecoder(name: PyValue.PyType, fields: List[(TextTree[PyValue], TextTree[PyValue])], dto: Typedef.Dto): TextTree[PyValue] = { // Use the keyword-escaped attribute name for constructor kwargs. // Keyword fields use `class_=decoder` (requires populate_by_name=True in model_config). + val fieldPlans = PyFieldPlan.forDto(domain, dto) val fieldsDecoders = dto.fields.zip(fields.map(_._2)).map { case (field, decoder) => - val attrName = if (PyKeywords.isKeyword(field.name.name)) s"${field.name.name}_" else field.name.name + val attrName = fieldPlans(field).constructorName q"$attrName=$decoder" } - q"""index = self.read_index(ctx, wire) + q"""index_count = self.consume_index(ctx, wire) | |if ctx.use_indices: - | assert len(index) == self.index_elements_count(ctx) + | assert index_count == self.index_elements_count(ctx) | |return ${name.name}( | ${fieldsDecoders.join(",\n").shift(4).trim} @@ -344,30 +345,7 @@ class PyUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$writerRef.write_bool($ref)" - case TypeId.Builtins.i08 => q"$writerRef.write_byte($ref)" - case TypeId.Builtins.i16 => q"$writerRef.write_i16($ref)" - case TypeId.Builtins.i32 => q"$writerRef.write_i32($ref)" - case TypeId.Builtins.i64 => q"$writerRef.write_i64($ref)" - case TypeId.Builtins.u08 => q"$writerRef.write_ubyte($ref)" - case TypeId.Builtins.u16 => q"$writerRef.write_u16($ref)" - case TypeId.Builtins.u32 => q"$writerRef.write_u32($ref)" - case TypeId.Builtins.u64 => q"$writerRef.write_u64($ref)" - case TypeId.Builtins.f32 => q"$writerRef.write_f32($ref)" - case TypeId.Builtins.f64 => q"$writerRef.write_f64($ref)" - - case TypeId.Builtins.f128 => q"$writerRef.write_f128($ref)" - case TypeId.Builtins.str => q"$writerRef.write_str($ref)" - - case TypeId.Builtins.uid => q"$writerRef.write_uuid($ref)" - case TypeId.Builtins.tsu => q"$writerRef.write_datetime($ref)" - case TypeId.Builtins.tso => q"$writerRef.write_datetime($ref)" - - case TypeId.Builtins.bytes => q"$writerRef.write_bytes($ref)" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + PyScalarCodecOps.encode(s, writerRef, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -407,30 +385,7 @@ class PyUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"wire.read_bool()" - case TypeId.Builtins.i08 => q"wire.read_byte()" - case TypeId.Builtins.i16 => q"wire.read_i16()" - case TypeId.Builtins.i32 => q"wire.read_i32()" - case TypeId.Builtins.i64 => q"wire.read_i64()" - case TypeId.Builtins.u08 => q"wire.read_ubyte()" - case TypeId.Builtins.u16 => q"wire.read_u16()" - case TypeId.Builtins.u32 => q"wire.read_u32()" - case TypeId.Builtins.u64 => q"wire.read_u64()" - case TypeId.Builtins.f32 => q"wire.read_f32()" - case TypeId.Builtins.f64 => q"wire.read_f64()" - - case TypeId.Builtins.f128 => q"wire.read_f128()" - case TypeId.Builtins.str => q"wire.read_string()" - - case TypeId.Builtins.uid => q"wire.read_uuid()" - case TypeId.Builtins.tsu => q"wire.read_datetime()" - case TypeId.Builtins.tso => q"wire.read_datetime()" - - case TypeId.Builtins.bytes => q"wire.read_bytes()" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + PyScalarCodecOps.decode(s, q"wire") case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -464,13 +419,8 @@ class PyUEBACodecGenerator( // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java/TS/Dart/Swift `hasAnyField`): a codec class // needs the any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) + case d: Typedef.Dto => d.fields.exists(f => PyFieldPlan.containsAny(f.tpe)) case _ => false } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index d071f3529..c9e0440b2 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -258,14 +258,26 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( // the real sameIn run: the envelope's byte-identical bound is its head, so a degenerate // `[own version]` here would make Rust-written envelopes of unchanged types unreadable by // older readers in every language - val sameInVec = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") + val sameInVec = sameInByTypeId.getOrElse(typeId, List(versionStr)).map(v => s""""$v".to_string()""").mkString(", ") + val firstSameIn = sameInByTypeId.getOrElse(typeId, List(versionStr)).head + val minReaderCases = minReadersByTypeId + .get(typeId).toList.flatMap(_.toList.sortBy(_._1.weight)).map { + case (tier, v) => q""""${tier.wireName}" => Some(std::borrow::Cow::Borrowed("${v.v.toString}")),""" + }.joinN() q"""impl BaboonGeneratedDyn for $fullPath { | fn baboon_domain_version_dyn(&self) -> &str { "$versionStr" } | fn baboon_domain_identifier_dyn(&self) -> &str { "$domainIdStr" } | fn baboon_type_identifier_dyn(&self) -> &str { "$typeId" } | fn baboon_same_in_versions_dyn(&self) -> Vec { vec![$sameInVec] } + | fn baboon_first_same_in_version_dyn(&self) -> Option> { Some(std::borrow::Cow::Borrowed("$firstSameIn")) } | fn baboon_forward_readable_dyn(&self) -> Vec<(String, String)> { vec![${fwdPairs(typeId)}] } | fn baboon_min_reader_versions_dyn(&self) -> Vec<(String, String)> { vec![${minReaderPairs(typeId)}] } + | fn baboon_min_reader_version_dyn(&self, tier: &str) -> Option> { + | match tier { + | ${minReaderCases.shift(12).trim} + | _ => None, + | } + | } | fn as_any(&self) -> &dyn std::any::Any { self } | fn into_any(self: Box) -> Box { self } |} @@ -424,15 +436,19 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( if (parts.size == 1) allDirs += "" } + val childrenByDir = allDirs.iterator + .filter(_.nonEmpty).toList.groupBy { + dir => + val separator = dir.lastIndexOf('/') + if (separator < 0) "" else dir.substring(0, separator) + }.view.mapValues(_.map(_.split('/').last).toSet).toMap + allDirs.toList.sorted.map { dir => val prefix = if (dir.isEmpty) "" else dir + "/" // Direct child directories of this directory - val childDirs = allDirs.filter { - d => - d.startsWith(prefix) && d != dir && !d.drop(prefix.length).contains('/') - }.map(_.drop(prefix.length)).toSet + val childDirs = childrenByDir.getOrElse(dir, Set.empty[String]) // File modules in this directory, EXCLUDING those that clash with child directories val fileModNames = filesByDir diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala index 749bc9b8b..ba074e540 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala @@ -1,9 +1,10 @@ package io.septimalmind.baboon.translator.rust import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.rust.RsDefnTranslator.{escapeRustModuleName, escapeRustTypeName, toSnakeCase, toSnakeCaseFileName} import io.septimalmind.baboon.translator.rust.RsValue.RsCrateId +import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* import io.septimalmind.baboon.typer.model.Conversion.FieldOp import izumi.functional.bio.{Error2, F} @@ -26,8 +27,6 @@ object RsConversionTranslator { case class RsRenderedConversion( fname: String, conv: TextTree[RsValue], - reg: Option[TextTree[RsValue]], - missing: Option[TextTree[RsValue]], ) class RsConversionTranslator[F[+_, +_]: Error2]( @@ -37,38 +36,94 @@ class RsConversionTranslator[F[+_, +_]: Error2]( domain: Domain @Id("current"), rules: BaboonRuleset, evo: BaboonEvolution, + enquiries: BaboonEnquiries, ) { private val srcVer = srcDom.version type Out[T] = F[NEList[BaboonIssue], T] - private def hasUserType(tpe: TypeRef): Boolean = { - tpe match { - case TypeRef.Scalar(_: TypeId.User) => true - case TypeRef.Constructor(_, args) => args.exists(hasUserType) - case _ => false - } - } - private def serdeConvert(expr: TextTree[RsValue]): TextTree[RsValue] = { q"serde_json::from_value(serde_json::to_value(&$expr).unwrap()).unwrap()" } - def makeConvs: Out[List[RsRenderedConversion]] = { - def makeName(prefix: String, conv: Conversion): String = - (Seq(prefix) ++ conv.sourceTpe.owner.asPseudoPkg.map(s => escapeRustModuleName(s.toLowerCase)) ++ Seq( + private val sourceRepresentation = new RsFieldRepresentation(srcDom, evo, trans, enquiries) + private val targetRepresentation = new RsFieldRepresentation(domain, evo, trans, enquiries) + + private def conversionName(conv: Conversion): String = + toSnakeCase( + (Seq("convert") ++ conv.sourceTpe.owner.asPseudoPkg.map(s => escapeRustModuleName(s.toLowerCase)) ++ Seq( conv.sourceTpe.name.name, "from", srcVer.v.toString.replace('.', '_'), )).mkString("__") + ) + + private def conversionFile(conv: Conversion): String = + (Seq("from", srcVer.v.toString.replace('.', '_')) ++ conv.sourceTpe.owner.asPseudoPkg.map(s => escapeRustModuleName(s.toLowerCase)) ++ Seq( + toSnakeCaseFileName(conv.sourceTpe.name.name) + )).mkString("_") + + private def transferField(name: FieldName, oldTpe: TypeRef, newTpe: TypeRef): TextTree[RsValue] = { + val field = toSnakeCase(name.name) + val ref = if (sourceRepresentation.needsBox(oldTpe)) q"from.$field.as_ref()" else q"&from.$field" + val value = transfer(oldTpe, newTpe, ref) + if (targetRepresentation.needsBox(newTpe)) q"Box::new($value)" else value + } + + // References here borrow surface values; field-level boxes are handled by transferField. + private def transfer(oldTpe: TypeRef, newTpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { + (oldTpe, newTpe) match { + case (TypeRef.Scalar(oldId: TypeId.User), TypeRef.Scalar(newId: TypeId.User)) => + rules.conversions.collectFirst { + case c: TargetedConversion if c.sourceTpe == oldId && c.targetTpe == newId && !c.isInstanceOf[Conversion.CustomConversionRequired] => + q"${crate.parts.mkString("::")}::${conversionFile(c)}::${conversionName(c)}($ref)" + }.getOrElse(serdeConvert(ref)) + case (TypeRef.Scalar(oldId: TypeId.BuiltinScalar), TypeRef.Scalar(newId: TypeId.BuiltinScalar)) if oldId != newId => + val integers = Set( + TypeId.Builtins.i08, + TypeId.Builtins.i16, + TypeId.Builtins.i32, + TypeId.Builtins.i64, + TypeId.Builtins.u08, + TypeId.Builtins.u16, + TypeId.Builtins.u32, + TypeId.Builtins.u64, + ) + if (integers.contains(oldId) && integers.contains(newId)) q"(*($ref)) as ${trans.asRsRef(newTpe, domain, evo)}" + else serdeConvert(ref) + case (old: TypeRef.Scalar, TypeRef.Constructor(newId, args)) => + val inner = transfer(old, args.head, ref) + newId match { + case TypeId.Builtins.opt => q"Some($inner)" + case TypeId.Builtins.lst => q"vec![$inner]" + case TypeId.Builtins.set => q"std::collections::BTreeSet::from([$inner])" + case _ => serdeConvert(ref) + } + case (TypeRef.Constructor(oldId, oldArgs), TypeRef.Constructor(newId, newArgs)) => + (oldId, newId) match { + case (TypeId.Builtins.map, TypeId.Builtins.map) => + val key = transfer(oldArgs.head, newArgs.head, q"k") + val value = transfer(oldArgs.last, newArgs.last, q"v") + q"($ref).iter().map(|(k, v)| ($key, $value)).collect()" + case (TypeId.Builtins.opt, TypeId.Builtins.opt) => + val inner = transfer(oldArgs.head, newArgs.head, q"e") + q"($ref).as_ref().map(|e| $inner)" + case (_, TypeId.Builtins.lst | TypeId.Builtins.set) => + val inner = transfer(oldArgs.head, newArgs.head, q"e") + q"($ref).iter().map(|e| $inner).collect()" + case _ => serdeConvert(ref) + } + case _ if oldTpe == newTpe => q"(*($ref)).clone()" + case _ => serdeConvert(ref) + } + } + def makeConvs: Out[List[RsRenderedConversion]] = { val targetedConversions = rules.conversions.collect { case tc: TargetedConversion => tc } F.flatTraverseAccumErrors(targetedConversions) { conv => - val fnName = toSnakeCase(makeName("convert", conv)) - val fname = (Seq("from", srcVer.v.toString.replace('.', '_')) ++ conv.sourceTpe.owner.asPseudoPkg.map(s => escapeRustModuleName(s.toLowerCase)) ++ Seq( - s"${toSnakeCaseFileName(conv.sourceTpe.name.name)}.rs" - )).mkString("_") + val fnName = conversionName(conv) + val fname = s"${conversionFile(conv)}.rs" val tin = trans.asRsType(conv.sourceTpe, srcDom, evo).fullyQualified val tout = trans.asRsType(conv.targetTpe, domain, evo).fullyQualified @@ -81,8 +136,6 @@ class RsConversionTranslator[F[+_, +_]: Error2]( q"""// Custom conversion required: $tin -> $tout |// Implement this function manually: |// pub fn $fnName(from: &$tin) -> $tout { todo!() }""".stripMargin, - None, - Some(q"pub fn $fnName(from: &$tin) -> $tout;"), ) ) @@ -110,16 +163,15 @@ class RsConversionTranslator[F[+_, +_]: Error2]( q"""pub fn $fnName(from: &$tin) -> $tout { | $mappedExpr |}""".stripMargin, - Some(q"$fnName"), - None, ) ) case c: Conversion.CopyAdtBranchByName => val cases = c.oldDefn.dataMembers(srcDom).map { oldId => - val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) - q"""$tin::${escapeRustTypeName(oldId.name.name.capitalize)}(x) => $tout::${escapeRustTypeName(newId.name.name.capitalize)}(serde_json::from_value(serde_json::to_value(x).unwrap()).unwrap()),""" + val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) + val converted = transfer(TypeRef.Scalar(oldId), TypeRef.Scalar(newId), q"x") + q"""$tin::${escapeRustTypeName(oldId.name.name.capitalize)}(x) => $tout::${escapeRustTypeName(newId.name.name.capitalize)}($converted),""" } List( RsRenderedConversion( @@ -129,8 +181,6 @@ class RsConversionTranslator[F[+_, +_]: Error2]( | ${cases.toList.joinN().shift(8).trim} | } |}""".stripMargin, - Some(q"$fnName"), - None, ) ) @@ -155,11 +205,7 @@ class RsConversionTranslator[F[+_, +_]: Error2]( val fld = toSnakeCase(f.name.name) val expr = op match { case _: FieldOp.Transfer => - if (hasUserType(f.tpe)) { - serdeConvert(q"from.$fld") - } else { - q"from.$fld.clone()" - } + transferField(f.name, f.tpe, f.tpe) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, _) => @@ -182,28 +228,12 @@ class RsConversionTranslator[F[+_, +_]: Error2]( throw new IllegalStateException("BUG: `any` field has no schema-agnostic default; evolution rules should reject InitializeWithDefault on Any") case _ => throw new IllegalStateException("Unsupported target field type") } - case _: FieldOp.WrapIntoCollection => - val innerExpr = if (hasUserType(f.tpe)) serdeConvert(q"from.$fld") else q"from.$fld.clone()" - f.tpe match { - case TypeRef.Constructor(TypeId.Builtins.opt, _) => q"Some($innerExpr)" - case TypeRef.Constructor(TypeId.Builtins.set, _) => q"std::collections::BTreeSet::from([$innerExpr])" - case TypeRef.Constructor(TypeId.Builtins.lst, _) => q"vec![$innerExpr]" - case _ => q"vec![$innerExpr]" - } - case _: FieldOp.ExpandPrecision => - serdeConvert(q"from.$fld") - case _: FieldOp.SwapCollectionType => - serdeConvert(q"from.$fld") + case o: FieldOp.Modify => + transferField(o.fieldName, o.oldTpe, o.newTpe) case o: FieldOp.Rename => - val srcFld = toSnakeCase(o.sourceFieldName.name) - if (hasUserType(f.tpe)) { - serdeConvert(q"from.$srcFld") - } else { - q"from.$srcFld.clone()" - } + transferField(o.sourceFieldName, f.tpe, f.tpe) case o: FieldOp.Redef => - val srcFld = toSnakeCase(o.sourceFieldName.name) - serdeConvert(q"from.$srcFld") + transferField(o.sourceFieldName, o.modify.oldTpe, o.modify.newTpe) } q"$fld: $expr," } @@ -216,17 +246,11 @@ class RsConversionTranslator[F[+_, +_]: Error2]( | ${assigns.joinN().shift(8).trim} | } |}""".stripMargin, - Some(q"$fnName"), - None, ) ) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RsRenderedConversion]] } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index cbc6b1d5e..faec32b01 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.rust import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.RsTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.rust.RsValue.RsType import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} @@ -43,17 +44,14 @@ object RsDefnTranslator { enquiries: BaboonEnquiries, wiringTranslator: RsServiceWiringTranslator, rsDomainTreeTools: RsDomainTreeTools, - rsTypes: RsTypes, ) extends RsDefnTranslator[F] { - private val baboonIdReprCursor: RsValue.RsType = rsTypes.baboonIdReprCursor - /** Prepend `///` doc lines before a tree when `docs` is non-empty. * Returns the tree unchanged when `docs` is empty. */ private def prependDocs(docs: Docs, tree: TextTree[RsValue]): TextTree[RsValue] = { val block = rsTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } override def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { @@ -351,44 +349,6 @@ object RsDefnTranslator { // - free function `pub fn parse_repr(s: &str) -> Result` // (NOT `impl FromStr` — Q-FU-4 keeps `.parse()` undiscoverable) // - `Result` (not `Either`) - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def signedTypeName(tpe: TypeRef): String = { tpe match { case TypeRef.Scalar(TypeId.Builtins.i08) => "i08" @@ -452,10 +412,10 @@ object RsDefnTranslator { // Rust's primitive Display for signed integers produces canonical signed // decimal; for u08/u16/u32 it produces unsigned decimal natively (Rust // u-types are actually unsigned). - case IdentifierFieldKind.SignedInt => q"self.$rsFieldName.to_string()" - case IdentifierFieldKind.UnsignedSmallInt => q"self.$rsFieldName.to_string()" - case IdentifierFieldKind.UnsignedLong => q"crate::baboon_identifier_repr::u64_to_string(self.$rsFieldName)" - case IdentifierFieldKind.Str => q"crate::baboon_identifier_repr::escape_str(&self.$rsFieldName)" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => q"self.$rsFieldName.to_string()" + case IdentifierFieldKind.UnsignedSmallInt => q"self.$rsFieldName.to_string()" + case IdentifierFieldKind.UnsignedLong => q"crate::baboon_identifier_repr::u64_to_string(self.$rsFieldName)" + case IdentifierFieldKind.Str => q"crate::baboon_identifier_repr::escape_str(&self.$rsFieldName)" // uuid::Uuid Display is the lowercase 36-char hyphenated form per RFC 4122. case IdentifierFieldKind.Uid => q"self.$rsFieldName.to_string()" case IdentifierFieldKind.Tsu => q"crate::baboon_identifier_repr::tsu_to_string(&self.$rsFieldName)" @@ -477,7 +437,7 @@ object RsDefnTranslator { case (f, idx) => val srcFieldName = f.name.name val rsFieldName = toSnakeCase(srcFieldName) - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valExpr = renderFieldValueExpr(rsFieldName, kind) val sep = if (idx == 0) q"" else q"""out.push(':');""" List( @@ -509,7 +469,7 @@ object RsDefnTranslator { val valVar = s"${toSnakeCase(srcFieldName)}_v" val rawVar = s"${toSnakeCase(srcFieldName)}_raw" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asRsRef(f.tpe, domain, evo) val parseHead = @@ -519,7 +479,7 @@ object RsDefnTranslator { case IdentifierFieldKind.Bit => q"""let $rawVar = cursor.read_until_structural(); |let $valVar: $tpe = crate::baboon_identifier_repr::parse_bit(&$rawVar)?;""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val typeName = signedTypeName(f.tpe) val rangeCheck = signedRangeCheck(f.tpe, "v") val narrow = signedNarrowFn(f.tpe) @@ -656,12 +616,11 @@ object RsDefnTranslator { private def makeDtoRepr(dto: Typedef.Dto, name: RsType): TextTree[RsValue] = { val fields = dto.fields.map { f => - val rawT = trans.asRsRef(f.tpe, domain, evo) - val t = if (needsBox(f.tpe)) q"Box<$rawT>" else rawT + val t = representation.field(f.tpe).stored val serdeAttrs = fieldSerdeAttributes(f) val attrLine = if (serdeAttrs.nonEmpty) serdeAttrs.joinN() else q"" - val fieldEx = q"""$attrLine - |pub ${toSnakeCase(f.name.name)}: $t,""".stripMargin.trim + val fieldEx = q"""$attrLine + |pub ${toSnakeCase(f.name.name)}: $t,""".stripMargin.trim prependDocs(f.docs, fieldEx) } val fieldsList = if (fields.nonEmpty) fields.joinN() else q"" @@ -675,8 +634,7 @@ object RsDefnTranslator { val innerFields = dto.fields.map { f => - val rawT = trans.asRsRef(f.tpe, domain, evo) - val t = if (needsBox(f.tpe)) q"Box<$rawT>" else rawT + val t = representation.field(f.tpe).stored val serdeAttrs = fieldSerdeAttributes(f) val attrLine = if (serdeAttrs.nonEmpty) serdeAttrs.joinN() else q"" q"""$attrLine @@ -810,8 +768,8 @@ object RsDefnTranslator { // body uses `self.field.as_ref()` which returns `&rawT` via Deref — exactly // what the trait signature expects. Declaring `&Box` caused E0053 // (return-type mismatch between the trait signature and the impl body). - val retT = rawT - val body = if (needsBox(f.tpe)) q"self.$rsName.as_ref()" else q"&self.$rsName" + val retT = rawT + val body = if (needsBox(f.tpe)) q"self.$rsName.as_ref()" else q"&self.$rsName" q"fn $rsName(&self) -> &$retT { $body }" } val body = if (methods.nonEmpty) methods.joinN() else q"" @@ -1006,9 +964,9 @@ object RsDefnTranslator { else "serde::Serialize, serde::Deserialize" val cmpDerives = - if (hasWrappedFlt) "PartialEq, PartialOrd, " + if (hasUnorderable) "PartialEq, " + else if (hasWrappedFlt) "PartialEq, PartialOrd, " else if (hasBareFloat) "" // manual PartialEq/Eq/PartialOrd/Ord via dtoOrdImpls - else if (hasUnorderable) "PartialEq, " else "PartialEq, Eq, PartialOrd, Ord, " q"#[derive(Clone, Debug, $cmpDerives$serdeDerives)]" @@ -1017,7 +975,7 @@ object RsDefnTranslator { private def dtoOrdImpls(dto: Typedef.Dto, name: RsType): TextTree[RsValue] = { val hasBareFloat = dto.fields.exists(f => isBareFloat(f.tpe)) val hasWrappedFlt = dto.fields.exists(f => hasFloatRecursive(f.tpe) && !isBareFloat(f.tpe)) - if (hasWrappedFlt) { + if (hasWrappedFlt || dto.fields.exists(f => hasAnyField(f.tpe))) { // BAB-R04: wrapped-float field types (Option, Vec, …) cannot have manual // total_cmp synthesized — `total_cmp` is a method of `f64` only. Rely on derive(PartialEq, // PartialOrd) (see dtoDerives); no manual Eq/Ord — they are unsound for NaN regardless. @@ -1064,80 +1022,13 @@ object RsDefnTranslator { } else q"" } - /** True iff `tpe` is exactly the scalar `f32` or `f64` (no wrapping). */ - private def isBareFloat(tpe: TypeRef): Boolean = tpe match { - case TypeRef.Scalar(TypeId.Builtins.f32) => true - case TypeRef.Scalar(TypeId.Builtins.f64) => true - case _ => false - } - - /** True iff `tpe` reaches an `f32`/`f64` either directly, through a generic - * constructor (`opt`, `lst`, `set`, `map`), or transitively through a referenced - * user `data`/`adt` typedef. Enums and foreigns are never float-bearing. - * - * Transitive propagation is required because Rust's `derive(Eq, Ord)` needs the - * field types to themselves implement `Eq, Ord`. Per BAB-R04 a float-bearing struct - * derives only `PartialEq, PartialOrd`; any struct that contains it as a field - * (directly, in a collection, or inside another wrapping struct) inherits the - * same constraint and must also drop `Eq, Ord`. - * - * Cycle protection: typedefs may contain self-references (recursive ADTs); we - * track visited user ids to terminate. - */ - private def hasFloatRecursive(tpe: TypeRef): Boolean = - hasFloatRecursive0(tpe, Set.empty) - - private def hasFloatRecursive0(tpe: TypeRef, seen: Set[TypeId.User]): Boolean = { - tpe match { - case TypeRef.Scalar(TypeId.Builtins.f32) => true - case TypeRef.Scalar(TypeId.Builtins.f64) => true - case TypeRef.Scalar(u: TypeId.User) => - if (seen.contains(u)) false - else { - domain.defs.meta.nodes.get(u) match { - case Some(DomainMember.User(_, defn, _, _)) => - userDefnHasFloat(defn, seen + u) - case _ => false - } - } - case TypeRef.Constructor(_, args) => args.exists(hasFloatRecursive0(_, seen)) - case _ => false - } - } - - private def userDefnHasFloat(defn: Typedef, seen: Set[TypeId.User]): Boolean = defn match { - case dto: Typedef.Dto => - dto.fields.exists(f => hasFloatRecursive0(f.tpe, seen)) - case adt: Typedef.Adt => - adt.dataMembers(domain).exists { - mid => - domain.defs.meta.nodes.get(mid) match { - case Some(DomainMember.User(_, branchDefn, _, _)) => userDefnHasFloat(branchDefn, seen + mid) - case _ => false - } - } - case _ => false // enums, foreigns, contracts, services, aliases — never float-bearing here - } + private def isBareFloat(tpe: TypeRef): Boolean = representation.isBareFloat(tpe) + private def hasFloatRecursive(tpe: TypeRef): Boolean = representation.containsFloat(tpe) + private def hasAnyField(tpe: TypeRef): Boolean = representation.containsAny(tpe) - private def hasAnyField(tpe: TypeRef): Boolean = { - tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAnyField) - } - } + private val representation = new RsFieldRepresentation(domain, evo, trans, enquiries) - private def needsBox(tpe: TypeRef): Boolean = { - tpe match { - case TypeRef.Scalar(u: TypeId.User) => - domain.defs.meta.nodes.get(u).exists { - case m: DomainMember.User => enquiries.isRecursiveTypedef(m, domain) - case _ => false - } - case TypeRef.Constructor(_, args) => args.exists(needsBox) - case _ => false - } - } + private def needsBox(tpe: TypeRef): Boolean = representation.needsBox(tpe) private def fieldSerdeAttributes(f: Field): List[TextTree[RsValue]] = { val attrs = scala.collection.mutable.ListBuffer.empty[TextTree[RsValue]] @@ -1458,13 +1349,14 @@ object RsDefnTranslator { } } val adtCmpDerives = - if (adtBranchHasWrappedFloat) "PartialEq, PartialOrd" + if (representation.containsAny(TypeRef.Scalar(adt.id))) "PartialEq" + else if (adtBranchHasWrappedFloat) "PartialEq, PartialOrd" else "PartialEq, Eq, PartialOrd, Ord" q"""${branchStructs.toList.joinNN()} | |${branchCodecs.toList.joinNN()} | - |${adtDocBlock}#[derive(Clone, Debug, $adtCmpDerives)] + |$adtDocBlock#[derive(Clone, Debug, $adtCmpDerives)] |pub enum ${name.asName} { | ${variants.toList.joinN().shift(4).trim} |} @@ -1507,7 +1399,7 @@ object RsDefnTranslator { val contract = defn.defn.asInstanceOf[Typedef.Contract] val methods = contract.fields.map { f => - val t = trans.asRsRef(f.tpe, domain, evo) + val t = trans.asRsRef(f.tpe, domain, evo) val methodEx = q"fn ${toSnakeCase(f.name.name)}(&self) -> &$t;" prependDocs(f.docs, methodEx) } @@ -1646,10 +1538,10 @@ object RsDefnTranslator { */ def escapeRustModuleName(s: String): String = { s match { - case "in" => "input" + case "in" => "input" case kw if nonRawEscapable.contains(kw) => s"${kw}_" - case kw if isRustKeyword(kw) => s"${kw}_" - case other => other + case kw if isRustKeyword(kw) => s"${kw}_" + case other => other } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsFieldRepresentation.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsFieldRepresentation.scala new file mode 100644 index 000000000..44a8d4431 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsFieldRepresentation.scala @@ -0,0 +1,70 @@ +package io.septimalmind.baboon.translator.rust + +import io.septimalmind.baboon.typer.BaboonEnquiries +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +final class RsFieldRepresentation(domain: Domain, evo: BaboonEvolution, trans: RsTypeTranslator, enquiries: BaboonEnquiries) { + private val recursiveTypes = domain.defs.meta.nodes.valuesIterator.collect { + case m: DomainMember.User if enquiries.isRecursiveTypedef(m, domain) => m.id + }.toSet + + private val references = domain.defs.meta.nodes.valuesIterator.collect { + case m: DomainMember.User => + val refs = m.defn match { + case dto: Typedef.Dto => dto.fields.map(_.tpe) + case adt: Typedef.Adt => adt.dataMembers(domain).toList.map(TypeRef.Scalar.apply) + case _ => Nil + } + m.id -> refs + }.toMap + + private def reaches(seed: TypeRef => Boolean): Set[TypeId.User] = { + var result = Set.empty[TypeId.User] + var changed = true + while (changed) { + val next = result ++ references.iterator.collect { + case (id, refs) if refs.exists(ref => reachesRef(ref, result, seed)) => id + } + changed = next != result + result = next + } + result + } + + private def reachesRef(ref: TypeRef, marked: Set[TypeId.User], seed: TypeRef => Boolean): Boolean = { + seed(ref) || (ref match { + case TypeRef.Scalar(id: TypeId.User) => marked.contains(id) + case TypeRef.Constructor(_, args) => args.exists(reachesRef(_, marked, seed)) + case _ => false + }) + } + + def isBareFloat(ref: TypeRef): Boolean = ref match { + case TypeRef.Scalar(TypeId.Builtins.f32 | TypeId.Builtins.f64) => true + case _ => false + } + + private val floatTypes = reaches(isBareFloat) + private val anyTypes = reaches(_.isInstanceOf[TypeRef.Any]) + + def containsFloat(ref: TypeRef): Boolean = reachesRef(ref, floatTypes, isBareFloat) + def containsAny(ref: TypeRef): Boolean = reachesRef(ref, anyTypes, _.isInstanceOf[TypeRef.Any]) + + def needsBox(tpe: TypeRef): Boolean = tpe match { + case TypeRef.Scalar(id: TypeId.User) => recursiveTypes.contains(id) + case TypeRef.Constructor(_, args) => args.exists(needsBox) + case _ => false + } + + def field(tpe: TypeRef): RsFieldRepresentation.Field = { + val surface = trans.asRsRef(tpe, domain, evo) + val indirect = needsBox(tpe) + RsFieldRepresentation.Field(surface, if (indirect) q"Box<$surface>" else surface, indirect) + } +} + +object RsFieldRepresentation { + final case class Field(surface: TextTree[RsValue], stored: TextTree[RsValue], indirect: Boolean) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMcpServerGenerator.scala index 8040e3944..e76cd70fe 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMcpServerGenerator.scala @@ -50,9 +50,10 @@ class RsMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } // The runtime file is emitted unconditionally (D40/T180): even a family with @@ -87,8 +88,8 @@ class RsMcpServerGenerator[F[+_, +_]: Error2]( if (swapped == resource) throw new IllegalStateException( "RsMcpServerGenerator: failed to locate the sync McpJsonInvoke alias in " + - "baboon-runtime/rust/baboon_mcp_server.rs for the async swap. The resource " + - "alias text drifted from RsMcpServerGenerator.syncInvokeAlias.", + "baboon-runtime/rust/baboon_mcp_server.rs for the async swap. The resource " + + "alias text drifted from RsMcpServerGenerator.syncInvokeAlias." ) swapped } @@ -125,21 +126,22 @@ class RsMcpServerGenerator[F[+_, +_]: Error2]( } } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) - val serviceName = svc.id.name.name - val snakeName = toSnakeCaseRaw(serviceName) - val structName = s"${serviceName}McpServer" - val modelVer = domain.version.v.toString - val modelId = domain.id.path.mkString(".") + val serviceName = svc.id.name.name + val snakeName = toSnakeCaseRaw(serviceName) + val structName = s"${serviceName}McpServer" + val modelVer = domain.version.v.toString + val modelId = domain.id.path.mkString(".") // Declaration-ordered tool entries (K4 §2.3): one per method. // The schema is carried as a parsed serde_json::Value constant. val toolEntries: List[String] = svc.methods.toList.map { m => - val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val toolName = s"${serviceName}_${m.name.name}" + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) val schemaLiteral = rustJsonLiteral(schema) val descriptionLiteral = McpDocs.flatten(m.docs) match { case Some(text) => s"Some(${jsonString(text)})" @@ -235,7 +237,7 @@ class RsMcpServerGenerator[F[+_, +_]: Error2]( |// Transport-abstract: `handle` delegates to BaboonMcpServerBase and |// performs no I/O. The `invoke_json` closure routes `tools/call` into the |// generated service dispatch; the integrator supplies it (typically the - |// errors-mode `invoke_json_${snakeName}` bound to this service) plus the + |// errors-mode `invoke_json_$snakeName` bound to this service) plus the |// per-request `Ctx`. |$blockOnHelper |pub struct $structName { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala index bd72ac173..b2d97afb7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala @@ -87,8 +87,14 @@ object RsServiceWiringTranslator { q"""pub trait IBaboonServiceRt { | fn pure(&self, value: R) -> ${ct("L", "R")}; | fn fail(&self, error: L) -> ${ct("L", "R")}; - | async fn left_map, F: FnOnce(A) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct("C", "B")}; - | async fn flat_map, F: FnOnce(B) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct("A", "C")}; + | async fn left_map, F: FnOnce(A) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct( + "C", + "B", + )}; + | async fn flat_map, F: FnOnce(B) -> Fut>(&self, value: ${ct( + "A", + "B", + )}, f: F) -> ${ct("A", "C")}; |}""".stripMargin } else { q"""pub trait IBaboonServiceRt { @@ -109,13 +115,19 @@ object RsServiceWiringTranslator { |impl IBaboonServiceRt for BaboonServiceRtDefault { | fn pure(&self, value: R) -> ${ct("L", "R")} { Ok(value) } | fn fail(&self, error: L) -> ${ct("L", "R")} { Err(error) } - | async fn left_map, F: FnOnce(A) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct("C", "B")} { + | async fn left_map, F: FnOnce(A) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct( + "C", + "B", + )} { | match value { | Err(a) => Err(f(a).await), | Ok(b) => Ok(b), | } | } - | async fn flat_map, F: FnOnce(B) -> Fut>(&self, value: ${ct("A", "B")}, f: F) -> ${ct("A", "C")} { + | async fn flat_map, F: FnOnce(B) -> Fut>(&self, value: ${ct( + "A", + "B", + )}, f: F) -> ${ct("A", "C")} { | match value { | Err(a) => Err(a), | Ok(b) => f(b).await, @@ -198,7 +210,7 @@ object RsServiceWiringTranslator { q"""pub ${asyncKw}fn ${toSnakeCase(m.name.name)}(&self, ${ctxParamDecl}arg: $inFq) -> Result<$outFq, Box> { | let mut buf = Vec::new(); | $baboonBinEncode::encode_ueba(&arg, &self.$clientCodecField, &mut buf)?; - | let resp = (self.transport_ueba)(${transportCtxArg}"$svcName", "${m.name.name}", &buf)$awaitSuffix$transportErrMap?; + | let resp = (self.transport_ueba)($transportCtxArg"$svcName", "${m.name.name}", &buf)$awaitSuffix$transportErrMap?; | ${decodeResult.shift(4).trim} |}""".stripMargin ) @@ -214,7 +226,7 @@ object RsServiceWiringTranslator { Some( q"""pub ${asyncKw}fn ${toSnakeCase(m.name.name)}_json(&self, ${ctxParamDecl}arg: $inFq) -> Result<$outFq, Box> { | let encoded = serde_json::to_string(&arg)?; - | let resp = (self.transport_json)(${transportCtxArg}"$svcName", "${m.name.name}", &encoded)$awaitSuffix$transportErrMap?; + | let resp = (self.transport_json)($transportCtxArg"$svcName", "${m.name.name}", &encoded)$awaitSuffix$transportErrMap?; | ${decodeResult.shift(4).trim} |}""".stripMargin ) @@ -241,10 +253,10 @@ object RsServiceWiringTranslator { if (hasUeba) { if (isAsync) { typeParams += "TU" - whereClauses += s"TU: Fn(${transportCtxType}&str, &str, &[u8]) -> std::pin::Pin, Box>> + Send>> + Send + Sync" + whereClauses += s"TU: Fn($transportCtxType&str, &str, &[u8]) -> std::pin::Pin, Box>> + Send>> + Send + Sync" } else { typeParams += "TU" - whereClauses += s"TU: Fn(${transportCtxType}&str, &str, &[u8]) -> Result, Box>" + whereClauses += s"TU: Fn($transportCtxType&str, &str, &[u8]) -> Result, Box>" } fields += q"transport_ueba: TU," ctorParams += q"transport_ueba: TU" @@ -253,10 +265,10 @@ object RsServiceWiringTranslator { if (hasJson) { if (isAsync) { typeParams += "TJ" - whereClauses += s"TJ: Fn(${transportCtxType}&str, &str, &str) -> std::pin::Pin>> + Send>> + Send + Sync" + whereClauses += s"TJ: Fn($transportCtxType&str, &str, &str) -> std::pin::Pin>> + Send>> + Send + Sync" } else { typeParams += "TJ" - whereClauses += s"TJ: Fn(${transportCtxType}&str, &str, &str) -> Result>" + whereClauses += s"TJ: Fn($transportCtxType&str, &str, &str) -> Result>" } fields += q"transport_json: TJ," ctorParams += q"transport_json: TJ" @@ -274,10 +286,11 @@ object RsServiceWiringTranslator { // variance-neutral `PhantomData Ctx>` marker is added to // satisfy Rust's E0392 (unused type parameter) — it stays Send+Sync // and Clone regardless of `Ctx`. - absCtxTypeParam.foreach { tp => - typeParams += tp - fields += q"_ctx_marker: std::marker::PhantomData $tp>," - ctorAssigns += q"_ctx_marker: std::marker::PhantomData," + absCtxTypeParam.foreach { + tp => + typeParams += tp + fields += q"_ctx_marker: std::marker::PhantomData $tp>," + ctorAssigns += q"_ctx_marker: std::marker::PhantomData," } val typeParamStr = typeParams.mkString(", ") @@ -382,7 +395,7 @@ object RsServiceWiringTranslator { // sync emission byte-identical. private val combinatorClosureOpen: String = if (isAsync) "async move {" else "{" private val combinatorClosureClose: String = "}" - private val combinatorAwait: String = if (isAsync) ".await" else "" + private val combinatorAwait: String = if (isAsync) ".await" else "" // The ASYNC client transport closures return // `Result<_, Box>` (the future must be @@ -472,9 +485,10 @@ object RsServiceWiringTranslator { private def generateOneWrapper(spec: WrapperSpec): TextTree[RsValue] = { val wrapperName = s"${spec.svcName}${if (spec.isJson) "JsonService" else "UebaService"}" val wireTypeStr = if (spec.isJson) "&str" else "&[u8]" - val invokerFn = if (spec.isJson) s"invoke_json_${toSnakeCase(spec.svcName)}" - else s"invoke_ueba_${toSnakeCase(spec.svcName)}" - val retTypeStr = wrapperRetType(spec.isJson) + val invokerFn = + if (spec.isJson) s"invoke_json_${toSnakeCase(spec.svcName)}" + else s"invoke_ueba_${toSnakeCase(spec.svcName)}" + val retTypeStr = wrapperRetType(spec.isJson) // `none` mode keeps the historical context-free trait (`IBaboon*Service`), // baking any dependency at construction; the service context (when active) @@ -509,13 +523,14 @@ object RsServiceWiringTranslator { // must be bound on `Clone` (E0599 `no method named 'clone'` otherwise). // SYNC mode forwards `&self.impl_`/`&self.rt` by reference, so no `Clone` // bound is added — keeping the sync bounds byte-identical. - val sendSync = if (isAsync) " + Clone + Send + Sync + 'static" else " + 'static" - val implBound: TextTree[RsValue] = q"Impl: $svcTraitRef$sendSync" - val rtBound: Option[TextTree[RsValue]] = + val sendSync = if (isAsync) " + Clone + Send + Sync + 'static" else " + 'static" + val implBound: TextTree[RsValue] = q"Impl: $svcTraitRef$sendSync" + val rtBound: Option[TextTree[RsValue]] = if (resolved.noErrors) None else Some(q"Rt: $ibaboonServiceRt$sendSync") - val absCtxBound: Option[TextTree[RsValue]] = absCtxTypeParam.map { p => - val tail = if (isAsync) ": Clone + Send + Sync + 'static" else ": Clone + 'static" - q"$p$tail" + val absCtxBound: Option[TextTree[RsValue]] = absCtxTypeParam.map { + p => + val tail = if (isAsync) ": Clone + Send + Sync + 'static" else ": Clone + 'static" + q"$p$tail" } val whereLines: List[TextTree[RsValue]] = @@ -551,26 +566,27 @@ object RsServiceWiringTranslator { val ctxCtorAssignOpt: Option[String] = storedCtxField.map { case (pn, _) => s"$pn," } val markerCtorAssignOpt: Option[String] = if (ctxActive && absCtxTypeParam.isDefined) Some("_ctx_marker: std::marker::PhantomData,") else None - val ctorAssignsList: List[String] = "impl_," :: rtCtorAssignOpt.toList ++ ctxCtorAssignOpt.toList ++ markerCtorAssignOpt.toList - val ctorAssigns: String = ctorAssignsList.mkString(" ") + val ctorAssignsList: List[String] = "impl_," :: rtCtorAssignOpt.toList ++ ctxCtorAssignOpt.toList ++ markerCtorAssignOpt.toList + val ctorAssigns: String = ctorAssignsList.mkString(" ") // ---- invoke() signature + body ---- // Context-active adds a per-invoke `ctx: ` param and renames the // codec ctx to `codec_ctx`. Context-free keeps the historical signature // (`invoke(&self, method, data, ctx: &BaboonCodecContext)`), byte-identical. - val codecCtxParam: String = if (ctxActive) "codec_ctx" else "ctx" + val codecCtxParam: String = if (ctxActive) "codec_ctx" else "ctx" val invokeCtxParamDecl: String = svcCtxTypeName.fold("")(tn => s"ctx: $tn, ") val body: TextTree[RsValue] = if (isAsync) { // Async path: clone everything the future captures into owned // bindings, then return a `Pin>`. Requires // Impl/Rt/SvcCtx: Clone + Send + Sync + 'static. - val cloneRt = if (resolved.noErrors) "" else "let rt_clone = self.rt.clone();\n" + val cloneRt = if (resolved.noErrors) "" else "let rt_clone = self.rt.clone();\n" val cloneStoredCtx = storedCtxField.fold("")(f => s"let svc_ctx_clone = self.${f._1}.clone();\n") val cloneInvokeCtx = if (ctxActive) "let svc_ctx_clone = ctx.clone();\n" else "" - val dataOwn = if (spec.isJson) "let data_owned: String = data.to_string();" - else "let data_owned: Vec = data.to_vec();" - val dataRef = if (spec.isJson) "data_owned.as_str()" else "data_owned.as_slice()" + val dataOwn = + if (spec.isJson) "let data_owned: String = data.to_string();" + else "let data_owned: Vec = data.to_vec();" + val dataRef = if (spec.isJson) "data_owned.as_str()" else "data_owned.as_slice()" val futArgs: List[String] = { val base = List("&method_owned", dataRef, "&impl_clone") @@ -588,8 +604,8 @@ object RsServiceWiringTranslator { |})""".stripMargin } else { val syncArgs: List[String] = { - val base = List("method", "data", "&self.impl_") - val withRt = if (resolved.noErrors) base else base :+ "&self.rt" + val base = List("method", "data", "&self.impl_") + val withRt = if (resolved.noErrors) base else base :+ "&self.rt" val withCtx = if (ctxActive) withRt :+ "ctx" else storedCtxField.fold(withRt)(f => withRt :+ s"self.${f._1}.clone()") @@ -619,7 +635,7 @@ object RsServiceWiringTranslator { |${whereBlock.shift(0).trim} |{ | fn service_name(&self) -> &str { "${spec.svcName}" } - | fn invoke(&self, method: &$baboonMethodId, data: $wireTypeStr, ${invokeCtxParamDecl}$codecCtxParam: &$baboonCodecContext) -> $retTypeStr { + | fn invoke(&self, method: &$baboonMethodId, data: $wireTypeStr, $invokeCtxParamDecl$codecCtxParam: &$baboonCodecContext) -> $retTypeStr { | ${body.shift(8).trim} | } |}""".stripMargin @@ -686,7 +702,7 @@ object RsServiceWiringTranslator { | method: &$baboonMethodId, | data: &str, | ${implParam(svcType)}, - | ${ctxParamDecl}${codecCtxName(isJson = true)}: &$baboonCodecContext, + | $ctxParamDecl${codecCtxName(isJson = true)}: &$baboonCodecContext, |) -> Result { | match method.method_name.as_str() { | ${cases.shift(8).trim} @@ -704,7 +720,9 @@ object RsServiceWiringTranslator { case Some(_) => q"""let result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}decoded)$awaitSuffix; |let mut out_buf = Vec::new(); - |$baboonBinEncode::encode_ueba(&result, ${codecCtxName(isJson = false)}, &mut out_buf).map_err(|e| $baboonWiringError::EncoderFailed(method.clone(), Box::new(e)))?; + |$baboonBinEncode::encode_ueba(&result, ${codecCtxName(isJson = + false + )}, &mut out_buf).map_err(|e| $baboonWiringError::EncoderFailed(method.clone(), Box::new(e)))?; |Ok(out_buf)""".stripMargin case None => q"""impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}decoded)$awaitSuffix; @@ -713,7 +731,9 @@ object RsServiceWiringTranslator { q""""${m.name.name}" => { | let mut cursor = std::io::Cursor::new(data); - | let decoded: $inFq = $baboonBinDecode::decode_ueba(${codecCtxName(isJson = false)}, &mut cursor).map_err(|e| $baboonWiringError::DecoderFailed(method.clone(), e))?; + | let decoded: $inFq = $baboonBinDecode::decode_ueba(${codecCtxName(isJson = + false + )}, &mut cursor).map_err(|e| $baboonWiringError::DecoderFailed(method.clone(), e))?; | ${encodeAndReturn.shift(4).trim} |}""".stripMargin }.join("\n") @@ -722,7 +742,7 @@ object RsServiceWiringTranslator { | method: &$baboonMethodId, | data: &[u8], | ${implParam(svcType)}, - | ${ctxParamDecl}${codecCtxName(isJson = false)}: &$baboonCodecContext, + | $ctxParamDecl${codecCtxName(isJson = false)}: &$baboonCodecContext, |) -> Result, $baboonWiringError> { | match method.method_name.as_str() { | ${cases.shift(8).trim} @@ -767,13 +787,41 @@ object RsServiceWiringTranslator { (Seq(jsonFn, uebaFn).flatten :+ wrappers).joinNN() } + private def renderErrorsCall(m: Typedef.MethodDef, emptyOutput: TextTree[RsValue]): TextTree[RsValue] = { + val lmHint = leftMapHint(m) + val hasErrType = m.err.isDefined && !resolved.noErrors + if (isAsync) { + if (hasErrType) { + q"""let call_result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; + |rt.left_map$lmHint(call_result, |err| async move { $bweFq::CallFailed(method.clone(), Box::new(err)) }).await""".stripMargin + } else if (m.out.isDefined) { + q"""let result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; + |rt.pure$pureHint(result)""".stripMargin + } else { + q"""impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; + |rt.pure$pureHint($emptyOutput)""".stripMargin + } + } else { + val success = if (hasErrType) { + q"Ok(call_result) => rt.left_map$lmHint(call_result, |err| $bweFq::CallFailed(method.clone(), Box::new(err)))," + } else if (m.out.isDefined) { + q"Ok(result) => rt.pure$pureHint(result)," + } else { + q"Ok(_) => rt.pure$pureHint($emptyOutput)," + } + q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { + | $success + | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), + |}""".stripMargin + } + } + private def generateErrorsJsonFn(service: Typedef.Service, svcName: String, svcType: RsValue.RsType): TextTree[RsValue] = { val wiringRetType = ct(bweFq, "String") val cases = service.methods.map { m => - val inFq = inTypeFq(m) - val lmHint = leftMapHint(m) + val inFq = inTypeFq(m) val decodeStep = q"""let input: ${ct(bweFq, inFq)} = match serde_json::from_str::<$inFq>(data) { @@ -781,31 +829,9 @@ object RsServiceWiringTranslator { | Err(e) => rt.fail($bweFq::DecoderFailed(method.clone(), Box::new(e))), |};""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors - val callAndEncodeStep = m.out match { case Some(_) => - val callBody = if (isAsync) { - if (hasErrType) { - q"""let call_result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.left_map$lmHint(call_result, |err| async move { $bweFq::CallFailed(method.clone(), Box::new(err)) }).await""".stripMargin - } else { - q"""let result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.pure$pureHint(result)""".stripMargin - } - } else { - if (hasErrType) { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(call_result) => rt.left_map$lmHint(call_result, |err| $bweFq::CallFailed(method.clone(), Box::new(err))), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } else { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(result) => rt.pure$pureHint(result), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } - } + val callBody = renderErrorsCall(m, q""""null".to_string()""") q"""let output = rt.flat_map$flatMapHint(input, |v| $combinatorClosureOpen | ${callBody.shift(4).trim} @@ -818,27 +844,7 @@ object RsServiceWiringTranslator { |$combinatorClosureClose)$combinatorAwait""".stripMargin case None => - val callBody = if (isAsync) { - if (hasErrType) { - q"""let call_result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.left_map$lmHint(call_result, |err| async move { $bweFq::CallFailed(method.clone(), Box::new(err)) }).await""".stripMargin - } else { - q"""impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.pure$pureHint("null".to_string())""".stripMargin - } - } else { - if (hasErrType) { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(call_result) => rt.left_map$lmHint(call_result, |err| $bweFq::CallFailed(method.clone(), Box::new(err))), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } else { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(_) => rt.pure$pureHint("null".to_string()), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } - } + val callBody = renderErrorsCall(m, q""""null".to_string()""") q"""rt.flat_map$flatMapHint(input, |v| $combinatorClosureOpen | ${callBody.shift(4).trim} @@ -856,7 +862,7 @@ object RsServiceWiringTranslator { | data: &str, | ${implParam(svcType)}, | rt: &Rt, - | ${ctxParamDecl}${codecCtxName(isJson = true)}: &$baboonCodecContext, + | $ctxParamDecl${codecCtxName(isJson = true)}: &$baboonCodecContext, |) -> $wiringRetType { | match method.method_name.as_str() { | ${cases.shift(8).trim} @@ -870,8 +876,7 @@ object RsServiceWiringTranslator { val cases = service.methods.map { m => - val inFq = inTypeFq(m) - val lmHint = leftMapHint(m) + val inFq = inTypeFq(m) val decodeStep = q"""let input: ${ct(bweFq, inFq)} = { @@ -882,31 +887,9 @@ object RsServiceWiringTranslator { | } |};""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors - val callAndEncodeStep = m.out match { case Some(_) => - val callBody = if (isAsync) { - if (hasErrType) { - q"""let call_result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.left_map$lmHint(call_result, |err| async move { $bweFq::CallFailed(method.clone(), Box::new(err)) }).await""".stripMargin - } else { - q"""let result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.pure$pureHint(result)""".stripMargin - } - } else { - if (hasErrType) { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(call_result) => rt.left_map$lmHint(call_result, |err| $bweFq::CallFailed(method.clone(), Box::new(err))), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } else { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(result) => rt.pure$pureHint(result), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } - } + val callBody = renderErrorsCall(m, q"Vec::new()") q"""let output = rt.flat_map$flatMapHint(input, |v| $combinatorClosureOpen | ${callBody.shift(4).trim} @@ -920,27 +903,7 @@ object RsServiceWiringTranslator { |$combinatorClosureClose)$combinatorAwait""".stripMargin case None => - val callBody = if (isAsync) { - if (hasErrType) { - q"""let call_result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.left_map$lmHint(call_result, |err| async move { $bweFq::CallFailed(method.clone(), Box::new(err)) }).await""".stripMargin - } else { - q"""impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v).await; - |rt.pure$pureHint(Vec::new())""".stripMargin - } - } else { - if (hasErrType) { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(call_result) => rt.left_map$lmHint(call_result, |err| $bweFq::CallFailed(method.clone(), Box::new(err))), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } else { - q"""match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}v))) { - | Ok(_) => rt.pure$pureHint(Vec::new()), - | Err(panic) => rt.fail($bweFq::CallFailed(method.clone(), panic)), - |}""".stripMargin - } - } + val callBody = renderErrorsCall(m, q"Vec::new()") q"""rt.flat_map$flatMapHint(input, |v| $combinatorClosureOpen | ${callBody.shift(4).trim} @@ -958,7 +921,7 @@ object RsServiceWiringTranslator { | data: &[u8], | ${implParam(svcType)}, | rt: &Rt, - | ${ctxParamDecl}${codecCtxName(isJson = false)}: &$baboonCodecContext, + | $ctxParamDecl${codecCtxName(isJson = false)}: &$baboonCodecContext, |) -> $wiringRetType { | match method.method_name.as_str() { | ${cases.shift(8).trim} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala index c9857ff11..c5eaec193 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala @@ -27,152 +27,27 @@ class RsUEBACodecGenerator( case _: Typedef.Contract => None case _: Typedef.Service => None } - // Prepend per-codec any-field helpers (free module-level functions) when the DTO has any - // any-bearing field. Mirrors PR 3.2 (C# `EncodeAnyField`/`DecodeAnyField`) and PR 2.2 - // (Scala `encodeAnyField`/`decodeAnyField`). One emission per DTO file because the codec - // generator's output is concatenated into the DTO's `.rs` module. - body.map { - b => - if (hasAnyField(defn)) { - q"""$anyFieldHelpers - | - |$b""".stripMargin - } else b - } + body } else None } - // Deep walk (mirrors Scala's hasAnyField): a codec object needs the any-field helpers if any - // direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Per-codec free module-level helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths. Mirrors `ScUEBACodecGenerator.anyFieldHelpers` and - // `CSUEBACodecGenerator.anyFieldHelpers`. Wire layout (locked, see - // docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // length covers everything after itself; meta-length covers (kind + strings); blob runs the - // rest. The helpers wrap `BaboonCodecError` into `std::io::Error` for the encoder (which - // returns `std::io::Result<()>`) and into `Box` for the decoder - // (which already uses that error type). - private def anyFieldHelpers: TextTree[RsValue] = { - q"""fn encode_any_field( - | ctx: &crate::baboon_runtime::BaboonCodecContext, - | writer: &mut dyn std::io::Write, - | expected_kind: u8, - | static_domain: Option<&str>, - | static_version: Option<&str>, - | static_typeid: Option<&str>, - | value: &crate::any_opaque::AnyOpaque, - |) -> std::io::Result<()> { - | if value.meta().kind != expected_kind { - | return Err(std::io::Error::new( - | std::io::ErrorKind::InvalidData, - | format!( - | "any: meta-kind 0x{:02x} does not match field-declared 0x{:02x}", - | value.meta().kind, expected_kind - | ), - | )); - | } - | let any_blob: Vec = match value { - | crate::any_opaque::AnyOpaque::Ueba(u) => u.bytes.clone(), - | crate::any_opaque::AnyOpaque::Json(j) => { - | let f = ctx.facade().ok_or_else(|| std::io::Error::new( - | std::io::ErrorKind::InvalidData, - | "Cannot encode AnyOpaque::Json into UEBA without a facade reference. Construct the codec context via BaboonCodecContext::with_facade(use_indices, facade), or supply AnyOpaque::Ueba directly." - | ))?; - | f.json_to_ueba_bytes(&j.meta, &j.json, static_domain, static_version, static_typeid) - | .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)))? - | } - | }; - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | let mut any_meta_buf: Vec = Vec::new(); - | crate::any_opaque::any_meta_codec::write_bin(value.meta(), &mut any_meta_buf)?; - | let any_total_length: i32 = (4 + any_meta_buf.len() + any_blob.len()) as i32; - | crate::baboon_runtime::bin_tools::write_i32(writer, any_total_length)?; - | crate::baboon_runtime::bin_tools::write_i32(writer, any_meta_buf.len() as i32)?; - | writer.write_all(&any_meta_buf)?; - | writer.write_all(&any_blob)?; - | Ok(()) - |} - | - |fn decode_any_field( - | wire: &mut dyn std::io::Read, - | expected_kind: u8, - |) -> Result> { - | let any_total_length_i = crate::baboon_runtime::bin_tools::read_i32(wire)?; - | if any_total_length_i < 0 { - | return Err(format!( - | "any: negative total-length {}", any_total_length_i - | ).into()); - | } - | let any_total_length = any_total_length_i as usize; - | let any_meta_length_i = crate::baboon_runtime::bin_tools::read_i32(wire)?; - | if any_meta_length_i < 0 { - | return Err(format!( - | "any: negative meta-length {}", any_meta_length_i - | ).into()); - | } - | let any_meta_length = any_meta_length_i as usize; - | if any_total_length < 4 + any_meta_length { - | return Err(format!( - | "any: total-length {} smaller than 4 + meta-length {}", - | any_total_length, any_meta_length - | ).into()); - | } - | let (any_meta, any_bytes_read) = crate::any_opaque::any_meta_codec::read_bin_with_length(wire) - | .map_err(|e| -> Box { Box::new(e) })?; - | if any_bytes_read > any_meta_length { - | return Err(format!( - | "any: meta-bytes-read {} exceeded meta-length window {}", - | any_bytes_read, any_meta_length - | ).into()); - | } - | if any_bytes_read < any_meta_length { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | let mut any_skip = vec![0u8; any_meta_length - any_bytes_read]; - | wire.read_exact(&mut any_skip)?; - | } - | if any_meta.kind != expected_kind { - | return Err(format!( - | "any: wire kind 0x{:02x} does not match field-declared 0x{:02x}", - | any_meta.kind, expected_kind - | ).into()); - | } - | let any_blob_len = any_total_length - 4 - any_meta_length; - | let mut any_blob = vec![0u8; any_blob_len]; - | wire.read_exact(&mut any_blob)?; - | Ok(crate::any_opaque::AnyOpaqueUeba::new(any_meta, any_blob)) - |}""".stripMargin - } - - // Encode delegates to the per-DTO `encode_any_field` helper. This site wires the expected kind + // Encode delegates to the runtime `encode_any_field` helper. This site wires the expected kind // byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[RsValue], wref: TextTree[RsValue]): TextTree[RsValue] = { val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encode_any_field(ctx, $wref, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &$ref)?;" + q"crate::any_opaque::any_field_codec::encode_any_field(ctx, $wref, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &$ref)?;" } - // Decode delegates to the per-DTO `decode_any_field` helper, returning an `AnyOpaqueUeba` + // Decode delegates to the runtime `decode_any_field` helper, returning an `AnyOpaqueUeba` // wrapped into the `AnyOpaque::Ueba` enum variant so the surface field type matches // `RsTypeTranslator.asRsRef(TypeRef.Any) = AnyOpaque`. private def mkAnyDecoder(a: TypeRef.Any): TextTree[RsValue] = { val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"crate::any_opaque::AnyOpaque::Ueba(decode_any_field(reader, ${expectedHex}u8)?)" + q"crate::any_opaque::AnyOpaque::Ueba(crate::any_opaque::any_field_codec::decode_any_field(reader, ${expectedHex}u8)?)" } // Static fallbacks for the cross-format facade helpers (`json_to_ueba_bytes`/`ueba_to_json`). @@ -199,17 +74,9 @@ class RsUEBACodecGenerator( (domainStatic, versionStatic, typeidStatic) } - private def needsBox(tpe: TypeRef): Boolean = { - tpe match { - case TypeRef.Scalar(u: TypeId.User) => - domain.defs.meta.nodes.get(u).exists { - case m: DomainMember.User => enquiries.isRecursiveTypedef(m, domain) - case _ => false - } - case TypeRef.Constructor(_, args) => args.exists(needsBox) - case _ => false - } - } + private val representation = new RsFieldRepresentation(domain, evo, trans, enquiries) + + private def needsBox(tpe: TypeRef): Boolean = representation.needsBox(tpe) private def genIndexedImpl(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { val indexCount = defn.defn match { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/JsonSchema.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/JsonSchema.scala new file mode 100644 index 000000000..10968b84c --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/JsonSchema.scala @@ -0,0 +1,20 @@ +package io.septimalmind.baboon.translator.schema + +import io.circe.Json + +object JsonSchema { + def nullable(inner: Json): Json = Json.obj("oneOf" -> Json.arr(inner, Json.obj("type" -> Json.fromString("null")))) + def array(items: Json): Json = Json.obj("type" -> Json.fromString("array"), "items" -> items) + def uniqueArray(items: Json): Json = array(items).mapObject(_.add("uniqueItems", Json.True)) + + def objectMap(value: Json, propertyNames: Option[Json]): Json = { + val base = Json.obj("type" -> Json.fromString("object"), "additionalProperties" -> value) + propertyNames.fold(base)(names => base.mapObject(_.add("propertyNames", names))) + } + + def entryMap(key: Json, value: Json): Json = array(Json.obj( + "type" -> Json.fromString("object"), + "required" -> Json.arr(Json.fromString("key"), Json.fromString("value")), + "properties" -> Json.obj("key" -> key, "value" -> value), + )) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/SchemaReferences.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/SchemaReferences.scala new file mode 100644 index 000000000..e9a2d641b --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/schema/SchemaReferences.scala @@ -0,0 +1,40 @@ +package io.septimalmind.baboon.translator.schema + +import io.septimalmind.baboon.typer.model.* + +final case class SchemaReferences( + foreigns: Map[TypeId.User, Typedef.Foreign], + enums: Map[TypeId.User, Typedef.Enum], +) { + val resolutions: Map[TypeId.User, Option[TypeRef]] = foreigns.map { case (id, f) => id -> f.runtimeMapping } + + def resolve(ref: TypeRef): TypeRef = SchemaReferences.resolve(ref, resolutions) +} + +object SchemaReferences { + def prepare(domain: Domain): SchemaReferences = { + val (foreigns, enums) = domain.defs.meta.nodes.valuesIterator.foldLeft( + (Map.empty[TypeId.User, Typedef.Foreign], Map.empty[TypeId.User, Typedef.Enum]) + ) { + case ((fs, es), DomainMember.User(_, f: Typedef.Foreign, _, _)) => (fs.updated(f.id, f), es) + case ((fs, es), DomainMember.User(_, e: Typedef.Enum, _, _)) => (fs, es.updated(e.id, e)) + case (acc, _) => acc + } + SchemaReferences(foreigns, enums) + } + + def resolve(ref: TypeRef, resolutions: Map[TypeId.User, Option[TypeRef]]): TypeRef = ref match { + case TypeRef.Scalar(id: TypeId.User) => + resolutions.get(id) match { + case Some(Some(target)) => resolve(target, resolutions) + case _ => ref + } + case TypeRef.Constructor(id, args) => TypeRef.Constructor(id, args.map(resolve(_, resolutions))) + case _ => ref + } + + def name(id: TypeId.User): String = + (id.pkg.path.toList ++ id.owner.asPseudoPkg :+ id.name.name).map(sanitize).mkString("_") + + def sanitize(s: String): String = s.replace("-", "_").replace(".", "_") +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala index 1505a5365..cbb94db64 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala @@ -10,7 +10,7 @@ import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* import izumi.fundamentals.collections.nonempty.NEList -import io.septimalmind.baboon.translator.BaboonRuntimeResources +import io.septimalmind.baboon.translator.{BaboonRuntimeResources, DomainProductTranslator} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -67,9 +67,9 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( if (!target.language.generateDomainFacade || !target.output.products.contains(CompilerProduct.Conversion)) { F.pure(List.empty) } else { - val domainPkg = trans.toScPkg(lineage.pkg, lineage.evolution.latest, omitVersion = true) - val domainIdStr = lineage.pkg.path.mkString(".") - val facadeName = "Domain" + lineage.pkg.path.map(s => s.capitalize).mkString + "Facade" + val domainPkg = trans.toScPkg(lineage.pkg, lineage.evolution.latest, omitVersion = true) + val domainIdStr = lineage.pkg.path.mkString(".") + val facadeName = "Domain" + lineage.pkg.path.map(s => s.capitalize).mkString + "Facade" val withMeta = target.language.writeEvolutionDict @@ -80,9 +80,9 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( // identically-named singletons (`BaboonCodecsJson` / `…Ueba` / `BaboonMetadata`); a // simple import would collide and the renderer would silently use the same singleton // for every register call. Forcing FQN-with-_root_ sidesteps the import collector. - val verPath = versionedPkg.parts.mkString(".") - val jsonCodecsRef = q"_root_.$verPath.BaboonCodecsJson" - val uebaCodecsRef = q"_root_.$verPath.BaboonCodecsUeba" + val verPath = versionedPkg.parts.mkString(".") + val jsonCodecsRef = q"_root_.$verPath.BaboonCodecsJson" + val uebaCodecsRef = q"_root_.$verPath.BaboonCodecsUeba" val domainVersionExpr = q"""$baboonDomainVersion("$domainIdStr", "${version.v.toString}")""" // `locally { … }` wrapper avoids Scala 2.13 + `-Wconf:any:error` rejection of @@ -104,7 +104,7 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( val facadeWrapped = scTreeTools.inNs(domainPkg.parts.toSeq, facadeTree) val basename = lineage.pkg.path.map(_.toLowerCase).mkString("/") - val facadeOutput = ScDefnTranslator.Output( + val facadeOutput = ScDefnTranslator.Output( s"$basename/$facadeName.scala", facadeWrapped, domainPkg, @@ -119,16 +119,8 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( domain: Domain, p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[ScDefnTranslator.Output]], - ): F[NEList[BaboonIssue], List[ScDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } - } + ): F[NEList[BaboonIssue], List[ScDefnTranslator.Output]] = + DomainProductTranslator.translate(domain, target.output.products, p, translate) private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[ScDefnTranslator.Output]] = { val evo = lineage.evolution @@ -255,12 +247,13 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( .distinct val (samePkg, otherPkg) = usedTypes.partition(_.pkg.parts.startsWith(o.pkg.parts)) + val nameCounts = usedTypes.groupMapReduce(tpe => (tpe.name, tpe.inObject))(_ => 1)(_ + _) val (sameNameOtherPkgs, diffNameOtherPkgs) = - otherPkg.partition(tpe => usedTypes.count(used => used.name == tpe.name && used.inObject == tpe.inObject) > 1) + otherPkg.partition(tpe => nameCounts((tpe.name, tpe.inObject)) > 1) val (sameNameThisPkg, diffNameThisPkg) = - samePkg.partition(tpe => usedTypes.count(used => used.name == tpe.name && used.inObject == tpe.inObject) > 1) + samePkg.partition(tpe => nameCounts((tpe.name, tpe.inObject)) > 1) val sameNameSet = (sameNameThisPkg ++ sameNameOtherPkgs).toSet @@ -322,6 +315,8 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( if (target.output.products.contains(CompilerProduct.Runtime)) { F.pure( List( + rt("BaboonAnyBinCodec.scala", "baboon-runtime/scala/BaboonAnyBinCodec.scala"), + rt("BaboonAnyJsonCodec.scala", "baboon-runtime/scala/BaboonAnyJsonCodec.scala"), rt("BaboonAnyOpaque.scala", "baboon-runtime/scala/BaboonAnyOpaque.scala"), rt("BaboonByteString.scala", "baboon-runtime/scala/BaboonByteString.scala", _.replace("""[\\s:-]""", """[\\\\s:-]""")), rt("BaboonCodecs.scala", "baboon-runtime/scala/BaboonCodecs.scala"), diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala index 7f714d26e..d7c0d2b16 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.scl import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.scl.ScBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.scl.ScTypes.* import io.septimalmind.baboon.translator.scl.ScValue.ScPackageId @@ -41,7 +41,7 @@ class ScConversionTranslator[F[+_, +_]: Error2]( import io.septimalmind.baboon.translator.FQNSymbol.* val oldTpe = maybeOldTpe.getOrElse(newTpe) - val newTypeRefTree = trans.asScRef(newTpe, domain, evo) + val newTypeRefTree = trans.asScRef(newTpe, domain, evo).fullyQualified val oldTypeRefTree = trans.asScRef(oldTpe, srcDom, evo).fullyQualified (newTpe, oldTpe) match { @@ -154,12 +154,14 @@ class ScConversionTranslator[F[+_, +_]: Error2]( )).mkString("-") val tin = trans.asScType(conv.sourceTpe, srcDom, evo).fullyQualified - val tout = trans.asScType(conv.targetTpe, domain, evo) + val tout = trans.asScType(conv.targetTpe, domain, evo).fullyQualified - val meta = q"""override def versionFrom: $scString = "${srcVer.v.toString}" - |override def versionTo: $scString = "${domain.version.v.toString}" - |override def typeId: $scString = "${conv.sourceTpe.toString}" + val targetMeta = if (conv.sourceTpe == conv.targetTpe) q"" else q"""override protected def targetTypeId: $scString = "${conv.targetTpe.toString}"""" + val sourceMeta = q"""override def versionFrom: $scString = "${srcVer.v.toString}" + |override def versionTo: $scString = "${domain.version.v.toString}" + |override def typeId: $scString = "${conv.sourceTpe.toString}" """.stripMargin.trim + val meta = if (conv.sourceTpe == conv.targetTpe) sourceMeta else Seq(sourceMeta, targetMeta).join("\n") val rendered = conv match { case _: Conversion.CustomConversionRequired => @@ -244,8 +246,8 @@ class ScConversionTranslator[F[+_, +_]: Error2]( val ops = c.ops.map(o => o.targetField -> o).toMap val assigns = dto.fields.map { f => - val op = ops(f) - val fld = escapeScKeyword(f.name.name) + val op = ops(f) + val fld = escapeScKeyword(f.name.name) val localVarName = escapeScKeyword(f.name.name.toLowerCase) val expr = op match { case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"_from.$fld", 1) @@ -304,11 +306,7 @@ class ScConversionTranslator[F[+_, +_]: Error2]( List(RenderedConversion(fname, tools.inNs(pkg.parts.toSeq, classDef), Some(regtree), None)) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RenderedConversion]] } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala index 174018bb0..228f88fcf 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.scl import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.ScTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.scl.ScValue.ScType import io.septimalmind.baboon.typer.EnumWireStyle @@ -33,24 +34,6 @@ object ScDefnTranslator { codecs: List[CodecReg], ) - /** Discriminator for identifier-repr field rendering — see PR-56 spec - * (`docs/spec/identifier-repr.md` §3). All cases are populated from the - * validator-restricted set of allowed field types. - */ - sealed trait IdentifierFieldKind - object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: io.septimalmind.baboon.typer.model.TypeId.User) extends IdentifierFieldKind - } - final case class Output( path: String, tree: TextTree[ScValue], @@ -284,7 +267,7 @@ object ScDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[ScValue]): TextTree[ScValue] = { val block = scTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } private def makeRepr( @@ -440,9 +423,10 @@ object ScDefnTranslator { } val methods = service.methods.map { m => - val in = trans.asScRef(m.sig, domain, evo) - val out = m.out.map(trans.asScRef(_, domain, evo)) - val err = m.err.map(trans.asScRef(_, domain, evo)) + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val in = plan.input + val out = plan.output + val err = plan.error val servicePkgParts = name.pkg.parts.toList val scFqName: ScValue => String = { case t: ScValue.ScType if t.predef => t.name @@ -454,7 +438,7 @@ object ScDefnTranslator { val outStr = out.map(_.mapRender(scFqName)).getOrElse("") val errStr = err.map(_.mapRender(scFqName)) val retStr = resolved.renderReturnType(outStr, errStr, "Unit") - val methodEx = q"def ${m.name.name}(${ctxParam}arg: $in): $retStr" + val methodEx = q"def ${plan.methodName}(${ctxParam}arg: $in): $retStr" prependDocs(m.docs, methodEx) } val typeParams = Seq( @@ -542,44 +526,15 @@ object ScDefnTranslator { } } - /** Resolve a TypeRef to its underlying scalar/user kind for identifier-repr - * dispatch. Aliases are already collapsed in BaboonTranslator (the field's - * TypeRef is the resolved scalar), so we only need to discriminate - * Scalar/User from collection/any (which the validator rejects). - */ - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - /** Per-field render expression. The variable holding the field value is * named `value` because we wrap the body inside a per-field block. */ private def renderFieldValueExpr(fieldName: String, kind: IdentifierFieldKind): TextTree[ScValue] = { val escapedFieldName = escapeScKeyword(fieldName) kind match { - case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(this.$escapedFieldName)" - case IdentifierFieldKind.SignedInt => q"this.$escapedFieldName.toString" - case IdentifierFieldKind.UnsignedSmallInt => + case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(this.$escapedFieldName)" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => q"this.$escapedFieldName.toString" + case IdentifierFieldKind.UnsignedSmallInt => // u08/u16/u32 require width-aware masking. The single 32-bit mask used // here would silently produce wrong values for u08/u16. All call sites // MUST special-case UnsignedSmallInt and dispatch to renderUnsignedSmallInt. @@ -614,7 +569,7 @@ object ScDefnTranslator { val fieldExprs: List[TextTree[ScValue]] = dto.fields.map { f => val fieldName = f.name.name - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = kind match { case IdentifierFieldKind.UnsignedSmallInt => f.tpe match { @@ -644,7 +599,7 @@ object ScDefnTranslator { case (f, idx) => val fieldName = f.name.name val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val parseHead = q"""$baboonIdRepr.parseFieldName(cursor, \"$fieldName\") match { | case Left(e) => return Left(e) | case Right(_) => () @@ -657,7 +612,7 @@ object ScDefnTranslator { | case Right(v) => v | case Left(e) => return Left(e) |}""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val tpeRef = trans.asScRef(f.tpe, domain, evo) val rangeCheck = signedRangeCheck(f.tpe) val typeName = signedTypeName(f.tpe) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala index bfbfe0009..3fd07bad8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala @@ -13,12 +13,22 @@ trait ScDomainTreeTools { object ScDomainTreeTools { final case class MetaField( - signature: TextTree[ScValue], - value: TextTree[ScValue], - refValue: TextTree[ScValue], - ) { - def valueField: TextTree[ScValue] = q"$signature = $value" - def refValueField: TextTree[ScValue] = q"$signature = $refValue" + valueField: TextTree[ScValue], + refValueField: TextTree[ScValue], + ) + + object MetaField { + def apply(signature: TextTree[ScValue], value: TextTree[ScValue], refValue: TextTree[ScValue]): MetaField = + MetaField(q"$signature = $value", q"$signature = $refValue") + + def cached(name: String, tpe: TextTree[ScValue], value: TextTree[ScValue], refValue: TextTree[ScValue]): MetaField = { + val backing = s"_$name" + MetaField( + q"""private lazy val $backing: $tpe = $value + |def $name: $tpe = $backing""".stripMargin, + q"def $name: $tpe = $refValue", + ) + } } final class ScDomainTreeToolsImpl( @@ -76,8 +86,9 @@ object ScDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = typeTranslator.asScType(defn.id, domain, evolution) val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") - val sameInVersion = MetaField( - q"def baboonSameInVersions: $scList[$scString]", + val sameInVersion = MetaField.cached( + "baboonSameInVersions", + q"$scList[$scString]", q"$scList(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) @@ -85,16 +96,18 @@ object ScDomainTreeTools { val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}" -> "${tier.wireName}"""" } - val forwardReadable = MetaField( - q"def baboonForwardReadable: ${ScTypes.scMap}[$scString, $scString]", + val forwardReadable = MetaField.cached( + "baboonForwardReadable", + q"${ScTypes.scMap}[$scString, $scString]", q"${ScTypes.scMap}(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s""""${tier.wireName}" -> "${v.v.toString}"""" } - val minReaders = MetaField( - q"def baboonMinReaderVersions: ${ScTypes.scMap}[$scString, $scString]", + val minReaders = MetaField.cached( + "baboonMinReaderVersions", + q"${ScTypes.scMap}[$scString, $scString]", q"${ScTypes.scMap}(${minReaderEntries.mkString(", ")})", q"$ref.baboonMinReaderVersions", ) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala index d8cc02f7f..a95816bd7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.scl.ScDomainTreeTools.MetaField import io.septimalmind.baboon.translator.scl.ScTypes.* import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -60,66 +60,6 @@ class ScJsonCodecGenerator( } } else None } - // Per-codec-object helpers consolidating the any-field envelope encode (JSON) and decode (JSON); - // emitted at most once per codec object and called from every field-level any site. See - // spec §172 (factor any-field encode/decode into helpers) and the symmetric UEBA helpers - // in `ScUEBACodecGenerator.anyFieldHelpers`. - private def anyFieldHelpers: TextTree[ScValue] = { - q"""private def encodeAnyField( - | ctx: $baboonCodecContext, - | expectedKind: $scByte, - | staticDomain: $scOption[$scString], - | staticVersion: $scOption[$scString], - | staticTypeid: $scOption[$scString], - | value: $baboonAnyOpaque, - |): $circeJson = { - | if (value.meta.kind != expectedKind) { - | throw $baboonCodecException.EncoderFailure(s"any: meta-kind mismatch on encode: expected 0x$${(expectedKind & 0xFF).toHexString}, got 0x$${(value.meta.kind & 0xFF).toHexString}") - | } - | val innerJson: $circeJson = value match { - | case anyJson: $baboonAnyOpaqueJson => - | anyJson.json - | case anyUeba: $baboonAnyOpaqueUeba => - | val f = ctx.facade.getOrElse( - | throw $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." - | ) - | ) - | f.uebaToJson(anyUeba.meta, anyUeba.bytes, staticDomain, staticVersion, staticTypeid) match { - | case Right(j) => j - | case Left(e) => throw e - | } - | } - | $baboonAnyMetaCodec.writeJson(value.meta).mapObject(_.add("$$c", innerJson)) - |} - | - |private def decodeAnyField(expectedKind: $scByte, wire: $circeJson): $scEither[$javaThrowable, $baboonAnyOpaqueJson] = { - | $baboonAnyMetaCodec.readJson(wire) match { - | case Left(e) => Left(e) - | case Right(meta) => - | if (meta.kind != expectedKind) { - | Left($baboonCodecException.DecoderFailure(s"any: meta-kind mismatch: expected 0x$${(expectedKind & 0xFF).toHexString}, got 0x$${(meta.kind & 0xFF).toHexString}")) - | } else { - | wire.hcursor.downField("$$c").as[$circeJson] match { - | case Right(content) => Right($baboonAnyOpaqueJson(meta, content)) - | case Left(err) => Left($baboonCodecException.DecoderFailure(s"any: missing or unreadable content key: $${err.getMessage}")) - | } - | } - | } - |}""".stripMargin - } - - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } private def genCodec( defn: DomainMember.User, @@ -164,15 +104,15 @@ class ScJsonCodecGenerator( } } - val meta = renderMeta(defn, scDomainTreeTools.makeCodecMeta(defn)) - val anyHelpers: List[TextTree[ScValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val tail = (anyHelpers ++ meta).joinNN() + val meta = renderMeta(defn, scDomainTreeTools.makeCodecMeta(defn)) + val tail = meta.joinNN() q"""object ${cName.name} extends $cParent { | ${baseMethods.joinNN().shift(2).trim} | | ${tail.shift(2).trim} | - | override protected def LazyInstance: $baboonLazy[$iName] = $baboonLazy($cName) + | private lazy val cachedLazyInstance: $baboonLazy[$iName] = $baboonLazy($cName) + | override protected def LazyInstance: $baboonLazy[$iName] = cachedLazyInstance | override def instance: $iName = LazyInstance.value |} |""".stripMargin @@ -358,26 +298,7 @@ class ScJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.uid => q"$circeJson.fromString($ref.toString())" - case TypeId.Builtins.tsu => q"$circeJson.fromString($baboonTimeFormats.formatTsu($ref))" - case TypeId.Builtins.tso => q"$circeJson.fromString($baboonTimeFormats.formatTso($ref))" - case TypeId.Builtins.bit => q"$circeJson.fromBoolean($ref)" - case TypeId.Builtins.i08 => q"$circeJson.fromInt($ref.toInt)" - case TypeId.Builtins.i16 => q"$circeJson.fromInt($ref.toInt)" - case TypeId.Builtins.i32 => q"$circeJson.fromInt($ref)" - - case TypeId.Builtins.i64 => q"$circeJson.fromLong($ref)" - case TypeId.Builtins.u08 => q"$circeJson.fromInt(java.lang.Byte.toUnsignedInt($ref))" - case TypeId.Builtins.u16 => q"$circeJson.fromInt(java.lang.Short.toUnsignedInt($ref))" - case TypeId.Builtins.u32 => q"$circeJson.fromLong(java.lang.Integer.toUnsignedLong($ref))" - case TypeId.Builtins.u64 => q"$circeJson.fromBigInt($baboonBinTools.toUnsignedBigInt($ref))" - - case TypeId.Builtins.f32 => q"$circeJson.fromFloat($ref).get" - case TypeId.Builtins.f64 => q"$circeJson.fromDouble($ref).get" - case TypeId.Builtins.f128 => q"$circeJson.fromBigDecimal($ref)" - - case TypeId.Builtins.str => q"$circeJson.fromString($ref)" - case TypeId.Builtins.bytes => q"$circeJson.fromString($ref.toHexString)" + case b: TypeId.BuiltinScalar => ScScalarCodecEmitter.jsonEncode(b, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -411,10 +332,10 @@ class ScJsonCodecGenerator( case o => throw new RuntimeException(s"BUG: Unexpected type: $o") } case a: TypeRef.Any => - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"""encodeAnyField(ctx, $expectedHex.toByte, $staticDom, $staticVer, $staticTid, $ref)""" + q"""$baboonAnyJsonCodec.encode(ctx, $expectedHex.toByte, $staticDom, $staticVer, $staticTid, $ref)""" } } @@ -423,20 +344,9 @@ class ScJsonCodecGenerator( // codec emits whatever is statically known so the facade can fill the gaps. See // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[ScValue], TextTree[ScValue], TextTree[ScValue]) = { - val none = q"_root_.scala.None" - def some(s: String) = q"""_root_.scala.Some("$s")""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) + val plan = AnyFieldPlan.forField(a, domain) + def render(value: Option[String]): TextTree[ScValue] = value.fold[TextTree[ScValue]](q"_root_.scala.None")(s => q"""_root_.scala.Some("$s")""") + (render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) } private def decoder(fieldName: String, tpe: TypeRef, jsonObjectRef: TextTree[ScValue]): TextTree[ScValue] = { @@ -524,28 +434,7 @@ class ScJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$circeDecodeBoolean" - case TypeId.Builtins.i08 => q"$baboonDecodeByte" - case TypeId.Builtins.i16 => q"$baboonDecodeShort" - case TypeId.Builtins.i32 => q"$baboonDecodeInt" - case TypeId.Builtins.i64 => q"$baboonDecodeLong" - case TypeId.Builtins.u08 => q"$baboonDecodeByte" - case TypeId.Builtins.u16 => q"$baboonDecodeShort" - case TypeId.Builtins.u32 => q"$baboonDecodeInt" - case TypeId.Builtins.u64 => q"$baboonDecodeLong" - - case TypeId.Builtins.f32 => q"$circeDecodeFloat" - case TypeId.Builtins.f64 => q"$circeDecodeDouble" - case TypeId.Builtins.f128 => q"$baboonDecodeBigDecimalLenient" - case TypeId.Builtins.str => q"$circeDecodeString" - case TypeId.Builtins.bytes => q"$baboonDecodeByteString" - case TypeId.Builtins.uid => q"$circeDecodeUuid" - case TypeId.Builtins.tsu => q"$baboonDecodeTsu" - case TypeId.Builtins.tso => q"$baboonDecodeTso" - case other => throw new RuntimeException(s"BUG: Unexpected type: $other") - } + case s: TypeId.BuiltinScalar => ScScalarCodecEmitter.jsonDecoder(s) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -578,13 +467,13 @@ class ScJsonCodecGenerator( case o => throw new RuntimeException(s"BUG: Unexpected type: $o") } case a: TypeRef.Any => - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) // Lift the helper's Either[Throwable, AnyOpaqueJson] into circe's Decoder.Result // (Either[DecodingFailure, AnyOpaqueJson]) so this slot is shape-compatible with the // surrounding `field.flatMap(v => $decoder(v.hcursor))` template. Decoder is symmetric // — no facade plumbing needed (decode never cross-converts). - q"""$circeDecoder.instance(c => decodeAnyField($expectedHex.toByte, c.value).left.map(t => $circeDecodingFailure(t.getMessage, c.history)))""" + q"""$circeDecoder.instance(c => $baboonAnyJsonCodec.decode($expectedHex.toByte, c.value).left.map(t => $circeDecodingFailure(t.getMessage, c.history)))""" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScMcpServerGenerator.scala index e9aef0406..782a8e8f0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScMcpServerGenerator.scala @@ -85,8 +85,8 @@ class ScMcpServerGenerator[F[+_, +_]: Error2]( private def isEitherErrorsMode(resolved: io.septimalmind.baboon.translator.ResolvedServiceResult): Boolean = !resolved.noErrors && - resolved.hkt.isEmpty && - resolved.resultType.exists(t => t == "Either" || t == "scala.util.Either") + resolved.hkt.isEmpty && + resolved.resultType.exists(t => t == "Either" || t == "scala.util.Either") private def describeResult(resolved: io.septimalmind.baboon.translator.ResolvedServiceResult): String = { if (resolved.noErrors) "no-errors mode (no error channel)" @@ -100,9 +100,10 @@ class ScMcpServerGenerator[F[+_, +_]: Error2]( private def emitSources(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -128,7 +129,8 @@ class ScMcpServerGenerator[F[+_, +_]: Error2]( } } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -141,10 +143,11 @@ class ScMcpServerGenerator[F[+_, +_]: Error2]( val toolEntries: List[String] = svc.methods.toList.map { m => val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) // The self-contained JSON Schema is carried as a constant literal value. // io.circe.Json literals are produced by io.circe.parser.parse. - val descArg = McpDocs.flatten(m.docs) + val descArg = McpDocs + .flatten(m.docs) .map(d => s"\n description = Some(${scalaString(d)}),") .getOrElse("") s""" _root_.baboon.runtime.shared.McpToolEntry( @@ -154,7 +157,8 @@ class ScMcpServerGenerator[F[+_, +_]: Error2]( | ),""".stripMargin } - val invokeJsonType = s"(_root_.baboon.runtime.shared.BaboonMethodId, String, Ctx, _root_.baboon.runtime.shared.BaboonCodecContext) => Either[_root_.baboon.runtime.shared.BaboonWiringError, String]" + val invokeJsonType = + s"(_root_.baboon.runtime.shared.BaboonMethodId, String, Ctx, _root_.baboon.runtime.shared.BaboonCodecContext) => Either[_root_.baboon.runtime.shared.BaboonWiringError, String]" val body = s"""// Generated MCP server for service `$serviceName` (model `${domain.id.path.mkString(".")}` v$modelVer). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala new file mode 100644 index 000000000..8b95a1d2e --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala @@ -0,0 +1,91 @@ +package io.septimalmind.baboon.translator.scl + +import io.septimalmind.baboon.translator.scl.ScTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +object ScScalarCodecEmitter { + + def jsonDecoder(b: TypeId.BuiltinScalar): TextTree[ScValue] = b match { + case TypeId.Builtins.bit => q"$circeDecodeBoolean" + case TypeId.Builtins.i08 => q"$baboonDecodeByte" + case TypeId.Builtins.i16 => q"$baboonDecodeShort" + case TypeId.Builtins.i32 => q"$baboonDecodeInt" + case TypeId.Builtins.i64 => q"$baboonDecodeLong" + case TypeId.Builtins.u08 => q"$baboonDecodeByte" + case TypeId.Builtins.u16 => q"$baboonDecodeShort" + case TypeId.Builtins.u32 => q"$baboonDecodeInt" + case TypeId.Builtins.u64 => q"$baboonDecodeLong" + case TypeId.Builtins.f32 => q"$circeDecodeFloat" + case TypeId.Builtins.f64 => q"$circeDecodeDouble" + case TypeId.Builtins.f128 => q"$baboonDecodeBigDecimalLenient" + case TypeId.Builtins.str => q"$circeDecodeString" + case TypeId.Builtins.bytes => q"$baboonDecodeByteString" + case TypeId.Builtins.uid => q"$circeDecodeUuid" + case TypeId.Builtins.tsu => q"$baboonDecodeTsu" + case TypeId.Builtins.tso => q"$baboonDecodeTso" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def jsonEncode(b: TypeId.BuiltinScalar, value: TextTree[ScValue]): TextTree[ScValue] = b match { + case TypeId.Builtins.uid => q"$circeJson.fromString($value.toString())" + case TypeId.Builtins.tsu => q"$circeJson.fromString($baboonTimeFormats.formatTsu($value))" + case TypeId.Builtins.tso => q"$circeJson.fromString($baboonTimeFormats.formatTso($value))" + case TypeId.Builtins.bit => q"$circeJson.fromBoolean($value)" + case TypeId.Builtins.i08 => q"$circeJson.fromInt($value.toInt)" + case TypeId.Builtins.i16 => q"$circeJson.fromInt($value.toInt)" + case TypeId.Builtins.i32 => q"$circeJson.fromInt($value)" + case TypeId.Builtins.i64 => q"$circeJson.fromLong($value)" + case TypeId.Builtins.u08 => q"$circeJson.fromInt(java.lang.Byte.toUnsignedInt($value))" + case TypeId.Builtins.u16 => q"$circeJson.fromInt(java.lang.Short.toUnsignedInt($value))" + case TypeId.Builtins.u32 => q"$circeJson.fromLong(java.lang.Integer.toUnsignedLong($value))" + case TypeId.Builtins.u64 => q"$circeJson.fromBigInt($baboonBinTools.toUnsignedBigInt($value))" + case TypeId.Builtins.f32 => q"$circeJson.fromFloat($value).get" + case TypeId.Builtins.f64 => q"$circeJson.fromDouble($value).get" + case TypeId.Builtins.f128 => q"$circeJson.fromBigDecimal($value)" + case TypeId.Builtins.str => q"$circeJson.fromString($value)" + case TypeId.Builtins.bytes => q"$circeJson.fromString($value.toHexString)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaDecode(b: TypeId.BuiltinScalar, br: TextTree[ScValue]): TextTree[ScValue] = b match { + case TypeId.Builtins.bit => q"$br.readBoolean()" + case TypeId.Builtins.i08 => q"$br.readByte()" + case TypeId.Builtins.i16 => q"$br.readShort()" + case TypeId.Builtins.i32 => q"$br.readInt()" + case TypeId.Builtins.i64 => q"$br.readLong()" + case TypeId.Builtins.u08 => q"$br.readByte()" + case TypeId.Builtins.u16 => q"$br.readShort()" + case TypeId.Builtins.u32 => q"$br.readInt()" + case TypeId.Builtins.u64 => q"$br.readLong()" + case TypeId.Builtins.f32 => q"$br.readFloat()" + case TypeId.Builtins.f64 => q"$br.readDouble()" + case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal($br)" + case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" + case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" + case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.readTimestamp($br)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaEncode(b: TypeId.BuiltinScalar, bw: TextTree[ScValue], value: TextTree[ScValue]): TextTree[ScValue] = b match { + case TypeId.Builtins.bit => q"$bw.writeBoolean($value)" + case TypeId.Builtins.i08 => q"$bw.writeByte($value.toInt)" + case TypeId.Builtins.i16 => q"$bw.writeShort($value.toInt)" + case TypeId.Builtins.i32 => q"$bw.writeInt($value)" + case TypeId.Builtins.i64 => q"$bw.writeLong($value)" + case TypeId.Builtins.u08 => q"$bw.writeByte($value.toInt)" + case TypeId.Builtins.u16 => q"$bw.writeShort($value.toInt)" + case TypeId.Builtins.u32 => q"$bw.writeInt($value)" + case TypeId.Builtins.u64 => q"$bw.writeLong($value)" + case TypeId.Builtins.f32 => q"$bw.writeFloat($value)" + case TypeId.Builtins.f64 => q"$bw.writeDouble($value)" + case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($bw, $value)" + case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value)" + case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value)" + case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value)" + case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.writeTimestamp($bw, $value)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala new file mode 100644 index 000000000..ceca1fe94 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala @@ -0,0 +1,20 @@ +package io.septimalmind.baboon.translator.scl + +import io.septimalmind.baboon.translator.ResolvedServiceResult +import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} +import izumi.fundamentals.platform.strings.TextTree + +final class ScServiceMethodPlan( + method: Typedef.MethodDef, + resolveType: TypeRef => TextTree[ScValue], + result: ResolvedServiceResult, +) { + val methodName: String = method.name.name + val hasError: Boolean = method.err.isDefined && !result.noErrors + + // Keep type resolution demand-driven: a disabled transport or error mode + // must not start resolving types that its renderer never used. + lazy val input: TextTree[ScValue] = resolveType(method.sig) + lazy val output: Option[TextTree[ScValue]] = method.out.map(resolveType) + lazy val error: Option[TextTree[ScValue]] = method.err.map(resolveType) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala index 6d2ed615c..d7bf9eb00 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala @@ -85,26 +85,7 @@ object ScServiceWiringTranslator { private def jsonDecodeExpr(id: TypeId, wire: TextTree[ScValue]): TextTree[ScValue] = id match { case u: TypeId.User => q"${jsonCodecName(u)}.instance.decode($codecCtxRef, $wire).fold(throw _, identity)" case b: TypeId.BuiltinScalar => - val decoder = b match { - case TypeId.Builtins.bit => q"$circeDecodeBoolean" - case TypeId.Builtins.i08 => q"$baboonDecodeByte" - case TypeId.Builtins.i16 => q"$baboonDecodeShort" - case TypeId.Builtins.i32 => q"$baboonDecodeInt" - case TypeId.Builtins.i64 => q"$baboonDecodeLong" - case TypeId.Builtins.u08 => q"$baboonDecodeByte" - case TypeId.Builtins.u16 => q"$baboonDecodeShort" - case TypeId.Builtins.u32 => q"$baboonDecodeInt" - case TypeId.Builtins.u64 => q"$baboonDecodeLong" - case TypeId.Builtins.f32 => q"$circeDecodeFloat" - case TypeId.Builtins.f64 => q"$circeDecodeDouble" - case TypeId.Builtins.f128 => q"$baboonDecodeBigDecimalLenient" - case TypeId.Builtins.str => q"$circeDecodeString" - case TypeId.Builtins.bytes => q"$baboonDecodeByteString" - case TypeId.Builtins.uid => q"$circeDecodeUuid" - case TypeId.Builtins.tsu => q"$baboonDecodeTsu" - case TypeId.Builtins.tso => q"$baboonDecodeTso" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + val decoder = ScScalarCodecEmitter.jsonDecoder(b) q"$decoder.decodeJson($wire).fold(e => throw e, identity)" case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } @@ -112,76 +93,21 @@ object ScServiceWiringTranslator { private def jsonEncodeExpr(id: TypeId, value: TextTree[ScValue]): TextTree[ScValue] = id match { case u: TypeId.User => q"${jsonCodecName(u)}.instance.encode($codecCtxRef, $value)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.uid => q"$circeJson.fromString($value.toString())" - case TypeId.Builtins.tsu => q"$circeJson.fromString($baboonTimeFormats.formatTsu($value))" - case TypeId.Builtins.tso => q"$circeJson.fromString($baboonTimeFormats.formatTso($value))" - case TypeId.Builtins.bit => q"$circeJson.fromBoolean($value)" - case TypeId.Builtins.i08 => q"$circeJson.fromInt($value.toInt)" - case TypeId.Builtins.i16 => q"$circeJson.fromInt($value.toInt)" - case TypeId.Builtins.i32 => q"$circeJson.fromInt($value)" - case TypeId.Builtins.i64 => q"$circeJson.fromLong($value)" - case TypeId.Builtins.u08 => q"$circeJson.fromInt(java.lang.Byte.toUnsignedInt($value))" - case TypeId.Builtins.u16 => q"$circeJson.fromInt(java.lang.Short.toUnsignedInt($value))" - case TypeId.Builtins.u32 => q"$circeJson.fromLong(java.lang.Integer.toUnsignedLong($value))" - case TypeId.Builtins.u64 => q"$circeJson.fromBigInt($baboonBinTools.toUnsignedBigInt($value))" - case TypeId.Builtins.f32 => q"$circeJson.fromFloat($value).get" - case TypeId.Builtins.f64 => q"$circeJson.fromDouble($value).get" - case TypeId.Builtins.f128 => q"$circeJson.fromBigDecimal($value)" - case TypeId.Builtins.str => q"$circeJson.fromString($value)" - case TypeId.Builtins.bytes => q"$circeJson.fromString($value.toHexString)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + ScScalarCodecEmitter.jsonEncode(b, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, br: TextTree[ScValue]): TextTree[ScValue] = id match { case u: TypeId.User => q"${uebaCodecName(u)}.instance.decode($codecCtxRef, $br).fold(throw _, identity)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$br.readBoolean()" - case TypeId.Builtins.i08 => q"$br.readByte()" - case TypeId.Builtins.i16 => q"$br.readShort()" - case TypeId.Builtins.i32 => q"$br.readInt()" - case TypeId.Builtins.i64 => q"$br.readLong()" - case TypeId.Builtins.u08 => q"$br.readByte()" - case TypeId.Builtins.u16 => q"$br.readShort()" - case TypeId.Builtins.u32 => q"$br.readInt()" - case TypeId.Builtins.u64 => q"$br.readLong()" - case TypeId.Builtins.f32 => q"$br.readFloat()" - case TypeId.Builtins.f64 => q"$br.readDouble()" - case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal($br)" - case TypeId.Builtins.str => q"$baboonBinTools.readString($br)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString($br)" - case TypeId.Builtins.uid => q"$baboonBinTools.readUid($br)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.readTimestamp($br)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + ScScalarCodecEmitter.uebaDecode(b, br) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, bw: TextTree[ScValue], value: TextTree[ScValue]): TextTree[ScValue] = id match { case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $bw, $value)" case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$bw.writeBoolean($value)" - case TypeId.Builtins.i08 => q"$bw.writeByte($value.toInt)" - case TypeId.Builtins.i16 => q"$bw.writeShort($value.toInt)" - case TypeId.Builtins.i32 => q"$bw.writeInt($value)" - case TypeId.Builtins.i64 => q"$bw.writeLong($value)" - case TypeId.Builtins.u08 => q"$bw.writeByte($value.toInt)" - case TypeId.Builtins.u16 => q"$bw.writeShort($value.toInt)" - case TypeId.Builtins.u32 => q"$bw.writeInt($value)" - case TypeId.Builtins.u64 => q"$bw.writeLong($value)" - case TypeId.Builtins.f32 => q"$bw.writeFloat($value)" - case TypeId.Builtins.f64 => q"$bw.writeDouble($value)" - case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($bw, $value)" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($bw, $value)" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($bw, $value)" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($bw, $value)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => q"$baboonBinTools.writeTimestamp($bw, $value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } + ScScalarCodecEmitter.uebaEncode(b, bw, value) case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } @@ -284,7 +210,8 @@ object ScServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val outRefOpt = m.out.map(o => trans.asScRef(o, domain, evo)) + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val outRefOpt = plan.output val outFq = outRefOpt.map(renderFq).getOrElse("Unit") val retType = if (resolved.noErrors) outFq else ct(bweFq, outFq) @@ -303,7 +230,7 @@ object ScServiceWiringTranslator { |val bw = new $binaryOutput(oms) |$encodeIn |bw.flush() - |val resp = transportUeba($ctxArgPass"$svcName", "${m.name.name}", oms.toByteArray) + |val resp = transportUeba($ctxArgPass"$svcName", "${plan.methodName}", oms.toByteArray) |val br = new $binaryInput(new java.io.ByteArrayInputStream(resp)) |$decodeOut""".stripMargin } else { @@ -311,13 +238,13 @@ object ScServiceWiringTranslator { |val bw = new $binaryOutput(oms) |$encodeIn |bw.flush() - |val resp = transportUeba($ctxArgPass"$svcName", "${m.name.name}", oms.toByteArray) + |val resp = transportUeba($ctxArgPass"$svcName", "${plan.methodName}", oms.toByteArray) |try { | val br = new $binaryInput(new java.io.ByteArrayInputStream(resp)) | rt.pure[$bweFq, $outFq]($decodeOut) |} catch { | case ex: Throwable => - | rt.fail[$bweFq, $outFq]($bweFq.DecoderFailed($baboonMethodId("$svcName", "${m.name.name}"), ex)) + | rt.fail[$bweFq, $outFq]($bweFq.DecoderFailed($baboonMethodId("$svcName", "${plan.methodName}"), ex)) |}""".stripMargin } case None => @@ -326,19 +253,19 @@ object ScServiceWiringTranslator { |val bw = new $binaryOutput(oms) |$encodeIn |bw.flush() - |val _ = transportUeba($ctxArgPass"$svcName", "${m.name.name}", oms.toByteArray) + |val _ = transportUeba($ctxArgPass"$svcName", "${plan.methodName}", oms.toByteArray) |()""".stripMargin } else { q"""val oms = new $byteArrayOutputStream() |val bw = new $binaryOutput(oms) |$encodeIn |bw.flush() - |val _ = transportUeba($ctxArgPass"$svcName", "${m.name.name}", oms.toByteArray) + |val _ = transportUeba($ctxArgPass"$svcName", "${plan.methodName}", oms.toByteArray) |rt.pure[$bweFq, Unit](())""".stripMargin } } Some( - q"""def ${m.name.name}(${ctxParamDecl}arg: ${trans.asScRef(m.sig, domain, evo)}): $retType = { + q"""def ${plan.methodName}(${ctxParamDecl}arg: ${plan.input}): $retType = { | ${body.shift(2).trim} |}""".stripMargin ) @@ -351,33 +278,33 @@ object ScServiceWiringTranslator { val decodeOut = jsonDecodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"wire") if (resolved.noErrors) { q"""val encoded = $encodeIn.noSpaces - |val resp = transportJson($ctxArgPass"$svcName", "${m.name.name}", encoded) + |val resp = transportJson($ctxArgPass"$svcName", "${plan.methodName}", encoded) |val wire = io.circe.parser.parse(resp).fold(throw _, identity) |$decodeOut""".stripMargin } else { q"""val encoded = $encodeIn.noSpaces - |val resp = transportJson($ctxArgPass"$svcName", "${m.name.name}", encoded) + |val resp = transportJson($ctxArgPass"$svcName", "${plan.methodName}", encoded) |try { | val wire = io.circe.parser.parse(resp).fold(throw _, identity) | rt.pure[$bweFq, $outFq]($decodeOut) |} catch { | case ex: Throwable => - | rt.fail[$bweFq, $outFq]($bweFq.DecoderFailed($baboonMethodId("$svcName", "${m.name.name}"), ex)) + | rt.fail[$bweFq, $outFq]($bweFq.DecoderFailed($baboonMethodId("$svcName", "${plan.methodName}"), ex)) |}""".stripMargin } case None => if (resolved.noErrors) { q"""val encoded = $encodeIn.noSpaces - |val _ = transportJson($ctxArgPass"$svcName", "${m.name.name}", encoded) + |val _ = transportJson($ctxArgPass"$svcName", "${plan.methodName}", encoded) |()""".stripMargin } else { q"""val encoded = $encodeIn.noSpaces - |val _ = transportJson($ctxArgPass"$svcName", "${m.name.name}", encoded) + |val _ = transportJson($ctxArgPass"$svcName", "${plan.methodName}", encoded) |rt.pure[$bweFq, Unit](())""".stripMargin } } Some( - q"""def ${m.name.name}Json(${ctxParamDecl}arg: ${trans.asScRef(m.sig, domain, evo)}): $retType = { + q"""def ${plan.methodName}Json(${ctxParamDecl}arg: ${plan.input}): $retType = { | ${body.shift(2).trim} |}""".stripMargin ) @@ -476,14 +403,14 @@ object ScServiceWiringTranslator { // renamed away. In `none` mode it stays `ctx`, keeping that output // byte-identical. private def svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } private def svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } private def codecCtxName: String = resolvedCtx match { case ResolvedServiceContext.NoContext => "ctx" @@ -506,7 +433,7 @@ object ScServiceWiringTranslator { case t: ScValue.ScType => if (t.predef) t.name else { - val parts = (t.pkg.parts :+ t.name).toList + val parts = (t.pkg.parts :+ t.name).toList val anchored = if (parts.headOption.contains("_root_")) parts else "_root_" :: parts anchored.mkString(".") } @@ -586,7 +513,7 @@ object ScServiceWiringTranslator { q"""class $wrapperName$classTypeParams($ctorParamList) extends $implementsClause { | val serviceName: String = "$svcName" - | def invoke(method: $baboonMethodId, data: $wireType, ${ctxParamDecl}$codecCtxName: $baboonCodecContext): $retType = + | def invoke(method: $baboonMethodId, data: $wireType, $ctxParamDecl$codecCtxName: $baboonCodecContext): $retType = | ${svcName}Wiring.$invokerFn($callArgs) |}""".stripMargin } @@ -621,6 +548,7 @@ object ScServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -633,11 +561,11 @@ object ScServiceWiringTranslator { } val callExpr = m.out match { - case Some(_) => q"val result = impl.${m.name.name}(${ctxArgPass}decoded)" - case None => q"impl.${m.name.name}(${ctxArgPass}decoded)" + case Some(_) => q"val result = impl.${plan.methodName}(${ctxArgPass}decoded)" + case None => q"impl.${plan.methodName}(${ctxArgPass}decoded)" } - q"""case "${m.name.name}" => + q"""case "${plan.methodName}" => | val wire = io.circe.parser.parse(data).fold(throw _, identity) | val decoded = $decodeIn | $callExpr @@ -648,7 +576,7 @@ object ScServiceWiringTranslator { | method: $baboonMethodId, | data: String, | impl: $svcName$svcTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): String = { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): String = { | method.methodName match { | ${cases.shift(4).trim} | case _ => @@ -661,6 +589,7 @@ object ScServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -676,11 +605,11 @@ object ScServiceWiringTranslator { } val callExpr = m.out match { - case Some(_) => q"val result = impl.${m.name.name}(${ctxArgPass}decoded)" - case None => q"impl.${m.name.name}(${ctxArgPass}decoded)" + case Some(_) => q"val result = impl.${plan.methodName}(${ctxArgPass}decoded)" + case None => q"impl.${plan.methodName}(${ctxArgPass}decoded)" } - q"""case "${m.name.name}" => + q"""case "${plan.methodName}" => | val ims = new java.io.ByteArrayInputStream(data) | val br = new $binaryInput(ims) | val decoded = $decodeIn @@ -692,7 +621,7 @@ object ScServiceWiringTranslator { | method: $baboonMethodId, | data: Array[Byte], | impl: $svcName$svcTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): Array[Byte] = { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): Array[Byte] = { | method.methodName match { | ${cases.shift(4).trim} | case _ => @@ -744,7 +673,8 @@ object ScServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asScRef(m.sig, domain, evo) + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val decodeStep = @@ -756,17 +686,17 @@ object ScServiceWiringTranslator { | rt.fail[$bweFq, $inRef]($bweFq.DecoderFailed(method, ex)) |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors + val hasErrType = plan.hasError val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asScRef(outRef, domain, evo) + val outType = plan.output.get val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") val callBody = if (hasErrType) { - val errType = trans.asScRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${m.name.name}(${ctxArgPass}v) + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) | rt.leftMap[$errType, $outType, $bweFq]( | callResult, err => $bweFq.CallFailed(method, err)) |} catch { @@ -775,7 +705,7 @@ object ScServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | rt.pure[$bweFq, $outType](impl.${m.name.name}(${ctxArgPass}v)) + | rt.pure[$bweFq, $outType](impl.${plan.methodName}(${ctxArgPass}v)) |} catch { | case ex: Throwable => | rt.fail[$bweFq, $outType]($bweFq.CallFailed(method, ex)) @@ -797,9 +727,9 @@ object ScServiceWiringTranslator { case None => val callBody = if (hasErrType) { - val errType = trans.asScRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${m.name.name}(${ctxArgPass}v) + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) | rt.leftMap[$errType, Unit, $bweFq]( | callResult, err => $bweFq.CallFailed(method, err)) |} catch { @@ -808,7 +738,7 @@ object ScServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | impl.${m.name.name}(${ctxArgPass}v) + | impl.${plan.methodName}(${ctxArgPass}v) | rt.pure[$bweFq, Unit](()) |} catch { | case ex: Throwable => @@ -822,7 +752,7 @@ object ScServiceWiringTranslator { |})""".stripMargin } - q"""case "${m.name.name}" => + q"""case "${plan.methodName}" => | ${decodeStep.shift(2).trim} | ${callAndEncodeStep.shift(2).trim}""".stripMargin }.join("\n") @@ -832,7 +762,7 @@ object ScServiceWiringTranslator { | data: String, | impl: $svcName$svcTypeArg, | rt: $iBaboonServiceRtFq$rtTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): $wiringRetType = { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): $wiringRetType = { | method.methodName match { | ${cases.shift(4).trim} | case _ => @@ -847,7 +777,8 @@ object ScServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asScRef(m.sig, domain, evo) + val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val decodeStep = @@ -860,17 +791,17 @@ object ScServiceWiringTranslator { | rt.fail[$bweFq, $inRef]($bweFq.DecoderFailed(method, ex)) |}""".stripMargin - val hasErrType = m.err.isDefined && !resolved.noErrors + val hasErrType = plan.hasError val callAndEncodeStep = m.out match { case Some(outRef) => - val outType = trans.asScRef(outRef, domain, evo) + val outType = plan.output.get val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"bw", q"v") val callBody = if (hasErrType) { - val errType = trans.asScRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${m.name.name}(${ctxArgPass}v) + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) | rt.leftMap[$errType, $outType, $bweFq]( | callResult, err => $bweFq.CallFailed(method, err)) |} catch { @@ -879,7 +810,7 @@ object ScServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | rt.pure[$bweFq, $outType](impl.${m.name.name}(${ctxArgPass}v)) + | rt.pure[$bweFq, $outType](impl.${plan.methodName}(${ctxArgPass}v)) |} catch { | case ex: Throwable => | rt.fail[$bweFq, $outType]($bweFq.CallFailed(method, ex)) @@ -904,9 +835,9 @@ object ScServiceWiringTranslator { case None => val callBody = if (hasErrType) { - val errType = trans.asScRef(m.err.get, domain, evo) + val errType = plan.error.get q"""try { - | val callResult = impl.${m.name.name}(${ctxArgPass}v) + | val callResult = impl.${plan.methodName}(${ctxArgPass}v) | rt.leftMap[$errType, Unit, $bweFq]( | callResult, err => $bweFq.CallFailed(method, err)) |} catch { @@ -915,7 +846,7 @@ object ScServiceWiringTranslator { |}""".stripMargin } else { q"""try { - | impl.${m.name.name}(${ctxArgPass}v) + | impl.${plan.methodName}(${ctxArgPass}v) | rt.pure[$bweFq, Unit](()) |} catch { | case ex: Throwable => @@ -929,7 +860,7 @@ object ScServiceWiringTranslator { |})""".stripMargin } - q"""case "${m.name.name}" => + q"""case "${plan.methodName}" => | ${decodeStep.shift(2).trim} | ${callAndEncodeStep.shift(2).trim}""".stripMargin }.join("\n") @@ -939,7 +870,7 @@ object ScServiceWiringTranslator { | data: Array[Byte], | impl: $svcName$svcTypeArg, | rt: $iBaboonServiceRtFq$rtTypeArg, - | ${ctxParamDecl}$codecCtxName: $baboonCodecContext): $wiringRetType = { + | $ctxParamDecl$codecCtxName: $baboonCodecContext): $wiringRetType = { | method.methodName match { | ${cases.shift(4).trim} | case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTreeTools.scala index 1d037c9f0..150ec5068 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTreeTools.scala @@ -1,6 +1,6 @@ package io.septimalmind.baboon.translator.scl -import io.septimalmind.baboon.translator.DocCommentEscaping +import io.septimalmind.baboon.translator.DocumentationRenderer import io.septimalmind.baboon.typer.model.Docs import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -40,35 +40,6 @@ object ScTreeTools { } } - def renderDocs(docs: Docs, indent: String): String = { - val prefixLines = docs.prefix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - val suffixLines = docs.suffix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - - if (prefixLines.isEmpty && suffixLines.isEmpty) return "" - - val mergedLines: List[String] = - if (suffixLines.isEmpty) prefixLines - else if (prefixLines.isEmpty) suffixLines - else prefixLines ++ List("") ++ suffixLines - - // D35: renderDocs introduces no backslashes (plain `/** */`), so the - // backslash-escape is applied at the renderDocs boundary. See - // DocCommentEscaping.escapeBackslashForQInterpolation. - val allLines: List[String] = - mergedLines.map(DocCommentEscaping.escapeBackslashForQInterpolation) - - // Single-line compact form: /** text */ - val rendered = - if (allLines.size == 1) { - s"$indent/** ${allLines.head} */\n" - } else { - val middle = allLines.map { - case "" => s"$indent *" - case l => s"$indent * $l" - }.mkString("\n") - s"$indent/**\n$middle\n$indent */\n" - } - rendered - } + def renderDocs(docs: Docs, indent: String): String = DocumentationRenderer.javadoc(docs, indent) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTypes.scala index 35795978d..01d38aadb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScTypes.scala @@ -67,6 +67,8 @@ object ScTypes { val baboonAnyOpaqueJson: ScType = ScType(baboonRuntimePkg, "AnyOpaqueJson") val baboonAnyMeta: ScType = ScType(baboonRuntimePkg, "AnyMeta") val baboonAnyMetaCodec: ScType = ScType(baboonRuntimePkg, "AnyMetaCodec") + val baboonAnyJsonCodec: ScType = ScType(baboonRuntimePkg, "BaboonAnyJsonCodec") + val baboonAnyBinCodec: ScType = ScType(baboonRuntimePkg, "BaboonAnyBinCodec") val baboonCodecException: ScType = ScType(baboonRuntimePkg, "BaboonCodecException") // baboon service wiring types @@ -85,8 +87,8 @@ object ScTypes { val baboonAbstractConversions = ScType(baboonRuntimePkg, "AbstractBaboonConversions") // baboon facade - val baboonCodecsFacade = ScType(baboonRuntimePkg, "BaboonCodecsFacade") - val baboonDomainVersion = ScType(baboonRuntimePkg, "BaboonDomainVersion") + val baboonCodecsFacade = ScType(baboonRuntimePkg, "BaboonCodecsFacade") + val baboonDomainVersion = ScType(baboonRuntimePkg, "BaboonDomainVersion") // scala @@ -203,15 +205,57 @@ object ScTypes { * Note: `default` is NOT a Scala keyword. */ val scReservedKeywords: Set[String] = Set( - "abstract", "case", "catch", "class", "def", "do", "else", "extends", - "false", "final", "finally", "for", "forSome", "if", "implicit", "import", - "lazy", "match", "new", "null", "object", "override", "package", "private", - "protected", "return", "sealed", "super", "this", "throw", "trait", "try", - "true", "type", "val", "var", "while", "with", "yield", + "abstract", + "case", + "catch", + "class", + "def", + "do", + "else", + "extends", + "false", + "final", + "finally", + "for", + "forSome", + "if", + "implicit", + "import", + "lazy", + "match", + "new", + "null", + "object", + "override", + "package", + "private", + "protected", + "return", + "sealed", + "super", + "this", + "throw", + "trait", + "try", + "true", + "type", + "val", + "var", + "while", + "with", + "yield", // Scala 3 soft keywords that can appear as identifiers in certain positions but are // safer to escape when used as field names: - "then", "export", "given", "using", "inline", "transparent", "opaque", "open", - "infix", "end", + "then", + "export", + "given", + "using", + "inline", + "transparent", + "opaque", + "open", + "infix", + "end", ) /** Escape a Scala identifier with backtick quoting when it collides with a reserved keyword. diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala index 67c322edf..34d21db36 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.scl.ScDomainTreeTools.MetaField import io.septimalmind.baboon.translator.scl.ScTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -73,87 +73,6 @@ class ScUEBACodecGenerator( } else None } - // Per-codec-object helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec object. - // See spec §172 (factor any-field encode/decode into helpers). - private def anyFieldHelpers: TextTree[ScValue] = { - q"""private def encodeAnyField( - | ctx: $baboonCodecContext, - | writer: $binaryOutput, - | expectedKind: $scByte, - | staticDomain: $scOption[$scString], - | staticVersion: $scOption[$scString], - | staticTypeid: $scOption[$scString], - | value: $baboonAnyOpaque, - |): $scUnit = { - | if (value.meta.kind != expectedKind) { - | throw $baboonCodecException.EncoderFailure(s"any: meta-kind mismatch on encode: expected 0x$${(expectedKind & 0xFF).toHexString}, got 0x$${(value.meta.kind & 0xFF).toHexString}") - | } - | val anyBytes: $scArray[$scByte] = value match { - | case anyUeba: $baboonAnyOpaqueUeba => - | anyUeba.bytes - | case anyJson: $baboonAnyOpaqueJson => - | val f = ctx.facade.getOrElse( - | throw $baboonCodecException.EncoderFailure( - | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." - | ) - | ) - | f.jsonToUebaBytes(anyJson.meta, anyJson.json, staticDomain, staticVersion, staticTypeid) match { - | case Right(b) => b - | case Left(e) => throw e - | } - | } - | val anyMetaBuf = new $byteArrayOutputStream() - | val anyMetaWriter = new $binaryOutput(anyMetaBuf) - | try { - | $baboonAnyMetaCodec.writeBin(value.meta, anyMetaWriter) - | } finally { - | anyMetaWriter.close() - | } - | val anyMetaBytes = anyMetaBuf.toByteArray - | val anyLength = 4 + anyMetaBytes.length + anyBytes.length - | writer.writeInt(anyLength) - | writer.writeInt(anyMetaBytes.length) - | writer.write(anyMetaBytes) - | writer.write(anyBytes) - |} - | - |private def decodeAnyField(wire: $binaryInput, expectedKind: $scByte): $baboonAnyOpaqueUeba = { - | val anyLength = wire.readInt() - | val anyMetaLen = wire.readInt() - | val (anyMeta, anyMetaBytesRead) = $baboonAnyMetaCodec.readBinWithLength(wire) - | if (anyMetaBytesRead > anyMetaLen) { - | throw $baboonCodecException.DecoderFailure(s"any: meta consumed $$anyMetaBytesRead bytes but meta-length=$$anyMetaLen") - | } - | if (anyMetaBytesRead < anyMetaLen) { - | // Skip future meta extension bytes within the meta-length window (forward-compat). - | wire.skipBytes(anyMetaLen - anyMetaBytesRead) - | } - | if (anyMeta.kind != expectedKind) { - | throw new $genericException(s"any: meta-kind mismatch: expected 0x$${(expectedKind & 0xFF).toHexString}, got 0x$${(anyMeta.kind & 0xFF).toHexString}") - | } - | val anyBlobLen = anyLength - 4 - anyMetaLen - | if (anyBlobLen < 0) { - | throw new $genericException(s"any: negative blob length $$anyBlobLen (length=$$anyLength, metaLen=$$anyMetaLen)") - | } - | val anyBlob = new $scArray[$scByte](anyBlobLen) - | wire.readFully(anyBlob) - | $baboonAnyOpaqueUeba(anyMeta, anyBlob) - |}""".stripMargin - } - - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - private def genCodec( defn: DomainMember.User, name: ScValue.ScType, @@ -229,15 +148,15 @@ class ScUEBACodecGenerator( val parents = List(cParent, q"$baboonBinCodecIndexed") val meta = renderMeta(defn, scDomainTreeTools.makeCodecMeta(defn)) - val anyHelpers: List[TextTree[ScValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val tail = (anyHelpers ++ meta).joinNN() + val tail = meta.joinNN() q"""object ${cName.name} extends ${parents.join(" with ")} { | ${baseMethods.joinNN().shift(2).trim} | | ${tail.shift(2).trim} | - | override protected def LazyInstance: $baboonLazy[$codecIface] = $baboonLazy($cName) + | private lazy val cachedLazyInstance: $baboonLazy[$codecIface] = $baboonLazy($cName) + | override protected def LazyInstance: $baboonLazy[$codecIface] = cachedLazyInstance | override def instance: $codecIface = LazyInstance.value |} """.stripMargin @@ -403,8 +322,8 @@ class ScUEBACodecGenerator( private def dtoDec(name: ScValue.ScType, fields: List[TextTree[ScValue]]): TextTree[ScValue] = { q"""for { - | index <- this.readIndex(ctx, wire) - | _ <- $scTry(if (ctx.useIndices) assert(index.size == indexElementsCount(ctx)) else ()).toEither + | index <- this.readIndexCount(ctx, wire) + | _ <- $scTry(if (ctx.useIndices) assert(index == indexElementsCount(ctx)) else ()).toEither | result <- $scTry { | $name( | ${fields.join(",\n").shift(6).trim} @@ -470,30 +389,7 @@ class ScUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"wire.readBoolean()" - case TypeId.Builtins.i08 => q"wire.readByte()" - case TypeId.Builtins.i16 => q"wire.readShort()" - case TypeId.Builtins.i32 => q"wire.readInt()" - case TypeId.Builtins.i64 => q"wire.readLong()" - case TypeId.Builtins.u08 => q"wire.readByte()" - case TypeId.Builtins.u16 => q"wire.readShort()" - case TypeId.Builtins.u32 => q"wire.readInt()" - case TypeId.Builtins.u64 => q"wire.readLong()" - case TypeId.Builtins.f32 => q"wire.readFloat()" - case TypeId.Builtins.f64 => q"wire.readDouble()" - - case TypeId.Builtins.f128 => q"$baboonBinTools.readBigDecimal(wire)" - case TypeId.Builtins.str => q"$baboonBinTools.readString(wire)" - case TypeId.Builtins.bytes => q"$baboonBinTools.readByteString(wire)" - - case TypeId.Builtins.uid => q"$baboonBinTools.readUid(wire)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => - q"$baboonBinTools.readTimestamp(wire)" - - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => ScScalarCodecEmitter.uebaDecode(s, q"wire") case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -529,43 +425,18 @@ class ScUEBACodecGenerator( } - // Wire layout (locked, see docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // length covers everything after itself; meta-length covers (kind + strings); blob runs the rest. - // The actual framing/kind-check/skip-extension logic lives in the per-codec-object - // `decodeAnyField` helper emitted by `anyFieldHelpers`; this just wires the expected kind in. + // The runtime helper owns envelope framing and metadata-window skipping. private def mkAnyDecoder(a: TypeRef.Any): TextTree[ScValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"""decodeAnyField(wire, $expectedHex.toByte)""" + q"""$baboonAnyBinCodec.decode(wire, $expectedHex.toByte)""" } private def mkEncoder(tpe: TypeRef, ref: TextTree[ScValue], wref: TextTree[ScValue]): TextTree[ScValue] = { tpe match { case TypeRef.Scalar(id) => id match { - case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.writeBoolean($ref)" - case TypeId.Builtins.i08 => q"$wref.writeByte($ref.toInt)" - case TypeId.Builtins.i16 => q"$wref.writeShort($ref.toInt)" - case TypeId.Builtins.i32 => q"$wref.writeInt($ref)" - case TypeId.Builtins.i64 => q"$wref.writeLong($ref)" - case TypeId.Builtins.u08 => q"$wref.writeByte($ref.toInt)" - case TypeId.Builtins.u16 => q"$wref.writeShort($ref.toInt)" - case TypeId.Builtins.u32 => q"$wref.writeInt($ref)" - case TypeId.Builtins.u64 => q"$wref.writeLong($ref)" - case TypeId.Builtins.f32 => q"$wref.writeFloat($ref)" - case TypeId.Builtins.f64 => q"$wref.writeDouble($ref)" - case TypeId.Builtins.f128 => q"$baboonBinTools.writeBigDecimal($wref, $ref)" - case TypeId.Builtins.str => q"$baboonBinTools.writeString($wref, $ref)" - case TypeId.Builtins.bytes => q"$baboonBinTools.writeByteString($wref, $ref)" - case TypeId.Builtins.uid => q"$baboonBinTools.writeUid($wref, $ref)" - case TypeId.Builtins.tsu | TypeId.Builtins.tso => - q"$baboonBinTools.writeTimestamp($wref, $ref)" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + case s: TypeId.BuiltinScalar => ScScalarCodecEmitter.uebaEncode(s, wref, ref) case u: TypeId.User => domain.defs.meta.nodes(u) match { case DomainMember.User(_, f: Typedef.Foreign, _, _) => @@ -621,14 +492,12 @@ class ScUEBACodecGenerator( } } - // Encode delegates to the per-codec-object `encodeAnyField` helper emitted by - // `anyFieldHelpers`. This site wires the expected kind byte and the field's static - // (codec-gen-time) fallbacks for cross-format meta resolution; see `anyStaticFallbacks`. + // The runtime helper owns envelope framing and cross-format conversion. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[ScValue], wref: TextTree[ScValue]): TextTree[ScValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedKind = AnyFieldPlan.forField(a, domain).kind val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"""encodeAnyField(ctx, $wref, $expectedHex.toByte, $staticDom, $staticVer, $staticTid, $ref)""" + q"""$baboonAnyBinCodec.encode(ctx, $wref, $expectedHex.toByte, $staticDom, $staticVer, $staticTid, $ref)""" } // Static fallbacks for the cross-format facade helpers (`jsonToUebaBytes` / `uebaToJson`). @@ -636,20 +505,9 @@ class ScUEBACodecGenerator( // codec emits whatever is statically known so the facade can fill the gaps. See // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[ScValue], TextTree[ScValue], TextTree[ScValue]) = { - val none = q"_root_.scala.None" - def some(s: String) = q"""_root_.scala.Some("$s")""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) + val plan = AnyFieldPlan.forField(a, domain) + def render(value: Option[String]): TextTree[ScValue] = value.fold[TextTree[ScValue]](q"_root_.scala.None")(s => q"""_root_.scala.Some("$s")""") + (render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[ScValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwAnyFieldRendering.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwAnyFieldRendering.scala new file mode 100644 index 000000000..348eae748 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwAnyFieldRendering.scala @@ -0,0 +1,17 @@ +package io.septimalmind.baboon.translator.swift + +import io.septimalmind.baboon.translator.AnyFieldPlan +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +private[swift] object SwAnyFieldRendering { + def kind(plan: AnyFieldPlan): String = "0x%02x".format(plan.kind & 0xFF) + + def arguments(plan: AnyFieldPlan): TextTree[SwValue] = { + def optional(value: Option[String]): TextTree[SwValue] = value match { + case Some(s) => q""""$s"""" + case None => q"nil" + } + q"${kind(plan)}, ${optional(plan.staticDomain)}, ${optional(plan.staticVersion)}, ${optional(plan.staticTypeId)}" + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala index e27c6f4a1..04a9ac32b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala @@ -102,11 +102,9 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( } } facade <- { - if ( - target.language.generateDomainFacade && + if (target.language.generateDomainFacade && target.output.products.contains(CompilerProduct.Conversion) && - domain.version == evo.latest - ) { + domain.version == evo.latest) { generateDomainFacade(domain, lineage) } else { F.pure(List.empty) @@ -397,6 +395,7 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( rt("BaboonRuntime/baboon_type_meta.swift", "baboon-runtime/swift/baboon_type_meta.swift"), rt("BaboonRuntime/baboon_service_wiring.swift", "baboon-runtime/swift/baboon_service_wiring.swift"), rt("BaboonRuntime/BaboonAnyOpaque.swift", "baboon-runtime/swift/BaboonAnyOpaque.swift"), + rt("BaboonRuntime/baboon_any_field_codec.swift", "baboon-runtime/swift/baboon_any_field_codec.swift"), rt("BaboonRuntime/BaboonCodecsFacade.swift", "baboon-runtime/swift/BaboonCodecsFacade.swift"), rt("BaboonRuntime/BaboonIdentifierRepr.swift", "baboon-runtime/swift/BaboonIdentifierRepr.swift"), ) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala index 7ac988407..589da84dd 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.swift import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.swift.SwBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.swift.SwTypes.* import io.septimalmind.baboon.translator.swift.SwValue.SwPackageId @@ -352,11 +352,7 @@ class SwConversionTranslator[F[+_, +_]: Error2]( List(RenderedConversion(fname, tools.inLib(classDef, Owner.Toplevel), Some(regtree), None)) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[RenderedConversion]] } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala index 41e9a635f..7523d3a87 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala @@ -2,6 +2,7 @@ package io.septimalmind.baboon.translator.swift import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.swift.SwValue.SwType import io.septimalmind.baboon.typer.EnumWireStyle @@ -62,7 +63,7 @@ object SwDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[SwValue]): TextTree[SwValue] = { val block = swTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } override def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { @@ -86,27 +87,29 @@ object SwDefnTranslator { imports = Set("BaboonRuntime"), ) - val wiringOutput = wiringTranslator.translate(defn).map { - wiringTree => - Output( - getOutputPath(defn, suffix = Some("_wiring")), - wiringTree, - getOutputModule(defn), - CompilerProduct.Definition, - imports = Set("BaboonRuntime"), - ) - }.toList - - val clientOutput = wiringTranslator.translateClient(defn).map { - clientTree => - Output( - getOutputPath(defn, suffix = Some("_client")), - clientTree, - getOutputModule(defn), - CompilerProduct.Definition, - imports = Set("BaboonRuntime"), - ) - }.toList + val wiringOutput = wiringTranslator + .translate(defn).map { + wiringTree => + Output( + getOutputPath(defn, suffix = Some("_wiring")), + wiringTree, + getOutputModule(defn), + CompilerProduct.Definition, + imports = Set("BaboonRuntime"), + ) + }.toList + + val clientOutput = wiringTranslator + .translateClient(defn).map { + clientTree => + Output( + getOutputPath(defn, suffix = Some("_client")), + clientTree, + getOutputModule(defn), + CompilerProduct.Definition, + imports = Set("BaboonRuntime"), + ) + }.toList F.pure(mainOutput :: wiringOutput ::: clientOutput) } @@ -400,14 +403,15 @@ object SwDefnTranslator { * type rather than a host primitive. Used only by the recursion-detection walk. */ private def foreignAliasRef(uid: TypeId.User): Option[TypeRef] = { - domain.defs.meta.nodes.get(uid).collect { - case DomainMember.User(_, f: Typedef.Foreign, _, _) => f - }.flatMap { - f => - f.bindings.valuesIterator.collectFirst { - case Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(ref)) => ref - } - } + domain.defs.meta.nodes + .get(uid).collect { + case DomainMember.User(_, f: Typedef.Foreign, _, _) => f + }.flatMap { + f => + f.bindings.valuesIterator.collectFirst { + case Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(ref)) => ref + } + } } private def renderDto( @@ -538,44 +542,6 @@ object SwDefnTranslator { } // ----- Identifier toString + parseRepr emission (PR-57c) ----- - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32/i64 */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 */ - case object UnsignedLong extends IdentifierFieldKind /* u64 */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` | `i64` => IdentifierFieldKind.SignedInt - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def signedTypeName(tpe: TypeRef): String = tpe match { case TypeRef.Scalar(TypeId.Builtins.i08) => "i08" case TypeRef.Scalar(TypeId.Builtins.i16) => "i16" @@ -623,11 +589,11 @@ object SwDefnTranslator { private def renderFieldValueExpr(swFieldName: String, kind: IdentifierFieldKind): TextTree[SwValue] = { kind match { - case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(self.$swFieldName)" - case IdentifierFieldKind.SignedInt => q"String(self.$swFieldName)" - case IdentifierFieldKind.UnsignedSmallInt => q"String(self.$swFieldName)" - case IdentifierFieldKind.UnsignedLong => q"$baboonIdRepr.u64ToString(self.$swFieldName)" - case IdentifierFieldKind.Str => q"$baboonIdRepr.escapeStr(self.$swFieldName)" + case IdentifierFieldKind.Bit => q"$baboonIdRepr.bitToString(self.$swFieldName)" + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => q"String(self.$swFieldName)" + case IdentifierFieldKind.UnsignedSmallInt => q"String(self.$swFieldName)" + case IdentifierFieldKind.UnsignedLong => q"$baboonIdRepr.u64ToString(self.$swFieldName)" + case IdentifierFieldKind.Str => q"$baboonIdRepr.escapeStr(self.$swFieldName)" // UUID.uuidString in Foundation is uppercase; canonical lowercase per spec. case IdentifierFieldKind.Uid => q"self.$swFieldName.uuidString.lowercased()" case IdentifierFieldKind.Tsu => q"$baboonIdRepr.tsuToString(self.$swFieldName)" @@ -646,7 +612,7 @@ object SwDefnTranslator { f => val srcFieldName = f.name.name val swFieldName = trans.escapeSwiftKeyword(srcFieldName) - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderFieldValueExpr(swFieldName, kind) q""""$srcFieldName:" + ($valueExpr)""" } @@ -675,7 +641,7 @@ object SwDefnTranslator { val valVar = s"${swFieldName}_v" val resVar = s"${swFieldName}_r" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val tpe = trans.asSwRef(f.tpe, domain, evo) val parseHead = @@ -688,7 +654,7 @@ object SwDefnTranslator { |let $resVar = $baboonIdRepr.parseBit($rawVar) |if case .left(let l) = $resVar { return .left(l) } |guard case .right(let $valVar) = $resVar else { return .left("internal: bit decode") }""".stripMargin - case IdentifierFieldKind.SignedInt => + case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => val typeName = signedTypeName(f.tpe) val rangeCheck = signedRangeCheck(f.tpe, "v_long") val narrow = signedNarrow(f.tpe) @@ -1003,18 +969,18 @@ object SwDefnTranslator { // the no-codec backends never see it as a real symbol. val swFqName: SwValue => String = { case t: SwType if t.fq => - val module = t.pkg.parts.head + val module = t.pkg.parts.head val escaped = t.name.split('.').map(trans.escapeSwiftKeyword).mkString(".") s"$module.$escaped" - case t: SwType => t.name.split('.').map(trans.escapeSwiftKeyword).mkString(".") + case t: SwType => t.name.split('.').map(trans.escapeSwiftKeyword).mkString(".") case n: SwValue.SwTypeName => trans.escapeSwiftKeyword(n.name) } val methods = service.methods.map { m => - val in = trans.asSwRef(m.sig, domain, evo) - val out = m.out.map(trans.asSwRef(_, domain, evo)) - val err = m.err.map(trans.asSwRef(_, domain, evo)) + val in = trans.asSwRef(m.sig, domain, evo) + val out = m.out.map(trans.asSwRef(_, domain, evo)) + val err = m.err.map(trans.asSwRef(_, domain, evo)) // Bare-`out` cases (noErrors mode, or an err-free method) keep the // original `out` SwType subtree verbatim so the render codec still diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala index a06939990..6f4b208da 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala @@ -6,7 +6,7 @@ import io.septimalmind.baboon.translator.swift.SwCodecTranslator.CodecMeta import io.septimalmind.baboon.translator.swift.SwDomainTreeTools.MetaField import io.septimalmind.baboon.translator.swift.SwTypes.* import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -60,10 +60,9 @@ class SwJsonCodecGenerator( |}""".stripMargin.trim ) - val anyHelpers: List[TextTree[SwValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encodeMethod ++ decodeMethod ++ anyHelpers - val cName = codecName(srcRef) - val meta = renderMeta(defn, swDomainTreeTools.makeCodecMeta(defn)) + val baseMethods = encodeMethod ++ decodeMethod + val cName = codecName(srcRef) + val meta = renderMeta(defn, swDomainTreeTools.makeCodecMeta(defn)) val cParent = if (isEncoderEnabled) { defn match { @@ -256,19 +255,7 @@ class SwJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"Int($ref)" - case TypeId.Builtins.i64 => q"String($ref)" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"Int($ref)" - case TypeId.Builtins.u64 => q"String($ref)" - case TypeId.Builtins.f32 => q"Double($ref)" - case TypeId.Builtins.f64 => q"$ref" - case TypeId.Builtins.f128 => q"$ref.stringValue" - case TypeId.Builtins.str => q"$ref" - case TypeId.Builtins.uid => q"$ref.uuidString" - case TypeId.Builtins.bytes => q"$baboonByteStringTools.toHexString($ref)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($ref)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($ref)" + case b: TypeId.BuiltinScalar => SwScalarCodecs.jsonEncode(b, ref) case u: TypeId.User => val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) q"$targetTpe.instance.encode(ctx, $ref)" @@ -306,23 +293,9 @@ class SwJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => (q"$ref as! Bool", false) - case TypeId.Builtins.i08 => (q"Int8(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.i16 => (q"Int16(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.i32 => (q"Int32(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.i64 => (q"($ref is String ? Int64($ref as! String)! : Int64(truncatingIfNeeded: ($ref as! NSNumber).int64Value))", false) - case TypeId.Builtins.u08 => (q"UInt8(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.u16 => (q"UInt16(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.u32 => (q"UInt32(truncatingIfNeeded: ($ref as! NSNumber).intValue)", false) - case TypeId.Builtins.u64 => (q"($ref is String ? UInt64($ref as! String)! : UInt64(truncatingIfNeeded: ($ref as! NSNumber).uint64Value))", false) - case TypeId.Builtins.f32 => (q"Float(($ref as! NSNumber).doubleValue)", false) - case TypeId.Builtins.f64 => (q"($ref as! NSNumber).doubleValue", false) - case TypeId.Builtins.f128 => (q"$baboonDecimal($ref is String ? $ref as! String : String(describing: $ref))", false) - case TypeId.Builtins.str => (q"($ref as! String)", false) - case TypeId.Builtins.uid => (q"UUID(uuidString: $ref as! String)!", false) - case TypeId.Builtins.bytes => (q"$baboonByteStringTools.fromHexString($ref as! String)", false) - case TypeId.Builtins.tsu => (q"$baboonTimeFormats.parseUtc($ref as! String)", false) - case TypeId.Builtins.tso => (q"$baboonTimeFormats.parseOffset($ref as! String)", false) + case b: TypeId.BuiltinScalar => + val decoded = SwScalarCodecs.jsonDecode(b, ref) + (decoded.expression, decoded.mayThrow) case u: TypeId.User => val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) (q"$targetTpe.instance.decode(ctx, $ref)", true) @@ -467,179 +440,14 @@ class SwJsonCodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java/TS/Dart `hasAnyField`): a codec class needs the - // any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-class `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[SwValue]): TextTree[SwValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, $expectedHex, $staticDom, $staticVer, $staticTid, $ref)" + val args = SwAnyFieldRendering.arguments(AnyFieldPlan.forField(a, domain)) + q"BaboonRuntime.BaboonAnyJsonFieldCodec.encodeAnyField(ctx, $args, $ref)" } - // Decode delegates to the per-codec-class `decodeAnyField` helper. Returns the bare call - // expression without `try` — the caller (`decodeElement`) surfaces `mayThrow = true` so the - // outer field-assignment site adds `try` exactly once. private def mkAnyDecoder(a: TypeRef.Any, ref: TextTree[SwValue]): TextTree[SwValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField($expectedHex, $ref)" - } - - // Static fallbacks for the cross-format facade helper (`uebaToJson`). The wire `meta` may omit - // components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(nil,nil,nil), B=(currentDomain,nil,nil), C=(currentDomain,currentVersion,nil), - // D1=(nil,nil,underlyingFqid), D2=(currentDomain,nil,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS/Dart UEBA + JSON sites — extraction deferred - // (textual emission diverges by language flavor; see PR 4.2 ledger entry's DRY analysis). - // 12th instance. - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[SwValue], TextTree[SwValue], TextTree[SwValue]) = { - val none = q"nil" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec class that has any any-bearing fields. Mirrors PR 6.3's Java - // helper shape and PR 8.3's Dart shape. Kind-check on encode runs before any envelope construction. - // - // Cast-via-`as?`: `ctx.facade` returns `BaboonCodecsFacadeBase?` (PR 9.1 import-cycle break). - // The concrete facade carries `uebaToJson`; the cast is safe because - // `BaboonCodecContext.withFacade` is the only construction path and accepts the same hierarchy. - // Mirrors the symmetric cast used by `SwUEBACodecGenerator.anyFieldHelpers` (PR 9.2). - // - // The encoder helper is non-throwing: `encode(...)` on `BaboonJsonCodecBase` is non-throwing - // (see `baboon_runtime.swift` line 100), so kind-mismatch / null-facade / facade-returned-failure - // paths use `preconditionFailure`. Mirrors the symmetric Swift type-erasure pattern in - // `AnyBaboonJsonEncoder.encodeAnyValue` and the UEBA helper. The `decodeAnyField` helper IS - // `throws` since the base `decode(...)` is `throws` — uses typed - // `BaboonCodecException.decoderFailure`. - // - // PR-08-D06 lesson: `AnyMetaCodec.writeJson` returns mutable `[String: Any]` already (Swift - // dictionaries are value types), so adding the `$c` envelope key needs no cast (cf. Java where - // an `(ObjectNode)` cast is required since the static type is `JsonNode`). - private def anyFieldHelpers: TextTree[SwValue] = { - q"""private func encodeAnyField( - | _ ctx: $baboonCodecContext, - | _ expectedKind: UInt8, - | _ staticDomain: String?, - | _ staticVersion: String?, - | _ staticTypeid: String?, - | _ value: $baboonAnyOpaque - |) -> Any { - | if value.meta.kind != expectedKind { - | preconditionFailure( - | "any: meta-kind 0x\\(String(format: \"%02x\", value.meta.kind & 0xFF)) " + - | "does not match field-declared 0x\\(String(format: \"%02x\", expectedKind & 0xFF))" - | ) - | } - | let anyInner: Any? - | switch value { - | case .json(_, let jsonValue): - | anyInner = jsonValue - | case .ueba(let uebaMeta, let uebaBytes): - | guard let anyFacadeBase = ctx.facade else { - | preconditionFailure( - | "Cannot encode AnyOpaque.ueba into JSON without a facade reference. " + - | "Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), " + - | "or supply AnyOpaque.json directly." - | ) - | } - | // Downcast to the concrete facade — the marker base is empty by design (PR 9.1 - | // import-cycle break). Construction goes through BaboonCodecContext.withFacade - | // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. - | guard let anyFacade = anyFacadeBase as? $baboonCodecsFacade else { - | preconditionFailure( - | "BaboonCodecContext.facade is not a BaboonCodecsFacade: " + - | "\\(type(of: anyFacadeBase))" - | ) - | } - | let anyConvResult = anyFacade.uebaToJson( - | uebaMeta, - | uebaBytes, - | staticDomain: staticDomain, - | staticVersion: staticVersion, - | staticTypeid: staticTypeid - | ) - | switch anyConvResult { - | case .failure(let err): - | preconditionFailure("any: uebaToJson failed: \\(err)") - | case .success(let json): - | anyInner = json - | } - | } - | var anyEnvelope = $baboonAnyMetaCodec.writeJson(value.meta) - | // Swift `[String: Any]` treats `dict[k] = nil` as "remove key" — use NSNull() so the - | // content envelope key is preserved when the inner JSON is null (PR-08-D06 analog: - | // keep wire-shape consistent with Java/Dart/TS regardless of payload-null state). - | anyEnvelope[$baboonAnyMetaCodec.ANY_CONTENT_KEY] = anyInner ?? NSNull() - | return anyEnvelope - |} - | - |private func decodeAnyField(_ expectedKind: UInt8, _ wire: Any) throws -> $baboonAnyOpaque { - | guard let anyEnvelope = wire as? [String: Any] else { - | throw $baboonCodecException.decoderFailure( - | "any: JSON envelope must be an object", - | nil - | ) - | } - | let anyMetaResult = $baboonAnyMetaCodec.readJson(anyEnvelope) - | let anyMeta: $baboonAnyMeta - | switch anyMetaResult { - | case .failure(let err): - | throw err - | case .success(let m): - | anyMeta = m - | } - | if anyMeta.kind != expectedKind { - | throw $baboonCodecException.decoderFailure( - | "any: wire kind 0x\\(String(format: \"%02x\", anyMeta.kind & 0xFF)) " + - | "does not match field-declared 0x\\(String(format: \"%02x\", expectedKind & 0xFF))", - | nil - | ) - | } - | guard anyEnvelope.keys.contains($baboonAnyMetaCodec.ANY_CONTENT_KEY) else { - | throw $baboonCodecException.decoderFailure( - | "any: JSON envelope missing '\\($baboonAnyMetaCodec.ANY_CONTENT_KEY)' content key", - | nil - | ) - | } - | // `keys.contains(...)` confirmed presence above; the value can still be NSNull() for an - | // explicit JSON null payload — pass through to the AnyOpaque.json variant. Swift - | // dictionary lookup returns `Any?` (None means absent — already excluded), so the - | // force-unwrap is safe inside the contains-guard. - | let anyContent = anyEnvelope[$baboonAnyMetaCodec.ANY_CONTENT_KEY]! - | return .json(meta: anyMeta, json: anyContent) - |}""".stripMargin + val kind = SwAnyFieldRendering.kind(AnyFieldPlan.forField(a, domain)) + q"BaboonRuntime.BaboonAnyJsonFieldCodec.decodeAnyField($kind, $ref)" } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[SwValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwMcpServerGenerator.scala index 0a8b01421..c5d01366d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwMcpServerGenerator.scala @@ -66,9 +66,10 @@ class SwMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -94,7 +95,8 @@ class SwMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/$fname" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val path = serverPath(svc, domain, evo) val serviceName = svc.id.name.name @@ -111,10 +113,12 @@ class SwMcpServerGenerator[F[+_, +_]: Error2]( val toolEntries: List[String] = svc.methods.toList.map { m => val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) val schemaLiteral = swiftStringLiteral(schema.noSpaces) val descArg = McpDocs.flatten(m.docs).map(d => s", ${swiftStringLiteral(d)}").getOrElse("") - s""" McpToolEntry(${swiftString(toolName)}, BaboonMethodId(serviceId: ${swiftString(serviceName)}, methodName: ${swiftString(m.name.name)}), $className._parseSchema($schemaLiteral)$descArg),""" + s""" McpToolEntry(${swiftString(toolName)}, BaboonMethodId(serviceId: ${swiftString(serviceName)}, methodName: ${swiftString( + m.name.name + )}), $className._parseSchema($schemaLiteral)$descArg),""" } // Async axis (D24/T67): the delegate type, the conformed protocol, and the @@ -142,26 +146,30 @@ class SwMcpServerGenerator[F[+_, +_]: Error2]( |// service) plus the per-request `Ctx`. |public final class $className: $mcpProtocol { | private let _invokeJson: (BaboonMethodId, String, Ctx, BaboonCodecContext) $delegateEffects -> String + | private let _tools: [McpToolEntry] + | private let _toolsByName: [String: McpToolEntry] | | public init(_ invokeJson: @escaping (BaboonMethodId, String, Ctx, BaboonCodecContext) $delegateEffects -> String) { | self._invokeJson = invokeJson + | let tools: [McpToolEntry] = [ + |${toolEntries.mkString("\n")} + | ] + | self._tools = tools + | self._toolsByName = Dictionary(tools.map { ($$0.name, $$0) }, uniquingKeysWith: { _, last in last }) | } | | public var serverInfo: McpServerInfo { | return McpServerInfo(${swiftString(serviceName)}, ${swiftString(modelVer)}) | } | - | // inputSchema values are parsed once via JSONSerialization. `jsonObject` - | // is not a constant expression so the registry is a lazily-computed - | // stored property carried as a constant for the server's lifetime. - | private lazy var _tools: [McpToolEntry] = [ - |${toolEntries.mkString("\n")} - | ] - | | public var tools: [McpToolEntry] { | return _tools | } | + | public func tool(named name: String) -> McpToolEntry? { + | return _toolsByName[name] + | } + | | public func invokeJson(_ method: BaboonMethodId, _ data: String, _ ctx: Ctx, _ codecCtx: BaboonCodecContext) $methodEffects -> String { | return $invokeCallPfx _invokeJson(method, data, ctx, codecCtx) | } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwScalarCodecs.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwScalarCodecs.scala new file mode 100644 index 000000000..001c2b7a3 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwScalarCodecs.scala @@ -0,0 +1,97 @@ +package io.septimalmind.baboon.translator.swift + +import io.septimalmind.baboon.translator.swift.SwTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +private[swift] object SwScalarCodecs { + sealed trait ThrowingEffect + case object NonThrowing extends ThrowingEffect + case object Throwing extends ThrowingEffect + final case class Decoded(expression: TextTree[SwValue], effect: ThrowingEffect) { + def mayThrow: Boolean = effect == Throwing + def withTry: TextTree[SwValue] = if (mayThrow) q"try $expression" else expression + } + + def jsonDecode(id: TypeId.BuiltinScalar, wire: TextTree[SwValue]): Decoded = id match { + case TypeId.Builtins.bit => Decoded(q"$wire as! Bool", NonThrowing) + case TypeId.Builtins.i08 => Decoded(q"Int8(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.i16 => Decoded(q"Int16(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.i32 => Decoded(q"Int32(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.i64 => Decoded(q"($wire is String ? Int64($wire as! String)! : Int64(truncatingIfNeeded: ($wire as! NSNumber).int64Value))", NonThrowing) + case TypeId.Builtins.u08 => Decoded(q"UInt8(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.u16 => Decoded(q"UInt16(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.u32 => Decoded(q"UInt32(truncatingIfNeeded: ($wire as! NSNumber).intValue)", NonThrowing) + case TypeId.Builtins.u64 => Decoded(q"($wire is String ? UInt64($wire as! String)! : UInt64(truncatingIfNeeded: ($wire as! NSNumber).uint64Value))", NonThrowing) + case TypeId.Builtins.f32 => Decoded(q"Float(($wire as! NSNumber).doubleValue)", NonThrowing) + case TypeId.Builtins.f64 => Decoded(q"($wire as! NSNumber).doubleValue", NonThrowing) + case TypeId.Builtins.f128 => Decoded(q"$baboonDecimal($wire is String ? $wire as! String : String(describing: $wire))", NonThrowing) + case TypeId.Builtins.str => Decoded(q"($wire as! String)", NonThrowing) + case TypeId.Builtins.uid => Decoded(q"UUID(uuidString: $wire as! String)!", NonThrowing) + case TypeId.Builtins.bytes => Decoded(q"$baboonByteStringTools.fromHexString($wire as! String)", NonThrowing) + case TypeId.Builtins.tsu => Decoded(q"$baboonTimeFormats.parseUtc($wire as! String)", NonThrowing) + case TypeId.Builtins.tso => Decoded(q"$baboonTimeFormats.parseOffset($wire as! String)", NonThrowing) + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def jsonEncode(id: TypeId.BuiltinScalar, value: TextTree[SwValue]): TextTree[SwValue] = id match { + case TypeId.Builtins.bit => q"$value" + case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"Int($value)" + case TypeId.Builtins.i64 => q"String($value)" + case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"Int($value)" + case TypeId.Builtins.u64 => q"String($value)" + case TypeId.Builtins.f32 => q"Double($value)" + case TypeId.Builtins.f64 => q"$value" + case TypeId.Builtins.f128 => q"$value.stringValue" + case TypeId.Builtins.str => q"$value" + case TypeId.Builtins.uid => q"$value.uuidString" + case TypeId.Builtins.bytes => q"$baboonByteStringTools.toHexString($value)" + case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($value)" + case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($value)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaDecode(id: TypeId.BuiltinScalar, reader: TextTree[SwValue]): Decoded = id match { + case TypeId.Builtins.bit => Decoded(q"$reader.readBool()", NonThrowing) + case TypeId.Builtins.i08 => Decoded(q"$reader.readI8()", NonThrowing) + case TypeId.Builtins.i16 => Decoded(q"$reader.readI16()", NonThrowing) + case TypeId.Builtins.i32 => Decoded(q"$reader.readI32()", NonThrowing) + case TypeId.Builtins.i64 => Decoded(q"$reader.readI64()", NonThrowing) + case TypeId.Builtins.u08 => Decoded(q"$reader.readU8()", NonThrowing) + case TypeId.Builtins.u16 => Decoded(q"$reader.readU16()", NonThrowing) + case TypeId.Builtins.u32 => Decoded(q"$reader.readU32()", NonThrowing) + case TypeId.Builtins.u64 => Decoded(q"$reader.readU64()", NonThrowing) + case TypeId.Builtins.f32 => Decoded(q"$reader.readF32()", NonThrowing) + case TypeId.Builtins.f64 => Decoded(q"$reader.readF64()", NonThrowing) + case TypeId.Builtins.f128 => Decoded(q"$reader.readDecimal()", NonThrowing) + case TypeId.Builtins.str => Decoded(q"$reader.readString()", Throwing) + case TypeId.Builtins.bytes => Decoded(q"$reader.readBytes()", Throwing) + case TypeId.Builtins.uid => Decoded(q"$reader.readUuid()", Throwing) + case TypeId.Builtins.tsu => Decoded(q"$reader.readTsu()", NonThrowing) + case TypeId.Builtins.tso => Decoded(q"$reader.readTso()", NonThrowing) + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + + def uebaEncode(id: TypeId.BuiltinScalar, writer: TextTree[SwValue], value: TextTree[SwValue]): TextTree[SwValue] = id match { + case TypeId.Builtins.bit => q"$writer.writeBool($value)" + case TypeId.Builtins.i08 => q"$writer.writeI8($value)" + case TypeId.Builtins.i16 => q"$writer.writeI16($value)" + case TypeId.Builtins.i32 => q"$writer.writeI32($value)" + case TypeId.Builtins.i64 => q"$writer.writeI64($value)" + case TypeId.Builtins.u08 => q"$writer.writeU8($value)" + case TypeId.Builtins.u16 => q"$writer.writeU16($value)" + case TypeId.Builtins.u32 => q"$writer.writeU32($value)" + case TypeId.Builtins.u64 => q"$writer.writeU64($value)" + case TypeId.Builtins.f32 => q"$writer.writeF32($value)" + case TypeId.Builtins.f64 => q"$writer.writeF64($value)" + case TypeId.Builtins.f128 => q"$writer.writeDecimal($value)" + case TypeId.Builtins.str => q"$writer.writeString($value)" + case TypeId.Builtins.bytes => q"$writer.writeBytes($value)" + case TypeId.Builtins.uid => q"$writer.writeUuid($value)" + case TypeId.Builtins.tsu => q"$writer.writeTsu($value)" + case TypeId.Builtins.tso => q"$writer.writeTso($value)" + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala index bf837a367..887e8e1ce 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala @@ -36,14 +36,10 @@ object SwServiceWiringTranslator { // identical to the prior output). private val implCallPrefix: String = if (isAsync) "try await " else "" - // Effect keywords for the noErrors invoke dispatchers and their per-method - // handler closures. Sync: `throws `; async: `async throws `. The trailing + // Effect keywords for the noErrors invoke dispatchers. Sync: `throws `; async: `async throws `. The trailing // space keeps the sync rendering identical to the prior output // (`) throws -> String {`). private val dispatcherEffects: String = if (isAsync) "async throws " else "throws " - private val closureEffects: String = dispatcherEffects - // Prefix for invoking the resolved handler closure (`return handler()`). - private val handlerCallPrefix: String = if (isAsync) "try await" else "try" // Client method effects (`public func ...(...) `), // the transport closure type effects, and the prefix used when invoking the @@ -144,103 +140,27 @@ object SwServiceWiringTranslator { // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). // Swift wiring JSON: wire is Any (from JSONSerialization.jsonObject), encode returns Any. private def jsonDecodeExpr(id: TypeId, wire: TextTree[SwValue]): TextTree[SwValue] = id match { - case u: TypeId.User => q"try ${jsonCodecName(u)}.instance.decode($codecCtxRef, $wire)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$wire as! Bool" - case TypeId.Builtins.i08 => q"Int8(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.i16 => q"Int16(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.i32 => q"Int32(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.i64 => q"($wire is String ? Int64($wire as! String)! : Int64(truncatingIfNeeded: ($wire as! NSNumber).int64Value))" - case TypeId.Builtins.u08 => q"UInt8(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.u16 => q"UInt16(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.u32 => q"UInt32(truncatingIfNeeded: ($wire as! NSNumber).intValue)" - case TypeId.Builtins.u64 => q"($wire is String ? UInt64($wire as! String)! : UInt64(truncatingIfNeeded: ($wire as! NSNumber).uint64Value))" - case TypeId.Builtins.f32 => q"Float(($wire as! NSNumber).doubleValue)" - case TypeId.Builtins.f64 => q"($wire as! NSNumber).doubleValue" - case TypeId.Builtins.f128 => q"$baboonDecimal($wire is String ? $wire as! String : String(describing: $wire))" - case TypeId.Builtins.str => q"($wire as! String)" - case TypeId.Builtins.uid => q"UUID(uuidString: $wire as! String)!" - case TypeId.Builtins.bytes => q"$baboonByteStringTools.fromHexString($wire as! String)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.parseUtc($wire as! String)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.parseOffset($wire as! String)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"try ${jsonCodecName(u)}.instance.decode($codecCtxRef, $wire)" + case b: TypeId.BuiltinScalar => SwScalarCodecs.jsonDecode(b, wire).withTry + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[SwValue]): TextTree[SwValue] = id match { - case u: TypeId.User => q"${jsonCodecName(u)}.instance.encode($codecCtxRef, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$value" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 => q"Int($value)" - case TypeId.Builtins.i64 => q"String($value)" - case TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 => q"Int($value)" - case TypeId.Builtins.u64 => q"String($value)" - case TypeId.Builtins.f32 => q"Double($value)" - case TypeId.Builtins.f64 => q"$value" - case TypeId.Builtins.f128 => q"$value.stringValue" - case TypeId.Builtins.str => q"$value" - case TypeId.Builtins.uid => q"$value.uuidString" - case TypeId.Builtins.bytes => q"$baboonByteStringTools.toHexString($value)" - case TypeId.Builtins.tsu => q"$baboonTimeFormats.formatUtc($value)" - case TypeId.Builtins.tso => q"$baboonTimeFormats.formatOffset($value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${jsonCodecName(u)}.instance.encode($codecCtxRef, $value)" + case b: TypeId.BuiltinScalar => SwScalarCodecs.jsonEncode(b, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, reader: TextTree[SwValue]): TextTree[SwValue] = id match { - case u: TypeId.User => q"try ${uebaCodecName(u)}.instance.decode($codecCtxRef, $reader)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$reader.readBool()" - case TypeId.Builtins.i08 => q"$reader.readI8()" - case TypeId.Builtins.i16 => q"$reader.readI16()" - case TypeId.Builtins.i32 => q"$reader.readI32()" - case TypeId.Builtins.i64 => q"$reader.readI64()" - case TypeId.Builtins.u08 => q"$reader.readU8()" - case TypeId.Builtins.u16 => q"$reader.readU16()" - case TypeId.Builtins.u32 => q"$reader.readU32()" - case TypeId.Builtins.u64 => q"$reader.readU64()" - case TypeId.Builtins.f32 => q"$reader.readF32()" - case TypeId.Builtins.f64 => q"$reader.readF64()" - case TypeId.Builtins.f128 => q"$reader.readDecimal()" - case TypeId.Builtins.str => q"try $reader.readString()" - case TypeId.Builtins.bytes => q"try $reader.readBytes()" - case TypeId.Builtins.uid => q"try $reader.readUuid()" - case TypeId.Builtins.tsu => q"$reader.readTsu()" - case TypeId.Builtins.tso => q"$reader.readTso()" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"try ${uebaCodecName(u)}.instance.decode($codecCtxRef, $reader)" + case b: TypeId.BuiltinScalar => SwScalarCodecs.uebaDecode(b, reader).withTry + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, writer: TextTree[SwValue], value: TextTree[SwValue]): TextTree[SwValue] = id match { - case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $writer, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$writer.writeBool($value)" - case TypeId.Builtins.i08 => q"$writer.writeI8($value)" - case TypeId.Builtins.i16 => q"$writer.writeI16($value)" - case TypeId.Builtins.i32 => q"$writer.writeI32($value)" - case TypeId.Builtins.i64 => q"$writer.writeI64($value)" - case TypeId.Builtins.u08 => q"$writer.writeU8($value)" - case TypeId.Builtins.u16 => q"$writer.writeU16($value)" - case TypeId.Builtins.u32 => q"$writer.writeU32($value)" - case TypeId.Builtins.u64 => q"$writer.writeU64($value)" - case TypeId.Builtins.f32 => q"$writer.writeF32($value)" - case TypeId.Builtins.f64 => q"$writer.writeF64($value)" - case TypeId.Builtins.f128 => q"$writer.writeDecimal($value)" - case TypeId.Builtins.str => q"$writer.writeString($value)" - case TypeId.Builtins.bytes => q"$writer.writeBytes($value)" - case TypeId.Builtins.uid => q"$writer.writeUuid($value)" - case TypeId.Builtins.tsu => q"$writer.writeTsu($value)" - case TypeId.Builtins.tso => q"$writer.writeTso($value)" - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case u: TypeId.User => q"${uebaCodecName(u)}.instance.encode($codecCtxRef, $writer, $value)" + case b: TypeId.BuiltinScalar => SwScalarCodecs.uebaEncode(b, writer, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def renderContainer(error: String, success: String): String = { @@ -354,7 +274,7 @@ object SwServiceWiringTranslator { q"""public func ${m.name.name}(${clientCtxParamDecl}arg: $inRef, $codecCtxName: $baboonCodecContext = $baboonCodecContext.defaultCtx) $clientEffects$retDecl { | let writer = $baboonBinTools.createWriter() | $encodeIn - | let resp = $transportCallPrefix self.transportUeba(${clientCtxArg}"$svcName", "${m.name.name}", writer.toData()) + | let resp = $transportCallPrefix self.transportUeba($clientCtxArg"$svcName", "${m.name.name}", writer.toData()) | ${decodeOut.shift(4).trim} |}""".stripMargin ) @@ -375,7 +295,7 @@ object SwServiceWiringTranslator { | let encoded = $encodeIn | let jsonData = try JSONSerialization.data(withJSONObject: encoded, options: [.sortedKeys, .fragmentsAllowed]) | let encodedStr = String(data: jsonData, encoding: .utf8)! - | let resp = $transportCallPrefix self.transportJson(${clientCtxArg}"$svcName", "${m.name.name}", encodedStr) + | let resp = $transportCallPrefix self.transportJson($clientCtxArg"$svcName", "${m.name.name}", encodedStr) | ${decodeOut.shift(4).trim} |}""".stripMargin ) @@ -431,9 +351,9 @@ object SwServiceWiringTranslator { q"${trans.toSwTypeRefKeepForeigns(service.id, domain, evo)}" private def svcCtxParamName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } // Leading labeled context argument forwarded to `impl.(...)`. The @@ -476,7 +396,7 @@ object SwServiceWiringTranslator { } private def generateNoErrorsJsonMethod(service: Typedef.Service): TextTree[SwValue] = { - val implType = implParamType(service) + val implType = implParamType(service) val genericDecl = implGenericDecl(service) val cases = service.methods.map { m => @@ -497,12 +417,12 @@ object SwServiceWiringTranslator { case None => q"${implCallPrefix}impl.${m.name.name}(${ctxImplCallArg}arg: decoded)" } - q""""${m.name.name}": { + q"""case "${m.name.name}": | let wire = try JSONSerialization.jsonObject(with: data.data(using: .utf8)!, options: [.fragmentsAllowed]) | let decoded = $decodeIn | $callExpr | ${encodeOutput.shift(4).trim} - |},""".stripMargin + |""".stripMargin }.join("\n") q"""public static func invokeJson$genericDecl( @@ -511,18 +431,16 @@ object SwServiceWiringTranslator { | _ impl: $implType, | ${ctxWiringParamDecl}_ $codecCtxName: $baboonCodecContext |) $dispatcherEffects-> String { - | let handlers: [String: () $closureEffects-> String] = [ + | switch method.methodName { | ${cases.shift(8).trim} - | ] - | guard let handler = handlers[method.methodName] else { + | default: | throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)) | } - | return $handlerCallPrefix handler() |}""".stripMargin } private def generateNoErrorsUebaMethod(service: Typedef.Service): TextTree[SwValue] = { - val implType = implParamType(service) + val implType = implParamType(service) val genericDecl = implGenericDecl(service) val cases = service.methods.map { m => @@ -543,12 +461,12 @@ object SwServiceWiringTranslator { case None => q"${implCallPrefix}impl.${m.name.name}(${ctxImplCallArg}arg: decoded)" } - q""""${m.name.name}": { + q"""case "${m.name.name}": | let reader = $baboonBinTools.createReader(data) | let decoded = $decodeIn | $callExpr | ${encodeOutput.shift(4).trim} - |},""".stripMargin + |""".stripMargin }.join("\n") q"""public static func invokeUeba$genericDecl( @@ -557,13 +475,11 @@ object SwServiceWiringTranslator { | _ impl: $implType, | ${ctxWiringParamDecl}_ $codecCtxName: $baboonCodecContext |) $dispatcherEffects-> Data { - | let handlers: [String: () $closureEffects-> Data] = [ + | switch method.methodName { | ${cases.shift(8).trim} - | ] - | guard let handler = handlers[method.methodName] else { + | default: | throw $baboonWiringException($baboonWiringError.noMatchingMethod(method)) | } - | return $handlerCallPrefix handler() |}""".stripMargin } @@ -617,8 +533,8 @@ object SwServiceWiringTranslator { // The pattern is a string like "<$error, $success>"; split on the two // placeholders so the surrounding literal punctuation stays text and the // error/success types become real SwValue subtrees. - val errIdx = p.indexOf("$error") - val sucIdx = p.indexOf("$success") + val errIdx = p.indexOf("$error") + val sucIdx = p.indexOf("$success") require(errIdx >= 0 && sucIdx >= 0, s"BUG: service result pattern must contain both \\$$error and \\$$success: $p") // $error always precedes $success in every supported pattern (e.g. // "<$error, $success>"); assert it to fail fast if a future pattern @@ -643,13 +559,7 @@ object SwServiceWiringTranslator { else generateErrorsJsonCaseSync(m, inRef, decodeIn) }.join("\n") - // Sync: handler closures are plain `() -> container` and the dispatcher - // forwards directly. Async: the impl hop is awaited inside each handler, - // so handler closures are `() async throws -> container` and the - // dispatcher (itself `async throws`) calls `try await handler()`. - val handlerType = if (isAsync) q"() async throws -> $wiringRetType" else q"() -> $wiringRetType" val dispatcherEffects0 = if (isAsync) q" async throws" else q"" - val handlerInvoke = if (isAsync) q"return try await handler()" else q"return handler()" q"""public static func invokeJson$genericDecl( | _ method: $baboonMethodId, @@ -658,17 +568,15 @@ object SwServiceWiringTranslator { | _ rt: IBaboonServiceRt, | ${ctxWiringParamDecl}_ $codecCtxName: $baboonCodecContext |)$dispatcherEffects0 -> $wiringRetType { - | let handlers: [String: $handlerType] = [ + | switch method.methodName { | ${cases.shift(8).trim} - | ] - | guard let handler = handlers[method.methodName] else { + | default: | return rt.fail($baboonWiringError.noMatchingMethod(method)) | } - | $handlerInvoke |}""".stripMargin } - /** Sync errors-mode JSON handler arm. Byte-identical to the prior output: + /** Sync errors-mode JSON handler arm: * decode wraps into the container via `rt.pure`/`rt.fail`, the impl hop is * threaded through `rt.flatMap`, and the encode step is a second * `rt.flatMap`. @@ -745,10 +653,10 @@ object SwServiceWiringTranslator { |}""".stripMargin } - q""""${m.name.name}": { + q"""case "${m.name.name}": | ${decodeStep.shift(4).trim} | ${callAndEncodeStep.shift(4).trim} - |},""".stripMargin + |""".stripMargin } /** Async errors-mode JSON handler arm. Mirrors the C# reference @@ -832,10 +740,10 @@ object SwServiceWiringTranslator { } } - q""""${m.name.name}": { + q"""case "${m.name.name}": | ${decodeStep.shift(4).trim} | ${callAndEncodeStep.shift(4).trim} - |},""".stripMargin + |""".stripMargin } private def generateErrorsUebaMethod(service: Typedef.Service): TextTree[SwValue] = { @@ -851,9 +759,7 @@ object SwServiceWiringTranslator { else generateErrorsUebaCaseSync(m, inRef, decodeIn) }.join("\n") - val handlerType = if (isAsync) q"() async throws -> $wiringRetType" else q"() -> $wiringRetType" val dispatcherEffects0 = if (isAsync) q" async throws" else q"" - val handlerInvoke = if (isAsync) q"return try await handler()" else q"return handler()" q"""public static func invokeUeba$genericDecl( | _ method: $baboonMethodId, @@ -862,17 +768,15 @@ object SwServiceWiringTranslator { | _ rt: IBaboonServiceRt, | ${ctxWiringParamDecl}_ $codecCtxName: $baboonCodecContext |)$dispatcherEffects0 -> $wiringRetType { - | let handlers: [String: $handlerType] = [ + | switch method.methodName { | ${cases.shift(8).trim} - | ] - | guard let handler = handlers[method.methodName] else { + | default: | return rt.fail($baboonWiringError.noMatchingMethod(method)) | } - | $handlerInvoke |}""".stripMargin } - /** Sync errors-mode UEBA handler arm. Byte-identical to the prior output. */ + /** Sync errors-mode UEBA handler arm. */ private def generateErrorsUebaCaseSync( m: Typedef.MethodDef, inRef: TextTree[SwValue], @@ -945,10 +849,10 @@ object SwServiceWiringTranslator { |}""".stripMargin } - q""""${m.name.name}": { + q"""case "${m.name.name}": | ${decodeStep.shift(4).trim} | ${callAndEncodeStep.shift(4).trim} - |},""".stripMargin + |""".stripMargin } /** Async errors-mode UEBA handler arm. See [[generateErrorsJsonCaseAsync]] @@ -1024,10 +928,10 @@ object SwServiceWiringTranslator { } } - q""""${m.name.name}": { + q"""case "${m.name.name}": | ${decodeStep.shift(4).trim} | ${callAndEncodeStep.shift(4).trim} - |},""".stripMargin + |""".stripMargin } /** Emits the cross-domain Muxer-entry wrapper classes for a service. @@ -1117,7 +1021,7 @@ object SwServiceWiringTranslator { val genericDecl = implGenericDecl(service) val implType = implParamType(service) val ifaceCtx = if (isJson) ibaboonJsonServiceCtx else ibaboonUebaServiceCtx - val ctxTypeStr = resolvedCtx match { + val ctxTypeStr = resolvedCtx match { case ResolvedServiceContext.ConcreteContext(tn, _) => tn case _ => "Impl.Ctx" } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwTypeTranslator.scala index bd4cb9fec..a31de8ba8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwTypeTranslator.scala @@ -8,46 +8,21 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote class SwTypeTranslator { - def asSwRef(tpe: TypeRef, domain: Domain, evo: BaboonEvolution): TextTree[SwValue] = { - def fieldsOf(p: Product): Map[String, Any] = { - p.productElementNames.zip(p.productIterator).toMap + private def foreignAlias(defn: Typedef.Foreign): Option[TypeRef] = { + def alias(entry: Typedef.ForeignEntry): Option[TypeRef] = entry.mapping match { + case Typedef.ForeignMapping.BaboonRef(ref) => Some(ref) + case _: Typedef.ForeignMapping.Custom => None } - - def extractBaboonRefFromForeignEntry(fe: Typedef.ForeignEntry): Option[TypeRef] = { - fe.mapping match { - case Typedef.ForeignMapping.BaboonRef(typeRef) => - Some(typeRef) - case _ => - val f = fieldsOf(fe) - f.get("mapping") match { - case Some(mapping: Product) => - fieldsOf(mapping).get("typeRef") match { - case Some(typeRef: TypeRef) => - Some(typeRef) - case _ => - None - } - case _ => - None - } - } + defn.bindings.get(BaboonLang.Swift).flatMap(alias).orElse { + defn.bindings.valuesIterator.flatMap(alias).take(1).toList.headOption } + } + def asSwRef(tpe: TypeRef, domain: Domain, evo: BaboonEvolution): TextTree[SwValue] = { def resolveForeignAlias(id: TypeId.User): Option[TypeRef] = { domain.defs.meta.nodes(id) match { - case DomainMember.User(_, defn: Typedef.Foreign, _, _) => - val swiftBindingAlias = defn.bindings.iterator.collectFirst { - case (k, v) if k.toString.equalsIgnoreCase("swift") && extractBaboonRefFromForeignEntry(v).nonEmpty => - extractBaboonRefFromForeignEntry(v).get - } - swiftBindingAlias.orElse { - defn.bindings.valuesIterator.collectFirst { - case entry if extractBaboonRefFromForeignEntry(entry).nonEmpty => - extractBaboonRefFromForeignEntry(entry).get - } - } - case _ => - None + case DomainMember.User(_, defn: Typedef.Foreign, _, _) => foreignAlias(defn) + case _ => None } } @@ -122,89 +97,25 @@ class SwTypeTranslator { } private def asSwTypeDerefForeigns(tid: TypeId.User, domain: Domain, evolution: BaboonEvolution): SwType = { - def isSwiftBindingKey(k: Any): Boolean = { - k.toString.equalsIgnoreCase("swift") - } - - def fieldsOf(p: Product): Map[String, Any] = { - p.productElementNames.zip(p.productIterator).toMap - } - - def extractDeclFromForeignEntry(fe: Typedef.ForeignEntry): String = { - fe.mapping match { - case Typedef.ForeignMapping.Custom(decl, _) => - return decl - case Typedef.ForeignMapping.BaboonRef(_) => - } - val f = fieldsOf(fe) - f.get("decl") match { - case Some(d: String) => - d - case _ => - f.get("mapping") match { - case Some(mapping: Product) => - fieldsOf(mapping).get("decl") match { - case Some(d: String) => - d - case _ => - throw new IllegalStateException(s"Unsupported swift foreign mapping shape: $mapping") - } - case _ => - throw new IllegalStateException(s"Unsupported swift foreign entry shape: $fe") - } - } - } - - def extractBaboonRefFromForeignEntry(fe: Typedef.ForeignEntry): Option[TypeRef] = { - fe.mapping match { - case Typedef.ForeignMapping.BaboonRef(typeRef) => - Some(typeRef) - case _ => - val f = fieldsOf(fe) - f.get("mapping") match { - case Some(mapping: Product) => - fieldsOf(mapping).get("typeRef") match { - case Some(typeRef: TypeRef) => - Some(typeRef) - case _ => - None - } - case _ => - None - } - } - } domain.defs.meta.nodes(tid) match { case DomainMember.User(_, defn: Typedef.Foreign, _, _) => - val swiftBinding = defn.bindings.iterator.collectFirst { - case (k, v) if isSwiftBindingKey(k) => v - } - - val aliasFromAnyBinding = defn.bindings.valuesIterator.collectFirst { - case entry if extractBaboonRefFromForeignEntry(entry).nonEmpty => extractBaboonRefFromForeignEntry(entry).get + def aliasType(ref: TypeRef): SwType = ref match { + case TypeRef.Scalar(refId) => asSwType(refId, domain, evolution) + case _ => toSwTypeRefKeepForeigns(tid, domain, evolution) } - (swiftBinding, aliasFromAnyBinding) match { - case (Some(fe), _) if extractBaboonRefFromForeignEntry(fe).nonEmpty => - extractBaboonRefFromForeignEntry(fe).get match { - case TypeRef.Scalar(refId) => - asSwType(refId, domain, evolution) - case other => - toSwTypeRefKeepForeigns(tid, domain, evolution) - } - case (Some(fe), _) => - val parts = extractDeclFromForeignEntry(fe).split('.').toList + defn.bindings.get(BaboonLang.Swift).map(_.mapping) match { + case Some(Typedef.ForeignMapping.BaboonRef(ref)) => aliasType(ref) + case Some(Typedef.ForeignMapping.Custom(decl, _)) => + val parts = decl.split('.').toList assert(parts.length > 1) - val pkg = parts.init - val id = parts.last - SwType(SwPackageId(NEList.unsafeFrom(pkg)), id) - case (None, Some(TypeRef.Scalar(refId))) => - asSwType(refId, domain, evolution) - case (None, Some(other)) => - toSwTypeRefKeepForeigns(tid, domain, evolution) - case (None, None) => - throw new IllegalStateException(s"Missing swift binding for foreign type: ${defn.id}") + SwType(SwPackageId(NEList.unsafeFrom(parts.init)), parts.last) + case None => + foreignAlias(defn) match { + case Some(ref) => aliasType(ref) + case None => throw new IllegalStateException(s"Missing swift binding for foreign type: ${defn.id}") + } } case _ => toSwTypeRefKeepForeigns(tid, domain, evolution) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala index e46e55e8d..e7d4f8434 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala @@ -8,7 +8,7 @@ import io.septimalmind.baboon.translator.swift.SwTypes.* import io.septimalmind.baboon.translator.swift.SwValue.SwType import io.septimalmind.baboon.typer.EnumWireStyle import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant +import io.septimalmind.baboon.translator.AnyFieldPlan import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -99,15 +99,13 @@ class SwUEBACodecGenerator( |}""".stripMargin ) - val anyHelpers: List[TextTree[SwValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encoderMethods ++ decoderMethods ++ branchDecoder.map { body => q"""func decodeBranch(_ ctx: $baboonCodecContext, _ reader: $baboonBinReader) throws -> $localName { | ${body.shift(4).trim} |}""".stripMargin - }.toList ++ List(indexGetter) ++ anyHelpers + }.toList ++ List(indexGetter) val cName = codecName(srcRef) @@ -296,8 +294,8 @@ class SwUEBACodecGenerator( q"""${fields.join(",\n").shift(4).trim}""" } - q"""let index = try readIndex(ctx, reader) - |if ctx.useIndices { assert(index.count == indexElementsCount) } + q"""let indexCount = try consumeIndex(ctx, reader) + |if ctx.useIndices { assert(indexCount == indexElementsCount) } |return ${name.asDeclName}( | $fieldAssignments |)""".stripMargin @@ -362,26 +360,8 @@ class SwUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => (q"reader.readBool()", false) - case TypeId.Builtins.i08 => (q"reader.readI8()", false) - case TypeId.Builtins.i16 => (q"reader.readI16()", false) - case TypeId.Builtins.i32 => (q"reader.readI32()", false) - case TypeId.Builtins.i64 => (q"reader.readI64()", false) - case TypeId.Builtins.u08 => (q"reader.readU8()", false) - case TypeId.Builtins.u16 => (q"reader.readU16()", false) - case TypeId.Builtins.u32 => (q"reader.readU32()", false) - case TypeId.Builtins.u64 => (q"reader.readU64()", false) - case TypeId.Builtins.f32 => (q"reader.readF32()", false) - case TypeId.Builtins.f64 => (q"reader.readF64()", false) - case TypeId.Builtins.f128 => (q"reader.readDecimal()", false) - case TypeId.Builtins.str => (q"reader.readString()", true) - case TypeId.Builtins.bytes => (q"reader.readBytes()", true) - case TypeId.Builtins.uid => (q"reader.readUuid()", true) - case TypeId.Builtins.tsu => (q"reader.readTsu()", false) - case TypeId.Builtins.tso => (q"reader.readTso()", false) - case o => throw new RuntimeException(s"BUG: Unexpected type: $o") - } + val decoded = SwScalarCodecs.uebaDecode(s, q"reader") + (decoded.expression, decoded.mayThrow) case u: TypeId.User => val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) (q"$targetTpe.instance.decode(ctx, reader)", true) @@ -420,27 +400,7 @@ class SwUEBACodecGenerator( case TypeRef.Scalar(id) => id match { case s: TypeId.BuiltinScalar => - s match { - case TypeId.Builtins.bit => q"$wref.writeBool($ref)" - case TypeId.Builtins.i08 => q"$wref.writeI8($ref)" - case TypeId.Builtins.i16 => q"$wref.writeI16($ref)" - case TypeId.Builtins.i32 => q"$wref.writeI32($ref)" - case TypeId.Builtins.i64 => q"$wref.writeI64($ref)" - case TypeId.Builtins.u08 => q"$wref.writeU8($ref)" - case TypeId.Builtins.u16 => q"$wref.writeU16($ref)" - case TypeId.Builtins.u32 => q"$wref.writeU32($ref)" - case TypeId.Builtins.u64 => q"$wref.writeU64($ref)" - case TypeId.Builtins.f32 => q"$wref.writeF32($ref)" - case TypeId.Builtins.f64 => q"$wref.writeF64($ref)" - case TypeId.Builtins.f128 => q"$wref.writeDecimal($ref)" - case TypeId.Builtins.str => q"$wref.writeString($ref)" - case TypeId.Builtins.bytes => q"$wref.writeBytes($ref)" - case TypeId.Builtins.uid => q"$wref.writeUuid($ref)" - case TypeId.Builtins.tsu => q"$wref.writeTsu($ref)" - case TypeId.Builtins.tso => q"$wref.writeTso($ref)" - case o => - throw new RuntimeException(s"BUG: Unexpected type: $o") - } + SwScalarCodecs.uebaEncode(s, wref, ref) case u: TypeId.User => val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" @@ -481,190 +441,14 @@ class SwUEBACodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java/TS/Dart hasAnyField): a codec class needs the - // any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-class `encodeAnyField` helper. This site wires the expected - // kind byte and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. - // See `anyStaticFallbacks` for the per-variant table. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[SwValue], wref: TextTree[SwValue]): TextTree[SwValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"encodeAnyField(ctx, $wref, $expectedHex, $staticDom, $staticVer, $staticTid, $ref)" + val args = SwAnyFieldRendering.arguments(AnyFieldPlan.forField(a, domain)) + q"BaboonRuntime.BaboonAnyUebaFieldCodec.encodeAnyField(ctx, $wref, $args, $ref)" } - // Decode delegates to the per-codec-class `decodeAnyField` helper, returning an `AnyOpaque` - // (the helper's return type is `AnyOpaque` since Swift enums don't have separate per-case - // surface types — the `.ueba` discriminator is in the value itself). private def mkAnyDecoder(a: TypeRef.Any): TextTree[SwValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"decodeAnyField(reader, $expectedHex)" - } - - // Static fallbacks for the cross-format facade helpers (`jsonToUebaBytes`/`uebaToJson`). The - // wire `meta` may omit components that are pinned by the field's static declaration; the codec - // emits whatever is statically known so the facade can fill the gaps. See - // `BaboonCodecsFacade.buildSyntheticTypeMeta` for the merge semantics. Per spec table: - // A=(nil,nil,nil), B=(currentDomain,nil,nil), C=(currentDomain,currentVersion,nil), - // D1=(nil,nil,underlyingFqid), D2=(currentDomain,nil,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS/Dart — extraction deferred (textual emission - // diverges by language flavor; see PR 4.2 ledger entry's DRY analysis). 11th instance. - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[SwValue], TextTree[SwValue], TextTree[SwValue]) = { - val none = q"nil" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec class that has any - // any-bearing field. Mirrors `JvUEBACodecGenerator.anyFieldHelpers` / - // `DtUEBACodecGenerator.anyFieldHelpers`. Wire layout (locked, see - // docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // - // Swift `encode` on `BaboonBinCodecBase` is non-throwing (see `baboon_runtime.swift`); the - // helper uses `preconditionFailure` for unrecoverable conditions (kind mismatch, missing - // facade, facade-returned failure). This mirrors the existing Swift type-erasure pattern in - // `AnyBaboonBinEncoder.encodeAnyValue`. The `decodeAnyField` helper IS `throws` since the - // base `decode(...)` is `throws`. - // - // PR-12-D01 lesson applied: explicit non-negative sanity check on BOTH `anyTotalLength` AND - // `anyMetaLength` before any size arithmetic. Swift `Int32` is signed; a malicious wire - // `0xFFFF_FFFF` decodes to `-1`, and `Data.subdata(in: 0..<-1)` would trap. - // - // Cast-via-`as`: `ctx.facade` returns `BaboonCodecsFacadeBase?` (PR 9.1 import-cycle break). - // The concrete facade carries `jsonToUebaBytes`; the cast is safe because - // `BaboonCodecContext.withFacade` is the only construction path and accepts the same hierarchy. - private def anyFieldHelpers: TextTree[SwValue] = { - q"""private func encodeAnyField( - | _ ctx: $baboonCodecContext, - | _ writer: $baboonBinWriter, - | _ expectedKind: UInt8, - | _ staticDomain: String?, - | _ staticVersion: String?, - | _ staticTypeid: String?, - | _ value: $baboonAnyOpaque - |) { - | if value.meta.kind != expectedKind { - | preconditionFailure( - | "any: meta-kind 0x\\(String(format: \"%02x\", value.meta.kind & 0xFF)) " + - | "does not match field-declared 0x\\(String(format: \"%02x\", expectedKind & 0xFF))" - | ) - | } - | let anyBlob: Data - | switch value { - | case .ueba(_, let bytes): - | anyBlob = bytes - | case .json(let jsonMeta, let jsonValue): - | guard let anyFacadeBase = ctx.facade else { - | preconditionFailure( - | "Cannot encode AnyOpaque.json into UEBA without a facade reference. " + - | "Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), " + - | "or supply AnyOpaque.ueba directly." - | ) - | } - | // Downcast to the concrete facade — the marker base is empty by design (PR 9.1 - | // import-cycle break). Construction goes through BaboonCodecContext.withFacade - | // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. - | guard let anyFacade = anyFacadeBase as? $baboonCodecsFacade else { - | preconditionFailure( - | "BaboonCodecContext.facade is not a BaboonCodecsFacade: " + - | "\\(type(of: anyFacadeBase))" - | ) - | } - | let anyConvResult = anyFacade.jsonToUebaBytes( - | jsonMeta, - | jsonValue, - | staticDomain: staticDomain, - | staticVersion: staticVersion, - | staticTypeid: staticTypeid - | ) - | switch anyConvResult { - | case .failure(let err): - | preconditionFailure("any: jsonToUebaBytes failed: \\(err)") - | case .success(let bytes): - | anyBlob = bytes - | } - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | let anyMetaBuf = $baboonBinWriter() - | $baboonAnyMetaCodec.writeBin(value.meta, anyMetaBuf) - | let anyMetaBytes = anyMetaBuf.toData() - | let anyTotalLength = Int32(4 + anyMetaBytes.count + anyBlob.count) - | writer.writeI32(anyTotalLength) - | writer.writeI32(Int32(anyMetaBytes.count)) - | writer.writeAll(anyMetaBytes) - | writer.writeAll(anyBlob) - |} - | - |private func decodeAnyField(_ wire: $baboonBinReader, _ expectedKind: UInt8) throws -> $baboonAnyOpaque { - | let anyTotalLength = wire.readI32() - | if anyTotalLength < 0 { - | throw $baboonCodecException.decoderFailure( - | "any: negative total-length \\(anyTotalLength)", - | nil - | ) - | } - | let anyMetaLength = wire.readI32() - | if anyMetaLength < 0 { - | throw $baboonCodecException.decoderFailure( - | "any: negative meta-length \\(anyMetaLength)", - | nil - | ) - | } - | if anyTotalLength < 4 + anyMetaLength { - | throw $baboonCodecException.decoderFailure( - | "any: total-length \\(anyTotalLength) smaller than 4 + meta-length \\(anyMetaLength)", - | nil - | ) - | } - | let (anyMeta, anyBytesRead) = try $baboonAnyMetaCodec.readBinWithLength(wire) - | if anyBytesRead > Int(anyMetaLength) { - | throw $baboonCodecException.decoderFailure( - | "any: meta bytes-read \\(anyBytesRead) exceeded meta-length window \\(anyMetaLength)", - | nil - | ) - | } - | if anyBytesRead < Int(anyMetaLength) { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | wire.skipBytes(Int(anyMetaLength) - anyBytesRead) - | } - | if anyMeta.kind != expectedKind { - | throw $baboonCodecException.decoderFailure( - | "any: wire kind 0x\\(String(format: \"%02x\", anyMeta.kind & 0xFF)) " + - | "does not match field-declared 0x\\(String(format: \"%02x\", expectedKind & 0xFF))", - | nil - | ) - | } - | let anyBlobLen = Int(anyTotalLength) - 4 - Int(anyMetaLength) - | let anyBlob = wire.readNBytes(anyBlobLen) - | return .ueba(meta: anyMeta, bytes: anyBlob) - |}""".stripMargin + val kind = SwAnyFieldRendering.kind(AnyFieldPlan.forField(a, domain)) + q"BaboonRuntime.BaboonAnyUebaFieldCodec.decodeAnyField(reader, $kind)" } private def renderMeta(defn: DomainMember.User, meta: List[MetaField]): List[TextTree[SwValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsAnyFieldPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsAnyFieldPlan.scala new file mode 100644 index 000000000..226a6fd32 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsAnyFieldPlan.scala @@ -0,0 +1,19 @@ +package io.septimalmind.baboon.translator.typescript + +import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.typer.model.{Domain, TypeRef} +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +final case class TsAnyFieldPlan(kindHex: String, staticDomain: TextTree[TsValue], staticVersion: TextTree[TsValue], staticTypeId: TextTree[TsValue]) + +object TsAnyFieldPlan { + def forField(field: TypeRef.Any, domain: Domain): TsAnyFieldPlan = { + val plan = AnyFieldPlan.forField(field, domain) + def render(value: Option[String]): TextTree[TsValue] = value match { + case Some(v) => q"\"$v\"" + case None => q"undefined" + } + TsAnyFieldPlan("0x%02x".format(plan.kind & 0xFF), render(plan.staticDomain), render(plan.staticVersion), render(plan.staticTypeId)) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index e53023c19..70341c8c4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -3,7 +3,6 @@ package io.septimalmind.baboon.translator.typescript import distage.Subcontext import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.TsTarget -import io.septimalmind.baboon.parser.model.RawMemberMeta import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.typescript.TsTypes.{tsBaboonAnyOpaqueModule, tsBaboonIdReprModule, tsBaboonRuntimeShared, tsCrossLangFixtureModule, tsFixtureShared} import io.septimalmind.baboon.translator.typescript.TsValue.{TsModuleId, TsType} @@ -124,31 +123,6 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val className = s"Domain${pascalDomainId}Facade" val domainIdStr = latestDomain.id.path.mkString(".") - // Per-type codec activation predicate, mirroring `isActive` in - // TsJsonCodecGenerator / TsUEBACodecGenerator. Foreign-bound types emit - // no codec class at all, and the global generate-X flags plus the - // per-type `derived[…]` opt-in further constrain emission. If either - // codec is inactive for a type, the facade must NOT import or register - // that flavour for it. - def jsonCodecActive(domain: Domain, id: TypeId): Boolean = { - !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Typescript) && - target.language.generateJsonCodecs && ( - target.language.generateJsonCodecsByDefault || - domain.derivationRequests.getOrElse(RawMemberMeta.Derived("json"), Set.empty[TypeId]).contains(id) - ) - } - def uebaCodecActive(domain: Domain, defn: DomainMember.User): Boolean = { - val id = defn.id - !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Typescript) && - target.language.generateUebaCodecs && ( - target.language.generateUebaCodecsByDefault || - domain.derivationRequests.getOrElse(RawMemberMeta.Derived("ueba"), Set.empty[TypeId]).contains(id) - ) - // Foreign-bearing types are no longer suppressed: a Custom foreign now emits a `_UEBACodec` - // value codec (throwing by default, host-overridable via lazyInstance), so containing types - // route the foreign field through it — mirroring the JSON side and the C# backend. - } - // Collect non-ADT-branch User types per version with codec-activation flags. // Returns list of (tsImportPath, typeAlias, typeName, typeIdentifier, hasJsonCodec, hasUebaCodec). // typeAlias differs from typeName when either (a) the version is older @@ -159,7 +133,9 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // the on-disk layout produced by TsDefnTranslator.getOutputPath. Types // where neither codec is active (foreign-bound types) are dropped — they // emit no codec class so there is nothing for the facade to register. - def collectTypes(domain: Domain): List[(String, String, String, String, Boolean, Boolean)] = { + case class CodecEntry(importPath: String, typeAlias: String, typeName: String, typeId: String, hasJson: Boolean, hasUeba: Boolean) + + def collectTypes(domain: Domain): List[CodecEntry] = { val versionStr = domain.version.v.toString val isLatest = domain.version == evo.latest val verSuffix = versionStr.replace('.', '_') @@ -172,8 +148,8 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( case owner => defn.defn match { case _: Typedef.Dto | _: Typedef.Enum | _: Typedef.Adt => - val hasJson = jsonCodecActive(domain, defn.id) - val hasUeba = uebaCodecActive(domain, defn) + val hasJson = TsCodecActivation.isActive(target, domain, defn.id, TsCodecActivation.Json) + val hasUeba = TsCodecActivation.isActive(target, domain, defn.id, TsCodecActivation.Ueba) if (!hasJson && !hasUeba) None else { // `typeName` is the TS-class symbol exported by the source @@ -204,7 +180,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val typeAlias = if (isLatest) baseAlias else s"${baseAlias}V$verSuffix" val importPath = s"$relPrefix/$nsPathSegment$fileBase" val typeId = defn.id.toString - Some((importPath, typeAlias, typeName, typeId, hasJson, hasUeba)) + Some(CodecEntry(importPath, typeAlias, typeName, typeId, hasJson, hasUeba)) } case _ => None } @@ -215,11 +191,12 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // non-deterministic across runs/JVMs. Sort the collected types by their // rendered import path then symbol name, giving the facade a total lexical // ordering for both its import lines and its codec/meta registrations. - collected.sortBy { case (importPath, _, typeName, typeId, _, _) => (importPath, typeName, typeId) } + collected.sortBy(e => (e.importPath, e.typeName, e.typeId)) } // Ordered versions (ascending) val orderedVersions = lineage.versions.toSeq.sortBy(_._1).map { case (_, domain) => domain } + val codecEntries = orderedVersions.map(domain => domain.version -> collectTypes(domain)).toMap val sfx = target.language.importSuffix @@ -237,8 +214,8 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // since identity registration goes through the codec class. val typeImports: List[TextTree[TsValue]] = orderedVersions.toList.flatMap { domain => - collectTypes(domain).map { - case (importPath, typeAlias, typeName, _, hasJson, hasUeba) => + codecEntries(domain.version).map { + case CodecEntry(importPath, typeAlias, typeName, _, hasJson, hasUeba) => val pieces = List( Some((typeName, typeAlias)), if (hasJson) Some((s"${typeName}_JsonCodec", s"${typeAlias}_JsonCodec")) else None, @@ -266,15 +243,15 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( val perVersion: List[TextTree[TsValue]] = orderedVersions.toList.map { domain => val versionStr = domain.version.v.toString - val types = collectTypes(domain) + val types = codecEntries(domain.version) val verClassName = versionClassName(domain) val jsonRegistrations = types.collect { - case (_, typeAlias, _, _, true, _) => + case CodecEntry(_, typeAlias, _, _, true, _) => q"this.register(${typeAlias}_JsonCodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_JsonCodec.instance as unknown as BaboonJsonCodec));" } val uebaRegistrations = types.collect { - case (_, typeAlias, _, _, _, true) => + case CodecEntry(_, typeAlias, _, _, _, true) => q"this.register(${typeAlias}_UEBACodec.BaboonTypeIdentifier, new Lazy>(() => ${typeAlias}_UEBACodec.instance as unknown as BaboonBinCodec));" } @@ -357,6 +334,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( moduleId, CompilerProduct.Runtime, doNotModify = true, + exports = Some(List(TsDefnTranslator.ExportedSymbol(className, typeOnly = false))), ) ) } @@ -504,7 +482,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( } } - val usedTypes = o.tree.values.collect { case t: TsValue.TsType => t } + val usedTypes = (o.tree.values.collect { case t: TsValue.TsType => t } ++ o.imports) .filterNot(_.moduleId.path.isEmpty) .filterNot(_.moduleId == o.module) .filterNot(_.predef) @@ -596,31 +574,12 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( } } - /** Extract type names that a file defines (types whose module matches the file's own module). - * Returns a map from name to typeOnly flag (true = type-only, false = value-bearing). - */ private def exportedNames(output: TsDefnTranslator.Output): Map[String, Boolean] = { - output.tree.values.collect { - case t: TsValue.TsType if t.moduleId == output.module && !t.predef => t.name -> t.typeOnly - }.toMap + output.exports.toList.flatten.map(s => s.name -> s.typeOnly).toMap } - /** Whether a file emits at least one top-level export. `exportedNames` only - * sees exports embedded as `TsType` values; declarations whose symbol is - * interpolated as a plain string (service interfaces, client/wiring classes - * and functions) are invisible to it. A textual scan catches those so a - * non-empty wiring/client/service file is barrel-able while a genuinely empty - * one (e.g. wiring suppressed by flags) is not (avoids TS2306). - */ private def hasExports(output: TsDefnTranslator.Output): Boolean = { - if (exportedNames(output).nonEmpty) true - else { - val text = output.tree.mapRender { - case TsValue.TsType(_, name, Some(alias), _, _) => alias - case t: TsValue.TsType => t.name - } - text.linesIterator.exists(_.trim.startsWith("export ")) - } + output.exports.exists(_.nonEmpty) } /** Render a named re-export line, splitting type-only and value-bearing names into separate statements. */ @@ -785,16 +744,8 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // empty .ts) must be skipped from the barrel — `export * from './X'` against an empty // file produces TS2306 "File ... is not a module" on strict tsc/deno. val fileExports = sortedFiles.map(f => (f, exportedNames(f))).filter(_._2.nonEmpty) - // D45: a file whose only export is string-form (the domain facade is a string-only TextTree - // carrying a plain-text `export class ...Facade extends BaboonCodecsFacade`) - // contributes no TsValue.TsType nodes, so exportedNames is empty and it is dropped from - // fileExports above. Detect such files via the textual hasExports fallback (the same - // predicate generateServiceBarrels uses) and re-export each with a plain `export *`. They - // carry no AST names, so they cannot collide with named exports and need no collision - // handling — the same treatment collision-free named files already receive. - val verbatimExportFiles = sortedFiles.filter(f => exportedNames(f).isEmpty && hasExports(f)) - val nameCount = fileExports.flatMap(_._2.keys).groupBy(identity).view.mapValues(_.size).toMap - val colliding = nameCount.filter(_._2 > 1).keySet + val nameCount = fileExports.flatMap(_._2.keys).groupBy(identity).view.mapValues(_.size).toMap + val colliding = nameCount.filter(_._2 > 1).keySet val reexports = if (colliding.isEmpty) { // No collisions — simple export * for all files with exports @@ -825,15 +776,9 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( } } - val verbatimReexports = verbatimExportFiles.map { - f => - val fname = f.path.drop(dir.length + 1).stripSuffix(".ts") - TextTree.text[TsValue](s"export * from './$fname$sfx';") - } - // A models-level dir that contains services also re-exports each // service as a namespace (`export * as PetStore from './pet-store'`). - val allReexports = reexports ++ verbatimReexports ++ modelsExtras.getOrElse(dir, Nil) + val allReexports = reexports ++ modelsExtras.getOrElse(dir, Nil) if (allReexports.nonEmpty) { Some(barrelOutput(s"$dir/index.ts", allReexports.reduce((a, b) => q"$a\n$b"))) @@ -884,6 +829,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( conv.conv, convModule, CompilerProduct.Conversion, + exports = Some(conv.exportedName.toList.map(TsDefnTranslator.ExportedSymbol(_, typeOnly = false))), ) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecActivation.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecActivation.scala new file mode 100644 index 000000000..c0b338492 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecActivation.scala @@ -0,0 +1,21 @@ +package io.septimalmind.baboon.translator.typescript + +import io.septimalmind.baboon.CompilerTarget.TsTarget +import io.septimalmind.baboon.parser.model.RawMemberMeta +import io.septimalmind.baboon.typer.BaboonEnquiries +import io.septimalmind.baboon.typer.model.* + +object TsCodecActivation { + sealed trait Kind + case object Json extends Kind + case object Ueba extends Kind + + def isActive(target: TsTarget, domain: Domain, id: TypeId, kind: Kind): Boolean = { + val (enabled, byDefault, derivation) = kind match { + case Json => (target.language.generateJsonCodecs, target.language.generateJsonCodecsByDefault, "json") + case Ueba => (target.language.generateUebaCodecs, target.language.generateUebaCodecsByDefault, "ueba") + } + !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Typescript) && enabled && + (byDefault || domain.derivationRequests.getOrElse(RawMemberMeta.Derived(derivation), Set.empty[TypeId]).contains(id)) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala index 9ea5bfb77..ac22cdfff 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala @@ -1,7 +1,9 @@ package io.septimalmind.baboon.translator.typescript import distage.Id -import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} +import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.typescript.TsTypes.tsBaboonDecimal +import io.septimalmind.baboon.translator.typescript.TsValue.{TsModuleId, TsType} import io.septimalmind.baboon.typer.model.* import io.septimalmind.baboon.typer.model.Conversion.FieldOp import izumi.functional.bio.{Error2, F} @@ -23,7 +25,7 @@ object TsConversionTranslator { case class TsRenderedConversion( fname: String, conv: TextTree[TsValue], - reg: Option[TextTree[TsValue]], + exportedName: Option[String], missing: Option[TextTree[TsValue]], ) @@ -46,26 +48,105 @@ class TsConversionTranslator[F[+_, +_]: Error2]( } } - private def jsonConvert(expr: TextTree[TsValue]): TextTree[TsValue] = { - q"JSON.parse(JSON.stringify($expr))" + private def conversionName(source: TypeId.User): String = { + val parts = Seq("convert") ++ source.owner.asPseudoPkg ++ Seq(source.name.name, "from", srcVer.v.toString.replace('.', '_')) + trans.camelToKebab(parts.mkString("__")).replace('-', '_') } - def makeConvs: Out[List[TsRenderedConversion]] = { - def makeName(prefix: String, conv: Conversion): String = - (Seq(prefix) ++ conv.sourceTpe.owner.asPseudoPkg ++ Seq( - conv.sourceTpe.name.name, - "from", - srcVer.v.toString.replace('.', '_'), - )).mkString("__") + private def conversionFile(source: TypeId.User): String = { + (Seq("from", srcVer.v.toString.replace('.', '_')) ++ source.owner.asPseudoPkg.map(_.toLowerCase) ++ Seq( + s"${trans.camelToKebab(source.name.name)}.ts" + )).mkString("_") + } + + private def conversionRef(source: TypeId.User, target: TypeId.User): TsType = { + require( + rules.conversions.exists { + case c: TargetedConversion => c.sourceTpe == source && c.targetTpe == target + case _ => false + }, + s"Missing typed conversion: $source -> $target", + ) + val outputPath = s"${tsFileTools.basename(domain, evo)}/${conversionFile(source)}" + TsType(TsModuleId(tsFileTools.definitionsBasePkg ++ outputPath.stripSuffix(".ts").split('/').toList), conversionName(source)) + } + private def convertUser(source: TypeId.User, target: TypeId.User, value: TextTree[TsValue]): TextTree[TsValue] = { + rules.conversions.find { + case c: TargetedConversion => c.sourceTpe == source && c.targetTpe == target + case _ => false + } match { + case Some(_: Conversion.CustomConversionRequired) => + q"""(() => { throw new Error("Custom conversion required: ${source.name.name} -> ${target.name.name}"); })()""" + case _ => q"${conversionRef(source, target)}($value)" + } + } + + private def emptyCollection(tpe: TypeRef.Constructor): TextTree[TsValue] = tpe.id match { + case TypeId.Builtins.opt => q"undefined" + case TypeId.Builtins.lst => q"[]" + case TypeId.Builtins.set => q"new Set()" + case TypeId.Builtins.map => if (trans.isStringKeyMap(tpe)) q"{}" else q"new Map()" + case other => throw new IllegalStateException(s"Unsupported collection default: $other") + } + + private def transfer(oldTpe: TypeRef, newTpe: TypeRef, value: TextTree[TsValue]): TextTree[TsValue] = { + if (oldTpe == newTpe && !hasUserType(newTpe)) return value + (oldTpe, newTpe) match { + case (old: TypeRef.Any, current: TypeRef.Any) if old == current => value + case (TypeRef.Scalar(oldId: TypeId.User), TypeRef.Scalar(newId: TypeId.User)) => + domain.defs.meta.nodes(newId) match { + case DomainMember.User(_, _: Typedef.Foreign, _, _) => value + case _ => convertUser(oldId, newId, value) + } + case (TypeRef.Scalar(_: TypeId.BuiltinScalar), TypeRef.Scalar(newId: TypeId.BuiltinScalar)) => + newId match { + case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"BigInt($value)" + case TypeId.Builtins.f128 => q"$tsBaboonDecimal.fromString(String($value))" + case TypeId.Builtins.i08 | TypeId.Builtins.u08 | TypeId.Builtins.i16 | TypeId.Builtins.u16 | TypeId.Builtins.i32 | TypeId.Builtins.u32 | TypeId.Builtins.f32 | + TypeId.Builtins.f64 => + q"Number($value)" + case other => throw new IllegalStateException(s"Unsupported scalar conversion: $oldTpe -> $other") + } + case (old: TypeRef.Scalar, current: TypeRef.Constructor) => + val element = transfer(old, current.args.head, value) + current.id match { + case TypeId.Builtins.opt => element + case TypeId.Builtins.lst => q"[$element]" + case TypeId.Builtins.set => q"new Set([$element])" + case other => throw new IllegalStateException(s"Unsupported collection wrapping: $other") + } + case (old: TypeRef.Constructor, current: TypeRef.Constructor) if old.id == TypeId.Builtins.opt => + val element = transfer(old.args.head, current.args.head, value) + val present = current.id match { + case TypeId.Builtins.opt => element + case TypeId.Builtins.lst => q"[$element]" + case TypeId.Builtins.set => q"new Set([$element])" + case other => throw new IllegalStateException(s"Unsupported optional conversion: $other") + } + q"($value === undefined ? ${emptyCollection(current)} : $present)" + case (old: TypeRef.Constructor, current: TypeRef.Constructor) + if Set(TypeId.Builtins.lst, TypeId.Builtins.set).contains(old.id) && Set(TypeId.Builtins.lst, TypeId.Builtins.set).contains(current.id) => + val element = transfer(old.args.head, current.args.head, q"item") + val mapped = q"Array.from($value, item => $element)" + if (current.id == TypeId.Builtins.set) q"new Set($mapped)" else mapped + case (old: TypeRef.Constructor, current: TypeRef.Constructor) if old.id == TypeId.Builtins.map && current.id == TypeId.Builtins.map => + val entries = if (trans.isStringKeyMap(old)) q"Object.entries($value)" else q"Array.from($value.entries())" + val key = transfer(old.args.head, current.args.head, q"key") + val entryValue = transfer(old.args.last, current.args.last, q"entryValue") + val mapped = q"$entries.map(([key, entryValue]) => [$key, $entryValue] as const)" + if (trans.isStringKeyMap(current)) q"Object.fromEntries($mapped)" else q"new Map($mapped)" + case _ => throw new IllegalStateException(s"Unsupported typed conversion: $oldTpe -> $newTpe") + } + } + + def makeConvs: Out[List[TsRenderedConversion]] = { val targetedConversions = rules.conversions.collect { case tc: TargetedConversion => tc } F.flatTraverseAccumErrors(targetedConversions) { conv => - val fnName = trans.camelToKebab(makeName("convert", conv)).replace('-', '_') - val fname = (Seq("from", srcVer.v.toString.replace('.', '_')) ++ conv.sourceTpe.owner.asPseudoPkg.map(_.toLowerCase) ++ Seq( - s"${trans.camelToKebab(conv.sourceTpe.name.name)}.ts" - )).mkString("_") + val fnName = conversionName(conv.sourceTpe) + val fname = conversionFile(conv.sourceTpe) val tout = trans.asTsTypeKeepForeigns(conv.targetTpe, domain, evo, tsFileTools.definitionsBasePkg) val tin = trans @@ -88,7 +169,7 @@ class TsConversionTranslator[F[+_, +_]: Error2]( case c: Conversion.CopyEnumByName => val mappingEntries = c.memberMapping.map { case (fromName, toName) => - q"""case "$fromName": return "$toName" as $tout;""" + q"case $tin.${trans.enumMemberIdentifier(fromName)}: return $tout.${trans.enumMemberIdentifier(toName)};" } val mappedExpr = if (mappingEntries.isEmpty) { q"return from as unknown as $tout;" @@ -105,7 +186,7 @@ class TsConversionTranslator[F[+_, +_]: Error2]( q"""export function $fnName(from: $tin): $tout { | $mappedExpr |}""".stripMargin, - Some(q"$fnName"), + Some(fnName), None, ) ) @@ -116,10 +197,9 @@ class TsConversionTranslator[F[+_, +_]: Error2]( val oldTpe = trans .asTsTypeKeepForeigns(oldId, srcDom, evo, tsFileTools.definitionsBasePkg) .withAlias(s"${oldId.name.name}_${srcDom.version.format(prefix = "_", delimiter = "_")}") - val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) - val newBranchType = trans.asTsTypeKeepForeigns(newId, domain, evo, tsFileTools.definitionsBasePkg) + val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) q"""if (from instanceof $oldTpe) { - | return JSON.parse(JSON.stringify(from)) as $newBranchType + | return ${convertUser(oldId, newId, q"from")} |}""".stripMargin } List( @@ -130,7 +210,7 @@ class TsConversionTranslator[F[+_, +_]: Error2]( | | throw new Error("Unknown ADT branch: " + from); |}""".stripMargin, - Some(q"$fnName"), + Some(fnName), None, ) ) @@ -148,21 +228,10 @@ class TsConversionTranslator[F[+_, +_]: Error2]( val fld = f.name.name op match { case _: FieldOp.Transfer => - if (hasUserType(f.tpe)) { - jsonConvert(q"from.$fld") - } else { - q"from.$fld" - } + transfer(f.tpe, f.tpe, q"from.$fld") case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { - case TypeRef.Constructor(id, _) => - id match { - case TypeId.Builtins.lst => q"[]" - case TypeId.Builtins.set => q"new Set()" - case TypeId.Builtins.map => q"new Map()" - case TypeId.Builtins.opt => q"undefined" - case _ => throw new IllegalStateException(s"Unsupported constructor type: $id") - } + case tpe: TypeRef.Constructor => emptyCollection(tpe) // `any` fields require a meta header bound to the field's variant; there is // no schema-agnostic default. Validator forbids adding new `any` fields without // an explicit migration path, so this branch is best-effort fail-fast — if it @@ -171,28 +240,14 @@ class TsConversionTranslator[F[+_, +_]: Error2]( case _: TypeRef.Any => throw new IllegalStateException("any-typed field cannot be initialised with a default") case _ => throw new IllegalStateException("Unsupported target field type") } - case _: FieldOp.WrapIntoCollection => - val innerExpr = if (hasUserType(f.tpe)) jsonConvert(q"from.$fld") else q"from.$fld" - f.tpe match { - case TypeRef.Constructor(TypeId.Builtins.opt, _) => q"$innerExpr" - case TypeRef.Constructor(TypeId.Builtins.set, _) => q"new Set([$innerExpr])" - case TypeRef.Constructor(TypeId.Builtins.lst, _) => q"[$innerExpr]" - case _ => q"[$innerExpr]" - } - case _: FieldOp.ExpandPrecision => - jsonConvert(q"from.$fld") - case _: FieldOp.SwapCollectionType => - jsonConvert(q"from.$fld") + case o: FieldOp.Modify => + transfer(o.oldTpe, o.newTpe, q"from.$fld") case o: FieldOp.Rename => val srcFld = o.sourceFieldName.name - if (hasUserType(f.tpe)) { - jsonConvert(q"from.$srcFld") - } else { - q"from.$srcFld" - } + transfer(f.tpe, f.tpe, q"from.$srcFld") case o: FieldOp.Redef => val srcFld = o.sourceFieldName.name - jsonConvert(q"from.$srcFld") + transfer(o.modify.oldTpe, o.modify.newTpe, q"from.$srcFld") } } @@ -204,17 +259,13 @@ class TsConversionTranslator[F[+_, +_]: Error2]( | ${assigns.join(",\n").shift(8).trim} | ) |}""".stripMargin, - Some(q"$fnName"), + Some(fnName), None, ) ) } - if (false) { - F.fail(BaboonIssue.of(TranslationIssue.TranslationBug())) - } else { - F.pure(rendered) - } + F.pure(rendered): Out[List[TsRenderedConversion]] } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala index 4ca137a70..50ee02acb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.typescript import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.TsTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.IdentifierFieldKind import io.septimalmind.baboon.translator.typescript.TsTypes.{tsBaboonAdtMemberMeta, tsBaboonDecoderFailure, tsBaboonEncoderFailure, tsBaboonGenerated, tsBaboonGeneratedLatest, tsBaboonIdReprBitToString, tsBaboonIdReprBytesToHex, tsBaboonIdReprCursor, tsBaboonIdReprEscapeStr, tsBaboonIdReprIsCanonicalUid, tsBaboonIdReprParseBit, tsBaboonIdReprParseBytesHex, tsBaboonIdReprParseFieldName, tsBaboonIdReprParseHeader, tsBaboonIdReprParseTso, tsBaboonIdReprParseTsu, tsBaboonIdReprTsoToString, tsBaboonIdReprTsuToString, tsBaboonIdReprU64ToString, tsBaboonRuntimeShared} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.typescript.TsValue.TsType @@ -22,18 +23,23 @@ trait TsDefnTranslator[F[+_, +_]] { } object TsDefnTranslator { + final case class ExportedSymbol(name: String, typeOnly: Boolean) + final case class Output( path: String, tree: TextTree[TsValue], module: TsValue.TsModuleId, product: CompilerProduct, - doNotModify: Boolean = false, - isBarrel: Boolean = false, + doNotModify: Boolean = false, + isBarrel: Boolean = false, + imports: List[TsType] = Nil, + exports: Option[List[ExportedSymbol]] = None, ) final case class DefnRepr( defn: TextTree[TsValue], codecs: List[TextTree[TsValue]], + exports: List[ExportedSymbol] = Nil, ) class TsDefnTranslatorImpl[F[+_, +_]: Applicative2]( @@ -55,7 +61,7 @@ object TsDefnTranslator { */ private def prependDocs(docs: Docs, tree: TextTree[TsValue]): TextTree[TsValue] = { val block = tsTrees.renderDocs(docs, "") - if (block.isEmpty) tree else q"${block}$tree" + if (block.isEmpty) tree else q"$block$tree" } override def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { @@ -97,6 +103,7 @@ object TsDefnTranslator { repr.defn, typeTranslator.toTsModule(defn.id, domain, evo, tsFileTools.definitionsBasePkg), CompilerProduct.Definition, + exports = Some(repr.exports), ) ) ++ wiring ++ client F.pure(all) @@ -117,17 +124,16 @@ object TsDefnTranslator { val tsTypeRef = typeTranslator.asTsType(defn.id, domain, evo, tsFileTools.definitionsBasePkg) val srcRef = typeTranslator.asTsTypeKeepForeigns(defn.id, domain, evo, tsFileTools.definitionsBasePkg) - val codecTrees = + val renderedCodecs = codecs.toList - .flatMap(t => t.translate(defn, tsTypeRef, srcRef).toList) - .map(obsoletePrevious) + .flatMap(t => t.translate(defn, tsTypeRef, srcRef).map(tree => (obsoletePrevious(tree), ExportedSymbol(t.codecName(srcRef).name, typeOnly = false)))) val repr = makeRepr(defn, tsTypeRef, isLatestVersion) val defnWithDocs = prependDocs(defn.docs, repr.defn) - val allDefs = (List(defnWithDocs) ++ codecTrees).joinNN() + val allDefs = (List(defnWithDocs) ++ renderedCodecs.map(_._1)).joinNN() - DefnRepr(allDefs, Nil) + DefnRepr(allDefs, Nil, repr.exports ++ renderedCodecs.map(_._2)) } private def makeRepr( @@ -135,7 +141,7 @@ object TsDefnTranslator { name: TsValue.TsType, isLatestVersion: Boolean, ): DefnRepr = { - defn.defn match { + val repr = defn.defn match { case dto: Typedef.Dto => makeDtoRepr(defn, dto, name, isLatestVersion) case e: Typedef.Enum => makeEnumRepr(e) case adt: Typedef.Adt => makeAdtRepr(defn, adt, name) @@ -143,6 +149,25 @@ object TsDefnTranslator { case _: Typedef.Service => makeServiceRepr(defn, name) case f: Typedef.Foreign => makeForeignKeyCodecRepr(f, name) } + val declared = defn.defn match { + case dto: Typedef.Dto => + List(ExportedSymbol(name.name, typeOnly = false)) ++ + (if (dto.isIdentifier) List(ExportedSymbol(name.name.head.toLower.toString + name.name.tail + "Codec", typeOnly = false)) else Nil) + case _: Typedef.Enum => + val enumName = typeTranslator.escapeTsKeyword(defn.id.name.name) + List(enumName, s"${enumName}_values", s"${enumName}_parse").map(ExportedSymbol(_, typeOnly = false)) + case _: Typedef.Adt => List(ExportedSymbol(name.name, typeOnly = false)) + case _: Typedef.Contract => List(ExportedSymbol(name.name, typeOnly = true)) + case _: Typedef.Service => List(ExportedSymbol(typeTranslator.serviceInterfaceName(name.name), typeOnly = true)) + case f: Typedef.Foreign => + f.bindings.get(BaboonLang.Typescript) match { + case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.Custom)) => + val foreignName = typeTranslator.asTsTypeKeepForeigns(f.id, domain, evo, tsFileTools.definitionsBasePkg).name + List(ExportedSymbol(s"${foreignName}_KeyCodec", typeOnly = true), ExportedSymbol(s"${foreignName}_KeyCodecHost", typeOnly = false)) + case _ => Nil + } + } + repr.copy(exports = declared ++ repr.exports) } /** PR-I.1d (M24 Phase 3.1) — emit a `_KeyCodec` extension hook for @@ -214,6 +239,14 @@ object TsDefnTranslator { fixtureTree, fixtureModule, CompilerProduct.Fixture, + exports = Some { + val base = s"random_${typeTranslator.camelToKebab(defn.id.name.name).replace('-', '_')}" + val names = defn.defn match { + case _: Typedef.Adt => List(base, s"${base}_json", s"${base}_all", s"${base}_json_all") + case _ => List(base, s"${base}_json") + } + names.map(ExportedSymbol(_, typeOnly = false)) + }, ) }.toList) } @@ -230,6 +263,7 @@ object TsDefnTranslator { testTree, testModule, CompilerProduct.Test, + exports = Some(Nil), ) }.toList) } @@ -277,7 +311,7 @@ object TsDefnTranslator { val getters = dto.fields.map { f => - val tpe = typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg) + val tpe = typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg) // The public getter is an accessor identifier; escape it for a keyword-named field // (`class` -> `class_`) so the codecs' `value.` reads (which assume the escaped // accessor name) resolve. The backing private field `_${name}` is `_`-prefixed and always @@ -294,7 +328,8 @@ object TsDefnTranslator { // in with/fromPlain/codecs), so the rename is local: only the param declaration and its `this._x = x` // RHS reference must agree. The private field (`_${name}`) and all object-literal/member-access wire // keys keep the raw name, so the wire format is unchanged. - val constrcutorParams = dto.fields.map(f => q"${typeTranslator.escapeTsKeyword(f.name.name)}: ${typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)}").join(", ") + val constrcutorParams = + dto.fields.map(f => q"${typeTranslator.escapeTsKeyword(f.name.name)}: ${typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)}").join(", ") val constructorInside = fieldsNameAndType.map { case (n, _) => @@ -439,8 +474,7 @@ object TsDefnTranslator { // PascalCase members — existing fixtures byte-identical. val memberIdents = enum.members.toList.map { m => - val pascal = EnumWireStyle.wireName(m.name) - if (lowercaseValues) typeTranslator.escapeTsKeyword(m.name) else pascal + typeTranslator.enumMemberIdentifier(m.name) } val branches = enum.members.toList.zip(memberIdents).map { case (m, ident) => @@ -497,6 +531,7 @@ object TsDefnTranslator { |${memberTrees.map(_.defn).toList.joinNN().trim} |""".stripMargin, Nil, + memberTrees.toList.flatMap(_.exports), ) } @@ -593,46 +628,6 @@ object TsDefnTranslator { // - exported `Codec` object literal with `parseRepr` / // `parseReprCursor` static methods (Q-FU-4: NOT a static on the class — // keeps `MyId.parseRepr` undiscoverable in autocomplete). - private sealed trait IdentifierFieldKind - private object IdentifierFieldKind { - case object Bit extends IdentifierFieldKind - case object SignedInt extends IdentifierFieldKind /* i08/i16/i32 — number */ - case object SignedLong extends IdentifierFieldKind /* i64 — bigint */ - case object UnsignedSmallInt extends IdentifierFieldKind /* u08/u16/u32 — number */ - case object UnsignedLong extends IdentifierFieldKind /* u64 — bigint */ - case object Str extends IdentifierFieldKind - case object Uid extends IdentifierFieldKind - case object Tsu extends IdentifierFieldKind - case object Tso extends IdentifierFieldKind - case object Bytes extends IdentifierFieldKind - final case class NestedId(id: TypeId.User) extends IdentifierFieldKind - } - - private def identifierFieldKind(tpe: TypeRef): IdentifierFieldKind = { - tpe match { - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - import TypeId.Builtins.* - b match { - case `bit` => IdentifierFieldKind.Bit - case `i08` | `i16` | `i32` => IdentifierFieldKind.SignedInt - case `i64` => IdentifierFieldKind.SignedLong - case `u08` | `u16` | `u32` => IdentifierFieldKind.UnsignedSmallInt - case `u64` => IdentifierFieldKind.UnsignedLong - case `str` => IdentifierFieldKind.Str - case `uid` => IdentifierFieldKind.Uid - case `tsu` => IdentifierFieldKind.Tsu - case `tso` => IdentifierFieldKind.Tso - case `bytes` => IdentifierFieldKind.Bytes - case other => - throw new IllegalStateException(s"Identifier field has unsupported scalar $other; validator should have rejected this.") - } - case TypeRef.Scalar(uid: TypeId.User) => - IdentifierFieldKind.NestedId(uid) - case other => - throw new IllegalStateException(s"Identifier field has unsupported TypeRef $other; validator should have rejected this.") - } - } - private def signedTypeName(tpe: TypeRef): String = tpe match { case TypeRef.Scalar(TypeId.Builtins.i08) => "i08" case TypeRef.Scalar(TypeId.Builtins.i16) => "i16" @@ -685,7 +680,7 @@ object TsDefnTranslator { val fieldExprs: List[TextTree[TsValue]] = dto.fields.map { f => val srcFieldName = f.name.name - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val valueExpr = renderFieldValueExprTs(srcFieldName, kind) q""""$srcFieldName:" + ($valueExpr)""" } @@ -713,7 +708,7 @@ object TsDefnTranslator { val valVar = s"${srcFieldName}_v" val resVar = s"${srcFieldName}_r" val isLast = idx == dto.fields.length - 1 - val kind = identifierFieldKind(f.tpe) + val kind = IdentifierFieldKind.classify(f.tpe) val parseHead = q"""const ${srcFieldName}_fnr = $tsBaboonIdReprParseFieldName(cursor, "$srcFieldName"); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala index 61ee34664..ce4f11ba0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala @@ -1,12 +1,10 @@ package io.septimalmind.baboon.translator.typescript import io.septimalmind.baboon.CompilerTarget.TsTarget -import io.septimalmind.baboon.parser.model.RawMemberMeta -import io.septimalmind.baboon.translator.typescript.TsTypes.{tsBaboonAnyMetaCodec, tsBaboonAnyOpaque, tsBaboonAnyOpaqueJsonCtor, tsBaboonCodecContext, tsBaboonDateTimeOffset, tsBaboonDateTimeUtc, tsBaboonDecimal, tsBaboonDecoderFailure, tsBaboonEncoderFailure, tsBaboonLazy, tsBinTools} +import io.septimalmind.baboon.translator.typescript.TsTypes.{tsBaboonCodecContext, tsBaboonDateTimeOffset, tsBaboonDateTimeUtc, tsBaboonDecimal, tsBaboonDecoderFailure, tsBaboonEncoderFailure, tsBaboonLazy, tsBinTools, tsDecodeAnyJsonField, tsEncodeAnyJsonField} import io.septimalmind.baboon.translator.typescript.TsValue.TsType import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -19,6 +17,7 @@ class TsJsonCodecGenerator( tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, ) extends TsCodecTranslator { + private val scalarOps = new TsScalarCodecOps(target) override def translate(defn: DomainMember.User, tsRef: TsValue.TsType, srcRef: TsValue.TsType): Option[TextTree[TsValue]] = { // PR-I.1d (M24 Phase 3.1): the prior `&& !enquiries.hasForeignType(defn, domain)` short-circuit // suppressed JsonCodec emission for any type containing a Custom-bound foreign — including @@ -28,9 +27,9 @@ class TsJsonCodecGenerator( // The UEBA gate in `TsUEBACodecGenerator` is left intact pending a UEBA-side hook (out of scope). if (isActive(defn.id)) { (defn.defn match { - case d: Typedef.Dto => Some(genDtoCodec(d, srcRef)) - case _: Typedef.Enum => Some(genEnumCodec(srcRef)) - case a: Typedef.Adt => Some(genAdtCodec(a, srcRef)) + case d: Typedef.Dto => Some(genDtoCodec(d, srcRef)) + case _: Typedef.Enum => Some(genEnumCodec(srcRef)) + case a: Typedef.Adt => Some(genAdtCodec(a, srcRef)) case f: Typedef.Foreign => // Mirror C# (CSJsonCodecGenerator): a Custom-bound foreign emits a value codec class whose // encode/decode throw by default; the host application overrides it via `lazyInstance`. @@ -82,9 +81,7 @@ class TsJsonCodecGenerator( |}""".stripMargin.trim ) - val anyHelpers: List[TextTree[TsValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - - val baseMethods = encodeMethod ++ decodeMethod ++ anyHelpers + val baseMethods = encodeMethod ++ decodeMethod val meta = tsDomainTreeTools.makeCodecMeta(defn, codecName(srcRef)) @@ -201,24 +198,7 @@ class TsJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => - q"$ref.toString()" - case TypeId.Builtins.f128 => - q"$ref.toString()" - case TypeId.Builtins.bytes => - q"$tsBinTools.hexEncode($ref)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => ref - case "date" => q"$ref.toISOString()" - case _ => q"$ref.toISOString()" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => ref - case "date" => q"$ref.toISOString()" - case _ => q"$ref.toISOString()" - } + case b: TypeId.BuiltinScalar => scalarOps.encodeJson(b, ref) case u: TypeId.User => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => @@ -244,10 +224,10 @@ class TsJsonCodecGenerator( cid match { case TypeId.Builtins.opt => q"$ref === undefined ? null : ${mkJsonEncoder(args.head, ref)}" - case TypeId.Builtins.lst => - q"Array.from($ref).map(item => ${mkJsonEncoder(args.head, q"item")})" - case TypeId.Builtins.set => - q"Array.from($ref).map(item => ${mkJsonEncoder(args.head, q"item")})" + case TypeId.Builtins.lst | TypeId.Builtins.set => + val item = q"item" + val encoded = mkJsonEncoder(args.head, item) + if (encoded == item) q"Array.from($ref)" else q"Array.from($ref).map(item => $encoded)" case TypeId.Builtins.map => val keyType = args.head val isRecord = trans.isStringKeyMap(tpe) @@ -404,30 +384,7 @@ class TsJsonCodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$ref as boolean" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 | TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 | TypeId.Builtins.f32 | - TypeId.Builtins.f64 => - q"$ref as number" - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => - q"BigInt($ref as string)" - case TypeId.Builtins.f128 => - q"$tsBaboonDecimal.fromString($ref as string)" - case TypeId.Builtins.str | TypeId.Builtins.uid => - q"$ref as string" - case TypeId.Builtins.bytes => - q"$tsBinTools.hexDecode($ref as string)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$ref as string" - case "date" => q"new Date($ref as string)" - case _ => q"$tsBaboonDateTimeUtc.fromISO($ref as string)" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$ref as string" - case "date" => q"new Date($ref as string)" - case _ => q"$tsBaboonDateTimeOffset.fromISO($ref as string)" - } + case b: TypeId.BuiltinScalar => scalarOps.decodeJson(b, ref) case u: TypeId.User => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => @@ -478,124 +435,14 @@ class TsJsonCodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java/TS-UEBA `hasAnyField`): a codec class needs the - // any-field helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-class `encodeAnyField` helper. Wires the expected kind byte - // and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[TsValue]): TextTree[TsValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"this.encodeAnyField(ctx, $expectedHex, $staticDom, $staticVer, $staticTid, $ref)" + val plan = TsAnyFieldPlan.forField(a, domain) + q"$tsEncodeAnyJsonField(ctx, ${plan.kindHex}, ${plan.staticDomain}, ${plan.staticVersion}, ${plan.staticTypeId}, $ref)" } - // Decode delegates to the per-codec-class `decodeAnyField` helper. JSON decode never cross- - // converts (always returns `AnyOpaqueJson` from JSON wire); user calls `facade.decodeAny(opaque)` - // for typed resolution. No `ctx` / no static fallbacks needed at the decode site. private def mkAnyDecoder(a: TypeRef.Any, ref: TextTree[TsValue]): TextTree[TsValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"this.decodeAnyField($expectedHex, $ref)" - } - - // Static fallbacks for the cross-format facade helper (`uebaToJson`). The wire `meta` may omit - // components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. Per spec table: - // A=(undef,undef,undef), B=(currentDomain,undef,undef), C=(currentDomain,currentVersion,undef), - // D1=(undef,undef,underlyingFqid), D2=(currentDomain,undef,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS — extraction deferred (textual emission diverges - // by language flavor; see PR 4.2 ledger entry's DRY analysis). 9th instance. - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[TsValue], TextTree[TsValue], TextTree[TsValue]) = { - val none = q"undefined" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field JSON envelope encode/decode (kind check, - // cross-format conversion via facade, envelope `$ak/$ad/$av/$at/$c` build & disassemble). - // Emitted at most once per codec class that has any any-bearing field. Mirrors PR 6.3's Java - // helper shape (closest precedent: JsonNode-style API). PR-08-D06 lesson: kind-check on encode - // runs before any envelope construction. - private def anyFieldHelpers: TextTree[TsValue] = { - q"""private encodeAnyField( - | ctx: $tsBaboonCodecContext, - | expectedKind: number, - | staticDomain: string | undefined, - | staticVersion: string | undefined, - | staticTypeid: string | undefined, - | value: $tsBaboonAnyOpaque, - |): unknown { - | if (value.meta.kind !== expectedKind) { - | throw new $tsBaboonEncoderFailure( - | `any: meta-kind 0x$${(value.meta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x$${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` - | ); - | } - | let anyInner: unknown; - | if (value.tag === "Json") { - | anyInner = value.json; - | } else { - | const anyFacade = ctx.facade; - | if (anyFacade === undefined) { - | throw new $tsBaboonEncoderFailure( - | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueJson directly." - | ); - | } - | const anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid); - | if (anyConvResult.tag === "Left") { - | throw anyConvResult.value; - | } - | anyInner = anyConvResult.value; - | } - | const anyEnvelope = $tsBaboonAnyMetaCodec.writeJson(value.meta); - | anyEnvelope[$tsBaboonAnyMetaCodec.ANY_CONTENT_KEY] = anyInner; - | return anyEnvelope; - |} - | - |private decodeAnyField(expectedKind: number, wire: unknown): $tsBaboonAnyOpaque { - | if (wire === null || typeof wire !== "object" || Array.isArray(wire)) { - | throw new $tsBaboonDecoderFailure("any: JSON envelope must be an object"); - | } - | const anyMetaResult = $tsBaboonAnyMetaCodec.readJson(wire); - | if (anyMetaResult.tag === "Left") { - | throw anyMetaResult.value; - | } - | const anyMeta = anyMetaResult.value; - | if (anyMeta.kind !== expectedKind) { - | throw new $tsBaboonDecoderFailure( - | `any: wire kind 0x$${(anyMeta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x$${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` - | ); - | } - | const anyObj = wire as Record; - | const anyContent = anyObj[$tsBaboonAnyMetaCodec.ANY_CONTENT_KEY]; - | if (anyContent === undefined) { - | throw new $tsBaboonDecoderFailure(`any: JSON envelope missing '$${$tsBaboonAnyMetaCodec.ANY_CONTENT_KEY}' content key`); - | } - | return $tsBaboonAnyOpaqueJsonCtor(anyMeta, anyContent); - |}""".stripMargin + val plan = TsAnyFieldPlan.forField(a, domain) + q"$tsDecodeAnyJsonField(${plan.kindHex}, $ref)" } def codecName(name: TsValue.TsType): TsValue.TsType = { @@ -619,11 +466,7 @@ class TsJsonCodecGenerator( } else None } - override def isActive(id: TypeId): Boolean = { - !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Typescript) && - target.language.generateJsonCodecs && (target.language.generateJsonCodecsByDefault || domain.derivationRequests - .getOrElse(RawMemberMeta.Derived("json"), Set.empty[TypeId]).contains(id)) - } + override def isActive(id: TypeId): Boolean = TsCodecActivation.isActive(target, domain, id, TsCodecActivation.Json) override def id: String = "Json" } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala index 0caf29826..ac03f9eda 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala @@ -50,9 +50,10 @@ class TsMcpServerGenerator[F[+_, +_]: Error2]( override def generateMcpServer(family: BaboonFamily): F[NEList[BaboonIssue], Sources] = { val perService: List[(String, OutputFile)] = family.domains.toMap.values.toList.flatMap { lineage => - val evo = lineage.evolution - val latestDomain = lineage.versions(evo.latest) - servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo)) + val evo = lineage.evolution + val latestDomain = lineage.versions(evo.latest) + val schemaContext = schemaEmitter.prepare(latestDomain) + servicesOf(latestDomain).map(svc => generateForService(svc, latestDomain, evo, schemaContext)) } val runtimeFile = @@ -76,7 +77,8 @@ class TsMcpServerGenerator[F[+_, +_]: Error2]( s"$basename/$serviceDir/mcp-server.ts" } - private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution): (String, OutputFile) = { + private def generateForService(svc: Typedef.Service, domain: Domain, evo: BaboonEvolution, schemaContext: McpInputSchemaEmitter.PreparedDomain) + : (String, OutputFile) = { val basename = tsFileTools.basename(domain, evo) val path = serverPath(svc, basename) // Relative prefix from the server file's directory back to the generated @@ -91,12 +93,14 @@ class TsMcpServerGenerator[F[+_, +_]: Error2]( // Declaration-ordered tool entries (K4 §2.3): one per method. val toolEntries: List[String] = svc.methods.toList.map { m => - val toolName = s"${serviceName}_${m.name.name}" - val schema = schemaEmitter.emitInputSchema(m.sig, domain) - val descField = McpDocs.flatten(m.docs).map(d => s" description: ${jsString(d)},").getOrElse("") + val toolName = s"${serviceName}_${m.name.name}" + val schema = schemaEmitter.emitInputSchema(m.sig, schemaContext) + val descField = McpDocs.flatten(m.docs).map(d => s" description: ${jsString(d)},").getOrElse("") // The self-contained JSON Schema is carried as a constant literal value. // description is only emitted when present (TS `description?: string` — key must be absent, not undefined/null, for undocumented tools). - s""" { name: ${jsString(toolName)}, method: { serviceName: ${jsString(serviceName)}, methodName: ${jsString(m.name.name)} },$descField inputSchema: ${jsonLiteral(schema)} },""" + s""" { name: ${jsString(toolName)}, method: { serviceName: ${jsString(serviceName)}, methodName: ${jsString( + m.name.name + )} },$descField inputSchema: ${jsonLiteral(schema)} },""" } // Async axis (`--ts-async-services=true`): the errors-mode wiring entry @@ -126,9 +130,18 @@ class TsMcpServerGenerator[F[+_, +_]: Error2]( |export class $className extends $baseClass { | public readonly serverInfo: McpServerInfo = { name: ${jsString(serviceName)}, version: ${jsString(modelVer)} }; | - | public readonly tools: readonly McpToolEntry[] = [ + | private readonly toolEntries: readonly McpToolEntry[] = [ |${toolEntries.mkString("\n")} | ]; + | private readonly toolsByName = new Map(this.toolEntries.map(tool => [tool.name, tool])); + | + | public get tools(): readonly McpToolEntry[] { + | return this.toolEntries.map(tool => ({ ...tool, method: { ...tool.method }, inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) })); + | } + | + | protected findTool(name: string): McpToolEntry | undefined { + | return this.toolsByName.get(name); + | } | | private readonly _invokeJson: (method: BaboonMethodId, data: string, ctx: Ctx, codecCtx: BaboonCodecContext) => $delegateRet; | diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala new file mode 100644 index 000000000..a6b0f621d --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala @@ -0,0 +1,120 @@ +package io.septimalmind.baboon.translator.typescript + +import io.septimalmind.baboon.CompilerTarget.TsTarget +import io.septimalmind.baboon.translator.typescript.TsTypes.* +import io.septimalmind.baboon.typer.model.TypeId +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +final class TsScalarCodecOps(target: TsTarget) { + def decodeJson(id: TypeId.BuiltinScalar, wire: TextTree[TsValue]): TextTree[TsValue] = { + id match { + case TypeId.Builtins.bit => q"$wire as boolean" + case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 | TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 | TypeId.Builtins.f32 | + TypeId.Builtins.f64 => + q"$wire as number" + case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"BigInt($wire as string)" + case TypeId.Builtins.f128 => q"$tsBaboonDecimal.fromString($wire as string)" + case TypeId.Builtins.str | TypeId.Builtins.uid => q"$wire as string" + case TypeId.Builtins.bytes => q"$tsBinTools.hexDecode($wire as string)" + case TypeId.Builtins.tsu => + target.language.timestampsUtcMode match { + case "string" => q"$wire as string" + case "date" => q"new Date($wire as string)" + case _ => q"$tsBaboonDateTimeUtc.fromISO($wire as string)" + } + case TypeId.Builtins.tso => + target.language.timestampsOffsetMode match { + case "string" => q"$wire as string" + case "date" => q"new Date($wire as string)" + case _ => q"$tsBaboonDateTimeOffset.fromISO($wire as string)" + } + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + } + + def encodeJson(id: TypeId.BuiltinScalar, value: TextTree[TsValue]): TextTree[TsValue] = { + id match { + case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"$value.toString()" + case TypeId.Builtins.f128 => q"$value.toString()" + case TypeId.Builtins.bytes => q"$tsBinTools.hexEncode($value)" + case TypeId.Builtins.tsu => + target.language.timestampsUtcMode match { + case "string" => value + case _ => q"$value.toISOString()" + } + case TypeId.Builtins.tso => + target.language.timestampsOffsetMode match { + case "string" => value + case _ => q"$value.toISOString()" + } + case _ => value + } + } + + def decodeUeba(id: TypeId.BuiltinScalar, reader: TextTree[TsValue]): TextTree[TsValue] = { + id match { + case TypeId.Builtins.bit => q"$tsBinTools.readBool($reader)" + case TypeId.Builtins.i08 => q"$tsBinTools.readI8($reader)" + case TypeId.Builtins.i16 => q"$tsBinTools.readI16($reader)" + case TypeId.Builtins.i32 => q"$tsBinTools.readI32($reader)" + case TypeId.Builtins.i64 => q"$tsBinTools.readI64($reader)" + case TypeId.Builtins.u08 => q"$tsBinTools.readU8($reader)" + case TypeId.Builtins.u16 => q"$tsBinTools.readU16($reader)" + case TypeId.Builtins.u32 => q"$tsBinTools.readU32($reader)" + case TypeId.Builtins.u64 => q"$tsBinTools.readU64($reader)" + case TypeId.Builtins.f32 => q"$tsBinTools.readF32($reader)" + case TypeId.Builtins.f64 => q"$tsBinTools.readF64($reader)" + case TypeId.Builtins.f128 => q"$tsBinTools.readDecimal($reader)" + case TypeId.Builtins.str => q"$tsBinTools.readString($reader)" + case TypeId.Builtins.bytes => q"$tsBinTools.readBytes($reader)" + case TypeId.Builtins.uid => q"$tsBinTools.readUuid($reader)" + case TypeId.Builtins.tsu => + target.language.timestampsUtcMode match { + case "string" => q"$tsBinTools.readTimestampUtc($reader).toISOString()" + case "date" => q"$tsBinTools.readTimestampUtc($reader).date" + case _ => q"$tsBinTools.readTimestampUtc($reader)" + } + case TypeId.Builtins.tso => + target.language.timestampsOffsetMode match { + case "string" => q"$tsBinTools.readTimestampOffset($reader).toISOString()" + case "date" => q"$tsBinTools.readTimestampOffset($reader).date" + case _ => q"$tsBinTools.readTimestampOffset($reader)" + } + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + } + + def encodeUeba(id: TypeId.BuiltinScalar, writer: TextTree[TsValue], value: TextTree[TsValue]): TextTree[TsValue] = { + id match { + case TypeId.Builtins.bit => q"$tsBinTools.writeBool($writer, $value);" + case TypeId.Builtins.i08 => q"$tsBinTools.writeI8($writer, $value);" + case TypeId.Builtins.i16 => q"$tsBinTools.writeI16($writer, $value);" + case TypeId.Builtins.i32 => q"$tsBinTools.writeI32($writer, $value);" + case TypeId.Builtins.i64 => q"$tsBinTools.writeI64($writer, $value);" + case TypeId.Builtins.u08 => q"$tsBinTools.writeU8($writer, $value);" + case TypeId.Builtins.u16 => q"$tsBinTools.writeU16($writer, $value);" + case TypeId.Builtins.u32 => q"$tsBinTools.writeU32($writer, $value);" + case TypeId.Builtins.u64 => q"$tsBinTools.writeU64($writer, $value);" + case TypeId.Builtins.f32 => q"$tsBinTools.writeF32($writer, $value);" + case TypeId.Builtins.f64 => q"$tsBinTools.writeF64($writer, $value);" + case TypeId.Builtins.f128 => q"$tsBinTools.writeDecimal($writer, $value);" + case TypeId.Builtins.str => q"$tsBinTools.writeString($writer, $value);" + case TypeId.Builtins.bytes => q"$tsBinTools.writeBytes($writer, $value);" + case TypeId.Builtins.uid => q"$tsBinTools.writeUuid($writer, $value);" + case TypeId.Builtins.tsu => + target.language.timestampsUtcMode match { + case "string" => q"$tsBinTools.writeTimestampUtc($writer, $tsBaboonDateTimeUtc.fromISO($value));" + case "date" => q"$tsBinTools.writeTimestampUtc($writer, $tsBaboonDateTimeUtc.fromDate($value));" + case _ => q"$tsBinTools.writeTimestampUtc($writer, $value);" + } + case TypeId.Builtins.tso => + target.language.timestampsOffsetMode match { + case "string" => q"$tsBinTools.writeTimestampOffset($writer, $tsBaboonDateTimeOffset.fromISO($value));" + case "date" => q"$tsBinTools.writeTimestampOffset($writer, $tsBaboonDateTimeOffset.fromISO($value.toISOString()));" + case _ => q"$tsBinTools.writeTimestampOffset($writer, $value);" + } + case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala index 084cab047..487610521 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala @@ -4,6 +4,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.TsTarget import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.typescript.TsTypes.* +import io.septimalmind.baboon.translator.typescript.TsDefnTranslator.ExportedSymbol import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -27,6 +28,7 @@ object TsServiceWiringTranslator { evo: BaboonEvolution, tsFileTools: TsFileTools, ) extends TsServiceWiringTranslator { + private val scalarOps = new TsScalarCodecOps(target) private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "typescript", target.language.serviceResult, target.language.pragmas) @@ -121,31 +123,8 @@ object TsServiceWiringTranslator { val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsJsonCodecGenerator => c }.get.codecName(tsType) q"$codec.instance.decode($tsBaboonCodecContext.Default, $wire)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$wire as boolean" - case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 | TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 | TypeId.Builtins.f32 | - TypeId.Builtins.f64 => - q"$wire as number" - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"BigInt($wire as string)" - case TypeId.Builtins.f128 => q"$tsBaboonDecimal.fromString($wire as string)" - case TypeId.Builtins.str | TypeId.Builtins.uid => q"$wire as string" - case TypeId.Builtins.bytes => q"$tsBinTools.hexDecode($wire as string)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$wire as string" - case "date" => q"new Date($wire as string)" - case _ => q"$tsBaboonDateTimeUtc.fromISO($wire as string)" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$wire as string" - case "date" => q"new Date($wire as string)" - case _ => q"$tsBaboonDateTimeOffset.fromISO($wire as string)" - } - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case b: TypeId.BuiltinScalar => scalarOps.decodeJson(b, wire) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def jsonEncodeExpr(id: TypeId, value: TextTree[TsValue]): TextTree[TsValue] = id match { @@ -153,24 +132,8 @@ object TsServiceWiringTranslator { val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsJsonCodecGenerator => c }.get.codecName(tsType) q"$codec.instance.encode($tsBaboonCodecContext.Default, $value)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"$value.toString()" - case TypeId.Builtins.f128 => q"$value.toString()" - case TypeId.Builtins.bytes => q"$tsBinTools.hexEncode($value)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => value - case _ => q"$value.toISOString()" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => value - case _ => q"$value.toISOString()" - } - case _ => value - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case b: TypeId.BuiltinScalar => scalarOps.encodeJson(b, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaDecodeExpr(id: TypeId, reader: TextTree[TsValue]): TextTree[TsValue] = id match { @@ -178,38 +141,8 @@ object TsServiceWiringTranslator { val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsUEBACodecGenerator => c }.get.codecName(tsType) q"$codec.instance.decode($codecCtxRef, $reader)" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$tsBinTools.readBool($reader)" - case TypeId.Builtins.i08 => q"$tsBinTools.readI8($reader)" - case TypeId.Builtins.i16 => q"$tsBinTools.readI16($reader)" - case TypeId.Builtins.i32 => q"$tsBinTools.readI32($reader)" - case TypeId.Builtins.i64 => q"$tsBinTools.readI64($reader)" - case TypeId.Builtins.u08 => q"$tsBinTools.readU8($reader)" - case TypeId.Builtins.u16 => q"$tsBinTools.readU16($reader)" - case TypeId.Builtins.u32 => q"$tsBinTools.readU32($reader)" - case TypeId.Builtins.u64 => q"$tsBinTools.readU64($reader)" - case TypeId.Builtins.f32 => q"$tsBinTools.readF32($reader)" - case TypeId.Builtins.f64 => q"$tsBinTools.readF64($reader)" - case TypeId.Builtins.f128 => q"$tsBinTools.readDecimal($reader)" - case TypeId.Builtins.str => q"$tsBinTools.readString($reader)" - case TypeId.Builtins.bytes => q"$tsBinTools.readBytes($reader)" - case TypeId.Builtins.uid => q"$tsBinTools.readUuid($reader)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$tsBinTools.readTimestampUtc($reader).toISOString()" - case "date" => q"$tsBinTools.readTimestampUtc($reader).date" - case _ => q"$tsBinTools.readTimestampUtc($reader)" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$tsBinTools.readTimestampOffset($reader).toISOString()" - case "date" => q"$tsBinTools.readTimestampOffset($reader).date" - case _ => q"$tsBinTools.readTimestampOffset($reader)" - } - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case b: TypeId.BuiltinScalar => scalarOps.decodeUeba(b, reader) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } private def uebaEncodeStmt(id: TypeId, writer: TextTree[TsValue], value: TextTree[TsValue]): TextTree[TsValue] = id match { @@ -217,38 +150,8 @@ object TsServiceWiringTranslator { val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsUEBACodecGenerator => c }.get.codecName(tsType) q"$codec.instance.encode($codecCtxRef, $value, $writer);" - case b: TypeId.BuiltinScalar => - b match { - case TypeId.Builtins.bit => q"$tsBinTools.writeBool($writer, $value);" - case TypeId.Builtins.i08 => q"$tsBinTools.writeI8($writer, $value);" - case TypeId.Builtins.i16 => q"$tsBinTools.writeI16($writer, $value);" - case TypeId.Builtins.i32 => q"$tsBinTools.writeI32($writer, $value);" - case TypeId.Builtins.i64 => q"$tsBinTools.writeI64($writer, $value);" - case TypeId.Builtins.u08 => q"$tsBinTools.writeU8($writer, $value);" - case TypeId.Builtins.u16 => q"$tsBinTools.writeU16($writer, $value);" - case TypeId.Builtins.u32 => q"$tsBinTools.writeU32($writer, $value);" - case TypeId.Builtins.u64 => q"$tsBinTools.writeU64($writer, $value);" - case TypeId.Builtins.f32 => q"$tsBinTools.writeF32($writer, $value);" - case TypeId.Builtins.f64 => q"$tsBinTools.writeF64($writer, $value);" - case TypeId.Builtins.f128 => q"$tsBinTools.writeDecimal($writer, $value);" - case TypeId.Builtins.str => q"$tsBinTools.writeString($writer, $value);" - case TypeId.Builtins.bytes => q"$tsBinTools.writeBytes($writer, $value);" - case TypeId.Builtins.uid => q"$tsBinTools.writeUuid($writer, $value);" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$tsBinTools.writeTimestampUtc($writer, $tsBaboonDateTimeUtc.fromISO($value));" - case "date" => q"$tsBinTools.writeTimestampUtc($writer, $tsBaboonDateTimeUtc.fromDate($value));" - case _ => q"$tsBinTools.writeTimestampUtc($writer, $value);" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$tsBinTools.writeTimestampOffset($writer, $tsBaboonDateTimeOffset.fromISO($value));" - case "date" => q"$tsBinTools.writeTimestampOffset($writer, $tsBaboonDateTimeOffset.fromISO($value.toISOString()));" - case _ => q"$tsBinTools.writeTimestampOffset($writer, $value);" - } - case other => throw new RuntimeException(s"BUG: Unsupported builtin scalar in service wiring: $other") - } - case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") + case b: TypeId.BuiltinScalar => scalarOps.encodeUeba(b, writer, value) + case other => throw new RuntimeException(s"BUG: Non-scalar type in service wiring: $other") } override def translateServiceRt(): Option[TsDefnTranslator.Output] = { @@ -289,12 +192,7 @@ object TsServiceWiringTranslator { ) } else None - // Include BaboonEither as TsType to trigger import (only for built-in) - val importTrigger: Option[TextTree[TsValue]] = if (isBuiltinEither) { - Some(q"type _BaboonEither = $baboonEither;") - } else None - - val tree = Seq[Option[TextTree[TsValue]]](importTrigger, Some(rtTrait), defaultImpl).flatten.joinNN() + val tree = (rtTrait :: defaultImpl.toList).joinNN() Some( TsDefnTranslator.Output( @@ -302,6 +200,9 @@ object TsServiceWiringTranslator { tree, rtModule, CompilerProduct.Definition, + imports = if (isBuiltinEither) List(baboonEither) else Nil, + exports = + Some(List(ExportedSymbol("IBaboonServiceRt", typeOnly = true)) ++ defaultImpl.toList.map(_ => ExportedSymbol("BaboonServiceRtDefault", typeOnly = false))), ) ) } @@ -315,6 +216,17 @@ object TsServiceWiringTranslator { val wiringPath = getWiringPath(defn) val wiringModule = TsValue.TsModuleId(tsFileTools.definitionsBasePkg ++ wiringPath.stripSuffix(".ts").split('/').toList) + val serviceName = typeTranslator.asTsType(service.id, domain, evo, tsFileTools.definitionsBasePkg).name + val exported = List(true -> activeJsonCodec(service), false -> activeBinCodec(service)).flatMap { + case (isJson, codec) => + codec.toList.flatMap { + _ => + List( + ExportedSymbol(typeTranslator.serviceInvokeName(serviceName, isJson), typeOnly = false), + ExportedSymbol(typeTranslator.serviceWrapperName(serviceName, isJson), typeOnly = false), + ) + } + } Some( TsDefnTranslator.Output( @@ -322,6 +234,8 @@ object TsServiceWiringTranslator { methods, wiringModule, CompilerProduct.Definition, + imports = if (resolved.noErrors) Nil else containerTypeRef.toList, + exports = Some(exported), ) ) case _ => None @@ -362,7 +276,7 @@ object TsServiceWiringTranslator { } q"""public async ${m.name.name}Json(${ctxParamDecl}arg: $inType): Promise<$retType> { | const encoded = JSON.stringify($encodeInExpr); - | const resp = await this.transportJson(${ctxArgPass}"${svcType.name}", "${m.name.name}", encoded); + | const resp = await this.transportJson($ctxArgPass"${svcType.name}", "${m.name.name}", encoded); | ${decodeOut.shift(4).trim} |}""".stripMargin } @@ -378,7 +292,7 @@ object TsServiceWiringTranslator { q"""public async ${m.name.name}(${ctxParamDecl}arg: $inType, $codecCtxName: $tsBaboonCodecContext = $tsBaboonCodecContext.Default): Promise<$retType> { | const writer = new $tsBaboonBinWriter(); | ${uebaEncodeStmt(m.sig.id.asInstanceOf[TypeId.Scalar], q"writer", q"arg")}; - | const resp = await this.transportUeba(${ctxArgPass}"${svcType.name}", "${m.name.name}", writer.toBytes()); + | const resp = await this.transportUeba($ctxArgPass"${svcType.name}", "${m.name.name}", writer.toBytes()); | ${decodeOut.shift(4).trim} |}""".stripMargin } @@ -423,6 +337,7 @@ object TsServiceWiringTranslator { clientTree, clientModule, CompilerProduct.Definition, + exports = Some(List(ExportedSymbol(typeTranslator.serviceClientName(svcType.name), typeOnly = false))), ) ) case _ => None @@ -471,10 +386,10 @@ object TsServiceWiringTranslator { ) if (resolved.noErrors) { q"""case "${s.id.name.name}": - | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, ${ctxArgPass}$codecCtxName);""".stripMargin + | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, $ctxArgPass$codecCtxName);""".stripMargin } else { q"""case "${s.id.name.name}": - | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, rt, ${ctxArgPass}$codecCtxName);""".stripMargin + | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, rt, $ctxArgPass$codecCtxName);""".stripMargin } } } @@ -492,7 +407,7 @@ object TsServiceWiringTranslator { | methodName: string, | data: Uint8Array, | impls: {${implFields.join("; ")}}, - | $rtParam${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $rtParam$ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $retTypeUeba { | switch (serviceName) { | ${cases.joinN().shift(8).trim} @@ -516,10 +431,10 @@ object TsServiceWiringTranslator { ) if (resolved.noErrors) { q"""case "${s.id.name.name}": - | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, ${ctxArgPass}$codecCtxName);""".stripMargin + | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, $ctxArgPass$codecCtxName);""".stripMargin } else { q"""case "${s.id.name.name}": - | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, rt, ${ctxArgPass}$codecCtxName);""".stripMargin + | return $awaitPrefix$wiringFnRef({ serviceName, methodName }, data, impls.${s.id.name.name}, rt, $ctxArgPass$codecCtxName);""".stripMargin } } } @@ -532,7 +447,7 @@ object TsServiceWiringTranslator { | methodName: string, | data: string, | impls: {${implFields.join("; ")}}, - | $rtParam${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $rtParam$ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $retTypeJson { | switch (serviceName) { | ${cases.joinN().shift(8).trim} @@ -543,19 +458,7 @@ object TsServiceWiringTranslator { ) } else None - // When the dispatcher's return type contains a result container (errors - // mode), trigger the symbol imports for `BaboonEither<…>` and - // `BaboonWiringError` via a phantom type alias — the dispatcher's - // return-type string `Promise>` is - // built textually via `renderContainer` and would otherwise reference - // unimported names. - val containerImportTrigger: Option[TextTree[TsValue]] = - if (resolved.noErrors) None - else containerTypeRef.map(ref => q"type _DispatcherContainerImport = $ref;") - val wiringErrorImportTrigger: Option[TextTree[TsValue]] = - if (resolved.noErrors) None else Some(q"type _DispatcherWiringErrorImport = $baboonWiringError;") - - val tree = (containerImportTrigger.toSeq ++ wiringErrorImportTrigger.toSeq ++ Seq(uebaFn, jsonFn).flatten).joinNN() + val tree = Seq(uebaFn, jsonFn).flatten.joinNN() val dispatcherPath = s"$fbase/baboon-dispatcher.ts" val dispatcherModule = TsValue.TsModuleId(tsFileTools.definitionsBasePkg ++ dispatcherPath.stripSuffix(".ts").split('/').toList) @@ -565,6 +468,9 @@ object TsServiceWiringTranslator { tree, dispatcherModule, CompilerProduct.Definition, + imports = if (resolved.noErrors) Nil else containerTypeRef.toList :+ baboonWiringError, + exports = + Some(uebaFn.toList.map(_ => ExportedSymbol("dispatchUeba", typeOnly = false)) ++ jsonFn.toList.map(_ => ExportedSymbol("dispatchJson", typeOnly = false))), ) ) } @@ -604,8 +510,8 @@ object TsServiceWiringTranslator { case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) case _ => None } - private def ctxTypeParamDecl: String = ctxTypeName.fold("")(tn => s"<$tn>") - private def ctxTypeArg: TextTree[TsValue] = ctxTypeName.fold(TextTree.text[TsValue](""))(tn => q"<$tn>") + private def ctxTypeParamDecl: String = ctxTypeName.fold("")(tn => s"<$tn>") + private def ctxTypeArg: TextTree[TsValue] = ctxTypeName.fold(TextTree.text[TsValue](""))(tn => q"<$tn>") // The codec context parameter is always present in wiring/client/wrapper // signatures; when a service context is active its parameter name (default @@ -654,19 +560,19 @@ object TsServiceWiringTranslator { isJson: Boolean, retType: TextTree[TsValue], ): TextTree[TsValue] = { - val wireType = if (isJson) q"string" else q"Uint8Array" - val invokerFn = typeTranslator.serviceInvokeName(svcType.name, isJson) + val wireType = if (isJson) q"string" else q"Uint8Array" + val invokerFn = typeTranslator.serviceInvokeName(svcType.name, isJson) val wrapperName: String = typeTranslator.serviceWrapperName(svcType.name, isJson) val svcCtxTypeName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) - case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(tn, _) => Some(tn) + case ResolvedServiceContext.ConcreteContext(tn, _) => Some(tn) } val svcCtxArgName: Option[String] = resolvedCtx match { - case ResolvedServiceContext.NoContext => None - case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) - case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) + case ResolvedServiceContext.NoContext => None + case ResolvedServiceContext.AbstractContext(_, pn) => Some(pn) + case ResolvedServiceContext.ConcreteContext(_, pn) => Some(pn) } // The implemented runtime contract follows the context mode: `none` keeps @@ -695,8 +601,8 @@ object TsServiceWiringTranslator { val invokerArgs: List[TextTree[TsValue]] = { val base: List[TextTree[TsValue]] = List(q"method", q"data", q"this.impl") - val withRt = rtField.fold(base)(_ => base :+ q"this.rt") - val withSvcCtx = svcCtxArgName.fold(withRt)(n => withRt :+ TextTree.text[TsValue](n)) + val withRt = rtField.fold(base)(_ => base :+ q"this.rt") + val withSvcCtx = svcCtxArgName.fold(withRt)(n => withRt :+ TextTree.text[TsValue](n)) withSvcCtx :+ codecCtxRef } @@ -709,7 +615,7 @@ object TsServiceWiringTranslator { | ${ctorAssigns.join("\n").shift(8).trim} | } | - | invoke(method: $baboonMethodId, data: $wireType, ${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext): $retType { + | invoke(method: $baboonMethodId, data: $wireType, $ctxParamDecl$codecCtxName: $tsBaboonCodecContext): $retType { | return $invokerFn(${invokerArgs.join(", ")}); | } |}""".stripMargin @@ -736,7 +642,7 @@ object TsServiceWiringTranslator { private def noErrorsJsonRetType: String = if (isAsync) "Promise" else "string" private def noErrorsUebaRetType: String = if (isAsync) "Promise" else "Uint8Array" - private def noErrorsJsonRetTree: TextTree[TsValue] = if (isAsync) q"Promise" else q"string" + private def noErrorsJsonRetTree: TextTree[TsValue] = if (isAsync) q"Promise" else q"string" private def noErrorsUebaRetTree: TextTree[TsValue] = if (isAsync) q"Promise" else q"Uint8Array" private def generateNoErrorsJsonFn( @@ -767,7 +673,7 @@ object TsServiceWiringTranslator { | method: $baboonMethodId, | data: string, | impl: ${typeTranslator.serviceInterfaceRef(svcType)}$ctxTypeArg, - | ${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $noErrorsJsonRetType { | switch (method.methodName) { | ${cases.shift(8).trim} @@ -808,7 +714,7 @@ object TsServiceWiringTranslator { | method: $baboonMethodId, | data: Uint8Array, | impl: ${typeTranslator.serviceInterfaceRef(svcType)}$ctxTypeArg, - | ${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $noErrorsUebaRetType { | switch (method.methodName) { | ${cases.shift(8).trim} @@ -836,14 +742,9 @@ object TsServiceWiringTranslator { if (activeBinCodec(service).isDefined) Some(generateErrorsUebaFn(service, svcType)) else None - val importTrigger: Option[TextTree[TsValue]] = containerTypeRef.map { - ref => - q"type _ContainerImport = $ref;" - } - val wrappers = generateServiceWrappers(service, svcType, errorsJsonRetTree, errorsUebaRetTree) - (importTrigger.toSeq ++ jsonFn.toSeq ++ uebaFn.toSeq :+ wrappers).joinNN() + (jsonFn.toSeq ++ uebaFn.toSeq :+ wrappers).joinNN() } private def errorsJsonRetType: String = { @@ -1033,7 +934,7 @@ object TsServiceWiringTranslator { | data: string, | impl: ${typeTranslator.serviceInterfaceRef(svcType)}$ctxTypeArg, | rt: $ibaboonServiceRt, - | ${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $errorsJsonRetType { | switch (method.methodName) { | ${cases.shift(8).trim} @@ -1057,7 +958,7 @@ object TsServiceWiringTranslator { | data: Uint8Array, | impl: ${typeTranslator.serviceInterfaceRef(svcType)}$ctxTypeArg, | rt: $ibaboonServiceRt, - | ${ctxParamDecl}$codecCtxName: $tsBaboonCodecContext + | $ctxParamDecl$codecCtxName: $tsBaboonCodecContext |): $errorsUebaRetType { | switch (method.methodName) { | ${cases.shift(8).trim} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTreeTools.scala index 1ed0a747c..e9b787361 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTreeTools.scala @@ -1,9 +1,7 @@ package io.septimalmind.baboon.translator.typescript -import io.septimalmind.baboon.translator.DocCommentEscaping +import io.septimalmind.baboon.translator.DocumentationRenderer import io.septimalmind.baboon.typer.model.Docs -import izumi.fundamentals.platform.strings.TextTree -import izumi.fundamentals.platform.strings.TextTree.* trait TsTreeTools { @@ -31,35 +29,6 @@ trait TsTreeTools { object TsTreeTools { class TsTreeToolsImpl extends TsTreeTools { - def renderDocs(docs: Docs, indent: String): String = { - val prefixLines = docs.prefix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - val suffixLines = docs.suffix.map(_.cleaned.split("\n", -1).toList).getOrElse(Nil) - - if (prefixLines.isEmpty && suffixLines.isEmpty) return "" - - val mergedLines: List[String] = - if (suffixLines.isEmpty) prefixLines - else if (prefixLines.isEmpty) suffixLines - else prefixLines ++ List("") ++ suffixLines - - // D35: renderDocs introduces no backslashes (plain `/** */`), so the - // backslash-escape is applied at the renderDocs boundary. See - // DocCommentEscaping.escapeBackslashForQInterpolation. - val allLines: List[String] = - mergedLines.map(DocCommentEscaping.escapeBackslashForQInterpolation) - - // Single-line compact form: /** text */ - val rendered = - if (allLines.size == 1) { - s"$indent/** ${allLines.head} */\n" - } else { - val middle = allLines.map { - case "" => s"$indent *" - case l => s"$indent * $l" - }.mkString("\n") - s"$indent/**\n$middle\n$indent */\n" - } - rendered - } + def renderDocs(docs: Docs, indent: String): String = DocumentationRenderer.javadoc(docs, indent) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypeTranslator.scala index b2583aaba..b2cdd5791 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypeTranslator.scala @@ -3,6 +3,7 @@ package io.septimalmind.baboon.translator.typescript import io.septimalmind.baboon.CompilerTarget.TsTarget import io.septimalmind.baboon.translator.typescript.TsTypes.* import io.septimalmind.baboon.translator.typescript.TsValue.{TsModuleId, TsType} +import io.septimalmind.baboon.typer.EnumWireStyle import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote @@ -12,6 +13,10 @@ class TsTypeTranslator(target: TsTarget) { private val timestampsUtcMode: String = target.language.timestampsUtcMode private val timestampsOffsetMode: String = target.language.timestampsOffsetMode + def enumMemberIdentifier(name: String): String = { + if (target.language.enumLowercaseValues) escapeTsKeyword(name) else EnumWireStyle.wireName(name) + } + def asTsRef( tpe: TypeRef, domain: Domain, @@ -282,12 +287,14 @@ class TsTypeTranslator(target: TsTarget) { /** Reference to a service interface as a type. Bare mode imports the bare `Service` aliased to the * service name, so multi-service files (the dispatcher) don't collide. `svcType` must be the - * `asTsType` of the service id (its `.name` is the real service name, used as the alias). */ + * `asTsType` of the service id (its `.name` is the real service name, used as the alias). + */ def serviceInterfaceRef(svcType: TsType): TsType = if (bareServices) TsType(svcType.moduleId, "Service", alias = Some(svcType.name)) else svcType /** Reference to a wiring `invokeJson`/`invokeUeba` function, aliased to the prefixed name in bare - * mode. `wiringModule` is the service's `wiring.ts` module. */ + * mode. `wiringModule` is the service's `wiring.ts` module. + */ def serviceInvokeRef(wiringModule: TsModuleId, serviceName: String, json: Boolean): TsType = { val base = if (json) "invokeJson" else "invokeUeba" if (bareServices) TsType(wiringModule, base, alias = Some(s"${base}_$serviceName")) @@ -301,8 +308,7 @@ class TsTypeTranslator(target: TsTarget) { */ private def serviceSegmentIndex(names: List[String], domain: Domain): Option[Int] = { domain.defs.meta.nodes.valuesIterator.collectFirst { - case DomainMember.User(_, svc: Typedef.Service, _, _) - if { + case DomainMember.User(_, svc: Typedef.Service, _, _) if { val scope = svc.id.owner.asPseudoPkg.toList :+ svc.id.name.name names.startsWith(scope) && names.size > scope.size } => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala index fce7db0c0..ff22bd8aa 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala @@ -35,6 +35,10 @@ object TsTypes { val tsBaboonAnyOpaqueUebaCtor: TsType = TsType(tsBaboonAnyOpaqueModule, "anyOpaqueUeba") val tsBaboonAnyOpaqueJsonCtor: TsType = TsType(tsBaboonAnyOpaqueModule, "anyOpaqueJson") val tsBaboonAnyMetaCodec: TsType = TsType(tsBaboonAnyOpaqueModule, "AnyMetaCodec") + val tsEncodeAnyJsonField: TsType = TsType(tsBaboonAnyOpaqueModule, "encodeAnyJsonField") + val tsDecodeAnyJsonField: TsType = TsType(tsBaboonAnyOpaqueModule, "decodeAnyJsonField") + val tsEncodeAnyUebaField: TsType = TsType(tsBaboonAnyOpaqueModule, "encodeAnyUebaField") + val tsDecodeAnyUebaField: TsType = TsType(tsBaboonAnyOpaqueModule, "decodeAnyUebaField") // any-feature failures (BaboonSharedRuntime module) val tsBaboonEncoderFailure: TsType = TsType(tsBaboonRuntimeShared, "BaboonEncoderFailure") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala index c9fd4509f..0feb16201 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala @@ -1,12 +1,10 @@ package io.septimalmind.baboon.translator.typescript import io.septimalmind.baboon.CompilerTarget.TsTarget -import io.septimalmind.baboon.parser.model.RawMemberMeta import io.septimalmind.baboon.translator.typescript.TsTypes.* import io.septimalmind.baboon.translator.typescript.TsValue.TsType import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -19,12 +17,13 @@ class TsUEBACodecGenerator( tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, ) extends TsCodecTranslator { + private val scalarOps = new TsScalarCodecOps(target) override def translate(defn: DomainMember.User, tsRef: TsValue.TsType, srcRef: TsValue.TsType): Option[TextTree[TsValue]] = { if (isActive(defn.id)) { (defn.defn match { - case d: Typedef.Dto => Some(genDtoCodec(srcRef, d)) - case e: Typedef.Enum => Some(genEnumCodec(srcRef, e)) - case a: Typedef.Adt => Some(genAdtCodec(srcRef, a)) + case d: Typedef.Dto => Some(genDtoCodec(srcRef, d)) + case e: Typedef.Enum => Some(genEnumCodec(srcRef, e)) + case a: Typedef.Adt => Some(genAdtCodec(a)) case f: Typedef.Foreign => // Mirror C# (CSUEBACodecGenerator): a Custom-bound foreign emits a value codec class whose // encode/decode throw by default; the host overrides it via `lazyInstance`. Containing types @@ -81,12 +80,10 @@ class TsUEBACodecGenerator( |}""".stripMargin.trim ) - val anyHelpers: List[TextTree[TsValue]] = if (hasAnyField(defn)) List(anyFieldHelpers) else Nil - val baseMethods = encodeMethod ++ decodeMethod ++ branchDecoder.map(tree => q"""public decodeBranch(ctx: $tsBaboonCodecContext, reader: $tsBaboonBinReader) { | ${tree.shift(4).trim} - |}""".stripMargin) ++ anyHelpers + |}""".stripMargin) val meta = tsDomainTreeTools.makeCodecMeta(defn, codecName(srcRef)) @@ -252,7 +249,7 @@ class TsUEBACodecGenerator( ) } - private def genAdtCodec(name: TsValue.TsType, adt: Typedef.Adt): (TextTree[TsValue], TextTree[TsValue]) = { + private def genAdtCodec(adt: Typedef.Adt): (TextTree[TsValue], TextTree[TsValue]) = { val branches = adt.dataMembers(domain).zipWithIndex.toList val encBranches = branches.map { @@ -284,17 +281,6 @@ class TsUEBACodecGenerator( } } - q"""export function encode_${name.name}_ueba(value: ${name.name}, ctx: $tsBaboonCodecContext, writer: $tsBaboonBinWriter): void { - | ${encBranches.joinN().shift(4).trim} - |} - | - |export function decode_${name.name}_ueba(ctx: $tsBaboonCodecContext, reader: $tsBaboonBinReader): ${name.name} { - | const tag = $tsBinTools.readByte(reader); - | switch (tag) { - | ${decBranches.joinN().shift(8).trim} - | default: throw new Error("Unknown ADT branch tag: " + tag); - | } - |}""".stripMargin ( q"""${encBranches.joinN().shift(4).trim} |throw new Error("Unhandled ADT branch: " + (value as {constructor?: {name?: string}}).constructor?.name);""".stripMargin, @@ -311,33 +297,7 @@ class TsUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$tsBinTools.writeBool($w, $ref);" - case TypeId.Builtins.i08 => q"$tsBinTools.writeI8($w, $ref);" - case TypeId.Builtins.i16 => q"$tsBinTools.writeI16($w, $ref);" - case TypeId.Builtins.i32 => q"$tsBinTools.writeI32($w, $ref);" - case TypeId.Builtins.i64 => q"$tsBinTools.writeI64($w, $ref);" - case TypeId.Builtins.u08 => q"$tsBinTools.writeU8($w, $ref);" - case TypeId.Builtins.u16 => q"$tsBinTools.writeU16($w, $ref);" - case TypeId.Builtins.u32 => q"$tsBinTools.writeU32($w, $ref);" - case TypeId.Builtins.u64 => q"$tsBinTools.writeU64($w, $ref);" - case TypeId.Builtins.f32 => q"$tsBinTools.writeF32($w, $ref);" - case TypeId.Builtins.f64 => q"$tsBinTools.writeF64($w, $ref);" - case TypeId.Builtins.f128 => q"$tsBinTools.writeDecimal($w, $ref);" - case TypeId.Builtins.str => q"$tsBinTools.writeString($w, $ref);" - case TypeId.Builtins.bytes => q"$tsBinTools.writeBytes($w, $ref);" - case TypeId.Builtins.uid => q"$tsBinTools.writeUuid($w, $ref);" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$tsBinTools.writeTimestampUtc($w, $tsBaboonDateTimeUtc.fromISO($ref));" - case "date" => q"$tsBinTools.writeTimestampUtc($w, $tsBaboonDateTimeUtc.fromDate($ref));" - case _ => q"$tsBinTools.writeTimestampUtc($w, $ref);" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$tsBinTools.writeTimestampOffset($w, $tsBaboonDateTimeOffset.fromISO($ref));" - case "date" => q"$tsBinTools.writeTimestampOffset($w, $tsBaboonDateTimeOffset.fromISO($ref.toISOString()));" - case _ => q"$tsBinTools.writeTimestampOffset($w, $ref);" - } + case b: TypeId.BuiltinScalar => scalarOps.encodeUeba(b, w, ref) case u: TypeId.User => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => @@ -406,33 +366,7 @@ class TsUEBACodecGenerator( tpe match { case TypeRef.Scalar(id) => id match { - case TypeId.Builtins.bit => q"$tsBinTools.readBool(reader)" - case TypeId.Builtins.i08 => q"$tsBinTools.readI8(reader)" - case TypeId.Builtins.i16 => q"$tsBinTools.readI16(reader)" - case TypeId.Builtins.i32 => q"$tsBinTools.readI32(reader)" - case TypeId.Builtins.i64 => q"$tsBinTools.readI64(reader)" - case TypeId.Builtins.u08 => q"$tsBinTools.readU8(reader)" - case TypeId.Builtins.u16 => q"$tsBinTools.readU16(reader)" - case TypeId.Builtins.u32 => q"$tsBinTools.readU32(reader)" - case TypeId.Builtins.u64 => q"$tsBinTools.readU64(reader)" - case TypeId.Builtins.f32 => q"$tsBinTools.readF32(reader)" - case TypeId.Builtins.f64 => q"$tsBinTools.readF64(reader)" - case TypeId.Builtins.f128 => q"$tsBinTools.readDecimal(reader)" - case TypeId.Builtins.str => q"$tsBinTools.readString(reader)" - case TypeId.Builtins.bytes => q"$tsBinTools.readBytes(reader)" - case TypeId.Builtins.uid => q"$tsBinTools.readUuid(reader)" - case TypeId.Builtins.tsu => - target.language.timestampsUtcMode match { - case "string" => q"$tsBinTools.readTimestampUtc(reader).toISOString()" - case "date" => q"$tsBinTools.readTimestampUtc(reader).date" - case _ => q"$tsBinTools.readTimestampUtc(reader)" - } - case TypeId.Builtins.tso => - target.language.timestampsOffsetMode match { - case "string" => q"$tsBinTools.readTimestampOffset(reader).toISOString()" - case "date" => q"$tsBinTools.readTimestampOffset(reader).date" - case _ => q"$tsBinTools.readTimestampOffset(reader)" - } + case b: TypeId.BuiltinScalar => scalarOps.decodeUeba(b, q"reader") case u: TypeId.User => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => @@ -472,142 +406,14 @@ class TsUEBACodecGenerator( } } - // Deep walk (mirrors Scala/C#/Rust/Kotlin/Java hasAnyField): a codec class needs the any-field - // helpers if any direct or nested-via-Constructor-arg field has type `any`. - private def hasAnyField(defn: DomainMember.User): Boolean = { - def hasAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(hasAny) - } - defn.defn match { - case d: Typedef.Dto => d.fields.exists(f => hasAny(f.tpe)) - case _ => false - } - } - - // Encode delegates to the per-codec-class `encodeAnyField` helper. Wires the expected kind byte - // and the field's static (codec-gen-time) fallbacks for cross-format meta resolution. private def mkAnyEncoder(a: TypeRef.Any, ref: TextTree[TsValue], writer: String): TextTree[TsValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"this.encodeAnyField(ctx, $writer, $expectedHex, $staticDom, $staticVer, $staticTid, $ref);" + val plan = TsAnyFieldPlan.forField(a, domain) + q"$tsEncodeAnyUebaField(ctx, $writer, ${plan.kindHex}, ${plan.staticDomain}, ${plan.staticVersion}, ${plan.staticTypeId}, $ref);" } - // Decode delegates to the per-codec-class `decodeAnyField` helper. private def mkAnyDecoder(a: TypeRef.Any): TextTree[TsValue] = { - val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) - val expectedHex = "0x%02x".format(expectedKind & 0xFF) - q"this.decodeAnyField(reader, $expectedHex)" - } - - // Static fallbacks for the cross-format facade helper (`jsonToUebaBytes`). The wire `meta` may - // omit components that are pinned by the field's static declaration; the codec emits whatever is - // statically known so the facade can fill the gaps. Per spec table: - // A=(undef,undef,undef), B=(currentDomain,undef,undef), C=(currentDomain,currentVersion,undef), - // D1=(undef,undef,underlyingFqid), D2=(currentDomain,undef,underlyingFqid), - // D3=(currentDomain,currentVersion,underlyingFqid). - // Duplicated across Scala/C#/Rust/Kotlin/Java/TS — extraction deferred (textual emission diverges - // by language flavor; see PR 4.2 ledger entry's DRY analysis). - private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[TsValue], TextTree[TsValue], TextTree[TsValue]) = { - val none = q"undefined" - def some(s: String) = q""""$s"""" - val currentDomain: String = domain.id.toString - val currentDomainVer: String = domain.version.v.toString - val typeidStatic = a.underlying match { - case Some(u) => some(u.id.toString) - case None => none - } - val (domainStatic, versionStatic) = a.variant match { - case AnyVariant.Global => (none, none) - case AnyVariant.ThisDom => (some(currentDomain), none) - case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) - } - (domainStatic, versionStatic, typeidStatic) - } - - // Per-codec-class helpers consolidating the any-field framing, kind-check, and - // buffer-then-write / read-then-skip paths — emitted at most once per codec class that has any - // any-bearing field. Mirrors `JvUEBACodecGenerator.anyFieldHelpers`. Wire layout (locked, see - // docs/drafts/20260424-1738-any-opaque-fields.md §"Wire format"): - // length:i32 | meta-length:i32 | meta-kind:u8 | meta-strings | blob - // - // PR-12-D01 lesson applied: explicit negative-i32 sanity guards on on-wire lengths run before - // any size arithmetic — `4 + Int32.min` overflows silently otherwise. - private def anyFieldHelpers: TextTree[TsValue] = { - q"""private encodeAnyField( - | ctx: $tsBaboonCodecContext, - | writer: $tsBaboonBinWriter, - | expectedKind: number, - | staticDomain: string | undefined, - | staticVersion: string | undefined, - | staticTypeid: string | undefined, - | value: $tsBaboonAnyOpaque, - |): void { - | if (value.meta.kind !== expectedKind) { - | throw new $tsBaboonEncoderFailure( - | `any: meta-kind 0x$${(value.meta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x$${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` - | ); - | } - | let anyBlob: Uint8Array; - | if (value.tag === "Ueba") { - | anyBlob = value.bytes; - | } else { - | const anyFacade = ctx.facade; - | if (anyFacade === undefined) { - | throw new $tsBaboonEncoderFailure( - | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueUeba directly." - | ); - | } - | const anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid); - | if (anyConvResult.tag === "Left") { - | throw anyConvResult.value; - | } - | anyBlob = anyConvResult.value; - | } - | // Buffer the meta to count its byte length precisely (the on-wire `meta-length` field). - | const anyMetaBuf = new $tsBaboonBinWriter(); - | $tsBaboonAnyMetaCodec.writeBin(value.meta, anyMetaBuf); - | const anyMetaBytes = anyMetaBuf.toBytes(); - | const anyTotalLength = 4 + anyMetaBytes.length + anyBlob.length; - | $tsBinTools.writeI32(writer, anyTotalLength); - | $tsBinTools.writeI32(writer, anyMetaBytes.length); - | writer.writeBytes(anyMetaBytes); - | writer.writeBytes(anyBlob); - |} - | - |private decodeAnyField(reader: $tsBaboonBinReader, expectedKind: number): $tsBaboonAnyOpaque { - | const anyTotalLength = $tsBinTools.readI32(reader); - | if (anyTotalLength < 0) { - | throw new $tsBaboonDecoderFailure(`any: negative total-length $${anyTotalLength}`); - | } - | const anyMetaLength = $tsBinTools.readI32(reader); - | if (anyMetaLength < 0) { - | throw new $tsBaboonDecoderFailure(`any: negative meta-length $${anyMetaLength}`); - | } - | if (anyTotalLength < 4 + anyMetaLength) { - | throw new $tsBaboonDecoderFailure(`any: total-length $${anyTotalLength} smaller than 4 + meta-length $${anyMetaLength}`); - | } - | const anyReadResult = $tsBaboonAnyMetaCodec.readBinWithLength(reader); - | const anyMeta = anyReadResult.meta; - | const anyBytesRead = anyReadResult.bytesRead; - | if (anyBytesRead > anyMetaLength) { - | throw new $tsBaboonDecoderFailure(`any: meta bytes-read $${anyBytesRead} exceeded meta-length window $${anyMetaLength}`); - | } - | if (anyBytesRead < anyMetaLength) { - | // Forward-compat: skip future meta-extension bytes within the meta-length window. - | reader.readBytes(anyMetaLength - anyBytesRead); - | } - | if (anyMeta.kind !== expectedKind) { - | throw new $tsBaboonDecoderFailure( - | `any: wire kind 0x$${(anyMeta.kind & 0xFF).toString(16).padStart(2, "0")} does not match field-declared 0x$${(expectedKind & 0xFF).toString(16).padStart(2, "0")}` - | ); - | } - | const anyBlobLen = anyTotalLength - 4 - anyMetaLength; - | const anyBlob = reader.readBytes(anyBlobLen); - | return $tsBaboonAnyOpaqueUebaCtor(anyMeta, anyBlob); - |}""".stripMargin + val plan = TsAnyFieldPlan.forField(a, domain) + q"$tsDecodeAnyUebaField(reader, ${plan.kindHex})" } def codecName(name: TsValue.TsType): TsValue.TsType = { @@ -629,11 +435,7 @@ class TsUEBACodecGenerator( } else None } - override def isActive(id: TypeId): Boolean = { - !BaboonEnquiries.isBaboonRefForeign(id, domain, BaboonLang.Typescript) && - target.language.generateUebaCodecs && (target.language.generateUebaCodecsByDefault || domain.derivationRequests - .getOrElse(RawMemberMeta.Derived("ueba"), Set.empty[TypeId]).contains(id)) - } + override def isActive(id: TypeId): Boolean = TsCodecActivation.isActive(target, domain, id, TsCodecActivation.Ueba) override def id: String = "Ueba" } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index deae83519..210ceef60 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -357,10 +357,28 @@ object BaboonComparator { val oldTypes = prev.defs.meta.nodes.keySet // Identify valid renames: new type has was[] pointing to an existing old type - val validRenames: Map[TypeId.User, TypeId.User] = last.renames.filter { + val explicitRenames: Map[TypeId.User, TypeId.User] = last.renames.filter { case (newId, oldId) => newTypes.contains(newId) && oldTypes.contains(oldId) && !newTypes.contains(oldId) } + val explicitSources = explicitRenames.values.toSet + // Moving an ADT changes its branches' owner IDs even when their names stay the same. + val branchRenames = explicitRenames.toList.flatMap { + case (newId, oldId) => + (last.defs.meta.nodes(newId), prev.defs.meta.nodes(oldId)) match { + case (DomainMember.User(_, current: Typedef.Adt, _, _), DomainMember.User(_, previous: Typedef.Adt, _, _)) => + val oldBranches = previous.dataMembers(prev).map(id => id.name -> id).toMap + current.dataMembers(last).flatMap { + newBranch => + oldBranches + .get(newBranch.name).filter { + oldBranch => !explicitRenames.contains(newBranch) && !explicitSources.contains(oldBranch) && !newTypes.contains(oldBranch) + }.map(oldBranch => newBranch -> oldBranch) + } + case _ => Nil + } + }.toMap + val validRenames = explicitRenames ++ branchRenames val renamedNewIds = validRenames.keySet.asInstanceOf[Set[TypeId]] val renamedOldIds = validRenames.values.toSet.asInstanceOf[Set[TypeId]] @@ -551,7 +569,9 @@ object BaboonComparator { // For renamed ADTs, compare branches by name since TypeIds will differ val members1ByName = a1.members.map(m => (m.name.name, m)).toMap val members2ByName = a2.members.map(m => (m.name.name, m)).toMap - val renamedByName = branchRenames.map { case (newId, oldId) => (oldId.name.name, newId.name.name) } + val renamedByName = branchRenames.collect { + case (newId, oldId) if newId.name != oldId.name => (oldId.name.name, newId.name.name) + } val names1 = members1ByName.keySet val names2 = members2ByName.keySet diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8e199d730..c76111bf9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -109,6 +109,8 @@ Not available on `:graphql` / `:openapi` (schema-only targets). | `---wrapped-adt-branch-codecs` | `false` | Every ADT branch codec embeds the ADT discriminator metadata and expects it when decoding, so a branch value can be decoded without knowing the enclosing ADT. `` is the per-language flag prefix (see table below). | | `---write-evolution-dict` | `false` (`true` for Python) | Emit evolution metadata (version/type dictionaries) as a language-native structure for runtime introspection. | +Generated definitions and their shared runtime must come from the same compiler release (the same revision for snapshot builds). `--runtime=without` changes which files are emitted; it does not make newly generated definitions compatible with an older runtime. When sharing a runtime across invocations, generate it with `--runtime=only` using the same compiler and language/platform options, and update the runtime together with the definitions. This source/runtime requirement is separate from model-version and wire-format compatibility. + ### Service generation Ignored by `:graphql` / `:openapi`. The same settings can be expressed as diff --git a/docs/drafts/20260916-any-envelope-runtime-helpers.md b/docs/drafts/20260916-any-envelope-runtime-helpers.md new file mode 100644 index 000000000..0e05716e0 --- /dev/null +++ b/docs/drafts/20260916-any-envelope-runtime-helpers.md @@ -0,0 +1,9 @@ +# Scala and Kotlin any-envelope runtime helpers + +Generated Scala and Kotlin codecs delegate any-field framing and JSON envelopes to `BaboonAnyBinCodec` and `BaboonAnyJsonCodec`. These runtime APIs are additive; existing generated code can continue using its private inline helpers with the updated runtime. + +When runtime generation is disabled, update the separately maintained runtime distribution together with the compiler. New generated codecs require these helpers and are not supported with an older runtime lacking them. Runtime-disabled generation still emits no runtime files. No reflection or silent fallback to an older runtime is introduced. + +Kotlin JVM and KMP share the JSON helper source. Their binary helpers retain distinct stream APIs and metadata buffering. JSON-disabled Kotlin output omits the JSON helper and strips the JSON-to-binary conversion branch from the binary helper, matching the existing runtime JSON-section mechanism. Scala retains its existing Circe runtime dependency even when JSON codec generation is disabled. + +The helper move preserves the six meta-kind variants, static fallback precedence, cross-format facade requirements, metadata-extension skipping and existing error behavior. It does not standardize malformed-input handling between Scala and Kotlin. Public runtime-envelope tests cover exact binary bytes, JSON content, extension skipping and errors; existing generated `AnyRoundTrip` suites remain required for all six variants, nested containers and registered-facade conversions. diff --git a/docs/drafts/20260916-scala-kotlin-runtime-boundaries.md b/docs/drafts/20260916-scala-kotlin-runtime-boundaries.md new file mode 100644 index 000000000..392a512ec --- /dev/null +++ b/docs/drafts/20260916-scala-kotlin-runtime-boundaries.md @@ -0,0 +1,25 @@ +# Scala/Kotlin runtime refactor boundaries + +Implemented the audit's bounded changes without treating every allocation as removable: + +- SC-01: compare the complete compiler-side and embedded `IdentifierRepr` objects, rather than pinning source hashes. This detects mirror drift but does not replace generated identifier compilation/wire tests. +- SC-05: generated Scala codec wrappers and immutable metadata collections have private lazy backing values behind existing methods. Codec resolution remains deferred; runtime singleton interfaces are unchanged. +- SC-06: generated Scala decoding uses a validating count-only index path. The public full-entry reader remains available and both use one validation loop. Length/offset checks, index framing, and the generated context-dependent count assertion remain. +- KT-05: the final KMP buffer writer can copy directly into another writer. Public snapshots still copy; transfer does not expose either backing array and supports self-append. The transfer method is public because generated models may be compiled separately from their runtime artifact. +- KT-07: only the shared Gregorian epoch-day calculation is extracted. Caller-specific millisecond division and formatting remain unchanged. Characterization records an existing discrepancy at -1 ms: the UTC identifier renders `1969-12-31T23:59:59.999Z`, while the offset wrapper renders `1970-01-01T00:00:00.999+00:00`. Correcting the latter is a separate wire-visible change, not part of this cleanup. +- KT-08: pure read-version selection is shared by JVM/KMP. Registration/storage, concurrency, preload, conversion traversal, platform types, and platform-specific error messages remain owned by each facade. No throughput improvement is claimed; the typed selection result itself is an allocation. +- SC-07/KT-06: protocol dispatch is shared within each runtime. Standalone tool access stays dynamic, mux registration keeps its existing snapshot/order/collision behavior, sessions remain caller-owned, and error-description hooks remain owner-specific. No new tool-registry cache is introduced. + +## Deliberately retained allocations + +Collection decoder/conversion staging remains. Direct destination population would interleave foreign hash/equality calls with element decoding or conversion. That can change exception precedence and reader position when a later element is malformed. No equivalence has been established for those hooks across the supported input domain. + +JVM/Scala indexed buffer copying remains. `ByteArrayOutputStream.writeTo` invokes the destination's three-argument `write` overload, while the current emitted expression invokes its single-array overload; runtime stream classes and these methods are extensible. Treating them as interchangeable would change observable override dispatch. + +Scala `readByteString` retains the public copying factory. Its extensible input can override `readFully` and retain the destination array; the array is not proven exclusively owned after that callback. An unchecked ownership-taking factory would weaken the existing behavior for such inputs. + +The JSON-off Kotlin compilation check also reproduced an existing defect: both facade `preload` implementations referenced the removed JSON registry outside JSON section markers. The correction only extends those markers around the JSON preload expression. Both regenerated JSON-disabled runtimes subsequently compiled with the pinned Kotlin plugin. + +## Verification scope + +Focused checks include generated Scala and Kotlin JVM/KMP any-envelope compilation and round trips, separately emitted runtime/model composition, JSON-disabled compilation, actual generated Scala metadata identity, full/count-only index error and cursor comparisons, KMP buffer ownership tests, calendar before/after vectors, version-selection vectors, and MCP ownership/protocol before/after transcripts. KMP runtime checks here execute on the JVM; they are not evidence of JS/Native execution. Allocation and throughput are unmeasured. Compiler JVM/JS cross-builds and the broader matrix are coordinated by the integrating worker. diff --git a/docs/research/20260916-backend-runtime-review/README.md b/docs/research/20260916-backend-runtime-review/README.md new file mode 100644 index 000000000..52e6d9788 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/README.md @@ -0,0 +1,100 @@ +# Backend and embedded-runtime maintainability review + +Date: 2026-09-16. Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. +Review model: GPT-6 Astra; three concurrent read-only reviewers, reused for bounded backend assignments. The parent reviewed shared code and consolidated findings. This is an audit, not an implemented refactor. + +Implementation was subsequently authorized. See the [implementation ledger](implementation.md) for current changes, verification results, and proposals deliberately narrowed to preserve observable behavior. The audit findings and measurements below describe the original baseline. + +## Recommendation + +There are concrete cleanup and generalization opportunities. The strongest boundary is **shared model decisions, backend-specific rendering, and runtime-owned protocol helpers**. Start with small extractions and observable allocation removal; use those results to decide whether larger service/evolution redesigns pay for their added abstractions. + +Do not pursue a target such as “remove 30% of lines.” A shorter universal renderer can make nine language implementations harder to understand, and moving repeated generated code into a runtime may leave maintained LoC approximately unchanged while materially reducing generated volume. + +## Measured scope and coverage + +The requested translator/runtime trees contain **316 files and 71,482 nonblank, noncomment code lines**: 43,352 generator lines and 28,130 embedded-runtime lines. Exact duplicate files are deliberately counted separately because both copies are maintained. Whole-repository LoC is a different scope, including compiler frontend, LSP, tests, tooling and documentation; it is not the denominator for these recommendations. + +| Review | Generator files | Generator code lines | Runtime files | Runtime code lines | +|---|---:|---:|---:|---:| +| [C#](csharp.md) | 17 | 5,359 | 15 | 4,046 | +| [Scala](scala.md) | 16 | 4,324 | 12 | 2,492 | +| [Python](python.md) | 17 | 4,207 | 10 | 2,489 | +| [Rust](rust.md) | 16 | 4,467 | 9 | 3,544 | +| [TypeScript](typescript.md) | 16 | 4,649 | 7 | 2,657 | +| [Kotlin JVM + KMP](kotlin.md) | 16 | 4,554 | 29 | 4,155 | +| [Java](java.md) | 16 | 4,880 | 59 | 2,688 | +| [Dart](dart.md) | 16 | 4,682 | 7 | 2,661 | +| [Swift](swift.md) | 16 | 4,997 | 9 | 3,398 | +| [GraphQL](graphql.md) | 2 | 372 | — | — | +| [OpenAPI](openapi.md) | 2 | 345 | — | — | +| [Shared translator/MCP code](cross-cutting.md) | 9 | 516 | — | — | +| **Total** | **159** | **43,352** | **157** | **28,130** | + +Each report records full-content coverage of its assigned files and distinguishes supporting files that were only sampled. Kotlin coverage includes complete counterpart-plus-diff/hash inspection for some paired runtime resources. No claim is made that the entire compiler, all tests, or all generated outputs were exhaustively reviewed. + +Reproduce the code census with: + +```sh +cloc baboon-compiler/src/main/scala/io/septimalmind/baboon/translator \\ + baboon-compiler/src/main/resources/baboon-runtime \\ + --skip-uniqueness --by-file --json --quiet +``` + +## Candidate work packages + +| Order | Bounded change | Evidence | Benefit | Effort / risk | +|---|---|---|---|---| +| 1 | Share identical doc renderers and three identical Kotlin JVM/KMP resources; remove proven compiler-internal dead scaffolding | X-02, X-06; KT-01; RS-08; TS-08; DT-03 | Fewer maintained copies, clearer active paths | S / low; public API checks still apply | +| 2 | Centralize identifier classification while retaining target renderers | X-01; JV-08 | One model-semantic classification across nine backends | S–M / low–medium | +| 3 | Pilot a backend-local scalar emitter shared by DTO and service codecs, then repeat where proven | CS-01, KT-02, JV-03, SC-02, SW-03, TS-03, DT-04, PY-07 | Removes independently maintained wire-policy tables | M / medium; characterize divergent cases first | +| 4 | Move schema-independent `any` envelope implementations into each language's runtime; preserve public forwarding methods and share static fallback planning | CS-03, KT-04, RS-01, JV-05, SC-04, SW-02, TS-04, DT-05 | Less repeated generated code; one framing implementation per runtime/format | M / medium–high | +| 5 | Remove known unnecessary work in isolated changes | CS-05, RS-07, SC-08, JV-06, SW-07, TS-05/06, DT-01/02, PY-06 | Sorting, repeated scans, unused materialization/copies disappear from inspected paths | S–M / low–medium; throughput benefit unmeasured | +| 6 | Share endpoint plans and MCP request/response policy within each backend/runtime | CS-02/07, KT-03/06, RS-02/03, JV-01/04, SC-03/07, SW-04/05, TS-07, DT-08, PY-04/05 | Fewer independent decisions across mode combinations | M–L / medium–high | +| 7 | Make shared JSON Schema fragments structured and prepare schema references once per domain | X-04/05; GQL-01/02; OAS-01/02/03 | Removes string/JSON round trips and repeated full-domain scans | S–M / medium; distinct projections remain explicit | +| 8 | Separately design larger language-specific changes, measuring performance claims where relevant | RS-05; TS-01/02; SW-01; KT-08; SC-05; JV-07; PY-01/02/08 | Typed conversions/resolution, explicit output/serialization contracts, and stable metadata ownership | M–L / medium–high | + +These are grouped work packages, not additive savings estimates or permission to implement everything. Findings that recur in several reports describe one family of work. Effort: S = localized change; M = several collaborating components; L = architectural or contract-sensitive redesign. Risk concerns compatibility, not implementation difficulty. + +Start with the small shared helpers/resources and a single backend-local scalar-emission pilot. Keep larger service/evolution changes in separate proposals. Typed Rust/TypeScript conversions may increase generated source while removing JSON intermediates; that tradeoff can be worthwhile but is not a LoC reduction claim. TypeScript already uses consume-only index reading and switch-based service dispatch: those particular Rust/Swift findings do not transfer to it. + +Work package 4 has a specific compatibility gate: establish supported compiler/runtime version combinations, including consumers that omit runtime output. New generated helper calls will not exist in previously distributed runtimes. Require an appropriate runtime version or an explicit compatibility strategy, verify helper-file registration, and compile JSON-disabled/binary-only distributions. Matching new compiler/new runtime tests alone are insufficient. + +Work package 5's collection optimizations require a separate proof from its exact-equivalence candidates. Direct population can interleave decoding with foreign hash/equality calls; fused iteration can change when user hooks run. Check failures, cursor position and side effects, or restrict optimization to domains whose equivalence is established. Preserve staged behavior elsewhere. Split buffer transfer, index consumption, collection construction and byte ownership into independent changes. + +For work package 6, separate protocol factoring from caching. Prove metadata ownership and mutation semantics before adding a cache; do not infer immutability from a read-only accessor. Reuse existing `ResolvedServiceContext`, `ResolvedServiceResult`, `TypeRef` and `Conversion.FieldOp`. A service plan should centralize derived facts consumed by multiple sites; a bag of pre-rendered strings merely relocates the existing branching. Pilot it in one backend before wider adoption. + +## Correctness follow-ups are separate from cleanup + +The [Python reproduction record](python-reproductions.md) executes unchanged extracted production method bodies: `write_str` succeeds for 127 ASCII bytes and fails for 128 because a continuation byte is sent to signed byte packing; `Lazy.is_value_created` accesses a missing attribute. The active generated string writer path was traced. These are helper-level reproductions; the full Pydantic runtime and generated application were not executed because the Python interpreter used lacked Pydantic. Resolve their intended contracts and verify the production environment before combining any correction with an optimization. + +Python conversion/metadata/JSON-boundary discrepancies and TypeScript's JSON-mediated conversion hazards also need supported-model reproductions. The TypeScript Node probe demonstrates that the expression fails on bigint and loses Map/Set contents; it does not prove that a specific accepted model reaches every expression. C# scalar service/DTO boolean divergence and schema projection alignment remain unconfirmed integration hypotheses. These findings must not be silently “fixed” during mechanical extraction. + +## Boundaries to preserve + +- Keep target syntax, imports, ownership/boxing, collection representation, effect/result types, and public runtime APIs local to each language. +- Keep JSON versus UEBA framing, map-key representations, and canonical identifier parsing distinct where their contracts differ. +- Keep Kotlin JVM/KMP platform mechanisms distinct even when pure policy or exact resource files can be shared. +- Preserve Swift recursive boxing and explicit package targets; Rust ownership and async contracts; Scala's deliberate MCP Either restriction; C# deduplication; TypeScript type/value imports and output modes. +- Do not merge resource files for aesthetics: embedded strings face the JVM constant-size limit. New runtime helpers need suitable files and output registration. +- Do not cache public mutable/dynamic tool metadata without an ownership/invalidation contract. Scope any compiler cache to a translation/domain, not a global registry. +- Retain Dart's public generated `encodeAnyField`/`decodeAnyField` signatures when relocating their bodies. A read-only collection type, including Kotlin `List`, does not alone prove safe immutable metadata ownership. +- Do not replace all backends/runtimes with a template framework. Share narrow typed decisions only where consumers and equivalent semantics are identified. + +## Acceptance criteria for later implementation + +For output-preserving generator extractions, compare file sets, product tags, and generated bytes before/after across the relevant flags. For runtime-helper relocation, generated text deliberately changes: compare public APIs, compiled behavior, JSON/UEBA bytes, schema evolution, and error categories instead. + +Exercise concrete service axes (sync/async, error/result/container modes, void/value outputs, contexts, wire formats) and any-envelope kinds. Retain generated-language compilation and cross-language fixtures; hand-written mirrors alone do not verify emitters. Test public behavior and keep performance measurements separate, following constructive-test-taxonomy. Izumi guidance informs explicit effect constraints and DI/resource lifetime preservation. + +Existing green lanes alone do not discharge these proofs. The Dart review identifies generated tests that return/catch on decoding failures, and the Scala review identifies skipped recursive/foreign types. Use mandatory fixtures with visible failures for the changed behavior, including async rejection/suspension and error ordering. These observations describe coverage limits, not demonstrated codec defects. + +Run focused lanes during development, then the project's `mdl :build :test` before committing and `mdl :ci` before pushing/nontrivial refactors. Compiler checks must cover JVM and Scala.js. Runtime resource edits require `sbt clean` before recompilation because the embedding macro caches resources. + +## What was actually checked + +Complete assigned source inspections; file inventories/line counts; exact-file hashes; normalized exact-block comparisons; tracked-file reference searches; selected test/build/spec reads. A C# report's XOR check is an algebra sanity check in JavaScript, not C# execution. Python extracted-method probes and the TypeScript expression probe have captured outputs and explicit limitations. Reports list actual diagnostics separately from proposed tests. + +A separate skeptical synthesis pass checked the major recommendations against selected source. Its material corrections are incorporated above and in the affected reports: Dart public forwarders, collection evaluation order, cache ownership, compiler/runtime compatibility, explicit schema projection policies, and removal of unmeasured executable-size claims. Report inventory and explicit file/line references were mechanically checked; that check does not itself prove the recommendations' semantics. + +No compiler/runtime source was modified. No full build, backend test suite, generated compilation, or performance benchmark was run. Consequently this audit establishes source-level duplication and candidate costs, not a measured speedup, defect-free behavior, or a guaranteed LoC reduction. Suspected defects remain explicitly unconfirmed unless a report records a reproduction. diff --git a/docs/research/20260916-backend-runtime-review/cross-cutting.md b/docs/research/20260916-backend-runtime-review/cross-cutting.md new file mode 100644 index 000000000..76535cc37 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/cross-cutting.md @@ -0,0 +1,65 @@ +# Shared generator and cross-backend findings + +Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. This report covers the nine shared translator/MCP files plus targeted comparisons between backend files. Per-backend reports provide complete backend/runtime coverage. Findings are source observations and design recommendations; no implementation or benchmark result is implied. + +## X-01 — Share identifier classification before sharing identifier rendering + +All nine executable-language backends define a local `IdentifierFieldKind` and classifier. Representative declarations/classifiers are in `translator/csharp/CSDefnTranslator.scala:722`, `translator/java/JvDefnTranslator.scala:669`, `translator/kotlin/KtDefnTranslator.scala:566`, `translator/rust/RsDefnTranslator.scala:354`, `translator/swift/SwDefnTranslator.scala:541`, `translator/scl/ScDefnTranslator.scala:550`, `translator/python/PyDefnTranslator.scala:646`, `translator/dart/DtDefnTranslator.scala:621`, and `translator/typescript/TsDefnTranslator.scala:596`. Here and below, `translator/` expands to `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/`. + +An exact-text comparison after trimming line edges and omitting blank lines found a 36-line common declaration/classification block in C#/Java/Kotlin/Rust/Swift. Dart and TypeScript share a 55-line block including classification and integer-range helpers (`DtDefnTranslator.scala:628`, `TsDefnTranslator.scala:603`). These counts overlap and must not be summed into a savings estimate. + +Extract the semantic classifier into one sealed type/function over the existing `TypeRef`; retain backend-specific formatting, integer representations, runtime calls, and literal syntax. Model integer width/range explicitly if sharing those decisions is subsequently justified. No duplicate classifier was found in typer/validator by the scoped symbol search. Do not infer that identical source makes every target's numeric domain interchangeable. + +Benefit: one place to change supported identifier categories and detect unsupported types. Effort S–M; risk low–medium; confidence high. Verify generated output identity plus canonical identifier parsing/printing, boundaries, nested identifiers, and map keys across all backends. This is a first extraction candidate, not a new universal type-system layer. + +## X-02 — Consolidate truly identical documentation renderers + +`translator/kotlin/KtTreeTools.scala:43`, `translator/scl/ScTreeTools.scala:43`, and `translator/typescript/TsTreeTools.scala:34` implement the same prefix/suffix merging, backslash processing, and compact/multiline Javadoc rendering. The common body is 28 nonblank lines, including comments. Rust/Dart also share a smaller line-comment rendering sequence (`RsTreeTools.scala:35`, `DtTreeTools.scala:32`). + +Extract the identical Javadoc-style function first; keep thin backend adapters. Preserve `DocCommentEscaping.scala:31` and its ordering contract. Do not replace it with `mcp/McpDocs.scala:40`: MCP emits normalized plain JSON descriptions, while source comments preserve formatting and obey `TextTree` escape processing. + +Benefit: fewer copies of an already defect-sensitive escaping policy. Effort S; risk low; confidence high. Compare exact emitted comments for empty/prefix/suffix/multiline cases, backslashes, comment delimiters, and Unicode. No whole-language printer abstraction is needed. + +## X-03 — Normalize domain product planning without erasing backend output structure + +The exact-text scan found 33 shared nonblank lines in `translator/java/JvBaboonTranslator.scala:134`, `translator/kotlin/KtBaboonTranslator.scala:152`, and `translator/scl/ScBaboonTranslator.scala:134`. These acquire the per-domain translator and sequence definitions, fixtures, generated tests, service runtime, conversions, and evolution metadata. Product filtering and model traversal also repeat (`JvBaboonTranslator.scala:118`, `SwBaboonTranslator.scala:64`). + +Start with a small typed helper for selecting domain members/products and sequencing common product work, using existing `CompilerProduct`, `BaboonLineage`, and `Error2` error accumulation. Keep acquired subcontext lifetimes explicit. Only introduce a shared domain plan where a second and third backend actually consume it. Facade placement already differs: Java/Scala emit one per lineage (`JvBaboonTranslator.scala:54`, `ScBaboonTranslator.scala:55`), while Swift's lineage traversal simply gathers per-domain outputs (`SwBaboonTranslator.scala:56`). Rendering, import resolution, output paths, C# deduplication, Rust modules, and TypeScript barrels should remain backend-owned. + +Benefit: common product-selection policy and fewer places to add a product. Effort M; risk medium; confidence high on duplication, medium on net simplification. Verify output path/content/product equality across flags, accumulated errors, and resource lifetimes. Reject an extraction if its callback/configuration surface is more complex than the repeated body. + +## X-04 — Make the shared schema fragment boundary structured + +MCP already shares schema semantics through `mcp/McpInputSchemaEmitter.scala`, but it consumes OpenAPI-generated JSON strings and parses them back into Circe values (`McpInputSchemaEmitter.scala:246`, `:267`, `:382`). `openapi/OasTypeTranslator.scala:182` manually builds scalar JSON text and `:203` maintains an escaper. MCP separately assembles typed JSON objects. + +Return `io.circe.Json` from the shared fragment layer and render once at output boundaries. Use narrow shared scalar/any/map fragment helpers; keep OpenAPI component assembly and MCP reachable local `$defs` assembly distinct. Projection differences go beyond reference prefixes: OpenAPI references enum-key component schemas while MCP inlines them, and MCP narrows some foreign mappings to strings that OpenAPI leaves opaque. Keep these decisions explicit rather than forcing one type walk. This removes serialization/parsing and escaping maintenance without inventing a schema DSL. The OpenAPI review found whitespace-sensitive substring assertions in `OasEnumKeyMapSchemaTest.scala:55`; preserve formatting or deliberately update those assertions to structural contracts. Dependence of external consumers on exact JSON text remains unknown. + +Benefit: fewer escaping and representation boundaries; allocation/runtime impact is unmeasured. Effort M; risk medium; confidence high. Compare parsed schema meaning and deterministic text formatting separately; validate recursive references, enums, maps, foreign mappings, documentation, and every MCP backend. Do not merge GraphQL type projection into JSON Schema emission. + +## X-05 — Prepare MCP semantic context once per domain + +`mcp/McpInputSchemaEmitter.scala:55` rebuilds foreign resolutions, foreign definitions, and enum definitions for each method's input schema. Its foreign/enum map builders each traverse `domain.defs.meta.nodes` (`:360`, `:369`). Every backend invokes it per service method; examples are `typescript/TsMcpServerGenerator.scala:95`, `java/JvMcpServerGenerator.scala:122`, and `rust/RsMcpServerGenerator.scala:142`. Each backend also constructs its own emitter instance from `OasTypeTranslator`. + +Expose a prepared per-domain schema context or a domain-scoped emitter, then compute per-method reachable closure from that stable context. If multi-target compilation is a measured bottleneck, a typed per-domain MCP tool plan can additionally share tool names, descriptions, and schema values across backend renderers. Start with domain preparation; do not add a process-global cache. This retains state isolation and avoids invalidation policy. + +Benefit: eliminates observed repeated full-domain scans per method; total compile-time benefit is unmeasured. Effort S–M for preparation, M for a shared tool plan; risk low–medium; confidence high. Verify output identity, recursive schemas, multiple domains/versions in one process, and error behavior. Measure models varying method count and domain size before broadening caching. + +## X-06 — Retire compiler-internal scaffolding with evidence + +Tracked-file Scala reference search (`git grep`, including hidden platform directories) finds `McpServerGeneratorHookStub` only at its definition and stale documentation in `translator/McpServerGeneratorHook.scala:15` and `:30`. All nine executable-language module bindings use concrete generators (`BaboonModule.scala:103`, `:142`, `:175`, `:208`, `:240`, `:273`, `:305`, `:337`, `:369`). The hook interface itself remains used. + +Seven conversion generators retain literal `if (false)` branches: `kotlin/KtConversionTranslator.scala:334`, `scl/ScConversionTranslator.scala:307`, `rust/RsConversionTranslator.scala:225`, `java/JvConversionTranslator.scala:347`, `swift/SwConversionTranslator.scala:355`, `dart/DtConversionTranslator.scala:368`, and `typescript/TsConversionTranslator.scala:213`. + +Remove the unused internal stub and obsolete rollout documentation; simplify unreachable branches while preserving the live result and error types. Audit per-backend descriptor fields separately before deleting them. No absence of repository callers proves a public embedded-runtime API unused. + +Benefit: modest source cleanup and clearer active invariants. Effort S; risk low for compiler-internal branches, subject to public compiler-library API policy for the stub; confidence high. Verify JVM/JS compilation and unchanged generated output. This is not a large reduction strategy. + +## Scope and verification record + +Fully read shared files: `AbstractBaboonTranslator.scala`, `BaboonRuntimeResources.scala`, `DocCommentEscaping.scala`, `FQNSymbol.scala`, `McpServerGeneratorHook.scala`, `ServiceContextResolver.scala`, `ServiceResultResolver.scala`, `mcp/McpDocs.scala`, and `mcp/McpInputSchemaEmitter.scala`. Supporting reads sampled the cited backend sections, `typer/model/Conversion.scala`, `BaboonModule.scala` bindings, `docs/new-transpiler-guide.md`, and build/test action definitions. Existing `Conversion.FieldOp` already represents evolution operations: extend/reuse it before proposing a replacement IR. + +Actual diagnostics: clean baseline git status; `cloc --skip-uniqueness --by-file`; exact-file SHA-256 comparisons; exact normalized block scan over 159 translator files; `rg` reference searches; targeted source reads. These are static diagnostics, not compiler or runtime tests. The scan proves repeated text only; the recommendations additionally rely on the inspected semantics. + +Codegen/runtime changes were not made. Future shared generator changes require JVM and Scala.js checks, generated compile tests and wire/API conformance checks. Existing `baboon-compiler/.jvm/src/test/scala/baboon/runtime/shared/IdentifierReprPropertyTest.scala:9` describes tests using hand-written mirrors of emitted identifiers; its first 125 lines were inspected. Those tests exercise useful runtime behavior, but cannot alone establish correctness of the actual emitter. Preserve generated-language tests rather than treating a mirrored implementation as the complete oracle. `DocCommentBackslashEscapingTest.scala` in the compiler's JVM test tree was also sampled as an existing escape-boundary check; no test execution is claimed. + +Run project-required `mdl :build :test` before committing an implementation, and `mdl :ci` before pushing/nontrivial refactors. Resource changes require `sbt clean` before recompilation. Behavioral tests should use public boundaries; benchmark performance claims separately. Izumi guidance informed the explicit effect/lifecycle boundary, and constructive-test-taxonomy informed these verification choices. diff --git a/docs/research/20260916-backend-runtime-review/csharp.md b/docs/research/20260916-backend-runtime-review/csharp.md new file mode 100644 index 000000000..0cd684203 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/csharp.md @@ -0,0 +1,69 @@ +# C# backend and runtime audit + +Revision inspected: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Entire assigned generator and runtime read (32 files, 11,953 lines, 517,014 bytes); supporting tests were searched/sampled, not exhaustively reviewed. Source-level costs below are observations; wall-clock, allocation totals, and output-size savings were not benchmarked. No repository changes, builds, installs, or test-suite executions occurred. + +## Findings + +### CS-01 — Centralize C# scalar wire emission + +The service generator repeats JSON scalar encode/decode and UEBA read/write tables already maintained by the DTO codecs. Sources: `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala:80`, `:106`, `:120`, `:145`; `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala:330`, `:388`; `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala:574`, `:641`. + +There is observable divergence: service `bit` encoding constructs a JValue from lowercase **string** (`CSServiceWiringTranslator.scala:114`), whereas DTO scalar encoding supplies the **boolean** directly (`CSJsonCodecGenerator.scala:345`). Whether supported service inputs expose an interoperability defect remains an unreproduced hypothesis, not a verified defect. + +Extract a C#-local scalar emitter taking explicit codec context, reader/writer and value expressions. Keep map-key emission separate: keys have intentional textual rules. First characterize existing service scalar outputs; do not silently change boolean wire behavior during extraction. Benefit: one maintenance point per primitive wire rule; generated volume need not change. Effort M, risk M, confidence high. Verify all scalar service inputs/outputs against DTO scalar fields, especially bit, decimal, UUID, timestamps and bytes; run service acceptance and JSON/UEBA golden checks. + +### CS-02 — Give service dispatch a typed method plan + +The four error-mode emitters independently construct decode, invocation/error mapping, output/void handling, and encode phases: `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala:676`, `:810`, `:918`, `:1051`. Service result/context resolution is also performed by declarations at `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala:502`, separately from wiring at `CSServiceWiringTranslator.scala:28`. + +Build one typed C# method plan containing resolved input/output/error types, context layout, async mode, and invocation shape. Let JSON/UEBA supply their own wire operations and keep synchronous/async control flow explicit: abstract service-result containers do not supply async bind. Extract common call/error fragments before considering larger restructuring. Benefit: fewer independent decisions when adding a service axis; generated code and public signatures remain comparable. Effort M–L, risk H, confidence high. Verify the product of sync/async, errors/no-errors, output/void, domain error/no domain error, and context modes, including custom result containers. Do not use a universal backend renderer. + +### CS-03 — Move schema-independent any framing into the runtime + +Every any-bearing generated codec receives an entire helper body: JSON at `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala:532`, UEBA at `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala:423`. The helpers already receive expected kind and static domain/version/type fallbacks as arguments; their framing logic does not depend on the containing DTO. The generator additionally duplicates `hasAnyField` (`CSJsonCodecGenerator.scala:604`, `CSUEBACodecGenerator.scala:518`) and static-fallback planning (`CSJsonCodecGenerator.scala:642`, `CSUEBACodecGenerator.scala:557`). + +Put JSON and UEBA envelope helpers in a dedicated runtime resource beside `baboon-compiler/src/main/resources/baboon-runtime/cs/AnyOpaque.cs:147`; generate only calls. Share a typed any-field plan inside the generator. This directly reduces generated repetition and relocates protocol maintenance to one runtime copy; no speedup is established. Keep JSON and binary implementations distinct. Effort M, risk M, confidence high. Verify six any kinds, nested containers, same/cross-format conversion, metadata extension skipping, malformed lengths, and exact envelope bytes. Clean-rebuild resource embedding; keep files comfortably below JVM constant limits. + +### CS-04 — Compute upgrade decisions once per translation + +`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeInfo.scala:105` computes each member's possible upgrades again for every candidate higher version. At `:84`, the owner's upgrade list is recomputed inside another candidate filter. Rendering asks upgrade eligibility both during import discovery and for each rendered symbol (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala:63`, `:145`), through `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSTypeTranslator.scala:12`. + +First hoist candidate-independent calculations outside these filters; then consider an immutable per-lineage upgrade plan keyed by type, version, and owner-check mode. Preserve derived-origin handling for foreign codecs. Benefit: removes repeated graph traversal in compiler work; output is unchanged. Effort M, risk M, confidence high on repetition, unmeasured on impact. Verify byte-identical output with deduplication on/off across nested ADTs, foreign mappings, and long version histories; measure traversal counts and compile time before adopting broader caching. + +### CS-05 — Remove sorting before commutative hash accumulation + +`baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTools.cs:70` and `:75` sort projected hashes immediately before XOR reduction. Sorting cannot change XOR, including duplicates and negative integers. Generated records call these helpers from `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala:673`, `:682`. + +Remove the ordering step while preserving the seed, element hash functions and public signatures. This removes an unnecessary sorting operation and its buffering; it does not establish an application-level speedup. Do not change the sequence-hash algorithm in the same change. Effort S, risk L, confidence high. An in-memory JavaScript check compared sorted/unsorted XOR for 4,096 signed triples and found equality; this was an algebra sanity check, not C# execution. Proposed verification: C# hash parity over empty/singleton/permuted/duplicate-hash collections, followed by equality/hash contract checks and allocation measurements. + +### CS-06 — Avoid materializing discarded UEBA index entries + +`baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs:194` allocates a list even for compact DTOs and one `BaboonIndexEntry` object per indexed variable field. The generated decoder only examines `Count` in a debug assertion before decoding fields sequentially (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala:360`). The public materializing API may have external callers; it is not dead code. + +Add an internal consume-index path that reads the same header/pairs and performs the same checks without storing entries; generated DTO decoders use it, while public `ReadIndex` remains available. Benefit: removes observed per-DTO/index-entry allocations from this generated path. Effort M, risk M, confidence high. Verify compact/indexed bytes consumed, nonseekable readers, malformed/truncated indices, wrapped branches and forward-readable envelopes. Preserve current validation separately from any hardening proposal. + +### CS-07 — Factor MCP protocol helpers and registry ownership + +Four copies implement the same protocol switch: `baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonMcpRuntime.cs:191`, `:374`, `:517`, `:648`. Sync/async per-service calls rebuild the complete tool dictionary per request (`:181`, `:248`, `:638`, `:705`), while muxers already construct tables during registration (`:359`, `:502`). The private `_servers` fields are only declared and appended (`:341`, `:371`, `:490`, `:514`); the scoped reference search found no readers. + +Extract common initialization, validation, tools-list and Channel-A/B response helpers, with separate sync/async invocation. Use an explicit immutable tool registry for generated servers, or preserve dynamic `Tools` semantics for external subclasses before caching. Remove write-only server lists only after checking whether retaining zero-tool servers has an intended lifetime effect. Benefit: one protocol policy implementation and avoids per-request registry rebuilding. Effort M, risk M, confidence high. Verify protocol response equality, order, duplicate registration, sessions, unknown tools, errors and async dispatch. Preserve virtual error descriptions and routable interfaces. + +### CS-08 — Parse registered versions once inside facade bookkeeping + +`baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs:64` parses on every `Version` access; parsing splits strings and creates a version record (`:34`). The facade repeatedly invokes it during sorting (`baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs:1162`), min/max lookup (`:1043`), conversion walking (`:853`), and compatibility search (`:1134`). + +Start by hoisting repeated local accesses, then store parsed versions in private registration entries while retaining public positional-record equality and `with` semantics. Keep original strings for wire identifiers and diagnostics. Benefit: avoids repeated parsing of stable registered versions; workload significance is unknown. Effort S–M, risk M, confidence high. Verify ordering, registration overrides, malformed-version behavior, compatible codec selection and intermediate conversion targets; measure multi-version workloads. Avoid an unbounded global string cache. + +## Sequence and constraints + +Start CS-05, then scalar characterization/CS-01, CS-03 and CS-06, then CS-04/CS-08 after profiling, followed by CS-02 and CS-07. Generator-only extraction should preserve generated snapshots; runtime relocation should preserve API and wire behavior even though generated text changes. Run focused C# lanes first, then project-required `mdl :build :test` before committing; resource edits require `sbt clean` before compilation. These checks are proposed, not executed. + +Intentional duplication: JSON vs UEBA framing; typed vs erased codec adapters; Task vs synchronous public APIs; identifier lowercase-hex/strict timestamp representation versus permissive ByteString parsing and ordinary timestamp codecs. `BaboonSingleton` replacement semantics explain generated delegation guards; do not delete them as redundant. `netstandard2.1` is pinned in `test/cs-stub/BaboonDefinitions/BaboonDefinitions.csproj:4`, so newer framework conveniences require compatibility checks. No public runtime API is declared unused merely because repository callers are absent. + +## Coverage appendix + +Full reads, generator (all `.scala` under `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/`): CSBaboonTranslator, CSCodecFixtureTranslator, CSCodecTestsTranslator, CSCodecTranslator, CSConversionTranslator, CSDefnTranslator, CSDomainTreeTools, CSFileTools, CSJsonCodecGenerator, CSServiceWiringTranslator, CSTreeTools, CSTypeInfo, CSTypeTranslator, CSTypes, CSUEBACodecGenerator, CSValue, CsMcpServerGenerator. + +Full reads, runtime (all `.cs` under `baboon-compiler/src/main/resources/baboon-runtime/cs/`): AnyOpaque, BaboonByteString, BaboonCodecs, BaboonCodecsFacade, BaboonConversions, BaboonExceptions, BaboonExt, BaboonFixtureShared, BaboonIdentifierRepr, BaboonMcpRuntime, BaboonRuntimeShared, BaboonServiceWiring, BaboonTime, BaboonTools, BaboonTypeMeta. Truncated multi-file outputs were completed with smaller reads. + +Supporting coverage: full CLAUDE.md; sampled/search-only test files AnyRoundTripTests, AnyMetaCodecTests, BinEnvelopeGoldenTests, FacadeLatestMethodsTests, WiringTests, DomainFacadeTests, MCP overlay McpTests; project target/dependency lines inspected. No generated tree or other backend was fully inspected. Exact checks executed: `git rev-parse HEAD`; `git status --short` (clean); `rg --files`; `wc -lc`; `cat`/`sed` source reads; focused `rg -n` references/tests; 4,096-case in-memory XOR equivalence check. No correctness hypothesis was patched or reported as reproduced. diff --git a/docs/research/20260916-backend-runtime-review/dart.md b/docs/research/20260916-backend-runtime-review/dart.md new file mode 100644 index 000000000..45cd8d820 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/dart.md @@ -0,0 +1,69 @@ +# Dart backend and embedded runtime audit + +Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. All 16 translator files and seven embedded runtime files fully read: 9,287 lines. No repository edits, builds, installations, tests or benchmarks. Recommendations distinguish maintained source, emitted code and execution; no runtime defect is classified as confirmed. + +Citation prefixes expand to repository-relative paths: **G** = `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/`; **R** = `baboon-compiler/src/main/resources/baboon-runtime/dart/`. + +## Ranked findings + +### DT-01 — Write primitives directly into the growable binary buffer + +Observed: R`baboon_runtime.dart:243` through `:312` allocate a new `ByteData` for each signed/integer/floating primitive write, obtain its byte view and copy it into `_buf`. The reader already holds a single view (`:425`). `toBytes` (`:417`), `readBytes` (`:515`) and `readNBytes` (`:526`) compose `sublist` with `Uint8List.fromList`, introducing intermediate copied containers. Indexed DTO emission calls `buffer.toBytes` before copying again (G`DtUEBACodecGenerator.scala:292`). + +Proposal: maintain a writer `ByteData` view refreshed whenever capacity growth replaces `_buf`; set values at `_pos`. Use one owned-copy extraction for public byte-returning methods and an internal writer-to-writer append operation for indexed payloads. Preserve ownership: replacing a returned copy with a mutable backing view would change behavior. Benefit: fewer source-visible allocations/copies; throughput impact unmeasured. Effort M; risk medium; confidence high. Verify every primitive, capacity boundaries, nonzero-offset readers, compact/indexed bytes, post-extraction mutation isolation, and native/web numeric behavior before benchmarking. + +### DT-02 — Avoid per-call service handler maps and repeated JSON parsing + +Observed: active no-errors JSON and UEBA dispatch construct a map containing a closure for every endpoint on every invocation (G`DtServiceWiringTranslator.scala:456`, `:504`). Client JSON response generation passes `jsonDecode(resp)` as an expression (`:297`) to scalar decoding (`:97`), whose i64/u64/decimal branches refer to that expression more than once. Consequently those generated branches evaluate JSON parsing both for the type test and selected conversion. + +Proposal: generate a direct method-name switch and bind decoded client JSON to one local. Introduce a small endpoint plan for codec availability, escaped names, argument/result types and context passing, shared by client/server/wrappers. Benefit: avoids handler-table/closure construction and repeated parsing without changing protocol payloads; generated and maintained complexity can both decrease. Effort M; risk medium; confidence high. Verify sync and Future server modes, always-Future clients, void results, all context modes and unknown methods; benchmark wide services separately. Preserve exception timing/classification and do not turn synchronous APIs asynchronous. + +### DT-03 — Remove the disconnected private errors-mode renderer island + +Observed: G`DtServiceWiringTranslator.scala:228` explicitly returns no wiring in errors mode. Private `generateErrorsWiring` at `:515` has no call site in tracked Scala sources (`git grep`); its JSON/UEBA renderers at `:697` and `:797` and return-shape helpers belong to that disconnected path. Private `renderConcreteContainer` (`:197`) also has only its declaration in the tracked Dart generator. Comments explicitly retain these as pending future work, not currently emitted functionality. + +Proposal: remove the unreachable private island after recording its intended future requirements, rather than continuing to refactor it alongside active wiring. Keep shared helpers and public runtime/result types that still have consumers. Alternatively, reinstatement is a separate feature with explicit acceptance criteria—not a cleanup shortcut. Benefit: less maintained inactive policy, no generated-volume/runtime change. Effort S–M; risk low for proven private reachability, high if mixed with feature enablement; confidence high. Verify reference closure, compiler cross-build and unchanged output across current modes, including the deliberate omission in errors mode. + +### DT-04 — Consolidate scalar and foreign wire decisions before rendering + +Observed: four scalar tables in G`DtServiceWiringTranslator.scala:97`, `:118`, `:139`, `:165` repeat DTO JSON and UEBA mappings (G`DtJsonCodecGenerator.scala:255`, `:315`; G`DtUEBACodecGenerator.scala:400`, `:476`). UEBA foreign eligibility is separately decided in `translate` (`:42`), recursive encode/decode (`:430`, `:505`) and `isActive` (`:760`). Their decisions distinguish BaboonRef, string-like custom, runtime-mapped custom and throwing-stub custom foreigns. + +Proposal: Dart-local scalar emitters plus a named resolved foreign-wire plan consumed by eligibility, registration and encode/decode. Keep JSON map-key hooks distinct from value-position passthrough. Benefit: one maintained policy per case, not necessarily smaller generated output. Effort M; risk medium; confidence high. Verify aliases, custom foreigns with/without runtime mappings, key hooks and direct service scalars. i64/u64 JSON strings and signed-carrier conversions must remain explicit; do not substitute other backends' integer policy or infer web precision guarantees from native `int` behavior. + +### DT-05 — Move repeated any-field helpers behind a typed runtime boundary + +Observed: G`DtJsonCodecGenerator.scala:503` and G`DtUEBACodecGenerator.scala:634` emit schema-independent envelope framing/checks per any-bearing codec. Schema facts are already parameters. Both downcast `ctx.facade` from an empty marker (R`baboon_runtime.dart:27`) to concrete `BaboonCodecsFacade`; static fallback decisions repeat at JSON `:476` and UEBA `:602`. + +Proposal: put JSON and binary envelope helper implementations in a dedicated runtime library, leaving kind/static-fallback arguments at generated sites. The existing generated `encodeAnyField`/`decodeAnyField` methods are public (`DtJsonCodecGenerator.scala:504`, `:554`; `DtUEBACodecGenerator.scala:635`, `:692`): retain forwarding methods with the same signatures, or classify their removal as an explicit API change. Define the narrow conversion capability it requires, or initially retain the existing checked cast inside that library to avoid public interface changes. Keep existing marker/subclass compatibility rather than adding abstract methods to every external subclass. Benefit: smaller emitted source per type and a single maintained framing implementation. Effort M; risk medium; confidence high. Verify six kind shapes, nested collections, native/cross-format conversion, missing facade, future metadata tails and malformed framing; preserve nullable JSON content, thrown versus Either errors, package imports and resource embedding limits. + +### DT-06 — Unify import planning without flattening Dart library semantics + +Observed: G`DtBaboonTranslator.scala:339` and `:344` compute identical file keys; `resolveFqImport` (`:369`) and `resolveImport` (`:416`) repeat core/runtime package routing but intentionally differ in aliasing and version-to-filesystem conversion. Types and branch codecs can share a physical file through `importAs` (G`DtTypeTranslator.scala:118`; G`DtJsonCodecGenerator.scala:590`). MCP independently implements filename snake case at G`DtMcpServerGenerator.scala:141` instead of G`DtTypeTranslator.scala:146`. + +Proposal: a typed resolved library reference containing URI, physical-file key and alias policy, used by both import paths; share a naming utility only after characterizing current acronym behavior. Do not mechanically equate logical packages and directories or rename emitted files. Benefit: less repeated packaging policy and clearer collision handling. Effort M; risk medium; confidence high. Verify nested namespaces, old versions, same-name imports, ADT branch files, foreign bindings and acronym-containing service names with generated analyzer/compile checks. This is Dart-specific dependency planning, not a universal printer. + +### DT-07 — Build decoded collections directly; remove tiny hashing containers + +Observed: G`DtUEBACodecGenerator.scala:464` materializes a list of `MapEntry` objects before constructing a map; `:468` materializes a list before a set. Runtime deep map hashing creates a two-element list per entry (R`baboon_runtime.dart:1627`); fixed-pair hashes do likewise (`:780`, `:879`). Generated DTO hashing constructs a field-hash list at G`DtDefnTranslator.scala:406`. + +Proposal: direct typed map/set population or collection-for emission and fixed-arity hashing where its semantics are compatible. Keep the general iterable path for wide DTOs. Avoid hash caching: final fields can contain mutable collections, and opaque bytes remain mutable (R`baboon_any_opaque.dart:100`). Benefit: fewer temporary containers; measured value unknown. Effort S–M; risk medium; confidence high. Verify duplicate-key last-write behavior, set order, equality/hash consistency, nested collections, and negative/truncated counts; benchmark collection-heavy cases. Do not silently change public hash behavior without reviewing its compatibility expectations. + +Direct population interleaves decoding with hashing/equality, whereas current list staging completes decoding first. Verify foreign hash/equality side effects or exceptions, malformed later elements and reader cursor state; restrict the change to proven-equivalent key/value domains where necessary. Treat hash-algorithm changes as separate from container-allocation removal. + +### DT-08 — Share synchronous MCP protocol handling, not transport contracts + +Observed: R`baboon_mcp_runtime.dart:148` and `:327` duplicate initialize/list/call/error mapping. `_byName` (`:141`) rebuilds the tool map each request. Generated `_tools` is `late final` but returns a mutable list (G`DtMcpServerGenerator.scala:125`), so caching indefinitely is not automatically equivalent. MCP serializes parsed arguments (`:199`) before string-based service wiring parses them (G`DtServiceWiringTranslator.scala:438`). + +Proposal: private shared protocol dispatcher with separate registry/routing adapters. Cache lookups only with an explicit immutability/invalidation contract. A parsed-JSON dispatch seam could remove the in-process stringify/parse cycle while preserving existing string entrypoints, but should be measured and treated as a separate API-design step. Benefit: one maintained protocol state machine; potential allocation reduction. Effort M; risk medium; confidence high. Verify ordering, collisions, notifications, IDs, Channel-A/B errors and mutable registry behavior. Preserve synchronous MCP despite optional Future service generation; do not claim async MCP support. + +## Sequence, verification and exclusions + +Start with DT-03's reference-proven private cleanup and DT-02's local parse binding; then DT-01/04/05, followed independently by packaging, collections and MCP work. Runtime-file splitting is acceptable for ownership/embedding, but retain public package imports and exports. Do not delete decoder-only codec classes, foreign extension hooks or marker interfaces merely because they are small. + +Actual checks: full reads, targeted line searches, tracked `git grep` reachability checks and clean `git status --short`; Dart executable discovered but not run. No defect reproduction or performance result claimed. Proposed tests follow constructive-test-taxonomy: behavioral black-box public-interface checks plus generated compilation/wire compatibility; benchmarks separately. Existing generated cross-language JSON tests silently return on parse failures (G`DtCodecTestsTranslator.scala:228`), and UEBA tests catch all failures (`:282`); those tests alone cannot certify wire preservation. Use settled fixtures and fail-visible acceptance checks. Native/web support and numeric precision require explicit target-specific verification, not reliance on runtime comments. + +## Full-read coverage appendix + +G (16, all `.scala`): DtBaboonTranslator 609; DtTypes 140; DtConversionTranslator 423; DtServiceWiringTranslator 898; DtCodecFixtureTranslator 223; DtCodecTranslator 17; DtJsonCodecGenerator 619; DtMcpServerGenerator 201; DtDomainTreeTools 130; DtDefnTranslator 935; DtUEBACodecGenerator 787; DtTypeTranslator 231; DtTreeTools 57; DtValue 31; DtFileTools 26; DtCodecTestsTranslator 333. + +R (7, all `.dart`): baboon_codecs_facade 694; baboon_identifier_repr 337; baboon_any_opaque 284; baboon_mcp_runtime 419; baboon_runtime 1637; baboon_fixture 129; cross_language_fixture_path 127. No assigned file sampled; supporting test suites were not exhaustively audited. CLAUDE.md and review brief read. Parent covers common docs/classifier/unreachable-conversion-branch cleanup. diff --git a/docs/research/20260916-backend-runtime-review/graphql.md b/docs/research/20260916-backend-runtime-review/graphql.md new file mode 100644 index 000000000..21685fbb2 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/graphql.md @@ -0,0 +1,35 @@ +# GraphQL backend review + +Baseline `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Both assigned files fully read: `GqlBaboonTranslator.scala` 327 lines and `GqlTypeTranslator.scala` 191 lines (518 total). No embedded runtime exists in this scope. **G/** below expands to `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/graphql/`; **O/** to its sibling `openapi/` directory. Repository unchanged; no generated-schema execution or performance measurement. + +## GQL-01 — Share runtime-mapping resolution and schema naming, not target projections + +Observed: G/GqlTypeTranslator.scala:40/:50 collects foreign `runtimeMapping` and recursively resolves scalar/constructor references. O/OasTypeTranslator.scala:23/:33 repeats that algorithm. G/GqlTypeTranslator.scala:124/:129 and O/OasTypeTranslator.scala:194/:199 construct the same underscore-separated package/owner/type name and replace hyphens/dots. These are semantic/reference preparation shared across schema-only consumers, not GraphQL syntax. + +Proposal: a small schema-reference utility over existing `Domain`, `TypeId` and `TypeRef`, returning the current typed references and names. Reuse it from GraphQL, OpenAPI and MCP's existing OpenAPI-dependent resolution path. Do not substitute executable-backend language bindings: `runtimeMapping` is intentionally the schema/wire mapping (`docs/language-features.md:527`). Keep GraphQL field/enum sanitation, SDL nullability and map-entry naming local. Preserve unresolved foreigns as references; do not introduce speculative cycle handling without establishing the typer's invariant. + +Benefit: one maintained traversal and naming contract, unchanged generated source. Effort S; risk low–medium; confidence high. Verify direct/chained `rt` aliases, constructor arguments, unresolved foreign scalars, namespace/ADT names and output identity. The sampled OpenAPI enum-map test explicitly pins recursive resolution through map arguments; use comparable public translator checks for GraphQL rather than only testing a copied traversal. + +## GQL-02 — Resolve field references once and derive dependencies from that result + +Observed: G/GqlBaboonTranslator.scala resolves DTO fields to collect map helper types (:109/:114), resolves them again to find custom scalars (:263), then resolves them again while rendering (:201/:217). Each pass recursively follows foreign aliases. Map helpers are deduplicated by projected name (:155), reflecting deliberate loss of source distinctions such as integer widths. + +Proposal: prepare resolved DTO/ADT field references once per domain, then collect map dependencies and used scalars from those same references. A compact record of original field plus resolved reference is sufficient; no GraphQL AST framework is necessary. Continue sorting declarations and deduplicating map helpers by the existing projected-name rule. Resolve the same domain's data once, without a process-global cache or changes to domain object ownership. + +Benefit: avoids repeated compiler traversal/allocation and makes the rendered types and dependency collection share inputs. Generated volume should remain identical; total compile-time benefit is unknown. Effort M; risk medium; confidence high. Verify nested maps/options, aliases ending in maps, custom scalars reached through foreign mappings, any-underlying references and ADT branch fields. Compare SDL byte identity and validate references after adding the harness's dummy Query. + +## GQL-03 — Isolate SDL description literal encoding from documentation composition + +Observed: G/GqlTypeTranslator.scala:17 both combines source documentation and chooses/escapes GraphQL string forms. G/GqlBaboonTranslator.scala:277 separately hand-builds a block-string description for `BaboonAny`; its field rendering deliberately uses `TextTree.verbatim` (:207) to preserve backslashes and margin characters. These are separate escaping layers with separate contracts. + +Proposal: factor a GraphQL-only literal encoder accepting already-composed text; let documentation composition and the fixed any description use it. Preserve current whitespace/indentation and short-versus-multiline policy unless separately approved. This is a responsibility split, not a confirmed escaping defect: quoted/control-character edge cases were not executed against a parser here. Do not reuse Java/Javadoc escaping or plain MCP description flattening. + +Benefit: an explicit boundary for user text and one place to verify GraphQL escaping, with modest maintained-code benefit and no assumed output-size gain. Effort S; risk medium; confidence high on split, unmeasured on practical payoff. Verify ordinary quotes, backslashes, triple quotes, CR/LF, control characters and Unicode through the real emitted SDL parser, then compare decoded descriptions and exact formatting separately. + +## Deliberate boundaries and verification + +The backend documents itself as a type-definition library, not an executable service schema (G/GqlBaboonTranslator.scala:15). Keep no Query root, skipped services/contracts, empty DTO placeholders, custom scalars, map-entry helper objects and collapsed any variants. GraphQL's Int/Float width projection and nullability are not interchangeable with OpenAPI scalar metadata. No codecs, conversions or runtime abstraction should be introduced to unify these two small backends. + +Sequence: GQL-01 first with output equality, then GQL-02 only if the small prepared record simplifies all consumers; isolate GQL-03 and its parser-facing checks. Parent X-04's structured JSON recommendation applies to OpenAPI/MCP, not SDL. X-05's domain preparation is analogous to GQL-02, but GraphQL does not need MCP's per-method reachable closure. + +Actual checks: complete reads of both assigned files and both OpenAPI files; complete `test/gql-stub/validate.mjs` and `test/oas-stub/validate.mjs`; targeted MCP and language-feature documentation reads; tracked Scala reference search including hidden JVM tests. `test/gql-stub/validate.mjs` parses SDL and builds with a dummy Query (:33 onward); no harness run is claimed. Supporting OpenAPI enum-key schema test was fully read, but other schema tests/specs were not audited. CLAUDE.md, review brief and cross-cutting report were read. Proposed verification follows constructive-test-taxonomy: public translation/parsed-schema behavior, plus deterministic output checks; benchmarks separate. Inventory has no sampled-only assigned files. diff --git a/docs/research/20260916-backend-runtime-review/implementation.md b/docs/research/20260916-backend-runtime-review/implementation.md new file mode 100644 index 000000000..afc1b3548 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/implementation.md @@ -0,0 +1,160 @@ +# Implementation ledger + +User authorized the full review backlog on 2026-09-16. Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. All 84 findings are addressed within the preservation decisions below; conditional optimizations without behavioral equivalence were narrowed or withheld. Integrated CI passed on 2026-09-17, resuming after a test-only dependency correction. Public APIs, wire representations, error behavior, ownership and supported output modes remain compatibility constraints. + +## Sequence and checks + +1. Shared helpers, exact resources and dead internal scaffolding → generated-output characterization and JVM/JS compilation. +2. Structured schema preparation and Python defect reproductions → schema behavior, actual runtime tests and supported generated fixtures. +3. Backend-local scalar/evolution/output plans and runtime allocation changes → generated-language compilation, public behavior and wire goldens. +4. Any-helper relocation and service/MCP factoring → runtime version compatibility, output mode matrix, sync/async/error ordering and metadata ownership. +5. Integrated verification → clean embedding rebuild and combined `mdl :build :test :ci`; no publication actions. + +Concurrent editors use isolated worktrees. Root integrates and validates changes. Workers have progressed from shared/schema/Python batches to Scala/Kotlin, Rust/Swift and TypeScript/Dart. Root owns C# and then Java. No concurrent editors share a worktree. + +## Findings + +| Finding | Proposal | State | +|---|---|---| +| [RS-01](rust.md) | Move invariant any-field framing from emitted modules into the runtime | Integrated; generated any/split-runtime checks and full matrix pass | +| [RS-02](rust.md) | Share service invocation/error policy across wire formats | Integrated; sync/async generated source byte-identical; focused muxer tests pass | +| [RS-03](rust.md) | Unify MCP protocol dispatch and use its existing routing index | Integrated; reproduced failed-registration corruption fixed; sync 11/async 9 MCP checks pass | +| [RS-04](rust.md) | Centralize Rust field representation decisions | Integrated; cycle-safe float/any capability analysis; reproduced ADT compile failure fixed | +| [RS-05](rust.md) | Replace JSON-mediated evolution selectively with typed conversions | Integrated selectively; all 6 generated regressions pass, including ADT branch evolution; intentional foreign/custom/float fallbacks retained | +| [RS-06](rust.md) | Reduce facade lookup allocations behind existing APIs | Integrated; reproduced LazyCodec publication race fixed; concurrency and 14 surrounding checks pass | +| [RS-07](rust.md) | Build module parent/child relationships once | Integrated; full regular generated corpus byte-identical | +| [RS-08](rust.md) | Remove demonstrably unused internal scaffolding | Integrated; full regular generated corpus byte-identical | +| [PY-01](python.md) | Give native JSON values and serialized JSON text distinct contracts | Integrated; explicit native-value API plus legacy text compatibility; focused tests pass | +| [PY-02](python.md) | Compute one Python field serialization/naming plan | Integrated immutable field feature analysis; 484/485 full-corpus files byte-identical (only intentional facade runtime differs); regular/wrapped fixtures pass | +| [PY-03](python.md) | Unify collection traversal in evolution rendering | Integrated; focused generated/runtime tests and full matrix pass | +| [PY-04](python.md) | Share MCP protocol decisions, not sync/async execution machinery | Integrated; 17 sync and 5 async MCP tests and full matrix pass | +| [PY-05](python.md) | Build immutable MCP metadata once per generated server | Integrated; generated registry ownership and dynamic-subclass checks pass | +| [PY-06](python.md) | Consume/validate UEBA indices without materializing discarded entries | Integrated; focused generated/runtime tests and full matrix pass | +| [PY-07](python.md) | Centralize scalar codec policy for DTO and RPC consumers | Integrated JSON/binary scalar policy; 10 failing native-RPC checks reproduced before correction; fresh regular/wrapped and async checks pass | +| [PY-08](python.md) | Establish an executable generated/runtime contract boundary before facade cleanup | Integrated generated-contract corrections; conversion and masked factory-error regressions reproduced; fresh 11-case review suites, runtime 172 and generated self-codec 52 pass | +| [X-01](cross-cutting.md) | Share identifier classification before sharing identifier rendering | Integrated; seven unchanged backend corpus outputs byte-identical, Python separately verified | +| [X-02](cross-cutting.md) | Consolidate truly identical documentation renderers | Integrated; Scala/Kotlin/TypeScript corpus outputs byte-identical | +| [X-03](cross-cutting.md) | Normalize domain product planning without erasing backend output structure | Integrated Scala/Kotlin/Java product traversal; Java four-mode full-corpus comparison passes | +| [X-04](cross-cutting.md) | Make the shared schema fragment boundary structured | Integrated; schema tests pass; all 44 OpenAPI documents structurally unchanged | +| [X-05](cross-cutting.md) | Prepare MCP semantic context once per domain | Integrated across nine MCP generators; all generated MCP lanes pass | +| [X-06](cross-cutting.md) | Retire compiler-internal scaffolding with evidence | Integrated; compiler tests and generated corpus checks pass | +| [KT-01](kotlin.md) | Share exact platform-neutral runtime resources | Integrated; 442 generated Kotlin files byte-identical before later helper changes | +| [KT-02](kotlin.md) | Give primitive wire emission one Kotlin owner | Integrated; generated corpus byte-identical | +| [KT-03](kotlin.md) | Separate service planning from dispatch rendering | Integrated; focused method-plan tests and full compiler suite pass | +| [KT-04](kotlin.md) | Move schema-independent any-envelope helpers into runtime support | Integrated; actual JVM/KMP any checks 18 each pass; split runtime and binary-only compile pass | +| [KT-05](kotlin.md) | Remove avoidable intermediate collection and buffer materializations | KMP direct buffer transfer integrated and tested; overridable JVM stream ownership and risky collection staging retained | +| [KT-06](kotlin.md) | Reuse MCP protocol handling between server and muxer | Integrated; actual standalone 14 and mux 8 MCP checks pass | +| [KT-07](kotlin.md) | Centralize KMP civil-date decomposition | Integrated; calendar characterization passes before/after; existing negative-fraction wire discrepancy preserved | +| [KT-08](kotlin.md) | Share pure evolution policy only after isolating runtime mechanisms | Integrated; pure version-selection extraction passes JVM/KMP checks; platform mechanisms retained | +| [SC-01](scala.md) | Give identifier runtime helpers one maintained source | Exact-object mirror parity guard integrated and passing; source/resource surfaces remain separate | +| [SC-02](scala.md) | Share Scala scalar codec emission between DTO and RPC generators | Integrated; generated corpus byte-identical | +| [SC-03](scala.md) | Separate service planning from wire/result rendering | Integrated; focused method-plan tests and full compiler suite pass | +| [SC-04](scala.md) | Move schema-independent any-envelope operations into runtime | Integrated; 18 generated any checks and split runtime/binary-only checks pass | +| [SC-05](scala.md) | Hoist immutable generated metadata and singleton wrappers | Integrated; generated wrapper/metadata reuse 2 checks pass | +| [SC-06](scala.md) | Remove intermediate binary buffers/collections where ownership permits | Validated count-only index path integrated; 7 cursor/error vectors pass; extensible JVM stream copies retained | +| [SC-07](scala.md) | Share MCP protocol handling, retain separate routing ownership | Integrated; actual standalone 14 and mux 8 MCP checks pass | +| [SC-08](scala.md) | Replace repeated import-collision scans with a name index | Integrated; full regular generated corpus byte-identical | +| [DT-01](dart.md) | Write primitives directly into the growable binary buffer | Integrated; zero-capacity hang reproduced before correction; 4 native checks pass; clean JVM/JS embedding passes | +| [DT-02](dart.md) | Avoid per-call service handler maps and repeated JSON parsing | Integrated typed endpoint plan, switch dispatch and single response parse; fresh sync 9 and actual Future JSON dispatch pass | +| [DT-03](dart.md) | Remove the disconnected private errors-mode renderer island | Integrated; controlled 476-file comparison and fresh native generated checks pass | +| [DT-04](dart.md) | Consolidate scalar and foreign wire decisions before rendering | Integrated scalar/foreign plans; controlled output comparison, 158 runtime checks and four codec-mode checks pass | +| [DT-05](dart.md) | Move repeated any-field helpers behind a typed runtime boundary | Integrated runtime helpers preserving public forwarders; fresh runtime and all four split-runtime codec-mode checks pass | +| [DT-06](dart.md) | Unify import planning without flattening Dart library semantics | Integrated typed physical-file/library routing; controlled generated output unchanged; differing acronym naming intentionally retained | +| [DT-07](dart.md) | Build decoded collections directly; remove tiny hashing containers | Integrated fixed-pair hashing; 49-pair SDK characterization passes; callback/error-sensitive collection staging retained | +| [DT-08](dart.md) | Share synchronous MCP protocol handling, not transport contracts | Integrated shared synchronous protocol; fresh MCP 15 tests pass; mutable registry and string transport contracts retained | +| [CS-01](csharp.md) | Centralize C# scalar wire emission | Integrated; existing boolean RPC/DTO divergence explicitly preserved; generated definitions and runtime checks pass | +| [CS-02](csharp.md) | Give service dispatch a typed method plan | Integrated typed method plan and three shared invocation fragments; 8 generated-output comparisons byte-identical; full CI mode matrix passes | +| [CS-03](csharp.md) | Move schema-independent any framing into the runtime | Integrated; all six any kinds/golden/extensions/errors pass; generated C# checks pass | +| [CS-04](csharp.md) | Compute upgrade decisions once per translation | Candidate-independent traversal hoisted; broader caching conditional on profiling, not introduced speculatively | +| [CS-05](csharp.md) | Remove sorting before commutative hash accumulation | Integrated; 3 C# characterization tests passed before/after | +| [CS-06](csharp.md) | Avoid materializing discarded UEBA index entries | Integrated; compact/indexed/nonseekable/truncated parity passes; public materializing API retained | +| [CS-07](csharp.md) | Factor MCP protocol helpers and registry ownership | Integrated; private owned generated registry and shared protocol helpers; actual generated MCP 22 checks pass; external dynamic registry preserved | +| [CS-08](csharp.md) | Parse registered versions once inside facade bookkeeping | Integrated immutable-parser-context cache guard; 4 culture/subclass regressions fail before and pass after; 86 focused tests, netstandard2.1 compile and clean embedding pass | +| [TS-01](typescript.md) | Make output symbols explicit instead of inferring exports from references | Explicit output metadata integrated; strict generated build passes; NodeNext extensionless runtime imports confirmed pre-existing and retained | +| [TS-02](typescript.md) | Lower evolution operations to typed value transformations | Integrated typed conversions; user-approved shared renamed-ADT branch planning correction passes rule test and regular/wrapped 10-case TS suites; affected runtime source/target validation corrections are integrated | +| [TS-03](typescript.md) | Share codec activation and scalar rendering, not wire containers | Integrated; shared activation, per-version entry plan and scalar emission; strict generated build passes | +| [TS-04](typescript.md) | Move invariant any helpers into runtime modules | Integrated in existing any runtime module; fresh runtime suite passes | +| [TS-05](typescript.md) | Remove collection intermediates from generated codecs | Safe identity primitive JSON collection mapping removal integrated; callback/failure-sensitive fusion retained; collection characterization passes | +| [TS-06](typescript.md) | Write fixed-width primitives directly into writer storage | Integrated; 6 storage tests passed before/after; 5 reader checks passed | +| [TS-07](typescript.md) | Factor MCP protocol preparation and response construction | Integrated; shared protocol preparation/results and owned generated index; fresh sync MCP 34 and async 3 checks pass | +| [TS-08](typescript.md) | Remove demonstrably discarded generator scaffolding | Integrated; generated corpus byte-identical before typed conversion changes | +| [OAS-01](openapi.md) | Keep schemas structured until final rendering | Integrated; 44 parsed documents structurally unchanged; compiler schema/docs tests pass | +| [OAS-02](openapi.md) | Share prepared schema references with GraphQL/MCP | Integrated; GraphQL byte-identical and OpenAPI structurally equal | +| [OAS-03](openapi.md) | Share JSON Schema combinators only behind explicit projection decisions | Integrated; explicit projection characterization passes | +| [JV-01](java.md) | Give fixed MCP metadata a construction-time lifetime | Integrated; worker generated MCP 18 and mux 8 checks pass; clean JVM/JS embedding passes | +| [JV-02](java.md) | Stop constructing JSON parsers per RPC invocation | Integrated; context-owned lazy immutable reader; empty/malformed/null/duplicate/trailing-input characterization passes | +| [JV-03](java.md) | Centralize scalar wire expressions within the Java backend | Integrated scalar emitter; compiler characterization, 132 generated runtime checks and four codec-mode compilations pass | +| [JV-04](java.md) | Represent service invocation stages explicitly | Integrated typed facts and shared sync/async invocation policy; four full-corpus result/async output comparisons byte-identical outside intended runtime changes | +| [JV-05](java.md) | Move schema-independent `any` framing out of each generated codec | Integrated runtime any helpers; 132 generated runtime checks and four split-runtime/codec-mode compilations pass | +| [JV-06](java.md) | Eliminate discarded UEBA materialization | Count-only index path integrated and tested; stream-copy optimization rejected after public-overload characterization demonstrates observable difference | +| [JV-07](java.md) | Consolidate reflective metadata discovery | Integrated typed reflective access without caching; 5 before/after checks and 132 integrated generated runtime checks pass | +| [JV-08](java.md) | Share identifier classification, not target-language parsers | Integrated through shared identifier classifier; full Java regular corpus byte-identical | +| [GQL-01](graphql.md) | Share runtime-mapping resolution and schema naming, not target projections | Integrated; entire GraphQL generated corpus byte-identical | +| [GQL-02](graphql.md) | Resolve field references once and derive dependencies from that result | Integrated; entire GraphQL generated corpus byte-identical | +| [GQL-03](graphql.md) | Isolate SDL description literal encoding from documentation composition | Integrated; description characterization and byte parity pass | +| [SW-01](swift.md) | Replace structural foreign-binding reflection with typed resolution | Integrated; fresh generated compilation and normalized corpus comparison pass | +| [SW-02](swift.md) | Ship invariant `any` field helpers once | Integrated; fresh runtime/any checks and runtime-only/without assembly pass | +| [SW-03](swift.md) | Share scalar codec lowering with service wiring | Integrated; normalized generated corpus unchanged; fresh generated checks pass | +| [SW-04](swift.md) | Avoid rebuilding a closure dictionary for each service invocation | Integrated switch dispatch; fresh async and errors-mode service smoke programs pass | +| [SW-05](swift.md) | Factor MCP's pure protocol work without erasing async semantics | Integrated protocol factoring and generated index; fresh sync 13/async 9 MCP checks pass, including actor isolation | +| [SW-06](swift.md) | Encode binary UUIDs as bytes, not text | Integrated direct UUID tuple bytes; primitive characterization and fresh runtime checks pass | +| [SW-07](swift.md) | Consume ignored UEBA index entries without materializing them | Integrated count-only index consumption; cursor/error characterization and fresh runtime checks pass | +| [SW-08](swift.md) | Make identifier fixed-width reads proportional to consumed text | Integrated bounded scalar cursor; identifier characterization and fresh runtime checks pass | + +## Verification record + +- Baseline `sbt -batch '+compile' 'baboonJVM/test'`: passed both platforms and all 818 tests. +- Four schema characterization cases passed before schema changes. +- First integrated Python batch: clean JVM/JS compile passed; regenerated collection evolution (2) and domain facade (4) tests passed after recorded failures. +- Python: actual regenerated runtime suite 172, generated self-codec checks 52, field-plan fixtures 2, sync MCP 17 and async MCP 5 passed; split runtime-only/without checks passed. Cross-language fixture matrix remains separate. +- C# collection hashing: 3 tests passed before and after sort removal, including callback order and exceptions. +- TypeScript storage: 6 characterization tests passed before and after fixed-width write allocation changes; 5 existing reader checks passed. +- Baseline generated corpus captured for all nine executable backends, GraphQL/OpenAPI, and MCP before corresponding generator refactors. +- Eleventh integrated clean JVM/Scala.js build and compiler suite passed: 839 tests, 135 suites; 3 pre-existing canceled tests. +- Twelfth integrated clean JVM/Scala.js build and compiler suite passed: 840 tests, 136 suites; 3 pre-existing canceled tests. This build includes shared ADT-branch rename planning and the final C# culture guard. +- Fourteenth integrated clean JVM/Scala.js build passed the same 840 tests and 136 suites, with the same 3 pre-existing cancellations. It includes all runtime source/target identity corrections, Scala target qualification and the reviewed Python custom-validator correction. +- Actual generated C# netstandard2.1 definitions compiled; 144 hand-written runtime/facade/identifier checks passed. An initial broad filter also selected generated peer-fixture tests, which failed because this isolated directory lacks cross-language fixtures; they are not counted as semantic regressions or passes. +- Current isolated C# runtime checks: 24 passed, including four MCP dispatch variants and owned-registry mutation isolation; actual freshly generated MCP suite also passes 22 checks. +- Rust: typed evolution 6 cases, regular/wrapped focused 36 each, split-runtime any 18, and fresh MCP sync 11/async 9 passed. The sixth case covers nested ADT branch evolution. +- Swift: fresh runtime/characterization 85, MCP sync 13/async 9 and split-runtime 81 checks passed; async and errors-mode service smoke programs passed. Comparison of 507 generated files found no changes beyond the intended helper/index changes and indentation. +- TypeScript: fresh strict generated build and 188 runtime checks passed; sync MCP 34/async 3 passed. +- Java: direct runtime suite passes 12 checks; actual generated runtime suite passes 132. Four split-runtime JSON/binary enablement combinations compile. Four sync/async × errors/no-errors full-corpus comparisons are byte-identical outside the intended runtime changes. +- C#: all four codec combinations compile as netstandard2.1 with warnings-as-errors; split runtime/model assembly passes 82 focused tests; async Either JSON/UEBA smoke passes. Runtime assembly preserves model-owned generated domain files. +- Dart: fresh 158 runtime, 9 sync wiring and 15 MCP tests pass; Future-service dispatch passes. All four independently generated runtime-only/without and codec-toggle combinations analyze and execute successfully; 476 unaffected generated files compare equal after the explicit writer-call normalization. +- `mdl :test-editors` passed. The tenth integrated build exposed an unannotated Python field-plan map inference error; corrected before the next clean build. +- Fresh Python follow-up: regular/wrapped review suites 11 each, async scalar checks 15, runtime 172 and self-codec 52 pass. Two facade failures and ten scalar subcase failures were recorded before correction. +- First `mdl :build :test`: native build passed; `test-gen-manual` stopped at the TypeScript renamed-ADT conversion regression. Full C# regular/wrapped lanes subsequently passed. Java and Dart regular/wrapped lanes also passed. Parallel language lanes stopped on Kotlin compiler heap exhaustion; KMP regular passed on an isolated serial retry. The final serial CI run below supersedes these incomplete results. +- Shared ADT rename follow-up: TypeScript regular/wrapped evolution suites pass 10 cases each with strict type-checking, and the full manual corpus generates and type-checks. Absolute/relative namespace runtime probes pass 2 cases. Actual Rust, Java, Dart and Swift renamed-parent conversions pass; Java/Dart/Swift also pass JSON and compact/indexed UEBA roundtrips. C#/Scala/Kotlin/Python initially emitted the missing converters but rejected renamed results in runtime validation; the authorized source/target contract correction below resolves this. +- Final integrated verification: `mdl --seq --keep-run-dir --simple-log :build :test :ci`, run `20260917-130210-567957856`, followed by `mdl --continue --seq --keep-run-dir --simple-log :build :test :ci`, run `20260917-135417-702846955`, passed all 205 planned actions cumulatively. The resume reused successful actions after adding the missing test-only Node types dependency; compiler/runtime sources were unchanged between the two runs. +- Final matrix evidence: clean JVM/Scala.js and native builds, native smoke, all regular/wrapped and manual backend lanes, MCP and service-wiring variants, GraphQL/OpenAPI, editor tests, binary compatibility and diff checks passed. Compiler tests: 843 passed, 136 suites, no cancellations. Serialization acceptance: 200/200; service RPC acceptance: 162/162; neither matrix had build, execution or unexpected failures. Kotlin KMP execution is JVM-backed. +- The new TypeScript Buffer ownership test exposed an undeclared `node:buffer` type dependency in the clean stub (`TS2307`). Added pinned `@types/node` 24.13.5, compatible with the project's Node 24 and TypeScript 5.7, without changing test semantics. Clean install, strict build and all six storage tests passed; both full TypeScript lanes then passed in the resumed run. Existing npm audit warnings were not remediated by unrelated dependency upgrades. +- Commit preparation removed only trailing whitespace/blank lines from the new Swift any-field runtime resource. A further clean JVM/Scala.js compile, compiler suite (840 passed, 3 fixture-dependent cancellations) and native build passed; the earlier complete CI run exercised all 843 compiler tests. Pre-commit `mdl --continue --seq --keep-run-dir --simple-log :build :test` reused the successful backend matrix; no semantic source changes followed the full matrix. +- No application-level throughput improvement is claimed. + +## Explicit preservation decisions + +- Foreign conversion hooks, floating-point-to-decimal behavior and custom-required conversions are not bypassed merely to remove serialization code. +- Collection fusion is withheld where it changes iterator, hashing, foreign-hook or failure/cursor ordering without a proof over the supported input domain. +- Generated C# MCP `Tools` now returns detached, read-only snapshots rather than exposing its stable backing list. This is intentional metadata-ownership strengthening, not object-identity or cast-and-mutate equivalence; external dynamic subclasses retain their lookup contract. +- C# public positional records retain equality and `with` behavior. Private entries cache only under the same immutable standard CultureInfo parser context; mutable, switched and subclass-defined formats use per-access parsing. Registration remains lazy for malformed version strings. +- Existing Kotlin offset-timestamp negative-fraction formatting differs from the UTC identifier formatter. Civil-date extraction preserves both policies; it is not a wire-format correction. +- C# muxer server-retention lists remain: removing them would change the lifetime of registered zero-tool servers. +- Java indexed encoding retains its copied bulk write: `IndexConsumptionTest.streamCopyOverloadsAreObservableToPublicSubclasses` demonstrates that `writeTo` selects a different overridable public overload. +- TypeScript NodeNext extensionless embedded-runtime imports predate this work; strict supported-mode builds pass, but this refactor does not claim to repair that separate output-mode limitation. +- Dart retains existing acronym-specific naming, callback-sensitive collection staging, mutable tool registries and string dispatch. Only equivalent physical-library routing, fixed-pair hashing and shared protocol decisions are consolidated. +- Python conversion registries remain caller-supplied because generated RequiredConversions may require custom hooks; versioned-facade regression tests register them through the existing public API. +- Conversion identifiers retain their source meaning in existing public APIs. C#/Scala/Kotlin generated renamed converters override a protected target identifier; legacy custom converters retain the same-identifier default. Validation timing and foreign-value exemptions remain unchanged. +- Python's overridable validator remains active on both source and result, including custom subclasses of regenerated renamed conversion bases. For renamed converters the public helper recognizes either endpoint, while `convert` checks each direction separately. The legacy no-target path preserves overridden validators without forcing metadata reads. The existing non-ADT validation omission is not changed by this correction. +- Runtime helper extraction requires matched generator/runtime versions; runtime-only and runtime-without are independently generated and assembled in focused checks. + +## Integration closeout — 2026-09-17 + +The user authorized the shared ADT rename planning correction. The comparator now derives same-name branch mappings when their owning ADT is renamed, allowing ordinary DTO evolution planning to emit branch conversions. Explicit rename sources/targets take precedence. Rule-level coverage checks transfers, widening/default initialization, retained custom requirements and unique retained-branch operations; the TypeScript regression exercises the actual generated parent conversion. The integrated rule test and TypeScript regular/wrapped probes pass. + +Baseline probes independently reproduced missing renamed-branch conversions in C#, Scala, Kotlin, Python, Java, Dart and Swift; TypeScript failed during generation. Rust's serialization fallback completed the same probe. A separate frontend limitation remains outside this correction: an explicit old-branch reference under a renamed parent can resolve its missing old owner as a namespace rather than an ADT. The explicit-rename regression uses the supported unchanged-parent form. + +The twelfth-build combined serial `mdl :build :test :ci` run passed native build and portability smoke, then failed Scala acceptance compilation: newly emitted namespace-renamed ADT branch conversions used unqualified target ADT names. Standalone Scala compilation reproduced six unresolved-symbol errors. The other nine acceptance builds and 162 serialization triplets passed; 38 triplets could not run because of the Scala build failure. After qualifying both target-reference sites, the final fourteenth-build matrix passes all 200 serialization triplets. + +Cross-backend verification exposed a second defect in the C#, Scala, Kotlin and Python conversion runtimes: their single source identifier is also used to validate the target value, rejecting legitimate renamed results. This is reproduced after successful conversion registration. The user authorized the runtime/generator contract correction on 2026-09-17. The correction and persistent runtime/manual regressions are integrated for all four languages, including Kotlin JVM/KMP. Scala conversion target references are fully qualified at both construction and field-transfer sites. + +Fresh thirteenth-build outputs pass 10 C# runtime cases and 3 manual rename cases, plus minimal and custom-required conversion probes. Scala/Kotlin JVM/KMP fresh generation, manual compilation and custom-required concrete-subclass execution pass; KMP execution is JVM-backed. Independent review reproduced a Python application-validator bypass in the first target-aware draft; the corrected path preserves both custom validator callbacks. Fresh fourteenth-build Python outputs pass 183 runtime tests, 5 manual conversion tests, renamed-ADT JSON/compact/indexed UEBA probes and generated custom-required subclass checks with and without `super()` in the validator. All focused checks are against embedded runtime output, not source overlays. Final integrated CI also passes as recorded above. Verification completed before committing; no publication actions were performed. diff --git a/docs/research/20260916-backend-runtime-review/java.md b/docs/research/20260916-backend-runtime-review/java.md new file mode 100644 index 000000000..365d4f8fb --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/java.md @@ -0,0 +1,71 @@ +# Java backend and runtime audit + +Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Read-only review. Full inspection: all 16 generator files and 59 embedded runtime files, 9,457 lines. Abbreviations below expand to repository-relative paths: **G/** = `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/`; **R/** = `baboon-compiler/src/main/resources/baboon-runtime/java/`. C# comparisons refer to its sibling translator directory. + +## Ranked findings + +### JV-01 — Give fixed MCP metadata a construction-time lifetime + +Observed: `G/JvMcpServerGenerator.scala:127` puts `parseSchema(...)` inside every tool-entry expression; generated `tools()` constructs the list afresh (`:173`). `R/AbstractBaboonMcpServer.java:42` calls that accessor and constructs a lookup map on every `tools/call` (`:106`). Thus a single call reparses schemas for all tools. The muxer already builds its routing tables during registration (`R/AbstractMcpMuxer.java:63`). + +Generate instance-owned metadata once and build the per-server lookup once, after construction—not by invoking an overridable accessor in a base constructor. Preserve declaration order and define ownership of mutable Jackson schema nodes: exposing cached nodes directly changes the current fresh-value behavior. Defensive copying at the public accessor or an internal immutable registry seam may be necessary. This removes repeated parsing/construction, but throughput savings are unmeasured. Effort M; risk medium; confidence high. Verify repeated `tools/list`/`tools/call`, mutation isolation, duplicate names, notifications, session isolation, and Channel-A/B responses through public dispatch. + +### JV-02 — Stop constructing JSON parsers per RPC invocation + +Observed: six emitted parse sites construct `ObjectMapper`: client sync/async (`G/JvServiceWiringTranslator.scala:428`, `:446`), no-errors server sync/async (`:604`, `:625`), and errors server sync/async (`:772`, `:881`). The facade instead retains a parser (`R/BaboonCodecsFacade.java:53`, used at `:347`). + +Provide a narrow runtime parse operation backed by a configured, encapsulated reusable reader; have generated call sites invoke it. Prefer an explicitly owned dependency where configurability is required; do not expose another mutable global mapper or add unnecessary configuration options. Preserve Jackson defaults and the existing decode-error boundary. Benefit: one maintained parsing policy and removal of mapper construction from each request; latency/allocation improvement requires measurement. Effort M; risk medium; confidence high. Verify identical JSON trees and DecoderFailed classification for malformed input in all six paths, then benchmark allocations separately. + +### JV-03 — Centralize scalar wire expressions within the Java backend + +Observed: service wiring maintains four scalar tables (`G/JvServiceWiringTranslator.scala:75`, `:101`, `:127`, `:152`), independently of DTO JSON encode/decode (`G/JvJsonCodecGenerator.scala:208`, `:333`) and UEBA encode/decode (`G/JvUEBACodecGenerator.scala:404`, `:467`). These include unsigned carriers, decimal, UUID, timestamp, and bytes policy, not just formatting. + +Extract Java-specific scalar codec operations taking typed scalar IDs, codec context, and input/output expression trees. Both service and DTO emitters consume them; collection traversal and JSON map-key string conversion remain separate. This improves maintained-source consistency without requiring runtime interpretation or reducing every emitted expression. Effort M; risk medium; confidence high. Compile generated scalar service signatures and DTOs, and compare JSON/UEBA golden bytes for every scalar, particularly u64, decimal limits, GUID order, and timestamp offsets. + +### JV-04 — Represent service invocation stages explicitly + +Observed: JSON errors-mode generation (`G/JvServiceWiringTranslator.scala:758`, `:871`) and UEBA counterparts (`:948`, `:1057`) independently implement decode/call/encode failure stages, output-present/output-absent cases, declared errors, and async composition. `bindOutputStep` (`:938`) already demonstrates a useful local extraction. + +Introduce a small typed Java method plan for input/output/error shape and resolved context/result modes; use format-specific decode/encode operations around shared invocation-stage emission. Keep sync and CompletableFuture composition explicit: Java checked exceptions, generic inference, and completion failures are real distinctions. Do not replace these with a universal template engine. Benefit: changes to invocation policy have fewer independent branches; emitted code volume need not change. Effort M–L; risk high; confidence high. Verify the cross-product of sync/async, void/value, declared/no declared error, and context modes, including synchronously thrown implementations and exceptionally completed futures. No correctness defect was reproduced here. + +### JV-05 — Move schema-independent `any` framing out of each generated codec + +Observed: JSON helpers (`G/JvJsonCodecGenerator.scala:560`) and binary helpers (`G/JvUEBACodecGenerator.scala:613`) emit entire envelope algorithms into each any-bearing codec. The generator separately duplicates any detection and static-fallback planning (`G/JvJsonCodecGenerator.scala:496`, `:536`; `G/JvUEBACodecGenerator.scala:546`, `:586`). Runtime metadata/framing support already lives in `R/BaboonAnyOpaque.java:110`. + +Move format-specific field-envelope helpers into runtime support, accepting expected kind and a named static-resolution descriptor. Compute that descriptor once in a small shared semantic helper; keep JSON and binary algorithms distinct. This reduces emitted code proportional to any-bearing codec count and separates wire policy from source rendering. It is not a measured speed improvement. Effort M; risk high; confidence high. Verify all six kinds, nested collections, wire-over-static precedence, cross-format conversion, truncated blobs, metadata extensions, and identical bytes/error categories. Add a separate resource if necessary; do not grow embedded files toward the JVM string-constant limit. + +### JV-06 — Eliminate discarded UEBA materialization + +Observed: `R/BaboonBinCodec.java:61` allocates an ArrayList plus one `int[2]` per index entry. Generated DTO decoding uses only its size (`G/JvUEBACodecGenerator.scala:344`). Indexed encoding copies its complete temporary payload through `toByteArray()` (`:299`), although `R/LEDataOutputStream.java:7` is an OutputStream and supports bulk writes (`:17`). + +Add a consume-index/count path for generated readers while retaining the public materializing API. Read the same integers so truncation behavior remains unchanged. Use the buffer's `writeTo(output)` for indexed payload transfer instead of an intermediate copied array; do not remove buffering needed to compute offsets. Benefit: explicit allocations/copy removed; actual impact depends on payloads. Effort S–M; risk medium; confidence high. Verify compact/indexed golden bytes, stream position, truncated indices, assertions enabled/disabled, and old public readIndex users; measure bytes allocated by payload/index size. + +### JV-07 — Consolidate reflective metadata discovery + +Observed: `R/BaboonExt.java:20`, `:31` and `R/BaboonTypeMeta.java:86`, `:129`, `:138` repeat reflective static-field retrieval and diagnostics. Conversion independently retrieves domain fields (`R/BaboonCodecsFacade.java:548`). These paths rediscover class metadata on repeated operations. + +Introduce an internal typed metadata accessor shared by these three consumers. First deduplicate retrieval without caching to preserve evaluation behavior; then assess class-scoped caching for generated immutable metadata. Keep declared-type-dependent ADT selection outside the cached descriptor. Consider class-loader lifetime and externally supplied mutable metadata before caching values. Benefit: one validation boundary and potentially fewer reflective lookups, not a proven throughput gain. Effort M; risk medium; confidence high. Verify missing/wrong-shaped fields, branch-versus-ADT envelopes, min-reader tiers, user-provided implementations, and class-loader isolation. + +### JV-08 — Share identifier classification, not target-language parsers + +Observed: `G/JvDefnTranslator.scala:669` defines the same identifier-kind algebra/classification as `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala:722`. Java then interprets it in formatting (`:707`) and parsing (`:837`). This classification describes model semantics rather than Java syntax. + +Move only the named kind/classification and, where justified, numeric-domain facts into shared translation support. Java owns unsigned-long operations, narrowing, parser diagnostics, and expression emission. Benefit: fewer semantic tables to extend when identifier support changes; no claimed emitted-size reduction. Effort M; risk medium; confidence high. Verify identifier repr round trips and canonical strings for every field kind, nested IDs, escaping, numeric extremes, and malformed input across participating backends. + +## Sequence, limits, and cleanup + +Start with JV-02/06 and local metadata extraction, then JV-03/08, followed by JV-05 and the higher-risk service/MCP changes. Keep performance benchmarks distinct from public-behavior correctness checks; the constructive-test-taxonomy skill informed that separation. + +The repeated domain orchestration at `G/JvBaboonTranslator.scala:133` is not sufficient reason for a renderer framework. Java's one-public-class-per-file layout, primitive boxing, checked exceptions, nested service wrappers, and MCP-only resource gating are intentional boundaries. Likewise, identifier hex parsing has stricter canonical-input requirements than general ByteString parsing; do not merge them merely because both decode hex. JSON/UEBA envelopes and context/no-context public interfaces are not interchangeable API surface. + +Small verified cleanup candidate: `G/JvDefnTranslator.scala:636` private `collectContractFieldNames` has no callers besides its own recursion (`:641`), established by `git grep -n collectContractFieldNames -- '*.scala'` across tracked Scala files, including hidden platform directories. Remove only after normal compiler checks; this is not a meaningful optimization project. + +Actual checks: complete source reads using sed/nl, rg reference/call-site searches, inventory/line counts, resource byte-size check (facade 38,312 bytes), and clean `git status --short`. No builds, generated compilations, behavior tests, or benchmarks were run. All verification above is proposed, not reported as passing. + +## Coverage appendix + +Full-read G/ (all `.scala`): JvBaboonTranslator, JvCodecFixtureTranslator, JvCodecTestsTranslator, JvCodecTranslator, JvConversionTranslator, JvDefnTranslator, JvDomainTreeTools, JvFileTools, JvJsonCodecGenerator, JvMcpServerGenerator, JvServiceWiringTranslator, JvTreeTools, JvTypeTranslator, JvTypes, JvUEBACodecGenerator, JvValue. + +Full-read R/ (all `.java`): AbstractBaboonCodecs, AbstractBaboonConversions, AbstractBaboonJsonCodecs, AbstractBaboonMcpServer, AbstractBaboonUebaCodecs, AbstractConversion, AbstractMcpMuxer, BaboonAdtMemberMeta, BaboonAnyOpaque, BaboonBinCodec, BaboonBinCodecIndexed, BaboonBinTools, BaboonClientTransport, BaboonCodecContext, BaboonCodecData, BaboonCodecException, BaboonCodecsFacade, BaboonDomainVersion, BaboonEither, BaboonException, BaboonExt, BaboonGenerated, BaboonGeneratedLatest, BaboonIdentifierRepr, BaboonJsonCodec, BaboonMcpWiringError, BaboonMcpWiringException, BaboonMeta, BaboonMethodId, BaboonRandom, BaboonRandomFactory, BaboonRandomImpl, BaboonTimeFormats, BaboonTypeMeta, BaboonVersion, BaboonWiringError, BaboonWiringException, ByteString, IBaboonJsonService, IBaboonJsonServiceCtx, IBaboonMcpServer, IBaboonRoutableMcpServer, IBaboonUebaService, IBaboonUebaServiceCtx, JsonMuxer, JsonMuxerCtx, JsonRpcError, JsonRpcRequest, JsonRpcResponse, LEDataInputStream, LEDataOutputStream, Lazy, McpJsonInvoke, McpProtocol, McpServerInfo, McpSession, McpToolEntry, UebaMuxer, UebaMuxerCtx. + +Truncated initial batches were repaired by narrower reads. Supporting coverage: CLAUDE.md full; C# generator fully read in the preceding assignment; other backends only search matches. No claim of Java test-suite inspection or generated-output coverage. diff --git a/docs/research/20260916-backend-runtime-review/kotlin.md b/docs/research/20260916-backend-runtime-review/kotlin.md new file mode 100644 index 000000000..0ef09fb4f --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/kotlin.md @@ -0,0 +1,70 @@ +# Kotlin JVM and Kotlin Multiplatform review + +Reviewed HEAD `69bbb4c48a519d9a4148f370b76bde19af4bac6a`; read `CLAUDE.md`. This is a read-only source audit, not a correctness certification or benchmark. All 16 Kotlin generator files and all 29 JVM/KMP runtime files received full-content coverage, either direct reads or a fully read counterpart plus complete diff/hash verification; details below. Findings distinguish maintained Scala templates, embedded Kotlin sources, and generated Kotlin volume. No production edits, builds, installations, or runtime reproductions were performed. Performance benefits below are hypotheses to measure, not reported speedups. + +## Findings + +### KT-01 — Share exact platform-neutral runtime resources + +**Observed:** SHA-256 checks confirm byte-identical JVM/KMP copies of `BaboonEither.kt`, `BaboonExceptions.kt`, and `BaboonServiceWiring.kt`. Their public declarations begin at `baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonEither.kt:3`, `baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonExceptions.kt:3`, `baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonServiceWiring.kt:3`, and the corresponding `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonEither.kt:3`, `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonExceptions.kt:3`, `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonServiceWiring.kt:3`. Selection currently changes the whole resource directory (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala:340`). + +**Recommendation:** resolve these three output filenames to one common embedded source, retaining separate emitted distributions. This reduces maintained copies, not generated volume. Do not turn every near-duplicate into a preprocessor template: Class/KClass, stream APIs, dates, and concurrency differ materially. Check byte-for-byte output for JVM/KMP, JSON enabled/disabled, and runtime embedding after a clean compiler build. **Effort S; risk L; confidence high.** + +### KT-02 — Give primitive wire emission one Kotlin owner + +**Observed:** service wiring independently implements JSON decode/encode and UEBA decode/encode tables (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala:123`, `:153`, `:181`, `:211`). The codec generators repeat these operations (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala:249`, `:303`; `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala:403`, `:468`). UUID, decimal, and timestamp branches also repeat platform decisions. + +**Recommendation:** introduce a Kotlin scalar codec emitter taking typed value/reader/writer/context trees and the existing platform mapping. Keep map-key semantics separate: keys have canonical string representations and custom hooks. Keep user-type/foreign resolution outside the primitive table initially. This reduces semantic maintenance duplication without changing generated size. Verify every scalar through both service parameters and DTO fields, including unsigned extremes and platform-specific decimal/time representations. **Effort M; risk M; confidence high.** + +### KT-03 — Separate service planning from dispatch rendering + +**Observed:** JSON/UEBA dispatch each have synchronous and asynchronous error-mode paths (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala:670`, `:779`, `:854`, `:959`), plus no-error paths, wrappers, clients, and runtime-interface emission. Service declarations separately resolve result/context and render signature policy (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala:425`). Existing shared `ServiceResultResolver`/`ServiceContextResolver` should remain the configuration authority. + +**Recommendation:** produce one typed Kotlin method plan containing resolved arguments/result/error/context, escaped names, codec availability, and invocation mode. Render declarations, dispatchers, and clients from it; extract JSON/UEBA transport operations while retaining distinct suspension control flow. The async path intentionally calls the suspend implementation outside non-suspend combinator lambdas; textual deduplication could invalidate it. Benefit is reviewable mode handling, not a promised line-count reduction. Verify errors/no-errors × sync/async × context modes × wire formats, including methods without outputs. **Effort L; risk H; confidence high.** + +### KT-04 — Move schema-independent any-envelope helpers into runtime support + +**Observed:** each any-bearing JSON codec receives the same helper body (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala:67`, `:500`); UEBA does likewise (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala:127`, `:619`). Parameters already carry expected kind and static domain/version/type information. JSON/UEBA also repeat static-fallback planning (`KtJsonCodecGenerator.scala:473`, `KtUEBACodecGenerator.scala:590`, under the same repository directory). + +**Recommendation:** emit calls to runtime-owned envelope operations and centralize static fallback facts in a small typed plan. Keep JSON and UEBA operations separate and preserve kind checks, future metadata-window skipping, facade requirements, and wire-data precedence. This primarily reduces generated helper repetition and moves policy out of string templates; it does not remove all maintained implementation. Preserve JSON stripping and runtime public APIs. Verify all six any forms, nested containers, cross-format conversion, malformed metadata, and binary-only output. **Effort M; risk M; confidence high.** + +### KT-05 — Remove avoidable intermediate collection and buffer materializations + +**Observed:** generated set decoders build a list then a set (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala:347`; `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala:460`). Conversion generation similarly uses `map(...).toMap()`/`toSet()` (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala:104`). Indexed DTO encoding copies an intermediate byte array into the destination (`KtUEBACodecGenerator.scala:273`); KMP `toByteArray()` explicitly copies (`baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonTools.kt:257`). + +**Recommendation:** first generate direct collection accumulation with preserved insertion order, duplicate-key overwrite behavior, and evaluation order. Separately consider an internal writer-to-writer transfer operation for KMP and JVM stream transfer where compatible, retaining the public copying `toByteArray` contract. Do not remove necessary index buffering or expose mutable backing storage. Benchmark allocation and throughput on collection-heavy and indexed DTOs; compare exact UEBA bytes and decoded collection order. **Effort M; risk M; confidence high for allocation sites, medium for net performance.** + +Direct population changes decode-versus-hash/equality sequencing compared with staging all decoded elements first. Verify throwing/effectful foreign hash/equality, malformed later elements and reader position; restrict the optimization to domains with established equivalence where necessary. Iterator/mapper fusion likewise needs evaluation-order checks. + +### KT-06 — Reuse MCP protocol handling between server and muxer + +**Observed:** individual-server and muxer `handle` implementations repeat initialize, tools/list, argument extraction, and Channel-A/B response construction (`baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonMcpRuntime.kt:156`, `:328`). The individual server rebuilds its name map per call (`:150`, `:206`), whereas the muxer indexes registrations (`:309`, `:318`). The generator emits list-valued tool metadata (`baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtMcpServerGenerator.scala:137`); its read-only Kotlin surface alone does not establish deep or interop immutability. + +**Recommendation:** compose both public classes with an internal protocol dispatcher supplied with server information, ordered tools, and lookup/invoke operations. Cache lookup for generated immutable registries; do not silently assume arbitrary subclasses have immutable `tools`. Preserve session ownership, notification behavior, duplicate-tool diagnostics, and context forwarding. Benefit is one protocol policy plus avoidable per-request map construction; no latency measurement was made. Verify equivalent transcripts for standalone and multiplexed servers. **Effort M; risk M; confidence high.** + +### KT-07 — Centralize KMP civil-date decomposition + +**Observed:** KMP owns two Gregorian epoch-to-calendar implementations: `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonOffsetDateTime.kt:19` and `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonIdentifierRepr.kt:135`. They repeat era/year/month arithmetic and floor helpers, but initial millisecond division differs (`:25` versus `:138`). This is evidence of duplicated policy, not a reproduced correctness finding. + +**Recommendation:** characterize boundaries first, then share the date-component calculation with explicit formatting choices for UTC `Z` and numeric offsets. Keep identifier fixed-width formatting distinct from general JSON timestamp formatting. Compare negative epoch milliseconds, leap days, midnight offset crossings, and supported year limits against pinned datetime/JVM behavior. Any failing case requires its own reproduction before a correction. **Effort M; risk H because timestamps are wire-visible; confidence high for duplication.** + +### KT-08 — Share pure evolution policy only after isolating runtime mechanisms + +**Observed:** both facades implement version-range codec choice and latest-version conversion (`baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt:239`, `:393`; `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt:234`, `:383`). Yet JVM uses ConcurrentHashMap and background preload (`JVM file :34`, `:328`), KMP HashMap and synchronous preload (`KMP file :30`, `:320`); Class/KClass and lazy initialization also differ (`baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonRuntimeShared.kt:53`; `baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonRuntimeShared.kt:53`). + +**Recommendation:** separate registration/storage, pure version-choice policy, conversion traversal, and transport adaptation behind existing facade APIs. Share pure policy or conformance vectors first; do not merge whole facades through textual substitutions or change concurrency semantics incidentally. Benefit is fewer independent implementations of strict/tolerant envelope selection. Verify v1/v2, min-compatible/readable bounds, unknown domains, deprecated decoder-only versions, and ADT conversions. **Effort L; risk H; confidence high.** + +## Sequence and constraints + +Start KT-01, then KT-02 and KT-05; follow with KT-04 and KT-06. KT-03/KT-08 need explicit mode contracts; KT-07 needs boundary characterization before consolidation. Preserve platform public types, standalone runtime packaging, JSON-off modes, generated naming, schema evolution, and embedded-resource clean-build requirements. No Java/Scala sharing claim is made: this review did not inspect their implementations. No unused public API is declared removable. + +Actual checks: HEAD verification, complete file inventory/line counts, full source coverage, pairwise runtime diffs, and identical-pair SHA-256 checks. Proposed verification above was not run. Main uncertainties are performance magnitude, native/JS execution behavior, and untested runtime paths. One bounded cleanup is statically established: `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala:334` has a literal `if (false)` failure branch; `TranslationIssue` is referenced only by that branch and its import. Removing that branch/import is separate from the architectural work (S/L/high; verify cross-compilation). + +## Coverage appendix + +Inventory: generator 16 files; JVM runtime 14; KMP runtime 15; 10,837 source lines total (inventory, not complexity or savings). + +- **Generator full reads:** KtBaboonTranslator, KtJsonCodecGenerator, KtMcpServerGenerator, KtValue, KtTreeTools, KtFileTools, KtCodecTranslator, KtDomainTreeTools, KtTypes, KtTypeTranslator, KtCodecTestsTranslator, KtCodecFixtureTranslator, KtDefnTranslator, KtConversionTranslator, KtUEBACodecGenerator, KtServiceWiringTranslator (all `.scala`). Initially truncated combined reads were completed with bounded slices. +- **JVM runtime full reads:** BaboonAnyOpaque, BaboonByteString, BaboonCodecs, BaboonRuntimeShared, BaboonServiceWiring, BaboonEither, BaboonExceptions, BaboonTimeFormats, BaboonTools, BaboonFixtureShared, BaboonIdentifierRepr, BaboonCodecsFacade, BaboonMcpRuntime (all `.kt`). BaboonConversions reconstructed from full KMP read plus complete diff. +- **KMP runtime full direct reads:** BaboonTools, BaboonConversions, BaboonDecimal, BaboonOffsetDateTime, BaboonTimeFormats, BaboonCodecsFacade. **Full counterpart-plus-diff coverage:** BaboonAnyOpaque, BaboonByteString, BaboonCodecs, BaboonRuntimeShared, BaboonFixtureShared, BaboonIdentifierRepr. **Identical counterpart plus hash coverage:** BaboonEither, BaboonExceptions, BaboonServiceWiring. No assigned source file remains sampled-only. +- Supporting material: CLAUDE.md and BaboonRuntimeResources.scala fully read; runtime-test filenames inventoried only. No claim that those tests establish the proposed changes' correctness. diff --git a/docs/research/20260916-backend-runtime-review/openapi.md b/docs/research/20260916-backend-runtime-review/openapi.md new file mode 100644 index 000000000..7b510c6e3 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/openapi.md @@ -0,0 +1,35 @@ +# OpenAPI backend review + +Baseline `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Full assigned coverage: `OasBaboonTranslator.scala` 272 lines and `OasTypeTranslator.scala` 242 lines (514 total); no embedded runtime. **O/** expands to `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/`; **M/** to sibling `mcp/`; **G/** to sibling `graphql/`. Repository unchanged. No schema generation, validator execution or benchmark was run. + +## OAS-01 — Keep schemas structured until final rendering + +Observed: O/OasTypeTranslator.scala returns scalar metadata with raw-JSON-string extras (:51), builds object fragments by concatenation (:80/:100/:143/:182), and maintains an escaper (:203). O/OasBaboonTranslator.scala:184 injects field descriptions by checking for and dropping the opening brace. MCP reparses these fragments into Circe Json (M/McpInputSchemaEmitter.scala:246/:267/:382), while already building its own schemas structurally. + +Proposal: return `Json`/`JsonObject` from fragment builders, assemble description/properties/required fields structurally, and render once at the OpenAPI document boundary. Reuse Circe already used by MCP, not a new schema DSL. Start with scalar/any fragments and typed description addition; migrate top-level component assembly next. This removes escaping maintenance and serialize-parse work, principally maintained complexity; allocation benefits remain unmeasured. + +Effort M; risk medium; confidence high. Preserve ordering, optionality, references and existing schema meanings. Test parsed-schema equality independently of deterministic formatting. Important qualification to parent X-04: `OasEnumKeyMapSchemaTest.scala:55` under `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/` asserts string substrings including whitespace. Those tests need deliberate structured assertions or a formatting-compatible printer. Sampled evidence does not establish whether external consumers rely on exact JSON text; do not claim that none do. + +## OAS-02 — Share prepared schema references with GraphQL/MCP + +Observed: foreign collection/resolution (O/OasTypeTranslator.scala:23/:33) and flattened naming (:194/:199) duplicate G/GqlTypeTranslator.scala:40/:50 and :124/:129. OpenAPI collects members, foreign mappings and enum ids in separate domain scans (O/OasBaboonTranslator.scala:97/:100; O/OasTypeTranslator.scala:164). MCP rebuilds resolutions, foreign definitions and enum definitions on every method emission (M/McpInputSchemaEmitter.scala:55/:360/:369). + +Proposal: small immutable per-domain schema context containing foreign definitions/runtime resolutions and enum definitions; let OpenAPI derive enum-key ids, GraphQL consume resolutions, and MCP compute each method's reachable closure from the same prepared facts. A shared qualified schema-name function can remain independent. Do not cache across mutable configuration or versions globally. + +Benefit: removes duplicate traversal policy and repeated domain scans, without changing component count or output size. Effort S–M; risk low–medium; confidence high. Verify multi-domain/version isolation, alias chains through map/option arguments, unresolved foreigns and deterministic names. Parent X-05 is supported: share preparation, not entire method schemas, because reachable sets and root descriptions differ per request. + +## OAS-03 — Share JSON Schema combinators only behind explicit projection decisions + +Observed: O/OasTypeTranslator.scala:100/:143 and M/McpInputSchemaEmitter.scala:243/:282 independently construct optional, list, set and map shapes. Both use string/UUID keys as object properties and other keys as entry arrays, but enum-key schemas differ: OpenAPI references components (:146), MCP embeds `enumSchema` (:289). Named refs likewise target components versus local `$defs`. MCP also narrows some unmapped foreigns to strings (:328), while OpenAPI emits opaque objects (O/OasBaboonTranslator.scala:259). + +Proposal: after OAS-01, share narrowly scoped constructors such as nullable/array/unique-array/object-map/entry-map that accept already-rendered child schemas. Keep reference resolution, enum-key representation and foreign narrowing in each consumer. If sharing the type walk later, pass a named projection policy rather than only a `$ref` prefix. Existing `TypeRef` remains the semantic input. + +Benefit: fewer maintained JSON shapes and consistent structural changes without erasing differing contracts. Effort M; risk medium–high; confidence high. Verify enum map propertyNames, constructor-recursed aliases, recursive `$defs`, reachable-only MCP closure and opaque foreign treatment. Do not silently change OpenAPI's existing non-string-map projection: the sampled test explicitly expects entry arrays for `map[i32,str]` (:104), even though executable-codec alignment merits a separate compatibility investigation. Likewise ADT output/description alignment is a hypothesis to reproduce, not a defect established by this static audit. + +## Scope boundaries and verification + +Keep OpenAPI a component library: empty paths, no service runtime and no conversion code (O/OasBaboonTranslator.scala:13). Preserve existing scalar formats, numeric bounds, optional-vs-required decisions, any-envelope documentation and deterministic domain/version paths. Whether those projections should evolve to exactly match every executable backend's JSON representation is a distinct contract decision, not an optimization prerequisite. + +Sequence: structural fragments first, prepared reference context second, then only the combinators used by both OpenAPI and MCP. Do not combine GraphQL syntax rendering with JSON Schema emission. X-04 needs both explicit reference and foreign/enum projection boundaries, not reference policy alone. No runtime-resource file merging is applicable. + +Actual checks: full assigned/source reads and cross-cutting report; full enum-key-map test and both schema validation scripts; sampled MCP fragment/context routines and `docs/language-features.md:527/:786/:1129`; `git grep` tracked test references including hidden JVM sources. The OpenAPI script first parses JSON then invokes SwaggerParser (`test/oas-stub/validate.mjs:33`); that validates document structure, not generated codec payload conformance. No executed-test success is asserted. Later verification should add public translator/parsed-schema behavioral checks and payload validation where contractually intended, consistent with constructive-test-taxonomy; benchmarks separate. CLAUDE.md/review brief read. All assigned files were fully read; supporting MCP/docs coverage was sampled, not full. diff --git a/docs/research/20260916-backend-runtime-review/python-reproductions.md b/docs/research/20260916-backend-runtime-review/python-reproductions.md new file mode 100644 index 000000000..61809a9a4 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/python-reproductions.md @@ -0,0 +1,34 @@ +# Python read-only fragment probes + +Baseline `69bbb4c48a519d9a4148f370b76bde19af4bac6a`; working directory repository root. No files/import caches written (`-B`). `python3 -B -c 'import sys; print(sys.version); import pydantic; print(pydantic.__version__)'` printed Python `3.12.12` then `ModuleNotFoundError: No module named 'pydantic'`. Consequently these are extracted method-body probes, **not execution of the installed runtime or generated application**. + +Exact executed command: + +```sh +sed -n '213,300p;435,449p' baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py | python3 -B -c 'import sys,struct; from io import BytesIO; from typing import Generic,TypeVar; T=TypeVar("T"); exec("from __future__ import annotations\n"+sys.stdin.read()); +for n in (127,128): + try: + stream=BytesIO(); LEDataOutputStream(stream).write_str("a"*n); print("write_str",n,"ok",len(stream.getvalue())) + except Exception as e: print("write_str",n,type(e).__name__,str(e)) +try: print(Lazy(lambda: 1).is_value_created) +except Exception as e: print("Lazy.is_value_created",type(e).__name__,str(e)) +values=[10,20]; print("swap expression",{v for v in range(len(values))},"element expression",{v for v in values}); print("empty optional-to-set",type({}).__name__)' +``` + +Captured output (exit 0 because exceptions were deliberately reported): + +```text +write_str 127 ok 128 +write_str 128 error 'b' format requires -128 <= number <= 127 +Lazy.is_value_created AttributeError 'Lazy' object has no attribute '_value_ref' +swap expression {0, 1} element expression {10, 20} +empty optional-to-set dict +``` + +The writer failure is for the expected reason: continuation bit sets the first length byte to 128, then signed `struct.pack(">` adds an outer Arc whose value is cloned again at `:305`/`:333`. + +Bounded change: snapshot only min/max for resolution; add compatible metadata accessors with default implementations and allocation-free generated overrides. A private typed registry can share storage/lookup mechanics while public JSON/UEBA interfaces stay distinct. Treat changing `LazyCodec` storage/public return types separately. + +Effort M; risk M; confidence high. Verify lookup precedence, same-in ordering, v1/v2 and tolerant/lossless envelopes, re-registration and concurrency. Existing preload test (`test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs:227`) is a smoke test; it is not evidence of initialization race freedom. Benchmark allocations before claiming throughput gains. + +### RS-07 — Build module parent/child relationships once + +Observed: `G/RsBaboonTranslator.scala:427` iterates all directories and at `:432` filters the entire directory set again to identify each node's direct children. Directory comparisons are quadratic in directory count, independently of output size. + +Bounded change: construct a parent-to-direct-children index during the existing path pass, then sort/render each directory's children. Preserve conflict absorption, ADT non-reexport policy, reexport modes and MCP/facade inclusion. This is compiler execution optimization; generated source and runtime complexity need not change. + +Effort S; risk L; confidence high. Verify byte-for-byte generation on deep namespaces, type/directory collisions, multiple versions and MCP/no-service fixtures; measure wide/deep synthetic families to establish materiality. + +### RS-08 — Remove demonstrably unused internal scaffolding + +Observed: `G/RsConversionTranslator.scala:225` has an unreachable `if (false)` error branch. `RsRenderedConversion` at `:26` carries `reg` and `missing`, but its only generator consumer (`G/RsBaboonTranslator.scala:786`) reads `fname` and `conv`. `G/RsDefnTranslator.scala:49` initializes a private `baboonIdReprCursor` value that has no other reference in the file; emitted cursor references are literal strings. + +Bounded cleanup: remove the unreachable branch and unused private binding. Remove descriptor fields only after repository-wide usage confirmation; preserve custom-conversion comments and explicitly assess whether the unused `missing` field represents deferred functionality. These are maintained-code savings, not a reason to delete public runtime helpers simply because generated output does not call them. + +Effort S; risk L for branch/binding, M for descriptor; confidence high. Verify JVM/JS compilation and unchanged generated conversion output. No runtime API deletion is recommended. + +## Sequence, boundaries, and validation + +Sequence: RS-08/RS-07 first; RS-04 then RS-02; RS-01 and RS-03 independently; RS-06 after concurrency-focused characterization; RS-05 last as a semantic migration. Keep serde ownership/lifetimes, context/result/async axes, foreign key hooks, canonical identifier parsing versus lenient JSON parsing, and envelope v1/v2 semantics distinct. Do not merge embedded files merely to reduce file count: the JVM 64KB constant limit requires the existing facade/type-meta separation. + +Checks actually run: `git rev-parse HEAD` (matched requested commit), `git status --short` (clean), `rg --files` inventories, `wc -l`, complete `cat`/`sed` reads, and targeted `rg -n` reference searches. An initial combined read was truncated; its omitted small files were reread completely. A guessed `test/rs-stub-mcp-overlay` path was absent; inventory located `test/rust-stub-mcp-overlay` and the separate Rust mux overlays. Test files were sampled by symbol search, with the preload body read; no tests/builds/benchmarks ran. + +Proposed verification: focused existing Rust regular/wrapped, manual evolution, wiring and MCP/mux async lanes, then `mdl :build :test` before commit and `mdl :ci` before push. Runtime changes require `sbt clean` before recompilation due to embedded-resource caching. No numeric speedup, binary-size or line-reduction estimate is supported by this audit. + +## Complete coverage inventory + +All entries below are **full-read**, including comments and implementation. No assigned file was sampled only. + +Generator (`G/`): + +| File | Lines | +|---|---:| +| RsBaboonTranslator.scala | 794 | +| RsCodecFixtureTranslator.scala | 253 | +| RsCodecTestsTranslator.scala | 156 | +| RsCodecTranslator.scala | 11 | +| RsConversionTranslator.scala | 232 | +| RsDefnTranslator.scala | 1691 | +| RsDomainTreeTools.scala | 55 | +| RsFileTools.scala | 26 | +| RsJsonCodecGenerator.scala | 70 | +| RsMcpServerGenerator.scala | 317 | +| RsServiceWiringTranslator.scala | 970 | +| RsTreeTools.scala | 60 | +| RsTypeTranslator.scala | 212 | +| RsTypes.scala | 118 | +| RsUEBACodecGenerator.scala | 587 | +| RsValue.scala | 36 | + +Runtime (`R/`): + +| File | Lines | +|---|---:| +| any_opaque.rs | 548 | +| baboon_codecs_facade.rs | 1284 | +| baboon_fixture.rs | 142 | +| baboon_identifier_repr.rs | 363 | +| baboon_mcp_server.rs | 549 | +| baboon_runtime.rs | 991 | +| baboon_service_wiring.rs | 237 | +| baboon_type_meta.rs | 338 | +| cross_language_fixture_path.rs | 119 | + +Supporting coverage: full `CLAUDE.md`; sampled test symbols in `test/rs-stub/tests/{any_round_trip_tests,baboon_codecs_facade_latest_tests,domain_facade_tests}.rs`; sampled `.mdl/defs/tests.md` action names; repository Scala reference-search matches only. Other backends were not fully reviewed here. + +Cross-backend questions: can existing typed evolution operations provide a shared conversion plan without sharing renderers? Do other runtimes duplicate single-server/muxer MCP protocol dispatch? Can fixed any framing be consistently runtime-owned while declared fallback metadata stays generator-owned? diff --git a/docs/research/20260916-backend-runtime-review/scala.md b/docs/research/20260916-backend-runtime-review/scala.md new file mode 100644 index 000000000..b7d620807 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/scala.md @@ -0,0 +1,71 @@ +# Scala backend and embedded runtime audit + +Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Read-only source review; all 16 assigned generator files and all 12 embedded Scala runtime files were fully read (8,565 lines total). No build, installation, benchmark, or behavioral reproduction was run. Findings below are evidenced refactoring/optimization opportunities, not confirmed defects or measured speedups. + +Citation prefixes: **G** = `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/`; **R** = `baboon-compiler/src/main/resources/baboon-runtime/scala/`; **M** = `baboon-compiler/src/main/scala/baboon/runtime/shared/`. Prefixes expand to repository-relative paths. + +## Ranked findings + +### SC-01 — Give identifier runtime helpers one maintained source + +Observed: R`BaboonRuntimeShared.scala:58` documents a byte-equality obligation with the compile-side mirror; the two `IdentifierRepr` implementations begin at R`BaboonRuntimeShared.scala:68` and M`BaboonRuntimeShared.scala:1019`. Both implementations were read, including their cursor, timestamp, escaping, hexadecimal and numeric helpers. This is intentional sharing by copying, not accidental similarity. The containing files are different runtime surfaces and should not be merged wholesale. + +Proposal: extract only this shared helper into a source/resource generation input, or first add an exact-object parity check if build-source sharing is inconvenient. Preserve the resource's escaping pipeline (G`ScBaboonTranslator.scala:325`) and embedded-source size constraints. Benefit: eliminate a manually maintained semantic mirror or make drift immediately observable; no generated-volume reduction promised. Effort M; risk medium; confidence high. Verify compile-side and emitted implementations against the same identifier vectors, then compile and execute actual generated identifiers. Mirror/property tests alone cannot establish generator correctness. + +### SC-02 — Share Scala scalar codec emission between DTO and RPC generators + +Observed: G`ScServiceWiringTranslator.scala:85`, `:112`, `:138`, `:163` contain four scalar dispatch tables corresponding to G`ScJsonCodecGenerator.scala:349`, `:526` and G`ScUEBACodecGenerator.scala:466`, `:546`. They repeat signed carriers for unsigned values, timestamp helpers, bytes and decimal behavior. The RPC layer adds distinct exception/result handling around those operations. + +Proposal: a Scala-local scalar-expression emitter parameterized by value, reader/writer, and codec-context references; leave collection recursion, key codecs, foreign resolution, and RPC error classification at their current boundaries. Benefit is one maintained scalar policy, not fewer emitted calls. Effort M; risk medium; confidence high. Verify every scalar as DTO field and direct service input/output, especially unsigned boundaries, timestamps, decimals and malformed values; compare generated compilation and wire output. Do not reuse Kotlin/Jackson emission merely because both run on the JVM. + +### SC-03 — Separate service planning from wire/result rendering + +Observed: G`ScServiceWiringTranslator.scala:741` and `:844` repeat decode → invoke → encode structure, including output/no-output and declared-error branching. Client generation at `:280` repeats the same endpoint facts. Service trait generation independently resolves context/result configuration at G`ScDefnTranslator.scala:433`; wiring does so at G`ScServiceWiringTranslator.scala:27`. + +Proposal: an immutable Scala service/endpoint plan carrying resolved context names, type parameters, input/output/error types, codec availability and result shape. Keep JSON/UEBA operations and result lifting as explicit small renderers; do not build a universal statement printer. Benefit: fewer inconsistent decisions across trait/client/wiring and smaller responsibility boundaries. Effort M–L; risk high; confidence high on duplication, medium on optimal split. Verify all context modes, concrete and higher-kinded result types, errors/no-errors, void outputs and optional codecs, including exact error-channel behavior. MCP must retain its deliberate Either-only validation at G`ScMcpServerGenerator.scala:61`. + +### SC-04 — Move schema-independent any-envelope operations into runtime + +Observed: G`ScJsonCodecGenerator.scala:67` and G`ScUEBACodecGenerator.scala:79` emit complete envelope helpers once per participating codec object. Their schema-specific inputs are already parameters: expected kind and static domain/version/type identifier. Static-fallback computation is duplicated at JSON `:425` and UEBA `:638`; runtime already owns opaque metadata and cross-format conversion (R`BaboonAnyOpaque.scala:1`, R`BaboonCodecsFacade.scala:259`). + +Proposal: runtime JSON and UEBA envelope helper methods, plus one generator-side fallback descriptor. Keep the two formats distinct and preserve missing-facade errors, metadata-extension skipping and exception/Either behavior. Benefit: reduced repeated generated source per any-bearing type and one maintained framing implementation; executable/bytecode-size and execution improvements are unmeasured. Effort M; risk medium; confidence high. Verify all six kind shapes, nested constructors, native/cross-format branches, future metadata tails and malformed lengths. Rebuild embedded resources cleanly and check the JVM constant-size ceiling. + +### SC-05 — Hoist immutable generated metadata and singleton wrappers + +Observed: JSON G`ScJsonCodecGenerator.scala:175` and UEBA G`ScUEBACodecGenerator.scala:240` emit `def LazyInstance = Lazy(codecObject)`. R`BaboonRuntimeShared.scala:278` shows each `Lazy` owns a closure and atomic reference; generated encode/decode guards also request it. G`ScDomainTreeTools.scala:80`, `:89`, `:97` emit collection-building metadata methods, installed on companions at G`ScDefnTranslator.scala:318` and `:401`. Envelope encoding consumes these metadata methods at R`BaboonRuntimeShared.scala:421` and `:444`. + +Proposal: private stable/lazy backing values behind the existing methods, with initialization order characterized first. Do not replace the public singleton contract, alter registration/override behavior, or eagerly resolve recursive codec graphs. Benefit: removes source-visible repeated wrapper and immutable-collection construction; actual allocation/throughput impact remains unmeasured. Effort S–M; risk medium; confidence high. Verify recursive types, codec registration and deprecated-encoder forwarding; benchmark allocation separately from behavioral tests. + +### SC-06 — Remove intermediate binary buffers/collections where ownership permits + +Observed: indexed encoding copies its complete temporary buffer via `toByteArray` at G`ScUEBACodecGenerator.scala:360`; collection decoding materializes a mapped range before `toList`/`toSet`/`toMap` at `:515`. Runtime index reading constructs full entries at R`BaboonCodecs.scala:224`, while generated DTO decoding uses only their count at G`ScUEBACodecGenerator.scala:405`. R`BaboonTools.scala:161` reads a fresh byte array and passes it to the copying public factory R`BaboonByteString.scala:169`. + +Proposal: direct buffer draining when supported by the existing writer, destination builders, a separate validating count/skip index path for generated DTOs, and an internal owned-array constructor. Keep public copying factories and full `readIndex` API unchanged. Benefit: fewer intermediate objects/copies; workload importance unknown. Effort M; risk medium; confidence high. Verify compact/indexed exact bytes, index validation, truncated input, duplicates and iteration order; benchmark large collections separately. Do not remove index framing or its validation to save allocations. + +Split buffer transfer, collection builders, index consumption and owned-array construction into separate changes. Direct collection population interleaves decoding with hashing/equality instead of staging decoded values; verify foreign hooks, malformed later elements and cursor state, or restrict it to domains with established equivalence. + +### SC-07 — Share MCP protocol handling, retain separate routing ownership + +Observed: R`BaboonMcpRuntime.scala:144` and `:301` implement parallel initialize/list/call state machines for a server and muxer. Server lookup rebuilds `tools.map(...).toMap` at `:141`; emitted servers expose immutable tool sequences at G`ScMcpServerGenerator.scala:175`. + +Proposal: a package-private synchronous protocol dispatcher receiving tool-list and invocation operations; retain muxer registration/order/collision semantics. Cache a lookup only for immutable generated registries or under an explicit snapshot/invalidation contract—arbitrary implementations may expose changing `tools`. Benefit: one maintained protocol/error mapping and potentially fewer per-call registry allocations. Effort M; risk medium; confidence high. Verify notifications, IDs, initialization, unknown tools, Channel-A/Channel-B errors and muxer ordering through public `handle`; preserve the Either-only axis rather than silently introducing async effects. + +### SC-08 — Replace repeated import-collision scans with a name index + +Observed: G`ScBaboonTranslator.scala:260` and `:263` count matches over all distinct used types for each type. This produces quadratic name-comparison work in the number of referenced types; it is unrelated to generated LoC. Collision identity includes both name and `inObject`. + +Proposal: precompute counts by `(name, inObject)` once and retain existing package partitioning, sorting, root qualification and import formatting. Benefit: bounded compiler algorithm improvement without a printer redesign; significance requires measurement on wide schemas. Effort S; risk low; confidence high. Verify same-name cross-package/object references, nested packages, forced FQNs and byte-identical output, then benchmark compiler rendering separately. + +## Sequence and boundaries + +Start with SC-08 and a SC-01 parity check; then SC-02, SC-04 and SC-05; tackle SC-03/06/07 independently with characterization tests. Keep Circe/JVM runtime APIs, Scala unsigned signed-carrier representation, ADT static-type metadata, override/registration semantics, foreign-key hooks, evolution bounds and resource preprocessing distinct. The existing weakest compiler effect constraints (`Applicative2`/`Error2`) need no expansion for these refactors. Compiler changes must still cross-compile JVM/JS; generated Scala compatibility is a separate check. + +Actual checks: complete assigned source reads, targeted symbol/reference searches, and supporting mirror-object read. No tests were executed and no performance estimates measured. Proposed verification uses behavioral black-box checks plus generated compilation/wire fixtures, following constructive-test-taxonomy; benchmarks are a separate evidence category. Generated tests currently use native-format any fixtures (G`ScCodecTestsTranslator.scala:57`) and skip recursive/foreign definitions (`:36`), so those tests alone are not sufficient acceptance coverage for these proposals. + +## Coverage appendix + +Full reads, generator (16): ScBaboonTranslator 428; ScTypeTranslator 133; ScFileTools 26; ScTreeTools 74; ScJsonCodecGenerator 625; ScConversionTranslator 352; ScMcpServerGenerator 204; ScCodecFixtureTranslator 259; ScDomainTreeTools 104; ScCodecTranslator 17; ScValue 32; ScUEBACodecGenerator 684; ScServiceWiringTranslator 951; ScCodecTestsTranslator 202; ScTypes 224; ScDefnTranslator 889. All filenames end `.scala` under G. + +Full reads, embedded runtime (12): BaboonConversions 141; BaboonTools 319; BaboonRuntimeShared 624; BaboonByteString 275; BaboonServiceWiring 141; CrossLanguageFixturePath 125; BaboonMcpRuntime 393; BaboonExceptions 20; BaboonCodecs 444; BaboonCodecsFacade 628; BaboonAnyOpaque 146; BaboonFixtureShared 105. All filenames end `.scala` under R. + +Supporting sampled coverage only: M`BaboonRuntimeShared.scala:1000` through EOF (complete IdentifierRepr object, not the whole mirror file). CLAUDE.md and review brief read. No generated output census or full supporting test-suite audit claimed. Common docs/classifier/unreachable-branch cleanup is covered by the parent cross-backend audit rather than repeated here. diff --git a/docs/research/20260916-backend-runtime-review/swift.md b/docs/research/20260916-backend-runtime-review/swift.md new file mode 100644 index 000000000..359e013d1 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/swift.md @@ -0,0 +1,73 @@ +# Swift backend and embedded runtime review + +Baseline: `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Repository unchanged. All 16 generator files (6,094 lines) and all nine embedded-runtime files (4,583 lines) were read completely, including recovered truncated output. Findings below are observations and bounded refactoring opportunities, not reproduced defects or measured performance claims. + +Citation prefixes expand to repository-relative paths: **G/** = `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/`; **R/** = `baboon-compiler/src/main/resources/baboon-runtime/swift/`. + +## Generator findings + +### SW-01 — Replace structural foreign-binding reflection with typed resolution + +Observed: G/SwTypeTranslator.scala:12 and :129 define identical `Product`→`Map[String, Any]` reflection helpers. Alias extraction is repeated at :16 and :158; declaration extraction at :133 contains compatibility paths for historical product layouts. The current sealed model has only `Custom` and `BaboonRef`, with a typed `ForeignEntry` (`baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/Typedef.scala:52`). `BaboonEnquiries.resolveForeignBinding` already matches those alternatives (:101 in the adjacent `typer/BaboonEnquiries.scala`). + +Proposal: use typed matching and `BaboonLang.Swift`, then share the Swift-specific fallback-to-another-language's-Baboon-alias policy between reference/type rendering. Do not blindly substitute the enquiries method: it deliberately does not implement that fallback. Preserve collection aliases versus scalar references and failure messages. This removes internal compatibility scaffolding and map allocation, principally improving maintained type safety rather than generated size. Effort M; risk M; confidence high. Verify foreign custom bindings, Swift aliases, absent Swift bindings, and nested collection aliases through generated-source comparison and Swift compilation. + +### SW-02 — Ship invariant `any` field helpers once + +Observed: JSON and UEBA inject complete helper bodies into every any-bearing codec (G/SwJsonCodecGenerator.scala:63, :550; G/SwUEBACodecGenerator.scala:102, :563). Bodies accept context, kind and static fallback arguments; they do not need the enclosing DTO. Detection and fallback tables are also duplicated (JSON :472/:513; UEBA :486/:526). + +Proposal: move helper bodies to a dedicated runtime file and generate calls; share the compiler's typed any-variant/fallback decision separately from Swift rendering. This reduces generated source volume proportional to affected codecs and centralizes runtime policy; no executable-size reduction is assumed because compiler deduplication is unknown. Preserve NSNull/nil distinctions, wire-over-static precedence, metadata trailer handling and nonthrowing encode traps versus throwing decode. Keep JSON and UEBA framing separate. Effort M; risk M; confidence high. Verify all six kinds, nested collections, cross-format conversion, missing facade and malformed metadata; compare JSON/UEBA bytes. + +### SW-03 — Share scalar codec lowering with service wiring + +Observed: G/SwServiceWiringTranslator.scala:146/:172 independently enumerate JSON decode/encode primitives already rendered in G/SwJsonCodecGenerator.scala:304/:210; UEBA tables at wiring :194/:220 duplicate G/SwUEBACodecGenerator.scala:360/:418. All four mappings encode wire policy, not service dispatch policy. + +Proposal: introduce Swift-local scalar expression builders with explicit context/reader/value inputs and a named throwing-effect result instead of an unlabelled Boolean. Have both DTO and service emitters consume them. Keep JSON dictionary-key representation distinct. Benefit: fewer maintained policy copies; generated code need not change. Effort M; risk M; confidence high. Verify every primitive as DTO field and direct RPC payload, with decode exceptions and async effect insertion checked by compilation and public-interface round trips. + +### SW-04 — Avoid rebuilding a closure dictionary for each service invocation + +Observed: generated dispatchers allocate a complete method→capturing-closure dictionary inside each call (G/SwServiceWiringTranslator.scala:514, :560, :661, :865) and immediately select one entry. This includes synchronous/asynchronous and errors/no-errors variants. + +Proposal: emit a direct `switch` on method name, retaining each existing body and default error branch. This removes source-level per-request table construction and unused closures; actual allocation/latency savings require measurement. Separately extract small repeated decode/call/encode fragments from sync/async bodies (:676/:765 and :876/:957), not a universal service renderer. Effort M; risk M; confidence high. Verify method-not-found, service-id validation, each error channel, context modes and direct async suspension; benchmark method-count scaling independently of correctness tests. + +## Runtime and generated-runtime interaction + +### SW-05 — Factor MCP's pure protocol work without erasing async semantics + +Observed: four state machines repeat initialize validation, session gating, tool-list formatting, argument serialization and response construction (R/baboon_mcp_runtime.swift:174, :345, :565, :733). Single-server `byName` recreates an index per call (:151/:329), whereas muxers build routing tables at registration. Tool-list formatting is repeated inline and at :647/:807. + +Proposal: share pure request preparation and response formatting, producing either an immediate reply/notification or a typed pending tool call. Retain thin synchronous and genuinely asynchronous invokers. For generated immutable tool lists, construct a reusable index; do not impose caching on arbitrary protocol conformers whose `tools` may change. Benefit: less maintained protocol duplication and avoidable indexing; no generated per-service bulk renderer needed. Effort M; risk H; confidence high. Verify identical channel-A/channel-B payloads, notification behavior, declaration/registration ordering and per-connection state across all four surfaces. Async must remain direct `await`, without semaphore or detached-task bridging. + +### SW-06 — Encode binary UUIDs as bytes, not text + +Observed: R/baboon_runtime.swift:479 converts UUID to string, removes separators and parses hexadecimal back to bytes. `readUuid` (:742) reverses the process by formatting bytes and reparsing a string. These transformations serve the fixed .NET mixed-endian permutation. + +Proposal: use UUID's byte tuple and an explicit fixed permutation, preserving the 16-byte wire layout and truncation behavior. This removes observed intermediate string/array work; throughput benefit is inferred, unmeasured. Effort S; risk M; confidence high. Verify known GUID golden bytes, all-zero/all-ones and randomized UUID round trips, including nonzero Data start indices and truncation. Retain the public methods and their throwing shape. + +### SW-07 — Consume ignored UEBA index entries without materializing them + +Observed: G/SwUEBACodecGenerator.scala:299 obtains `readIndex`, checks only its count when indices are requested, then discards entries. R/baboon_runtime.swift:285 allocates and appends every offset/length pair. Index presence is determined by the wire header, not the context flag. + +Proposal: add an internal consume-and-count path for generated sequential decoders while retaining public `readIndex`. Read both integers exactly as today rather than unchecked cursor arithmetic; preserve header interpretation, count assertion and malformed-input failure behavior. Benefit: eliminate an otherwise unused per-object collection; effort S; risk M; confidence high. Verify indexed/compact DTO and ADT branches, header/context mismatch, truncation and forward-readable trailing fields. Same opportunity was observed in Rust; this is format-shared semantics, not grounds for sharing runtime implementation languages. + +### SW-08 — Make identifier fixed-width reads proportional to consumed text + +Observed: R/BaboonIdentifierRepr.swift:287 creates a String from the entire remaining byte suffix before consuming `n` Unicode scalars. Repeated fixed-width fields therefore repeatedly process suffixes; input/schema scaling can make aggregate work quadratic. Cursor also retains `source` (:222/:227), but all reads use `bytes`; full-file inspection found no read of that private property. + +Proposal: advance over the existing UTF-8 buffer for exactly `n` scalars, decoding only the consumed slice; remove redundant source storage after confirming lifetime/memory behavior. Preserve Unicode-scalar semantics, cursor position on short input and exact error strings; do not substitute a general byte count merely because current timestamp callers use ASCII. Effort M; risk M; confidence high on source behavior, medium on practical impact. Verify multibyte scalars, empty/short input, repeated timestamp fields and identifiers with long suffixes; measure allocations separately. + +## Sequencing and boundaries + +Start SW-01/SW-03 with output-preserving generator changes, then SW-02/SW-07, isolated SW-06/SW-08, and finally dispatch/MCP changes. Keep Swift's recursive value-layout analysis and `@BaboonIndirect` copy-on-write property semantics (G/SwDefnTranslator.scala:355; R/baboon_runtime.swift:30), native async effects, type-erased public service protocols, evolution contracts and Foundation JSON bridging distinct. Runtime file splitting is required by the JVM 64KB embedded-string limit: new helpers should use suitably sized files, never merge existing splits for aesthetics. No replacement of typed conversions with serialization round trips is proposed. + +Actual checks: `rg --files`, `wc -l`, complete `cat`/`sed` reads, targeted `rg -n` evidence searches, sampled typed-model/enquiries inspection, sampled SwiftPM target references, `git rev-parse HEAD` and clean `git status --short`. No builds, executions, benchmarks or failing reproductions. Proposed verification follows the constructive-test-taxonomy skill: behavioral checks through public interfaces plus compile/wire conformance, with benchmarks explicitly separate. Later implementation needs clean resource recompilation, Swift regular/wrapped/manual/service/MCP lanes and cross-language acceptance; new fixtures require explicit entries in `test/sw-stub/Package.swift`, not just source directories. + +## Full inventory / coverage + +Every entry below was read in full; numbers are lines, not complexity scores. + +Generator: SwBaboonTranslator.scala 563; SwCodecFixtureTranslator.scala 344; SwCodecTestsTranslator.scala 300; SwCodecTranslator.scala 17; SwConversionTranslator.scala 399; SwDefnTranslator.scala 1062; SwDomainTreeTools.scala 120; SwFileTools.scala 15; SwJsonCodecGenerator.scala 670; SwMcpServerGenerator.scala 201; SwServiceWiringTranslator.scala 1150; SwTreeTools.scala 64; SwTypeTranslator.scala 346; SwTypes.scala 109; SwUEBACodecGenerator.scala 695; SwValue.scala 39. + +Runtime: BaboonAnyOpaque.swift 281; BaboonCodecsFacade.swift 775; BaboonIdentifierRepr.swift 372; CrossLanguageFixturePath.swift 139; baboon_fixture.swift 140; baboon_mcp_runtime.swift 826; baboon_runtime.swift 1436; baboon_service_wiring.swift 282; baboon_type_meta.swift 332. + +Supporting material: CLAUDE.md and review brief fully read; Typedef.scala, BaboonEnquiries.scala and SwiftPM manifest sampled only. Test bodies were not audited. Cross-backend questions: share typed any/foreign decisions centrally while preserving Swift fallback policy? Apply consume-only UEBA index handling to other sequential decoders? Share MCP protocol fixtures across languages without forcing a shared renderer? diff --git a/docs/research/20260916-backend-runtime-review/typescript.md b/docs/research/20260916-backend-runtime-review/typescript.md new file mode 100644 index 000000000..041e2d1e2 --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/typescript.md @@ -0,0 +1,73 @@ +# TypeScript backend and embedded runtime review + +Baseline `69bbb4c48a519d9a4148f370b76bde19af4bac6a`. Full read: 16 generator files, 5,662 lines; seven runtime files, 3,734 lines; 9,396 total. Truncated translator output was recovered with a narrower read. Repository unchanged. Performance effects below are hypotheses from explicit allocations, not benchmark results. + +Citation prefixes: **G/** = `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/`; **R/** = `baboon-compiler/src/main/resources/baboon-runtime/typescript/`. + +## Generator + +### TS-01 — Make output symbols explicit instead of inferring exports from references + +Observed: G/TsBaboonTranslator.scala:602 derives exports from `TsType` occurrences whose module matches the output. String-rendered declarations require `hasExports` text scanning (:615), and barrel generation has a separate verbatim-file path (:795). Conversely, service signatures rendered as strings require phantom aliases solely to trigger imports (G/TsServiceWiringTranslator.scala:293, :554, :839). MCP and facade generators bypass the normal symbolic import machinery with hand-written import text (G/TsMcpServerGenerator.scala:116; G/TsBaboonTranslator.scala:228). + +Proposal: enrich existing `Output` with explicit exported symbols and required imports, carrying type/value usage; populate these at declaration sites and consume them in import/barrel rendering. First migrate service/facade outputs, then delete textual fallbacks when exhaustive. Retain symbolic `TextTree` references for expression aliasing. Benefit: fewer hidden coupling rules and phantom generated declarations, not a universal AST renderer. Effort M; risk H; confidence high. Verify strict ESM/NodeNext, type-only imports, suffix modes, duplicate names, bare services, service-only domains, foreign-empty files and JSON-disabled output; compare existing public export names before/after. + +### TS-02 — Lower evolution operations to typed value transformations + +Observed: G/TsConversionTranslator.scala:49 emits `JSON.parse(JSON.stringify(...))`; ADT copies repeat it (:119), and DTO transfer/wrapping/precision/collection/redefinition paths use it (:148–195). This is independent of the generated JSON codec, whose bigint/bytes rules differ (G/TsJsonCodecGenerator.scala:201). The facade conversion API remains an explicit failure stub (R/BaboonCodecsFacade.ts:503), while generated facade conversion registries contain only version accessors (G/TsBaboonTranslator.scala:310). + +Proposal: recursively lower the existing typed `FieldOp`/`TypeRef` to constructors, collection mapping and numeric conversion; preserve registered custom-conversion requirements. Treat connecting facade dispatch as a separate public-API feature, not cleanup hidden in this refactor. Benefit: removes serialization work and represents migration semantics explicitly. Effort L; risk H; confidence high on mechanism, generated-domain impact requires reproduction. A small Node probe confirmed this expression throws on `1n` and changes `Set([1,2])`/`Map([["x",1]])` to `{}`; it did not execute compiler-generated conversions. Verify compiled multi-version models with bigint, Map/Record, Set, nested DTO/ADT prototypes, rename/redef and lowercase enum modes before claiming a model-specific defect. + +### TS-03 — Share codec activation and scalar rendering, not wire containers + +Observed: facade activation logic (G/TsBaboonTranslator.scala:133/:140) duplicates JSON/UEBA `isActive` (:622 in G/TsJsonCodecGenerator.scala; :632 in G/TsUEBACodecGenerator.scala). Its six-element type tuple is recomputed for imports and registries (:240/:269). Service primitive mappings (:119/:151/:176/:215 in G/TsServiceWiringTranslator.scala) repeat codec mappings (JSON :200/:403; UEBA :309/:405). + +Proposal: derive a named per-version codec-entry plan once, sharing an activation predicate, and use TS-local scalar expression builders from codec and service emitters. Include timestamp representation and foreign binding decisions explicitly; keep key parsing separate from value decoding. This reduces maintained policy copies and repeat compiler traversal, with unchanged generated code as initial acceptance. Effort M; risk M; confidence high. Verify all scalar modes, derivation opt-ins, JSON-only/UEBA-only output, foreign hooks and context propagation; test bigint wire strings separately from numeric JSON values. + +### TS-04 — Move invariant any helpers into runtime modules + +Observed: complete parameterized helper methods are emitted per any-bearing codec (G/TsJsonCodecGenerator.scala:85/:543; G/TsUEBACodecGenerator.scala:84/:538). They depend on runtime types and explicit kind/fallback arguments, not DTO identity. Detection/fallback policy is separately repeated in both generators. + +Proposal: dedicated runtime functions with generated call sites, plus a shared typed compiler decision for kind/static fallbacks. Benefit: generated-source reduction scales with affected codecs and runtime logic has one maintained implementation; bundle-size gain is unmeasured. Preserve kind checks before conversion, exception messages, undefined-versus-null, trailer consumption and facade requirements. Avoid a runtime value-import cycle: preserve existing type-only facade imports and keep helper resources below JVM constant limits. Effort M; risk M; confidence high. Verify all six variants, nested fields, native/cross-format paths and runtime emission with JSON codecs disabled. + +### TS-05 — Remove collection intermediates from generated codecs + +Observed: UEBA list encoding copies an array merely to read its length (G/TsUEBACodecGenerator.scala:370). JSON list/set encoding uses `Array.from(...).map(...)` (G/TsJsonCodecGenerator.scala:248/:250); map encoding materializes entry arrays and mapped arrays (:256–268). UEBA Set/Map decoding materializes complete intermediate arrays (:459–466). + +Proposal: use array length for supported list values, fuse `Array.from(iterable, mapper)` where equivalent, and use small loops to construct map/set results directly. Benefit: fewer temporary collections and traversals, not fundamentally different asymptotics. Preserve iteration order, sparse-array semantics where relevant, duplicate-key behavior, bigint decoding and Map-versus-Record representation; do not casually replace `Object.fromEntries` with unsafe property assignment. Effort M; risk M; confidence high. Verify nested collections, empty/sparse arrays, `__proto__` keys and both representation modes; benchmark large collections separately. + +Collection/iterator fusion changes when mapper calls and hashing/equality occur relative to iteration or decoding. Characterize mutation, throwing foreign hooks, malformed later elements and cursor state; retain staged behavior where equivalence cannot be established. + +## Embedded runtime + +### TS-06 — Write fixed-width primitives directly into writer storage + +Observed: R/BaboonSharedRuntime.ts:384/:390/:396/:402/:412/:418/:424/:430/:436 allocate an ArrayBuffer, DataView and Uint8Array per primitive before copying into `BaboonBinWriter`. Reader `readBytes` (:160) performs `slice`, then constructs another Uint8Array from that copy. + +Proposal: writer-owned DataView, refreshed after growth, with fixed-width write methods and existing BinTools wrappers retained. For reads, eliminate redundant copying while explicitly preserving ownership; Buffer subclasses and overridden slice behavior need checking before returning the first slice directly. Benefit: removes concrete temporary allocations; speedup unknown. Effort M; risk M; confidence high. Verify little-endian golden bytes, bigint extrema, float bit patterns, growth boundaries, Buffer/nonzero-offset inputs, output independence and malformed-input cursor/error behavior. Do not silently change current truncation policy as part of optimization. + +### TS-07 — Factor MCP protocol preparation and response construction + +Observed: R/BaboonMcpRuntime.ts repeats the state machine at :195/:314/:441/:539. Single-server `byName` recreates a Map per call (:189/:435); muxers already construct routing tables at registration. Shared service runtime independently repeats registry mechanics across four muxers (R/BaboonSharedRuntime.ts:759/:782/:821/:844). + +Proposal: pure MCP preparation returning immediate response/notification or a typed pending call; thin sync/Promise invokers and shared response formatting. A small generic registration/lookup component can serve service muxers while public wrappers remain. Cache generated immutable tool indexes, but do not assume arbitrary subclass getters are immutable. Benefit: less maintained protocol duplication and request-local indexing. Effort M; risk H; confidence high. Verify session isolation, tool order, duplicate registration, exceptions versus Left results, notifications and Promise rejection timing. Keep MCP optional and transport-abstract. + +### TS-08 — Remove demonstrably discarded generator scaffolding + +Observed: G/TsUEBACodecGenerator.scala:287 constructs an export-function TextTree and discards it before returning the actual codec bodies. G/TsConversionTranslator.scala:213 has an unreachable `if (false)` error arm. These are local expression/control-flow observations, not repository-wide claims that public symbols are unused. + +Proposal: remove the unused expression and constant-false branch; then remove imports made unused by that edit. Benefit: less maintained misleading source and needless tree construction; emitted source should be byte-identical. Effort S; risk L; confidence high. Verify compiler cross-build and representative generated-output identity; no runtime API removal. + +## Sequence, verification and boundaries + +Start TS-08, then activation/scalar sharing and explicit output metadata; separate runtime allocation work from any-helper relocation; leave typed evolution and MCP restructuring for independently reviewable changes. TS already consumes UEBA indices without allocating entry objects (G/TsUEBACodecGenerator.scala:149), and service dispatch already uses switches: Rust/Swift recommendations to change those structures do not apply here. Preserve native bigint, enum-lowercase choices, public toJSON behavior, type/value namespace distinctions, barrels, timestamp modes and always-Promise clients even for synchronous servers. Do not merge runtime resources for file-count reduction. + +Actual checks: complete `cat`/`sed` reads, `rg --files`, `wc -l`, targeted `rg -n`, Node JSON-clone probe and clean `git status --short`. No builds/installations or generated-code executions. Probe command: `node -e 'for (const [name,value] of [["bigint",1n],["set",new Set([1,2])],["map",new Map([["x",1]])]]) {try {console.log(name,JSON.stringify(JSON.parse(JSON.stringify(value))))} catch(e){console.log(name,e.name+": "+e.message)}}'`. Output: bigint TypeError “Do not know how to serialize a BigInt”; set `{}`; map `{}`. + +Proposed checks follow constructive-test-taxonomy: behavioral checks through public interfaces plus generated compile/wire conformance; performance measurement separate. Future changes require clean runtime embedding rebuild and regular/wrapped/manual/service/MCP plus cross-language acceptance lanes. No test bodies were audited this assignment. + +## Complete inventory + +Full-read generator files (lines): TsBaboonTranslator.scala 891; TsCodecFixtureTranslator.scala 241; TsCodecTestsTranslator.scala 180; TsCodecTranslator.scala 12; TsConversionTranslator.scala 220; TsDefnTranslator.scala 910; TsDomainTreeTools.scala 111; TsFileTools.scala 68; TsJsonCodecGenerator.scala 629; TsMcpServerGenerator.scala 157; TsServiceWiringTranslator.scala 1070; TsTreeTools.scala 65; TsTypeTranslator.scala 347; TsTypes.scala 93; TsUEBACodecGenerator.scala 639; TsValue.scala 29. + +Full-read runtime files (lines): BaboonAnyOpaque.ts 304; BaboonCodecsFacade.ts 706; BaboonMcpRuntime.ts 609; BaboonSharedFixture.ts 112; BaboonSharedRuntime.ts 1536; CrossLanguageFixturePath.ts 118; baboon-identifier-repr.ts 349. CLAUDE.md and review brief fully read. No sampled-only assigned files. Cross-backend questions: reuse typed evolution lowering decisions and any metadata plans? Share MCP conformance fixtures while leaving target-language effect execution distinct? diff --git a/test/conv-test-cs/ConvTest/RenamedConversionTests.cs b/test/conv-test-cs/ConvTest/RenamedConversionTests.cs new file mode 100644 index 000000000..44a490d28 --- /dev/null +++ b/test/conv-test-cs/ConvTest/RenamedConversionTests.cs @@ -0,0 +1,41 @@ +using Baboon.Runtime.Shared; +using Convtest.Testpkg; +using NUnit.Framework; + +namespace ConvTest +{ + [TestFixture] + public class RenamedConversionTests + { + [Test] + public void RenamedDtoRetainsPayload() + { + var conversions = new BaboonConversions(new RequiredConversionsImpl()); + var result = conversions.Convert(new Convtest.Testpkg.v1_0_0.OldTypeName("kept")).To(); + Assert.That(result.Value, Is.EqualTo("kept")); + } + + [Test] + public void RenamedAdtAndBranchConversionsValidateTheirOwnTargetIds() + { + var conversions = new BaboonConversions(new RequiredConversionsImpl()); + var source = new Convtest.Testpkg.v1_0_0.OldAdtName.Branch1("kept"); + var parentResult = conversions.Convert(source).To(); + var branchResult = conversions.Convert(source).To(); + Assert.That(parentResult, Is.TypeOf()); + Assert.That(((NewAdtName.Branch1)parentResult).F, Is.EqualTo("kept")); + Assert.That(branchResult, Is.EqualTo(parentResult)); + } + + [Test] + public void NamespacedRenamedAdtRetainsPayload() + { + var conversions = new BaboonConversions(new RequiredConversionsImpl()); + var source = new Convtest.Testpkg.v1_0_0.abs.core.OldAbsAdt.BranchAbs("namespaced"); + var result = conversions.Convert(source) + .To(); + Assert.That(result, Is.TypeOf()); + Assert.That(((Convtest.Testpkg.abs.core.deeper.NewAbsAdt.BranchAbs)result).V, Is.EqualTo("namespaced")); + } + } +} diff --git a/test/conv-test-kt-kmp/src/test/kotlin/example/RenamedAdtConversionTest.kt b/test/conv-test-kt-kmp/src/test/kotlin/example/RenamedAdtConversionTest.kt new file mode 100644 index 000000000..b6d581c8b --- /dev/null +++ b/test/conv-test-kt-kmp/src/test/kotlin/example/RenamedAdtConversionTest.kt @@ -0,0 +1,23 @@ +package example + +import convtest.testpkg.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class RenamedAdtConversionTest { + private val conversions = BaboonConversions(object : RequiredConversions {}) + + @Test fun renamedParentRetainsBranchPayload() { + val result = Convert__OldAdtName__From__1_0_0.convert( + null, conversions, convtest.testpkg.v1_0_0.OldAdtName.Branch1("kept") + ) + assertEquals(NewAdtName.Branch1("kept"), result) + } + + @Test fun renamedBranchAddsOptionalDefault() { + val result = Convert__AdtBranchRename__From__1_0_0.convert( + null, conversions, convtest.testpkg.v1_0_0.AdtBranchRename.OldBranch("kept") + ) + assertEquals(AdtBranchRename.NewBranch("kept", null), result) + } +} diff --git a/test/conv-test-kt/src/test/kotlin/example/RenamedAdtConversionTest.kt b/test/conv-test-kt/src/test/kotlin/example/RenamedAdtConversionTest.kt new file mode 100644 index 000000000..b6d581c8b --- /dev/null +++ b/test/conv-test-kt/src/test/kotlin/example/RenamedAdtConversionTest.kt @@ -0,0 +1,23 @@ +package example + +import convtest.testpkg.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class RenamedAdtConversionTest { + private val conversions = BaboonConversions(object : RequiredConversions {}) + + @Test fun renamedParentRetainsBranchPayload() { + val result = Convert__OldAdtName__From__1_0_0.convert( + null, conversions, convtest.testpkg.v1_0_0.OldAdtName.Branch1("kept") + ) + assertEquals(NewAdtName.Branch1("kept"), result) + } + + @Test fun renamedBranchAddsOptionalDefault() { + val result = Convert__AdtBranchRename__From__1_0_0.convert( + null, conversions, convtest.testpkg.v1_0_0.AdtBranchRename.OldBranch("kept") + ) + assertEquals(AdtBranchRename.NewBranch("kept", null), result) + } +} diff --git a/test/conv-test-py/test_conversions.py b/test/conv-test-py/test_conversions.py index a23fdb049..7945a8710 100644 --- a/test/conv-test-py/test_conversions.py +++ b/test/conv-test-py/test_conversions.py @@ -10,6 +10,19 @@ from Generated.convtest.testpkg.from_1_0_0__EnumMemberRename import Convert__EnumMemberRename__From__1_0_0 class TestConversions(TestCase): + def test_renamed_adt_owner_converts_unchanged_branch(self): + from Generated.convtest.testpkg.v1_0_0.OldAdtName import OldAdtName, Branch1 as OldBranch + from Generated.convtest.testpkg.NewAdtName import NewAdtName, Branch1 as NewBranch + + source = OldBranch(f="retained") + conversions = BaboonConversions(required=RequiredConversions()) + direct = conversions.convert_by_type(None, source, OldBranch, NewBranch) + through_parent = conversions.convert_by_type(None, source, OldAdtName, NewAdtName) + self.assertIs(type(direct), NewBranch) + self.assertIs(type(through_parent), NewBranch) + self.assertEqual("retained", through_parent.f) + self.assertEqual(direct, through_parent) + def test_derived_conversion_adt_autoupgrade(self): b1 = v1_0_0.Adt0.B1(f="value") conv = BaboonConversions(required=RequiredConversions()) diff --git a/test/conv-test-sc/src/test/scala/example/RenamedAdtConversionSpec.scala b/test/conv-test-sc/src/test/scala/example/RenamedAdtConversionSpec.scala new file mode 100644 index 000000000..527fe12ff --- /dev/null +++ b/test/conv-test-sc/src/test/scala/example/RenamedAdtConversionSpec.scala @@ -0,0 +1,31 @@ +package example + +import org.scalatest.funsuite.AnyFunSuite + +class RenamedAdtConversionSpec extends AnyFunSuite { + private val conversions = new convtest.testpkg.BaboonConversions(new convtest.testpkg.RequiredConversions {}) + + test("renamed parent retains branch payload") { + val result = conversions.convertWithContext[Unit, convtest.testpkg.v1_0_0.OldAdtName, convtest.testpkg.NewAdtName]( + None, + convtest.testpkg.v1_0_0.OldAdtName.Branch1("kept"), + ) + assert(result == convtest.testpkg.NewAdtName.Branch1("kept")) + } + + test("renamed branch adds optional default") { + val result = conversions.convertWithContext[Unit, convtest.testpkg.v1_0_0.AdtBranchRename, convtest.testpkg.AdtBranchRename]( + None, + convtest.testpkg.v1_0_0.AdtBranchRename.OldBranch("kept"), + ) + assert(result == convtest.testpkg.AdtBranchRename.NewBranch("kept", None)) + } + + test("namespace-renamed ADT branches use qualified target types") { + val result = conversions.convertWithContext[Unit, convtest.testpkg.v1_0_0.abs.core.OldAbsAdt, convtest.testpkg.abs.core.deeper.NewAbsAdt]( + None, + convtest.testpkg.v1_0_0.abs.core.OldAbsAdt.BranchAbs("kept"), + ) + assert(result == convtest.testpkg.abs.core.deeper.NewAbsAdt.BranchAbs("kept")) + } +} diff --git a/test/cs-stub-mcp-overlay/McpTests/McpDispatchRuntimeTests.cs b/test/cs-stub-mcp-overlay/McpTests/McpDispatchRuntimeTests.cs new file mode 100644 index 000000000..77b5bd4c5 --- /dev/null +++ b/test/cs-stub-mcp-overlay/McpTests/McpDispatchRuntimeTests.cs @@ -0,0 +1,101 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using Baboon.Runtime.Shared; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace McpTest +{ + [TestFixture] + public class McpDispatchRuntimeTests + { + private sealed class SyncServer : AbstractBaboonMcpServer + { + public override McpServerInfo ServerInfo => new("test", "1.0.0"); + public readonly List Entries = new(); + public override IReadOnlyList Tools => Entries; + protected override Either InvokeJson(BaboonMethodId method, string data, int ctx, BaboonCodecContext codecCtx) + => Either.Right($"{method.MethodName}:{data}:{ctx}"); + } + + private sealed class AsyncServer : AbstractBaboonMcpServerAsync + { + public override McpServerInfo ServerInfo => new("test", "1.0.0"); + public readonly List Entries = new(); + public override IReadOnlyList Tools => Entries; + protected override Task> InvokeJson(BaboonMethodId method, string data, int ctx, BaboonCodecContext codecCtx) + => Task.FromResult(Either.Right($"{method.MethodName}:{data}:{ctx}")); + } + + private static McpToolEntry Tool(string name, string method) + => new(name, new BaboonMethodId("svc", method), new JObject(), "description"); + + [Test] + public void OwnedRegistryCopiesMutableSchemasAndRetainsDeclarationOrder() + { + var input = new List { Tool("first", "one"), Tool("second", "two") }; + var registry = new McpToolRegistry(input); + input[0].InputSchema["changed"] = true; + input.Clear(); + var snapshot = registry.Snapshot(); + snapshot[0].InputSchema["changed"] = true; + Assert.That(registry.Snapshot()[0].InputSchema["changed"], Is.Null); + Assert.That(registry.Snapshot()[1].Name, Is.EqualTo("second")); + Assert.That(registry.TryFind("first", out var method), Is.True); + Assert.That(method.MethodName, Is.EqualTo("one")); + Assert.That(registry.TryFind("absent", out _), Is.False); + } + + [Test] + public async Task SyncAsyncServersAndMuxersHaveEquivalentProtocolResponses() + { + var sync = new SyncServer(); + var asyncServer = new AsyncServer(); + sync.Entries.Add(Tool("echo", "echo")); + asyncServer.Entries.Add(Tool("echo", "echo")); + var mux = new AbstractMcpMuxer(sync.ServerInfo, sync); + var asyncMux = new AbstractAsyncMcpMuxer(sync.ServerInfo, asyncServer); + var sessions = new[] { new McpSession(), new McpSession(), new McpSession(), new McpSession() }; + var requests = new[] + { + new JsonRpcRequest(new JValue(1), "tools/list", null), + new JsonRpcRequest(new JValue(2), "initialize", new JObject()), + new JsonRpcRequest(new JValue(3), "initialize", JObject.Parse("{\"protocolVersion\":\"old\"}")), + new JsonRpcRequest(null, "notifications/initialized", null), + new JsonRpcRequest(new JValue(4), "tools/list", null), + new JsonRpcRequest(new JValue(5), "tools/call", new JObject()), + new JsonRpcRequest(new JValue(6), "tools/call", JObject.Parse("{\"name\":\"absent\"}")), + new JsonRpcRequest(new JValue(7), "tools/call", JObject.Parse("{\"name\":\"echo\",\"arguments\":{\"value\":42}}")), + new JsonRpcRequest(new JValue(8), "unknown", null), + }; + foreach (var request in requests) + { + var responses = new[] + { + sync.Handle(request, sessions[0], 17, BaboonCodecContext.Compact), + await asyncServer.Handle(request, sessions[1], 17, BaboonCodecContext.Compact), + mux.Handle(request, sessions[2], 17, BaboonCodecContext.Compact), + await asyncMux.Handle(request, sessions[3], 17, BaboonCodecContext.Compact), + }; + var expected = JToken.FromObject(new { Response = responses[0] }); + foreach (var response in responses) + Assert.That(JToken.DeepEquals(expected, JToken.FromObject(new { Response = response })), Is.True, request.Method); + if (request.Id?.Value() == 7) + Assert.That(responses[0]!.Result!["content"]![0]!["text"]!.Value(), Is.EqualTo("echo:{\"value\":42}:17")); + } + } + + [Test] + public void ExternalServerToolRegistryRemainsDynamicAndLastDuplicateWins() + { + var server = new SyncServer(); + server.Entries.Add(Tool("echo", "first")); + var session = new McpSession { Initialized = true }; + var request = new JsonRpcRequest(new JValue(1), "tools/call", JObject.Parse("{\"name\":\"echo\"}")); + Assert.That(server.Handle(request, session, 0, BaboonCodecContext.Compact)!.Result!.ToString(), Does.Contain("first:{}:0")); + server.Entries.Add(Tool("echo", "last")); + Assert.That(server.Handle(request, session, 0, BaboonCodecContext.Compact)!.Result!.ToString(), Does.Contain("last:{}:0")); + } + } +} diff --git a/test/cs-stub-mcp-overlay/McpTests/McpTests.cs b/test/cs-stub-mcp-overlay/McpTests/McpTests.cs index 1b1757c06..bbce7115f 100644 --- a/test/cs-stub-mcp-overlay/McpTests/McpTests.cs +++ b/test/cs-stub-mcp-overlay/McpTests/McpTests.cs @@ -125,6 +125,14 @@ public void RegisterPassthroughCodec() private McpToolsMcpServer MakeServer() => new McpToolsMcpServer(MakeFakeInvoke()); + [Test] + public void ToolSchemasReturnedToCallersDoNotMutateGeneratedRegistry() + { + var server = MakeServer(); + server.Tools[0].InputSchema["callerMutation"] = true; + Assert.That(server.Tools[0].InputSchema["callerMutation"], Is.Null); + } + // Helper: send one JSON-RPC request and assert a response was returned. private static JsonRpcResponse Send( McpToolsMcpServer server, diff --git a/test/cs-stub/BaboonTests/AnyEnvelopeCodecTests.cs b/test/cs-stub/BaboonTests/AnyEnvelopeCodecTests.cs new file mode 100644 index 000000000..8360f834e --- /dev/null +++ b/test/cs-stub/BaboonTests/AnyEnvelopeCodecTests.cs @@ -0,0 +1,76 @@ +#nullable enable +using System.IO; +using Baboon.Runtime.Shared; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class AnyEnvelopeCodecTests + { + [TestCase(0x07, "domain", "1.2.3", "type")] + [TestCase(0x03, null, "1.2.3", "type")] + [TestCase(0x01, null, null, "type")] + [TestCase(0x06, "domain", "1.2.3", null)] + [TestCase(0x02, null, "1.2.3", null)] + [TestCase(0x00, null, null, null)] + public void NativeFormatsRoundTripAllKinds(byte kind, string? domain, string? version, string? type) + { + var meta = new AnyMeta(kind, domain, version, type); + var json = new AnyOpaqueJson(meta, JValue.CreateNull()); + var encoded = BaboonAnyJsonCodec.Encode(BaboonCodecContext.Compact, kind, null, null, null, json); + var decoded = BaboonAnyJsonCodec.Decode(kind, encoded); + Assert.That(decoded.Meta, Is.EqualTo(meta)); + Assert.That(decoded.Json.Type, Is.EqualTo(JTokenType.Null)); + + var bytes = new byte[] { 0xaa, 0xbb }; + using var output = new MemoryStream(); + using var writer = new BinaryWriter(output); + BaboonAnyBinCodec.Encode(BaboonCodecContext.Compact, writer, kind, null, null, null, new AnyOpaqueUeba(meta, bytes)); + writer.Write((byte)0x42); + using var reader = new BinaryReader(new MemoryStream(output.ToArray())); + var binary = BaboonAnyBinCodec.Decode(reader, kind); + Assert.That(binary.Meta, Is.EqualTo(meta)); + Assert.That(binary.Bytes, Is.EqualTo(bytes)); + Assert.That(reader.ReadByte(), Is.EqualTo(0x42)); + if (kind == 0) + Assert.That(output.ToArray(), Is.EqualTo(new byte[] { 7, 0, 0, 0, 1, 0, 0, 0, 0, 0xaa, 0xbb, 0x42 })); + } + + [Test] + public void BinaryDecodeSkipsMetadataExtensionsWithoutConsumingTrailer() + { + using var reader = new BinaryReader(new MemoryStream(new byte[] { 9, 0, 0, 0, 3, 0, 0, 0, 0, 0x11, 0x22, 0xaa, 0xbb, 0x42 })); + var value = BaboonAnyBinCodec.Decode(reader, 0); + Assert.That(value.Bytes, Is.EqualTo(new byte[] { 0xaa, 0xbb })); + Assert.That(reader.ReadByte(), Is.EqualTo(0x42)); + } + + [Test] + public void KindValidationPrecedesCrossFormatConversion() + { + var meta = new AnyMeta(0, null, null, null); + var binary = new AnyOpaqueUeba(meta, new byte[] { 1 }); + var mismatch = Assert.Throws(() => + BaboonAnyJsonCodec.Encode(BaboonCodecContext.Compact, 1, null, null, null, binary)); + Assert.That(mismatch!.Message, Does.Contain("does not match field-declared")); + var missingFacade = Assert.Throws(() => + BaboonAnyJsonCodec.Encode(BaboonCodecContext.Compact, 0, null, null, null, binary)); + Assert.That(missingFacade!.Message, Does.Contain("without a facade reference")); + using var writer = new BinaryWriter(new MemoryStream()); + Assert.Throws(() => + BaboonAnyBinCodec.Encode(BaboonCodecContext.Compact, writer, 0, null, null, null, new AnyOpaqueJson(meta, new JValue(1)))); + } + + [Test] + public void RejectsMissingJsonContentAndShortBinaryPayload() + { + Assert.Throws(() => BaboonAnyJsonCodec.Decode(0, null)); + Assert.Throws(() => BaboonAnyJsonCodec.Decode(0, JObject.Parse("{\"$ak\":0}"))); + using var reader = new BinaryReader(new MemoryStream(new byte[] { 7, 0, 0, 0, 1, 0, 0, 0, 0, 0xaa })); + var failure = Assert.Throws(() => BaboonAnyBinCodec.Decode(reader, 0)); + Assert.That(failure!.Message, Does.Contain("short read on blob")); + } + } +} diff --git a/test/cs-stub/BaboonTests/CollectionHashTests.cs b/test/cs-stub/BaboonTests/CollectionHashTests.cs new file mode 100644 index 000000000..c6d057a02 --- /dev/null +++ b/test/cs-stub/BaboonTests/CollectionHashTests.cs @@ -0,0 +1,65 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Baboon.Runtime.Shared; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class CollectionHashTests + { + private const int Seed = 0x1EAFDEAD; + + [Test] + public void SetHashPreservesOrderIndependentSignedHashAccumulation() + { + var hashes = new[] { int.MinValue, -42, -1, 0, 1, 42, int.MaxValue }; + Assert.That(BaboonTools.SetHashcode(new HashSet(), v => v), Is.EqualTo(Seed)); + foreach (var a in hashes) + foreach (var b in hashes) + foreach (var c in hashes) + { + var values = new HashSet { a, b, c }; + var expected = values.OrderBy(v => v).Aggregate(Seed, (acc, v) => acc ^ v); + Assert.That(BaboonTools.SetHashcode(values, v => v), Is.EqualTo(expected)); + Assert.That(BaboonTools.SetHashcode(new HashSet { c, b, a }, v => v), Is.EqualTo(expected)); + } + } + + [Test] + public void MapHashPreservesDuplicatesAndCallbackOrder() + { + var values = new[] + { + new KeyValuePair(3, -9), + new KeyValuePair(1, int.MinValue), + new KeyValuePair(3, -9), + }; + var calls = new List(); + var expected = values.Select(v => HashCode.Combine(v.Key, v.Value)) + .OrderBy(v => v).Aggregate(Seed, (acc, v) => acc ^ v); + var actual = BaboonTools.MapHashcode(values, + key => { calls.Add($"key:{key}"); return key; }, + value => { calls.Add($"value:{value}"); return value; }); + Assert.That(actual, Is.EqualTo(expected)); + Assert.That(calls, Is.EqualTo(new[] { "key:3", "value:-9", "key:1", "value:-2147483648", "key:3", "value:-9" })); + Assert.That(BaboonTools.MapHashcode(Array.Empty>(), v => v, v => v), Is.EqualTo(Seed)); + } + + [Test] + public void ThrowingHashStopsAtTheSameElement() + { + var values = new HashSet { 1, 2, 3 }; + var calls = new List(); + Assert.Throws(() => BaboonTools.SetHashcode(values, value => + { + calls.Add(value); + if (value == 2) throw new InvalidOperationException("hash failed"); + return value; + })); + Assert.That(calls, Is.EqualTo(new[] { 1, 2 })); + } + } +} diff --git a/test/cs-stub/BaboonTests/ConversionValidationTests.cs b/test/cs-stub/BaboonTests/ConversionValidationTests.cs new file mode 100644 index 000000000..9c789ec24 --- /dev/null +++ b/test/cs-stub/BaboonTests/ConversionValidationTests.cs @@ -0,0 +1,149 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Baboon.Runtime.Shared; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class ConversionValidationTests + { + private sealed class Conversions : AbstractBaboonConversions + { + public override List VersionsFrom() => new() { "1.0.0" }; + public override string VersionTo() => "2.0.0"; + } + + private class Model : IBaboonGenerated + { + private readonly string _typeId; + public Model(string typeId) { _typeId = typeId; } + public string BaboonTypeIdentifier() => _typeId; + public string BaboonDomainVersion() => "1.0.0"; + public string BaboonDomainIdentifier() => "test"; + public IReadOnlyList BaboonSameInVersions() => new[] { "1.0.0" }; + public IReadOnlyDictionary BaboonForwardReadable() => new Dictionary(); + public IReadOnlyDictionary BaboonMinReaderVersions() => new Dictionary(); + } + + private sealed class Branch : Model, IBaboonAdtMemberMeta + { + private readonly string _parentId; + public Branch(string typeId, string parentId) : base(typeId) { _parentId = parentId; } + public string BaboonAdtTypeIdentifier() => _parentId; + public Type BaboonAdtType() => typeof(Model); + } + + private class LegacyConversion : AbstractConversion + { + private readonly string _source; + private readonly object? _result; + private readonly bool _enabled; + public readonly List Calls = new(); + + public LegacyConversion(string source, object? result, bool enabled) + { + _source = source; + _result = result; + _enabled = enabled; + } + + public override string VersionFrom() => "1.0.0"; + public override string VersionTo() => "2.0.0"; + public override string TypeId() { Calls.Add("source"); return _source; } + protected override bool ConversionValidationEnabled() => _enabled; + public void ValidateSource(object? value) => ValidateBaboonType(value); + protected override object? DoConvert(TCtx? context, AbstractBaboonConversions conversions, object? from) where TCtx : default + { + Calls.Add("convert"); + return _result; + } + } + + private sealed class RenamingConversion : LegacyConversion + { + private readonly string _target; + public RenamingConversion(string source, string target, object? result, bool enabled) : base(source, result, enabled) + { + _target = target; + } + + protected override string TargetTypeId() { Calls.Add("target"); return _target; } + } + + [Test] + public void RenamedResultUsesTargetIdAfterConversionWithoutChangingSourceId() + { + var result = new Model("new"); + var conversion = new RenamingConversion("old", "new", result, true); + Assert.That(conversion.Convert(null, new Conversions(), new Model("old")), Is.SameAs(result)); + Assert.That(conversion.Calls, Is.EqualTo(new[] { "convert", "source", "target" })); + Assert.That(((IConversion)conversion).TypeId(), Is.EqualTo("old")); + } + + [Test] + public void WrongSourceStillFailsAfterDoConvertAndBeforeTargetValidation() + { + var conversion = new RenamingConversion("old", "new", new Model("new"), true); + Assert.Throws(() => conversion.Convert(null, new Conversions(), new Model("wrong"))); + Assert.That(conversion.Calls, Is.EqualTo(new[] { "convert", "source" })); + } + + [Test] + public void WrongResultFailsAgainstTargetId() + { + var conversion = new RenamingConversion("old", "new", new Model("old"), true); + var error = Assert.Throws(() => conversion.Convert(null, new Conversions(), new Model("old"))); + Assert.That(error!.Message, Does.Contain("must be new")); + } + + [TestCase(true)] + [TestCase(false)] + public void AdtValuesAcceptParentOrExactBranchIds(bool parentIds) + { + var source = new Branch("old-branch", "old-parent"); + var result = new Branch("new-branch", "new-parent"); + var conversion = new RenamingConversion(parentIds ? "old-parent" : "old-branch", parentIds ? "new-parent" : "new-branch", result, true); + Assert.That(conversion.Convert(null, new Conversions(), source), Is.SameAs(result)); + } + + [Test] + public void AdtResultRejectsUnrelatedParentAndBranch() + { + var conversion = new RenamingConversion("old-parent", "new-parent", new Branch("wrong-branch", "wrong-parent"), true); + Assert.Throws(() => conversion.Convert(null, new Conversions(), new Branch("old-branch", "old-parent"))); + } + + [Test] + public void LegacySubclassKeepsSameIdValidationAndProtectedSourceHelper() + { + var result = new Model("old"); + var conversion = new LegacyConversion("old", result, true); + Assert.That(conversion.Convert(null, new Conversions(), new Model("old")), Is.SameAs(result)); + Assert.Throws(() => conversion.ValidateSource(new Model("new"))); + var invalidLegacy = new LegacyConversion("old", new Model("new"), true); + Assert.Throws(() => invalidLegacy.Convert(null, new Conversions(), new Model("old"))); + var renamed = new RenamingConversion("old", "new", new Model("new"), true); + Assert.Throws(() => renamed.ValidateSource(new Model("new"))); + } + + [Test] + public void DisabledValidationDoesNotResolveEitherTypeId() + { + var result = new Model("wrong"); + var conversion = new RenamingConversion("old", "new", result, false); + Assert.That(conversion.Convert(null, new Conversions(), new Model("wrong")), Is.SameAs(result)); + Assert.That(conversion.Calls, Is.EqualTo(new[] { "convert" })); + } + + [TestCase(null)] + [TestCase("foreign")] + public void NonBaboonResultsDoNotResolveTargetId(object? result) + { + var conversion = new RenamingConversion("old", "new", result, true); + Assert.That(conversion.Convert(null, new Conversions(), new Model("old")), Is.SameAs(result)); + Assert.That(conversion.Calls, Is.EqualTo(new[] { "convert", "source" })); + } + } +} diff --git a/test/cs-stub/BaboonTests/FacadeRegistrationTests.cs b/test/cs-stub/BaboonTests/FacadeRegistrationTests.cs new file mode 100644 index 000000000..77cf8c90c --- /dev/null +++ b/test/cs-stub/BaboonTests/FacadeRegistrationTests.cs @@ -0,0 +1,122 @@ +#nullable enable +using System; +using System.Globalization; +using Baboon.Runtime.Shared; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class FacadeRegistrationTests + { + private static void WithCulture(CultureInfo culture, Action test) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + test(); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + private static BaboonCodecsFacade Registered(string version) + { + var facade = new BaboonCodecsFacade(); + facade.RegisterMeta(new BaboonDomainVersion("test", version), () => throw new AssertionException("metadata was forced")); + return facade; + } + + [Test] + public void MutableCultureSignChangesAreObservedAfterFirstLookup() + { + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + WithCulture(culture, () => + { + var facade = Registered("+1.0.0"); + Assert.That(facade.Latest("test"), Is.EqualTo(new BaboonVersion(1, 0, 0))); + culture.NumberFormat.PositiveSign = "p"; + Assert.Throws(() => facade.Latest("test")); + culture.NumberFormat.PositiveSign = "+"; + Assert.That(facade.Latest("test"), Is.EqualTo(new BaboonVersion(1, 0, 0))); + }); + } + + [TestCase("+1.0.0", "p", null)] + [TestCase("11.0.0", "1", 1)] + public void DistinctReadOnlyCultureFormatsAreObserved(string version, string positiveSign, int? expectedMajor) + { + var first = CultureInfo.ReadOnly((CultureInfo)CultureInfo.InvariantCulture.Clone()); + var changed = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + changed.NumberFormat.PositiveSign = positiveSign; + var second = CultureInfo.ReadOnly(changed); + WithCulture(first, () => + { + var facade = Registered(version); + Assert.That(facade.Latest("test"), Is.EqualTo(BaboonVersion.From(version))); + CultureInfo.CurrentCulture = second; + if (expectedMajor.HasValue) + Assert.That(facade.Latest("test"), Is.EqualTo(new BaboonVersion(expectedMajor.Value, 0, 0))); + else + Assert.Throws(() => facade.Latest("test")); + }); + } + + private sealed class CustomCulture : CultureInfo + { + public readonly NumberFormatInfo ParsingFormat = (NumberFormatInfo)NumberFormatInfo.InvariantInfo.Clone(); + public CustomCulture() : base("") { } + public override NumberFormatInfo NumberFormat + { + get => NumberFormatInfo.InvariantInfo; + set => throw new InvalidOperationException(); + } + public override object? GetFormat(Type? formatType) + => formatType == typeof(NumberFormatInfo) ? ParsingFormat : base.GetFormat(formatType); + } + + [Test] + public void CultureSubclassFormattingRemainsDynamic() + { + var culture = new CustomCulture(); + WithCulture(culture, () => + { + var facade = Registered("+1.0.0"); + Assert.That(facade.Latest("test"), Is.EqualTo(new BaboonVersion(1, 0, 0))); + culture.ParsingFormat.PositiveSign = "p"; + Assert.Throws(() => facade.Latest("test")); + }); + } + + [Test] + public void RegistrationOrdersNumericallyAndKeepsFactoriesLazy() + { + var facade = new BaboonCodecsFacade(); + foreach (var version in new[] { "1.2.0", "1.10.0", "1.3.0", "1.10.0" }) + facade.RegisterMeta(new BaboonDomainVersion("test", version), () => throw new AssertionException("metadata was forced")); + Assert.That(facade.Latest("test"), Is.EqualTo(new BaboonVersion(1, 10, 0))); + var other = new BaboonCodecsFacade(); + other.Register(facade); + Assert.That(other.Latest("test"), Is.EqualTo(facade.Latest("test"))); + } + + [Test] + public void MalformedSingleVersionIsParsedOnLookupNotRegistration() + { + var facade = new BaboonCodecsFacade(); + facade.RegisterMeta(new BaboonDomainVersion("test", "invalid"), () => throw new AssertionException("metadata was forced")); + Assert.Throws(() => facade.Latest("test")); + } + + [Test] + public void PublicDomainVersionRecordWithKeepsValueSemantics() + { + var first = new BaboonDomainVersion("test", "1.0.0"); + Assert.That(first.Version, Is.EqualTo(new BaboonVersion(1, 0, 0))); + var second = first with { DomainVersion = "2.0.0" }; + Assert.That(second.Version, Is.EqualTo(new BaboonVersion(2, 0, 0))); + Assert.That(second, Is.EqualTo(new BaboonDomainVersion("test", "2.0.0"))); + Assert.That(first.Version, Is.EqualTo(new BaboonVersion(1, 0, 0))); + } + } +} diff --git a/test/cs-stub/BaboonTests/IndexConsumptionTests.cs b/test/cs-stub/BaboonTests/IndexConsumptionTests.cs new file mode 100644 index 000000000..165a2cb50 --- /dev/null +++ b/test/cs-stub/BaboonTests/IndexConsumptionTests.cs @@ -0,0 +1,67 @@ +#nullable enable +using System; +using System.IO; +using Baboon.Runtime.Shared; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class IndexConsumptionTests + { + private sealed class TwoEntries : IBaboonBinCodecIndexed + { + public ushort IndexElementsCount(BaboonCodecContext ctx) => 2; + } + + private sealed class NonSeekingStream : MemoryStream + { + public NonSeekingStream(byte[] bytes) : base(bytes) { } + public override bool CanSeek => false; + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public void ConsumesOnlyHeaderAndEntries(bool indexed, bool consumeOnly) + { + using var data = new MemoryStream(); + using (var writer = new BinaryWriter(data, System.Text.Encoding.UTF8, true)) + { + writer.Write((byte)(indexed ? 1 : 0)); + if (indexed) + { + writer.Write((uint)0); + writer.Write((uint)2); + writer.Write((uint)2); + writer.Write((uint)3); + } + writer.Write((byte)0x42); + } + IBaboonBinCodecIndexed codec = new TwoEntries(); + using var reader = new BinaryReader(new NonSeekingStream(data.ToArray())); + var count = consumeOnly ? codec.ConsumeIndex(BaboonCodecContext.Compact, reader) : codec.ReadIndex(BaboonCodecContext.Compact, reader).Count; + Assert.That(count, Is.EqualTo(indexed ? 2 : 0)); + Assert.That(reader.ReadByte(), Is.EqualTo(0x42)); + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsTruncatedPairs(bool consumeOnly) + { + IBaboonBinCodecIndexed codec = new TwoEntries(); + using var reader = new BinaryReader(new MemoryStream(new byte[] { 1, 0 })); + Assert.Throws(() => + { + if (consumeOnly) codec.ConsumeIndex(BaboonCodecContext.Compact, reader); + else codec.ReadIndex(BaboonCodecContext.Compact, reader); + }); + } + } +} diff --git a/test/dart-stub-mcp-overlay/test/dispatch_contract_test.dart b/test/dart-stub-mcp-overlay/test/dispatch_contract_test.dart new file mode 100644 index 000000000..0db011faa --- /dev/null +++ b/test/dart-stub-mcp-overlay/test/dispatch_contract_test.dart @@ -0,0 +1,57 @@ +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:baboon_runtime/baboon_mcp_runtime.dart'; +import 'package:test/test.dart'; + +class DynamicServer extends AbstractBaboonMcpServer { + @override + final tools = [ + const McpToolEntry('tool', BaboonMethodId('S', 'first'), {}), + ]; + @override + McpServerInfo get serverInfo => const McpServerInfo('test', '1'); + @override + String Function(BaboonMethodId, String, int, BaboonCodecContext) get invokeJsonFn => + (method, data, ctx, codecCtx) { + if (data == '{"fail":true}') throw const BaboonWiringException(NoMatchingMethod(BaboonMethodId('S', 'missing'))); + return '${method.methodName}:$ctx:$data'; + }; +} + +void main() { + final codecCtx = BaboonCodecContext.defaultCtx; + for (final muxed in [false, true]) { + test('protocol and routing contract muxed=$muxed', () { + final member = DynamicServer(); + final IBaboonMcpServer server = muxed + ? AbstractMcpMuxer(const McpServerInfo('merged', '1'), [member]) + : member; + final session = McpSession(); + JsonRpcResponse? call(String method, Object? params) => + server.handle(JsonRpcRequest(7, method, params), session, 42, codecCtx); + expect(call('tools/list', null)!.error!.code, jsonRpcErrorInvalidRequest); + expect(call('initialize', {})!.error!.code, jsonRpcErrorInvalidParams); + expect(session.initialized, false); + expect(call('initialize', {'protocolVersion': 'anything'})!.id, 7); + expect(call('notifications/initialized', null), null); + expect(call('tools/list', null)!.result['tools'][0]['name'], 'tool'); + final circular = []; + circular.add(circular); + expect(call('tools/call', {'name': 'missing', 'arguments': circular})!.error!.code, jsonRpcErrorInvalidParams); + expect(() => call('tools/call', {'name': 'tool', 'arguments': circular}), throwsA(isA())); + final result = call('tools/call', {'name': 'tool'})!; + expect(result.result['content'][0]['text'], 'first:42:{}'); + expect(result.result['isError'], false); + expect(call('tools/call', {'name': 'tool', 'arguments': {'fail': true}})!.result['isError'], true); + expect(call('unknown', null)!.error!.code, jsonRpcErrorMethodNotFound); + }); + } + test('standalone lookup observes mutations and selects last duplicate', () { + final server = DynamicServer(); + final session = McpSession()..initialized = true; + server.tools.add(const McpToolEntry('tool', BaboonMethodId('S', 'last'), {})); + final result = server.handle(const JsonRpcRequest(1, 'tools/call', {'name': 'tool'}), session, 0, codecCtx)!; + expect(result.result['content'][0]['text'], 'last:0:{}'); + server.tools.clear(); + expect(server.handle(const JsonRpcRequest(1, 'tools/call', {'name': 'tool'}), session, 0, codecCtx)!.error!.code, jsonRpcErrorInvalidParams); + }); +} diff --git a/test/dt-stub/test/runtime/any_field_helpers_test.dart b/test/dt-stub/test/runtime/any_field_helpers_test.dart new file mode 100644 index 000000000..150487b78 --- /dev/null +++ b/test/dt-stub/test/runtime/any_field_helpers_test.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; +import 'package:baboon_runtime/baboon_any_opaque.dart'; +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:test/test.dart'; + +void main() { + final ctx = BaboonCodecContext.defaultCtx; + final metas = [ + AnyMeta(7, 'domain', '1.0.0', 'type'), + AnyMeta(3, null, '1.0.0', 'type'), + AnyMeta(1, null, null, 'type'), + AnyMeta(6, 'domain', '1.0.0', null), + AnyMeta(2, null, '1.0.0', null), + AnyMeta(0, null, null, null), + ]; + test('shared helpers preserve all six native envelope kinds and nullable JSON', () { + for (final meta in metas) { + final json = AnyOpaqueJson(meta, null); + final encoded = encodeAnyJsonField(ctx, meta.kind, null, null, null, json); + expect(decodeAnyJsonField(meta.kind, encoded), json); + final binary = AnyOpaqueUeba(meta, Uint8List.fromList([0, 1, 255])); + final writer = BaboonBinWriter(); + encodeAnyUebaField(ctx, writer, meta.kind, null, null, null, binary); + expect(decodeAnyUebaField(BaboonBinReader(writer.toBytes()), meta.kind), binary); + } + }); + test('shared helpers preserve missing facade and malformed framing failures', () { + final meta = metas.last; + expect(() => encodeAnyJsonField(ctx, 0, null, null, null, AnyOpaqueUeba(meta, Uint8List(0))), throwsA(isA())); + expect(() => encodeAnyUebaField(ctx, BaboonBinWriter(), 0, null, null, null, AnyOpaqueJson(meta, null)), throwsA(isA())); + expect(() => decodeAnyJsonField(0, {r'$ak': 0}), throwsA(isA())); + final writer = BaboonBinWriter()..writeI32(-1); + expect(() => decodeAnyUebaField(BaboonBinReader(writer.toBytes()), 0), throwsA(isA())); + expect(() => encodeAnyJsonField(ctx, 1, null, null, null, AnyOpaqueJson(meta, null)), throwsA(isA())); + }); +} diff --git a/test/dt-stub/test/runtime/binary_storage_test.dart b/test/dt-stub/test/runtime/binary_storage_test.dart new file mode 100644 index 000000000..55676bf01 --- /dev/null +++ b/test/dt-stub/test/runtime/binary_storage_test.dart @@ -0,0 +1,58 @@ +import 'dart:typed_data'; +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:test/test.dart'; + +void main() { + test('zero-capacity writers grow and writer append does not expose storage', () { + final source = BaboonBinWriter(0)..writeI32(42); + final target = BaboonBinWriter(1)..writeU8(99); + target.writeBuffer(source); + source.writeU8(77); + expect(target.toBytes(), [99, 42, 0, 0, 0]); + target.writeBuffer(target); + expect(target.toBytes(), [99, 42, 0, 0, 0, 99, 42, 0, 0, 0]); + }); + + test('primitive writes preserve little-endian bytes across capacity growth', () { + final writer = BaboonBinWriter(1); + writer.writeI8(-128); + writer.writeU16(0xffff); + writer.writeI16(-32768); + writer.writeU32(0xffffffff); + writer.writeI32(-2147483648); + writer.writeU64(-1); + writer.writeI64(-9223372036854775808); + writer.writeF32(-0.0); + writer.writeF64(double.infinity); + expect(writer.toBytes(), [ + 0x80, 0xff, 0xff, 0, 0x80, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0x80, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0, 0, 0, 0, 0, 0, 0, 0x80, + 0, 0, 0, 0x80, 0, 0, 0, 0, 0, 0, 0xf0, 0x7f, + ]); + final snapshot = writer.toBytes(); + snapshot[0] = 0; + writer.writeU8(42); + expect(writer.toBytes()[0], 0x80); + expect(snapshot.length, 41); + }); + + test('reader extractions own their bytes and honor nonzero-offset input', () { + final backing = Uint8List.fromList([99, 2, 0, 0, 0, 10, 20, 30, 99]); + final reader = BaboonBinReader(Uint8List.sublistView(backing, 1, 8)); + final bytes = reader.readBytes(); + final tail = reader.readNBytes(1); + backing[5] = 88; + bytes[1] = 77; + expect(bytes, [10, 77]); + expect(backing[6], 20); + expect(tail, [30]); + expect(reader.position, 7); + }); + + test('malformed raw extraction leaves the cursor at its original position', () { + final reader = BaboonBinReader(Uint8List.fromList([1])); + expect(() => reader.readNBytes(2), throwsRangeError); + expect(reader.position, 0); + }); +} diff --git a/test/dt-stub/test/runtime/pair_hash_test.dart b/test/dt-stub/test/runtime/pair_hash_test.dart new file mode 100644 index 000000000..e542c11cb --- /dev/null +++ b/test/dt-stub/test/runtime/pair_hash_test.dart @@ -0,0 +1,19 @@ +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:test/test.dart'; + +void main() { + test('fixed-pair hashing preserves the iterable hash algorithm', () { + final values = [null, 0, -1, 9223372036854775807, '', 'x', true]; + for (final left in values) { + for (final right in values) { + expect(Object.hash(left, right), Object.hashAll([left, right])); + } + } + const method = BaboonMethodId('service', 'method'); + expect(method.hashCode, Object.hashAll(['service', 'method'])); + final data = {'one': [1, 2], 'two': {'a', 'b'}}; + expect(baboonDeepHashCode(data), Object.hashAllUnordered(data.entries.map( + (entry) => Object.hashAll([baboonDeepHashCode(entry.key), baboonDeepHashCode(entry.value)]), + ))); + }); +} diff --git a/test/java-stub-mcp-overlay/src/test/java/mcp/McpMetadataOwnershipTest.java b/test/java-stub-mcp-overlay/src/test/java/mcp/McpMetadataOwnershipTest.java new file mode 100644 index 000000000..fff02aa1c --- /dev/null +++ b/test/java-stub-mcp-overlay/src/test/java/mcp/McpMetadataOwnershipTest.java @@ -0,0 +1,84 @@ +package mcp; + +import baboon.runtime.shared.*; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import java.util.Map; +import mcp.stub.McpToolsMcpServer; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class McpMetadataOwnershipTest { + private final JsonNodeFactory json = JsonNodeFactory.instance; + + private JsonRpcRequest call(String name) { + return new JsonRpcRequest(json.numberNode(7), "tools/call", json.objectNode().put("name", name)); + } + + @Test + void generatedSchemasRemainIsolatedAcrossAccessorsResponsesAndServers() { + var server = new McpToolsMcpServer((method, data, ctx, codecCtx) -> BaboonEither.right(ctx + ":" + method.methodName())); + var other = new McpToolsMcpServer((method, data, ctx, codecCtx) -> BaboonEither.right(data)); + var first = server.tools(); + var second = server.tools(); + assertEquals(7, first.size()); + assertNotSame(first.get(0).inputSchema, second.get(0).inputSchema); + assertThrows(UnsupportedOperationException.class, () -> first.add(first.get(0))); + ((ObjectNode) first.get(0).inputSchema).put("mutated", true); + ((ObjectNode) first.get(0).inputSchema.get("properties")).put("nestedMutation", true); + assertFalse(second.get(0).inputSchema.has("mutated")); + assertFalse(second.get(0).inputSchema.get("properties").has("nestedMutation")); + assertFalse(server.tools().get(0).inputSchema.has("mutated")); + assertFalse(other.tools().get(0).inputSchema.has("mutated")); + var session = new McpSession(); + session.initialized = true; + var request = new JsonRpcRequest(json.numberNode(8), "tools/list", null); + var response = server.handle(request, session, "ctx", BaboonCodecContext.Default); + ((ObjectNode) response.result.get("tools").get(0).get("inputSchema")).put("responseMutation", true); + assertFalse(server.handle(request, session, "ctx", BaboonCodecContext.Default).result.get("tools").get(0).get("inputSchema").has("responseMutation")); + var invoked = server.handle(call("McpTools_ping"), session, "ctx", BaboonCodecContext.Default); + assertNull(invoked.error); + assertEquals("ctx:ping", invoked.result.get("content").get(0).get("text").textValue()); + assertNotNull(server.handle(call("McpTools_ping"), new McpSession(), "ctx", BaboonCodecContext.Default).error); + } + + @Test + void generatedLookupIsOneImmutableSchemaFreeIndex() throws Exception { + var server = new McpToolsMcpServer((method, data, ctx, codecCtx) -> BaboonEither.right(data)); + var accessor = server.getClass().getDeclaredMethod("toolMethods"); + accessor.setAccessible(true); + Map index = (Map) accessor.invoke(server); + assertSame(index, accessor.invoke(server)); + assertEquals(7, index.size()); + assertTrue(index.values().stream().allMatch(value -> value instanceof BaboonMethodId)); + assertThrows(UnsupportedOperationException.class, index::clear); + } + + @Test + void extensibleRuntimeRetainsDynamicToolsDuplicateLastWinsAndNullMethods() { + class DynamicServer extends AbstractBaboonMcpServer { + int reads; + List current = List.of(new McpToolEntry("first", new BaboonMethodId("service", "a"), json.objectNode())); + @Override public McpServerInfo serverInfo() { return new McpServerInfo("dynamic", "1"); } + @Override public List tools() { reads++; return current; } + @Override protected BaboonEither invokeJson(BaboonMethodId method, String data, String ctx, BaboonCodecContext codecCtx) { + return BaboonEither.right(method == null ? "null-method" : method.methodName()); + } + } + var server = new DynamicServer(); + var session = new McpSession(); + session.initialized = true; + assertNull(server.handle(call("first"), session, "ctx", BaboonCodecContext.Default).error); + assertEquals(1, server.reads); + server.current = List.of( + new McpToolEntry("second", new BaboonMethodId("service", "a"), json.objectNode()), + new McpToolEntry("second", new BaboonMethodId("service", "b"), json.objectNode()) + ); + var response = server.handle(call("second"), session, "ctx", BaboonCodecContext.Default); + assertEquals("b", response.result.get("content").get(0).get("text").textValue()); + assertNotNull(server.handle(call("first"), session, "ctx", BaboonCodecContext.Default).error); + server.current = List.of(new McpToolEntry("null", null, json.objectNode())); + assertEquals("null-method", server.handle(call("null"), session, "ctx", BaboonCodecContext.Default).result.get("content").get(0).get("text").textValue()); + } +} diff --git a/test/jv-stub/src/test/java/runtime/AnyEnvelopeCodecTest.java b/test/jv-stub/src/test/java/runtime/AnyEnvelopeCodecTest.java new file mode 100644 index 000000000..33147830d --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/AnyEnvelopeCodecTest.java @@ -0,0 +1,39 @@ +package runtime; + +import baboon.runtime.shared.*; +import baboon.runtime.shared.BaboonAnyOpaque.*; +import com.fasterxml.jackson.databind.node.NullNode; +import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import static org.junit.jupiter.api.Assertions.*; + +public class AnyEnvelopeCodecTest { + @Test + public void nativeFormatsRoundTripAllKindsAndLeaveTheTrailer() throws Exception { + for (byte kind : new byte[] {0, 1, 2, 3, 6, 7}) { + var meta = new AnyMeta(kind, (kind & 4) != 0 ? "domain" : null, (kind & 2) != 0 ? "1.0.0" : null, (kind & 1) != 0 ? "type" : null); + var json = new AnyOpaqueJson(meta, NullNode.getInstance()); + assertEquals(json, BaboonAnyJsonCodec.decode(kind, BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, kind, null, null, null, json))); + var binary = new AnyOpaqueUeba(meta, new byte[] {(byte) 0xaa, (byte) 0xbb}); + var buffer = new ByteArrayOutputStream(); + var writer = new LEDataOutputStream(buffer); + BaboonAnyBinCodec.encode(BaboonCodecContext.Compact, writer, kind, null, null, null, binary); + writer.writeByte(42); + if (kind == 0) assertArrayEquals(new byte[] {7,0,0,0,1,0,0,0,0,(byte) 0xaa,(byte) 0xbb,42}, buffer.toByteArray()); + var reader = new LEDataInputStream(new ByteArrayInputStream(buffer.toByteArray())); + assertEquals(binary, BaboonAnyBinCodec.decode(reader, kind)); + assertEquals(42, reader.readByte()); + } + } + + @Test + public void consumesMetadataExtensionsAndRejectsTruncatedPayloads() throws Exception { + var extended = new LEDataInputStream(new ByteArrayInputStream(new byte[] {8,0,0,0,3,0,0,0,0,17,34,51,42})); + assertArrayEquals(new byte[] {51}, BaboonAnyBinCodec.decode(extended, (byte) 0).bytes()); + assertEquals(42, extended.readByte()); + var truncated = new LEDataInputStream(new ByteArrayInputStream(new byte[] {8,0,0,0,3,0,0,0,0,17})); + var failure = assertThrows(BaboonCodecException.DecoderFailure.class, () -> BaboonAnyBinCodec.decode(truncated, (byte) 0)); + assertTrue(failure.getMessage().contains("short read while skipping meta-extension bytes")); + } +} diff --git a/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java b/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java new file mode 100644 index 000000000..10c314f4a --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java @@ -0,0 +1,61 @@ +package runtime; + +import baboon.runtime.shared.BaboonBinCodec; +import baboon.runtime.shared.BaboonCodecContext; +import baboon.runtime.shared.LEDataInputStream; +import baboon.runtime.shared.LEDataOutputStream; +import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import static org.junit.jupiter.api.Assertions.*; + +public class IndexConsumptionTest { + @Test + public void streamCopyOverloadsAreObservableToPublicSubclasses() throws Exception { + class ObservingOutput extends LEDataOutputStream { + int wholeWrites; + int sliceWrites; + ObservingOutput() { super(new ByteArrayOutputStream()); } + @Override public void write(byte[] bytes) { wholeWrites++; } + @Override public void write(byte[] bytes, int offset, int length) { sliceWrites++; } + } + var buffer = new ByteArrayOutputStream(); + buffer.write(42); + var output = new ObservingOutput(); + output.write(buffer.toByteArray()); + assertEquals(1, output.wholeWrites); + assertEquals(0, output.sliceWrites); + buffer.writeTo(output); + assertEquals(1, output.wholeWrites); + assertEquals(1, output.sliceWrites); + } + + @Test + public void materializationConsumesOnlyHeaderAndExpectedPairs() throws Exception { + byte[] indexed = {1, 0,0,0,0, 2,0,0,0, 2,0,0,0, 3,0,0,0, 42}; + for (byte[] bytes : new byte[][] {indexed, {0, 42}}) { + var input = new LEDataInputStream(new ByteArrayInputStream(bytes)); + var entries = BaboonBinCodec.readIndex(BaboonCodecContext.Compact, input, 2); + assertEquals(bytes[0] == 1 ? 2 : 0, entries.size()); + assertEquals(42, input.readByte()); + var countInput = new LEDataInputStream(new ByteArrayInputStream(bytes)); + assertEquals(entries.size(), BaboonBinCodec.consumeIndex(BaboonCodecContext.Compact, countInput, 2)); + assertEquals(42, countInput.readByte()); + } + } + + @Test + public void materializationRetainsTruncationAndNegativeCountBehavior() throws Exception { + var input = new LEDataInputStream(new ByteArrayInputStream(new byte[] {1, 0})); + assertThrows(EOFException.class, () -> BaboonBinCodec.readIndex(BaboonCodecContext.Compact, input, 2)); + var negative = new LEDataInputStream(new ByteArrayInputStream(new byte[] {1, 42})); + assertEquals(0, BaboonBinCodec.readIndex(BaboonCodecContext.Compact, negative, -1).size()); + assertEquals(42, negative.readByte()); + var countInput = new LEDataInputStream(new ByteArrayInputStream(new byte[] {1, 0})); + assertThrows(EOFException.class, () -> BaboonBinCodec.consumeIndex(BaboonCodecContext.Compact, countInput, 2)); + var negativeCount = new LEDataInputStream(new ByteArrayInputStream(new byte[] {1, 42})); + assertEquals(0, BaboonBinCodec.consumeIndex(BaboonCodecContext.Compact, negativeCount, -1)); + assertEquals(42, negativeCount.readByte()); + } +} diff --git a/test/jv-stub/src/test/java/runtime/JsonParsingTest.java b/test/jv-stub/src/test/java/runtime/JsonParsingTest.java new file mode 100644 index 000000000..8b773328f --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/JsonParsingTest.java @@ -0,0 +1,29 @@ +package runtime; + +import baboon.runtime.shared.BaboonCodecContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class JsonParsingTest { + @Test + public void contextReaderPreservesDefaultMapperTreeSemantics() throws Exception { + var mapper = new ObjectMapper(); + for (String text : new String[] { "", " ", "null", "true", "42", "1.234567890123456789", "\"é\"", "[1,null]", "{\"n\":1,\"n\":2}", "{} []" }) { + assertEquals(mapper.readTree(text), BaboonCodecContext.Compact.parseJson(text), text); + assertEquals(mapper.readTree(text), BaboonCodecContext.Indexed.parseJson(text), text); + } + } + + @Test + public void contextReaderPreservesParseErrors() { + for (String text : new String[] { "{", "[1,]", "undefined", "{\"a\":NaN}" }) { + var expected = assertThrows(JsonProcessingException.class, () -> new ObjectMapper().readTree(text)); + var actual = assertThrows(JsonProcessingException.class, () -> BaboonCodecContext.Compact.parseJson(text)); + assertEquals(expected.getClass(), actual.getClass()); + assertEquals(expected.getOriginalMessage(), actual.getOriginalMessage()); + assertEquals(expected.getLocation().getCharOffset(), actual.getLocation().getCharOffset()); + } + } +} diff --git a/test/jv-stub/src/test/java/runtime/MetadataAccessTest.java b/test/jv-stub/src/test/java/runtime/MetadataAccessTest.java new file mode 100644 index 000000000..49901eef1 --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/MetadataAccessTest.java @@ -0,0 +1,117 @@ +package runtime; + +import baboon.runtime.shared.*; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class MetadataAccessTest { + public static class External implements BaboonGenerated { + public static String baboonDomainIdentifier = "example"; + public static String baboonDomainVersion = "1.0.0"; + public static String baboonTypeIdentifier = "Branch"; + public static String baboonAdtTypeIdentifier = "Adt"; + public static List baboonSameInVersions = List.of("1.0.0"); + public static Map baboonMinReaderVersions = Map.of("json-additive", "1.0.0"); + } + public static class Branch extends External implements BaboonAdtMemberMeta {} + public static class Missing implements BaboonGenerated {} + public static class Target implements BaboonGeneratedLatest {} + public static class WrongString extends External { public static Object baboonDomainIdentifier = 7; } + public static class WrongList extends External { public static Object baboonSameInVersions = "not-list"; } + public static class WrongMap extends External { public static Object baboonMinReaderVersions = "not-map"; } + public static class NonStatic extends External { public String baboonDomainIdentifier = "instance"; } + + @BeforeEach + void resetExternalMetadata() { + External.baboonDomainIdentifier = "example"; + External.baboonDomainVersion = "1.0.0"; + External.baboonSameInVersions = List.of("1.0.0"); + External.baboonMinReaderVersions = Map.of("json-additive", "1.0.0"); + } + + @Test + void mutableExternalMetadataIsReadOnEveryCall() { + External value = new External(); + assertEquals("1.0.0", BaboonExt.domainVersion(value).domainVersion()); + assertEquals("1.0.0", BaboonTypeMeta.from(value, External.class).domainVersion()); + External.baboonDomainVersion = "2.0.0"; + External.baboonSameInVersions = List.of("1.5.0", "2.0.0"); + External.baboonMinReaderVersions = Map.of("json-additive", "1.0.0"); + assertEquals("2.0.0", BaboonExt.domainVersion(value).domainVersion()); + assertEquals("1.5.0", BaboonExt.baboonUnmodifiedSinceVersion(value)); + BaboonTypeMeta meta = BaboonTypeMeta.from(value, External.class); + assertEquals("2.0.0", meta.domainVersion()); + assertEquals("1.5.0", meta.domainVersionMinCompat()); + assertEquals("1.0.0", meta.domainVersionReadableMin()); + } + + @Test + void declaredAdtTypeStillControlsEnvelopeIdentity() { + Branch value = new Branch(); + assertEquals("Branch", BaboonTypeMeta.from(value, Branch.class).typeIdentifier()); + assertEquals("Adt", BaboonTypeMeta.from(value, BaboonAdtMemberMeta.class).typeIdentifier()); + assertEquals("Branch", BaboonTypeMeta.from(value, null).typeIdentifier()); + } + + @Test + void malformedMetadataPreservesFailureCategoriesAndDiagnostics() { + BaboonException missing = assertThrows(BaboonException.class, () -> BaboonExt.domainVersion(new Missing())); + assertEquals("Type " + Missing.class.getName() + " is missing static field 'baboonDomainIdentifier'", missing.getMessage()); + assertInstanceOf(NoSuchFieldException.class, missing.getCause()); + BaboonException wrong = assertThrows(BaboonException.class, () -> BaboonExt.domainVersion(new WrongString())); + assertEquals("Type " + WrongString.class.getName() + " field 'baboonDomainIdentifier' is not a String", wrong.getMessage()); + assertThrows(ClassCastException.class, () -> BaboonExt.baboonUnmodifiedSinceVersion(new WrongList())); + assertThrows(ClassCastException.class, () -> BaboonTypeMeta.from(new WrongMap(), WrongMap.class)); + assertThrows(NullPointerException.class, () -> BaboonExt.domainVersion(new NonStatic())); + External.baboonSameInVersions = List.of(); + assertThrows(IndexOutOfBoundsException.class, () -> BaboonTypeMeta.from(new External(), External.class)); + External.baboonSameInVersions = List.of("1.0.0"); + External.baboonMinReaderVersions = Map.of(); + assertThrows(BaboonException.class, () -> BaboonTypeMeta.from(new External(), External.class)); + } + + @Test + void conversionRetainsItsEitherBoundaryAndUncheckedTypeFailures() { + BaboonCodecsFacade facade = new BaboonCodecsFacade(); + var failure = facade.convert(new Missing(), Missing.class, Target.class); + var left = assertInstanceOf(BaboonEither.Left.class, failure); + var error = assertInstanceOf(BaboonCodecException.ConverterFailure.class, left.value()); + assertEquals("Cannot read baboon domain metadata from " + Missing.class.getName(), error.getMessage()); + assertInstanceOf(NoSuchFieldException.class, error.getCause()); + assertThrows(ClassCastException.class, () -> facade.convert(new WrongString(), WrongString.class, Target.class)); + } + + @Test + void sameBinaryNameInDistinctClassLoadersDoesNotShareMetadata() throws Exception { + Class first = isolatedExternal(); + Class second = isolatedExternal(); + first.getField("baboonDomainIdentifier").set(null, "first"); + second.getField("baboonDomainIdentifier").set(null, "second"); + assertNotSame(first, second); + assertEquals("first", BaboonExt.domainVersion((BaboonGenerated) first.getConstructor().newInstance()).domainIdentifier()); + assertEquals("second", BaboonExt.domainVersion((BaboonGenerated) second.getConstructor().newInstance()).domainIdentifier()); + } + + private static Class isolatedExternal() throws ClassNotFoundException { + ClassLoader loader = new ClassLoader(MetadataAccessTest.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(External.class.getName())) return super.loadClass(name, resolve); + try (var bytes = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (bytes == null) throw new ClassNotFoundException(name); + byte[] code = bytes.readAllBytes(); + Class loaded = defineClass(name, code, 0, code.length); + if (resolve) resolveClass(loaded); + return loaded; + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + }; + return loader.loadClass(External.class.getName()); + } +} diff --git a/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpProtocolOwnershipTest.kt b/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpProtocolOwnershipTest.kt new file mode 100644 index 000000000..3596574e6 --- /dev/null +++ b/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpProtocolOwnershipTest.kt @@ -0,0 +1,57 @@ +package mcp + +import baboon.runtime.shared.* +import kotlinx.serialization.json.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class McpProtocolOwnershipTest { + private val info = McpServerInfo("test", "1") + private val method = BaboonMethodId("service", "call") + private fun tool(name: String) = McpToolEntry(name, method, buildJsonObject { }) + private inner class Server : AbstractBaboonMcpServer() { + override val serverInfo = info + var current = listOf(tool("first")) + override val tools get() = current + override fun invokeJson(method: BaboonMethodId, data: String, ctx: String, codecCtx: BaboonCodecContext): Either = + if (ctx == "fail") Either.Left(BaboonWiringError.NoMatchingMethod(method)) else Either.Right("$ctx:$data") + override fun describeWiringError(e: BaboonWiringError) = "custom-server-error" + } + private fun request(method: String, params: JsonElement?) = JsonRpcRequest(JsonPrimitive(7), method, params) + private fun call(name: String) = request("tools/call", buildJsonObject { put("name", JsonPrimitive(name)) }) + private val initialize = request("initialize", buildJsonObject { put("protocolVersion", JsonPrimitive("ignored")) }) + + @Test + fun standaloneAndMuxRetainSessionsIdsAndSuccessTranscripts() { + val server = Server() + val mux = AbstractMcpMuxer(info, server) + val a = McpSession() + val b = McpSession() + val requests = listOf( + call("first"), request("tools/list", null), request("initialize", null), initialize, + request("notifications/initialized", null), request("tools/list", null), call("first"), + call("absent"), request("tools/call", null), request("unknown", null), + ) + for (req in requests) { + assertEquals(server.handle(req, a, "ctx", BaboonCodecContext.Compact), mux.handle(req, b, "ctx", BaboonCodecContext.Compact)) + assertEquals(a.initialized, b.initialized) + } + val failure = server.handle(call("first"), a, "fail", BaboonCodecContext.Compact)!! + assertTrue(failure.result!!.jsonObject["isError"]!!.jsonPrimitive.boolean) + assertEquals("custom-server-error", failure.result!!.jsonObject["content"]!!.jsonArray[0].jsonObject["text"]!!.jsonPrimitive.content) + assertNotNull(server.handle(call("first"), McpSession(), "ctx", BaboonCodecContext.Compact)!!.error) + } + + @Test + fun standaloneToolsRemainDynamicAndMuxRetainsRegistrationSnapshot() { + val server = Server() + val mux = AbstractMcpMuxer(info, server) + val session = McpSession().also { it.initialized = true } + server.current = listOf(tool("second")) + assertNull(server.handle(call("second"), session, "ctx", BaboonCodecContext.Compact)!!.error) + assertNotNull(server.handle(call("first"), session, "ctx", BaboonCodecContext.Compact)!!.error) + assertNull(mux.handle(call("first"), session, "ctx", BaboonCodecContext.Compact)!!.error) + assertNotNull(mux.handle(call("second"), session, "ctx", BaboonCodecContext.Compact)!!.error) + assertThrows(BaboonMcpWiringException::class.java) { mux.register(Server()) } + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt new file mode 100644 index 000000000..f7943fa81 --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt @@ -0,0 +1,61 @@ +package runtime + +import baboon.runtime.shared.* +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AnyEnvelopeCodecTest { + private val meta = AnyMeta(0, null, null, null) + private val payload = byteArrayOf(1, 2) + + private fun encode(value: AnyOpaque, kind: Byte): ByteArray { + val writer = BaboonBinaryWriter() + BaboonAnyBinCodec.encode(BaboonCodecContext.Compact, writer, kind, null, null, null, value) + return writer.toByteArray() + } + + private fun decode(bytes: ByteArray, kind: Byte): AnyOpaqueUeba = + BaboonAnyBinCodec.decode(BaboonBinaryReader(bytes), kind) + + @Test + fun binaryEnvelopeRetainsExactBytes() { + val expected = byteArrayOf(7, 0, 0, 0, 1, 0, 0, 0, 0, 1, 2) + assertContentEquals(expected, encode(AnyOpaqueUeba(meta, payload), 0)) + assertEquals(AnyOpaqueUeba(meta, payload), decode(expected, 0)) + } + + @Test + fun binaryEnvelopeSkipsFutureMetadataExtensions() { + val extended = byteArrayOf(9, 0, 0, 0, 3, 0, 0, 0, 0, 42, 43, 1, 2) + assertEquals(AnyOpaqueUeba(meta, payload), decode(extended, 0)) + } + + @Test + fun jsonEnvelopeRetainsContentAndChecksKind() { + val content = JsonObject(mapOf("x" to JsonPrimitive(1))) + val value = AnyOpaqueJson(meta, content) + val expected = JsonObject(mapOf("${'$'}ak" to JsonPrimitive(0), "${'$'}c" to content)) + assertEquals(expected, BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0, null, null, null, value)) + assertEquals(value, BaboonAnyJsonCodec.decode(0, expected)) + assertFailsWith { BaboonAnyJsonCodec.decode(1, expected) } + assertFailsWith { + BaboonAnyJsonCodec.decode(0, JsonObject(mapOf("${'$'}ak" to JsonPrimitive(0)))) + } + } + + @Test + fun encodingRetainsKindAndFacadeRequirements() { + val binary = AnyOpaqueUeba(meta, payload) + assertFailsWith { encode(binary, 1) } + assertFailsWith { + BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0, null, null, null, binary) + } + assertFailsWith { + encode(AnyOpaqueJson(meta, JsonPrimitive(1)), 0) + } + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/BinaryWriterTransferTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/BinaryWriterTransferTest.kt new file mode 100644 index 000000000..f7c20d56a --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/BinaryWriterTransferTest.kt @@ -0,0 +1,40 @@ +package runtime + +import baboon.runtime.shared.BaboonBinaryWriter +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BinaryWriterTransferTest { + @Test + fun transferAppendsOnlyWrittenBytesAndRetainsIndependentOwnership() { + val source = BaboonBinaryWriter() + val destination = BaboonBinaryWriter() + source.write(byteArrayOf(1, 2, 3)) + destination.writeByte(9) + source.writeTo(destination) + assertArrayEquals(byteArrayOf(9, 1, 2, 3), destination.toByteArray()) + assertEquals(3, source.size()) + source.writeByte(4) + destination.writeByte(5) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), source.toByteArray()) + assertArrayEquals(byteArrayOf(9, 1, 2, 3, 5), destination.toByteArray()) + val snapshot = destination.toByteArray() + snapshot[0] = 0 + assertEquals(9.toByte(), destination.toByteArray()[0]) + } + + @Test + fun transferHandlesEmptyBuffersGrowthAndSelfAppend() { + val source = BaboonBinaryWriter() + val destination = BaboonBinaryWriter() + source.writeTo(destination) + assertEquals(0, destination.size()) + val payload = ByteArray(1025) { it.toByte() } + source.write(payload) + source.writeTo(destination) + assertArrayEquals(payload, destination.toByteArray()) + destination.writeTo(destination) + assertArrayEquals(payload + payload, destination.toByteArray()) + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/CivilDateFormattingTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/CivilDateFormattingTest.kt new file mode 100644 index 000000000..d4585502e --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/CivilDateFormattingTest.kt @@ -0,0 +1,43 @@ +package runtime + +import baboon.runtime.shared.BaboonIdentifierRepr +import baboon.runtime.shared.BaboonOffsetDateTime +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class CivilDateFormattingTest { + @Test + fun calendarBoundariesMatchJvmCalendar() { + val utc = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) + val offset = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSxxx") + val instants = listOf( + "0001-01-02T00:00:00Z", + "1900-03-01T00:00:00Z", + "1969-12-31T23:59:59Z", + "1970-01-01T00:00:00Z", + "2000-02-29T23:59:59.999Z", + "2024-02-29T00:00:00.001Z", + "9999-12-30T23:59:59.999Z", + ) + for (text in instants) { + val reference = java.time.Instant.parse(text) + val millis = reference.toEpochMilli() + assertEquals(utc.format(reference), BaboonIdentifierRepr.tsuToString(Instant.fromEpochMilliseconds(millis))) + for (seconds in listOf(-12600, 0, 7200)) { + val expected = offset.format(reference.atOffset(ZoneOffset.ofTotalSeconds(seconds))) + assertEquals(expected, BaboonOffsetDateTime.fromEpochMilliseconds(millis, seconds).toString()) + } + } + } + + @Test + fun preserveExistingNegativeFractionalMillisecondRoundingDifference() { + // This characterization is not a correctness endorsement: changing the + // offset formatter's truncation is a separate wire-visible correction. + assertEquals("1969-12-31T23:59:59.999Z", BaboonIdentifierRepr.tsuToString(Instant.fromEpochMilliseconds(-1))) + assertEquals("1970-01-01T00:00:00.999+00:00", BaboonOffsetDateTime.fromEpochMilliseconds(-1, 0).toString()) + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/CodecVersionSelectionTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/CodecVersionSelectionTest.kt new file mode 100644 index 000000000..2bb30ff20 --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/CodecVersionSelectionTest.kt @@ -0,0 +1,44 @@ +package runtime + +import baboon.runtime.shared.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CodecVersionSelectionTest { + private fun version(value: String) = BaboonDomainVersion("example", value) + private fun meta(writer: String, compatible: String, readable: String) = + BaboonTypeMeta(1, "example", writer, compatible, "Example", readable) + + @Test + fun latestCurrentAndOlderWritersRetainSelectionRules() { + val min = version("1.0.0") + val max = version("3.0.0") + for (exact in listOf(false, true)) { + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(meta("3.0.0", "3.0.0", "3.0.0"), min, max, exact, false)) + assertEquals(BaboonCodecVersionSelection.Compatible(version("2.0.0")), + BaboonCodecVersionSelection.select(meta("2.0.0", "2.0.0", "2.0.0"), min, max, exact, false)) + assertEquals(BaboonCodecVersionSelection.Compatible(min), + BaboonCodecVersionSelection.select(meta("0.5.0", "0.5.0", "0.5.0"), min, max, exact, false)) + } + assertEquals(BaboonCodecVersionSelection.Exact(min), + BaboonCodecVersionSelection.select(meta("1.0.0", "1.0.0", "1.0.0"), min, min, false, false)) + } + + @Test + fun forwardBoundsRespectExactAndTolerantPolicies() { + val min = version("1.0.0") + val max = version("3.0.0") + val forward = meta("4.0.0", "4.0.0", "2.0.0") + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(forward, min, max, false, true)) + assertEquals(BaboonCodecVersionSelection.UnsupportedForward(version("4.0.0")), + BaboonCodecVersionSelection.select(forward, min, max, false, false)) + assertEquals(BaboonCodecVersionSelection.Unsupported(version("4.0.0")), + BaboonCodecVersionSelection.select(forward, min, max, true, true)) + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(meta("4.0.0", "3.0.0", ""), min, max, false, false)) + assertEquals(BaboonCodecVersionSelection.UnsupportedForward(version("4.0.0")), + BaboonCodecVersionSelection.select(meta("4.0.0", "", ""), min, max, false, true)) + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/ConversionValidationTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/ConversionValidationTest.kt new file mode 100644 index 000000000..f77002b12 --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/ConversionValidationTest.kt @@ -0,0 +1,85 @@ +package runtime + +import baboon.runtime.shared.* +import kotlin.test.* + +class ConversionValidationTest { + private open class Value(override val baboonTypeIdentifier: String) : BaboonGenerated { + override val baboonDomainVersion = "1.0.0" + override val baboonDomainIdentifier = "test" + override val baboonSameInVersions = listOf("1.0.0") + override val baboonForwardReadable = emptyMap() + override val baboonMinReaderVersions = emptyMap() + } + private class Branch(id: String, override val baboonAdtTypeIdentifier: String) : Value(id), BaboonAdtMemberMeta { + override val baboonAdtType: kotlin.reflect.KClass<*> = Value::class + } + private val conversions = object : AbstractBaboonConversions() { + override val versionsFrom = listOf("1.0.0") + override val versionTo = "2.0.0" + } + private open class Legacy(override val typeId: String, private val result: Value) : + AbstractConversion(Value::class, Value::class) { + var calls = 0 + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override fun doConvert(context: C?, conversions: AbstractBaboonConversions, from: Value): Value { + calls++ + return result + } + } + private class Renamed(source: String, private val target: String, result: Value) : Legacy(source, result) { + var targetReads = 0 + override val targetTypeId: String get() { targetReads++; return target } + } + + @Test fun legacyDefaultRetainsSameIdentifierValidation() { + val good = Legacy("same", Value("same")) + assertEquals("same", good.convert(null, conversions, Value("same")).baboonTypeIdentifier) + val wrongSource = Legacy("same", Value("same")) + assertFailsWith { wrongSource.convert(null, conversions, Value("wrong")) } + assertEquals(0, wrongSource.calls) + val wrongResult = Legacy("same", Value("wrong")) + assertFailsWith { wrongResult.convert(null, conversions, Value("same")) } + assertEquals(1, wrongResult.calls) + } + + @Test fun renamedResultUsesTargetIdentifierWithoutEagerHook() { + val conversion = Renamed("old", "new", Value("new")) + assertEquals(0, conversion.targetReads) + assertFailsWith { conversion.convert(null, conversions, Value("wrong")) } + assertEquals(0, conversion.calls) + assertEquals(0, conversion.targetReads) + assertEquals("new", conversion.convert(null, conversions, Value("old")).baboonTypeIdentifier) + assertEquals(1, conversion.calls) + assertTrue(conversion.targetReads > 0) + } + + @Test fun renamedResultRejectsSourceOrUnrelatedIdentifier() { + for (id in listOf("old", "unrelated")) { + val conversion = Renamed("old", "new", Value(id)) + assertFailsWith { conversion.convert(null, conversions, Value("old")) } + assertEquals(1, conversion.calls) + } + } + + @Test fun adtValidationAcceptsParentOrExactBranchAndRejectsNeither() { + val parent = Renamed("oldParent", "newParent", Branch("newBranch", "newParent")) + assertTrue(parent.convert(null, conversions, Branch("oldBranch", "oldParent")) is Branch) + val exact = Renamed("oldBranch", "newBranch", Branch("newBranch", "otherParent")) + assertTrue(exact.convert(null, conversions, Branch("oldBranch", "otherParent")) is Branch) + val wrong = Renamed("oldParent", "newParent", Branch("wrongBranch", "wrongParent")) + assertFailsWith { wrong.convert(null, conversions, Branch("oldBranch", "oldParent")) } + } + + @Test fun foreignValuesDoNotReadMetadataHooks() { + val conversion = object : AbstractConversion(String::class, String::class) { + override val typeId: String get() = error("source metadata must not be read") + override val targetTypeId: String get() = error("target metadata must not be read") + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override fun doConvert(context: C?, conversions: AbstractBaboonConversions, from: String) = from.uppercase() + } + assertEquals("VALUE", conversion.convert(null, conversions, "value")) + } +} diff --git a/test/kt-stub/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt b/test/kt-stub/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt new file mode 100644 index 000000000..e34d38375 --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/AnyEnvelopeCodecTest.kt @@ -0,0 +1,65 @@ +package runtime + +import baboon.runtime.shared.* +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class AnyEnvelopeCodecTest { + private val meta = AnyMeta(0, null, null, null) + private val payload = byteArrayOf(1, 2) + + private fun encode(value: AnyOpaque, kind: Byte): ByteArray { + val buffer = ByteArrayOutputStream() + val writer = LEDataOutputStream(buffer) + BaboonAnyBinCodec.encode(BaboonCodecContext.Compact, writer, kind, null, null, null, value) + writer.flush() + return buffer.toByteArray() + } + + private fun decode(bytes: ByteArray, kind: Byte): AnyOpaqueUeba = + BaboonAnyBinCodec.decode(LEDataInputStream(ByteArrayInputStream(bytes)), kind) + + @Test + fun binaryEnvelopeRetainsExactBytes() { + val expected = byteArrayOf(7, 0, 0, 0, 1, 0, 0, 0, 0, 1, 2) + assertContentEquals(expected, encode(AnyOpaqueUeba(meta, payload), 0)) + assertEquals(AnyOpaqueUeba(meta, payload), decode(expected, 0)) + } + + @Test + fun binaryEnvelopeSkipsFutureMetadataExtensions() { + val extended = byteArrayOf(9, 0, 0, 0, 3, 0, 0, 0, 0, 42, 43, 1, 2) + assertEquals(AnyOpaqueUeba(meta, payload), decode(extended, 0)) + } + + @Test + fun jsonEnvelopeRetainsContentAndChecksKind() { + val content = JsonObject(mapOf("x" to JsonPrimitive(1))) + val value = AnyOpaqueJson(meta, content) + val expected = JsonObject(mapOf("${'$'}ak" to JsonPrimitive(0), "${'$'}c" to content)) + assertEquals(expected, BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0, null, null, null, value)) + assertEquals(value, BaboonAnyJsonCodec.decode(0, expected)) + assertFailsWith { BaboonAnyJsonCodec.decode(1, expected) } + assertFailsWith { + BaboonAnyJsonCodec.decode(0, JsonObject(mapOf("${'$'}ak" to JsonPrimitive(0)))) + } + } + + @Test + fun encodingRetainsKindAndFacadeRequirements() { + val binary = AnyOpaqueUeba(meta, payload) + assertFailsWith { encode(binary, 1) } + assertFailsWith { + BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0, null, null, null, binary) + } + assertFailsWith { + encode(AnyOpaqueJson(meta, JsonPrimitive(1)), 0) + } + } +} diff --git a/test/kt-stub/src/test/kotlin/runtime/CodecVersionSelectionTest.kt b/test/kt-stub/src/test/kotlin/runtime/CodecVersionSelectionTest.kt new file mode 100644 index 000000000..2bb30ff20 --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/CodecVersionSelectionTest.kt @@ -0,0 +1,44 @@ +package runtime + +import baboon.runtime.shared.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CodecVersionSelectionTest { + private fun version(value: String) = BaboonDomainVersion("example", value) + private fun meta(writer: String, compatible: String, readable: String) = + BaboonTypeMeta(1, "example", writer, compatible, "Example", readable) + + @Test + fun latestCurrentAndOlderWritersRetainSelectionRules() { + val min = version("1.0.0") + val max = version("3.0.0") + for (exact in listOf(false, true)) { + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(meta("3.0.0", "3.0.0", "3.0.0"), min, max, exact, false)) + assertEquals(BaboonCodecVersionSelection.Compatible(version("2.0.0")), + BaboonCodecVersionSelection.select(meta("2.0.0", "2.0.0", "2.0.0"), min, max, exact, false)) + assertEquals(BaboonCodecVersionSelection.Compatible(min), + BaboonCodecVersionSelection.select(meta("0.5.0", "0.5.0", "0.5.0"), min, max, exact, false)) + } + assertEquals(BaboonCodecVersionSelection.Exact(min), + BaboonCodecVersionSelection.select(meta("1.0.0", "1.0.0", "1.0.0"), min, min, false, false)) + } + + @Test + fun forwardBoundsRespectExactAndTolerantPolicies() { + val min = version("1.0.0") + val max = version("3.0.0") + val forward = meta("4.0.0", "4.0.0", "2.0.0") + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(forward, min, max, false, true)) + assertEquals(BaboonCodecVersionSelection.UnsupportedForward(version("4.0.0")), + BaboonCodecVersionSelection.select(forward, min, max, false, false)) + assertEquals(BaboonCodecVersionSelection.Unsupported(version("4.0.0")), + BaboonCodecVersionSelection.select(forward, min, max, true, true)) + assertEquals(BaboonCodecVersionSelection.Exact(max), + BaboonCodecVersionSelection.select(meta("4.0.0", "3.0.0", ""), min, max, false, false)) + assertEquals(BaboonCodecVersionSelection.UnsupportedForward(version("4.0.0")), + BaboonCodecVersionSelection.select(meta("4.0.0", "", ""), min, max, false, true)) + } +} diff --git a/test/kt-stub/src/test/kotlin/runtime/ConversionValidationTest.kt b/test/kt-stub/src/test/kotlin/runtime/ConversionValidationTest.kt new file mode 100644 index 000000000..dc42c9636 --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/ConversionValidationTest.kt @@ -0,0 +1,85 @@ +package runtime + +import baboon.runtime.shared.* +import kotlin.test.* + +class ConversionValidationTest { + private open class Value(override val baboonTypeIdentifier: String) : BaboonGenerated { + override val baboonDomainVersion = "1.0.0" + override val baboonDomainIdentifier = "test" + override val baboonSameInVersions = listOf("1.0.0") + override val baboonForwardReadable = emptyMap() + override val baboonMinReaderVersions = emptyMap() + } + private class Branch(id: String, override val baboonAdtTypeIdentifier: String) : Value(id), BaboonAdtMemberMeta { + override val baboonAdtType: Class<*> = Value::class.java + } + private val conversions = object : AbstractBaboonConversions() { + override val versionsFrom = listOf("1.0.0") + override val versionTo = "2.0.0" + } + private open class Legacy(override val typeId: String, private val result: Value) : + AbstractConversion(Value::class.java, Value::class.java) { + var calls = 0 + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override fun doConvert(context: C?, conversions: AbstractBaboonConversions, from: Value): Value { + calls++ + return result + } + } + private class Renamed(source: String, private val target: String, result: Value) : Legacy(source, result) { + var targetReads = 0 + override val targetTypeId: String get() { targetReads++; return target } + } + + @Test fun legacyDefaultRetainsSameIdentifierValidation() { + val good = Legacy("same", Value("same")) + assertEquals("same", good.convert(null, conversions, Value("same")).baboonTypeIdentifier) + val wrongSource = Legacy("same", Value("same")) + assertFailsWith { wrongSource.convert(null, conversions, Value("wrong")) } + assertEquals(0, wrongSource.calls) + val wrongResult = Legacy("same", Value("wrong")) + assertFailsWith { wrongResult.convert(null, conversions, Value("same")) } + assertEquals(1, wrongResult.calls) + } + + @Test fun renamedResultUsesTargetIdentifierWithoutEagerHook() { + val conversion = Renamed("old", "new", Value("new")) + assertEquals(0, conversion.targetReads) + assertFailsWith { conversion.convert(null, conversions, Value("wrong")) } + assertEquals(0, conversion.calls) + assertEquals(0, conversion.targetReads) + assertEquals("new", conversion.convert(null, conversions, Value("old")).baboonTypeIdentifier) + assertEquals(1, conversion.calls) + assertTrue(conversion.targetReads > 0) + } + + @Test fun renamedResultRejectsSourceOrUnrelatedIdentifier() { + for (id in listOf("old", "unrelated")) { + val conversion = Renamed("old", "new", Value(id)) + assertFailsWith { conversion.convert(null, conversions, Value("old")) } + assertEquals(1, conversion.calls) + } + } + + @Test fun adtValidationAcceptsParentOrExactBranchAndRejectsNeither() { + val parent = Renamed("oldParent", "newParent", Branch("newBranch", "newParent")) + assertTrue(parent.convert(null, conversions, Branch("oldBranch", "oldParent")) is Branch) + val exact = Renamed("oldBranch", "newBranch", Branch("newBranch", "otherParent")) + assertTrue(exact.convert(null, conversions, Branch("oldBranch", "otherParent")) is Branch) + val wrong = Renamed("oldParent", "newParent", Branch("wrongBranch", "wrongParent")) + assertFailsWith { wrong.convert(null, conversions, Branch("oldBranch", "oldParent")) } + } + + @Test fun foreignValuesDoNotReadMetadataHooks() { + val conversion = object : AbstractConversion(String::class.java, String::class.java) { + override val typeId: String get() = error("source metadata must not be read") + override val targetTypeId: String get() = error("target metadata must not be read") + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override fun doConvert(context: C?, conversions: AbstractBaboonConversions, from: String) = from.uppercase() + } + assertEquals("VALUE", conversion.convert(null, conversions, "value")) + } +} diff --git a/test/py-stub-mcp-async-overlay/BaboonTests/mcp/test_mcp.py b/test/py-stub-mcp-async-overlay/BaboonTests/mcp/test_mcp.py index f94e53125..29db51795 100644 --- a/test/py-stub-mcp-async-overlay/BaboonTests/mcp/test_mcp.py +++ b/test/py-stub-mcp-async-overlay/BaboonTests/mcp/test_mcp.py @@ -109,6 +109,15 @@ async def _send(server, session, codec_ctx, req): class Sec3AsyncToolsCallTests(unittest.TestCase): + def test_public_tool_mutation_does_not_change_server_metadata(self): + server = _make_server() + expected = server.tools + exposed = server.tools + exposed[0].name = "changed" + exposed[0].input_schema.clear() + exposed.clear() + self.assertEqual(expected, server.tools) + def test_ping_returns_ok_true_async(self): # THE GREEN POINT (post-T61). diff --git a/test/py-stub-mcp-overlay/BaboonTests/mcp/test_dispatch_runtime.py b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_dispatch_runtime.py new file mode 100644 index 000000000..9eee8d184 --- /dev/null +++ b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_dispatch_runtime.py @@ -0,0 +1,104 @@ +import asyncio +import unittest + +from BaboonDefinitions.Generated.baboon_mcp_runtime import ( + AbstractBaboonMcpServer, AbstractAsyncBaboonMcpServer, + McpMuxer, AsyncMcpMuxer, McpServerInfo, McpToolEntry, McpSession, + BaboonMcpWiringException, +) +from BaboonDefinitions.Generated.baboon_service_wiring import BaboonMethodId, BaboonRight, BaboonLeft + + +class SyncServer(AbstractBaboonMcpServer): + def __init__(self): + self.tool_name = "Service_call" + + @property + def server_info(self): + return McpServerInfo("Service", "1.0.0") + + @property + def tools(self): + return [McpToolEntry(self.tool_name, BaboonMethodId("Service", "call"), {"type": "object"}, "description")] + + def invoke_json(self, method, data, ctx, codec_ctx): + if ctx == "error": + return BaboonLeft("rejected") + return BaboonRight(data) + + +class AsyncServer(AbstractAsyncBaboonMcpServer): + server_info = SyncServer.server_info + tools = SyncServer.tools + __init__ = SyncServer.__init__ + + async def invoke_json(self, method, data, ctx, codec_ctx): + return SyncServer.invoke_json(self, method, data, ctx, codec_ctx) + + +class McpDispatchRuntimeTest(unittest.TestCase): + def test_per_service_error_hooks_remain_effective(self): + class Customized(SyncServer): + def _error_response(self, req_id, code, message): + return {"custom": code} + + def _describe_wiring_error(self, error): + return "custom description" + + server = Customized() + session = McpSession() + self.assertEqual({"custom": -32602}, server.handle({"method": "initialize"}, session, None, None)) + self.assertEqual({"custom": -32600}, server.handle({"method": "tools/call"}, session, None, None)) + session.initialized = True + result = server.handle({"method": "tools/call", "params": {"name": "Service_call"}}, session, "error", None) + self.assertEqual("custom description", result["result"]["content"][0]["text"]) + + def variants(self): + sync = SyncServer() + async_server = AsyncServer() + return ( + (sync, False), + (async_server, True), + (McpMuxer(sync.server_info, sync), False), + (AsyncMcpMuxer(async_server.server_info, async_server), True), + ) + + def invoke(self, server, is_async, request, session, ctx=None): + result = server.handle(request, session, ctx, None) + return asyncio.run(result) if is_async else result + + def test_protocol_responses_match_across_four_dispatchers(self): + for server, is_async in self.variants(): + with self.subTest(server=type(server).__name__): + session = McpSession() + call = lambda req, ctx=None: self.invoke(server, is_async, req, session, ctx) + before = call({"id": 1, "method": "tools/list"}) + self.assertEqual(-32600, before["error"]["code"]) + invalid_init = call({"id": 2, "method": "initialize", "params": {}}) + self.assertEqual(-32602, invalid_init["error"]["code"]) + self.assertFalse(session.initialized) + initialized = call({"id": 3, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}) + self.assertEqual({"name": "Service", "version": "1.0.0"}, initialized["result"]["serverInfo"]) + self.assertIsNone(call({"method": "notifications/initialized"})) + tools = call({"id": 4, "method": "tools/list"})["result"]["tools"] + self.assertEqual([{"name": "Service_call", "inputSchema": {"type": "object"}, "description": "description"}], tools) + for params in (None, {}, {"name": 42}, {"name": "unknown"}): + response = call({"id": 5, "method": "tools/call", "params": params}) + self.assertEqual(-32602, response["error"]["code"]) + request = {"id": 6, "method": "tools/call", "params": {"name": "Service_call", "arguments": {"x": 1}}} + self.assertEqual({"content": [{"type": "text", "text": '{"x": 1}'}], "isError": False}, call(request)["result"]) + self.assertEqual({"content": [{"type": "text", "text": "'rejected'"}], "isError": True}, call(request, "error")["result"]) + self.assertEqual(-32601, call({"id": 7, "method": "unknown"})["error"]["code"]) + + def test_external_servers_keep_dynamic_tools(self): + for server, is_async in ((SyncServer(), False), (AsyncServer(), True)): + session = McpSession() + session.initialized = True + server.tool_name = "renamed" + response = self.invoke(server, is_async, {"id": 1, "method": "tools/call", "params": {"name": "renamed"}}, session) + self.assertFalse(response["result"]["isError"]) + + def test_duplicate_registration_is_rejected(self): + for server, is_async in self.variants()[2:]: + with self.assertRaises(BaboonMcpWiringException): + server.register(AsyncServer() if is_async else SyncServer()) diff --git a/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py index b868ca3dd..fcd884dec 100644 --- a/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py +++ b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py @@ -406,6 +406,15 @@ def test_initialized_notification_produces_no_response(self): # --------------------------------------------------------------------------- class Sec2ToolsListTests(unittest.TestCase): + def test_public_tool_mutation_does_not_change_server_metadata(self): + server = _make_server() + expected = server.tools + exposed = server.tools + exposed[0].name = "changed" + exposed[0].input_schema.clear() + exposed.clear() + self.assertEqual(expected, server.tools) + def test_exactly_seven_tools_in_declaration_order(self): tools, resp, *_ = _init_and_list() diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_collection_evolution.py b/test/py-stub/BaboonTests/RuntimeTests/test_collection_evolution.py new file mode 100644 index 000000000..ce2afb6de --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_collection_evolution.py @@ -0,0 +1,40 @@ +import unittest + +from BaboonDefinitions.Generated.baboon_conversions import AbstractBaboonConversions +from BaboonDefinitions.Generated.testpkg.pkg0.v1_0_0.T6_D1 import T6_D1 as Old +from BaboonDefinitions.Generated.testpkg.pkg0.v1_0_0.T6_D2 import T6_D2 as OldChild +from BaboonDefinitions.Generated.testpkg.pkg0.v2_0_0.T6_D1 import T6_D1 as New +from BaboonDefinitions.Generated.testpkg.pkg0.v2_0_0.T6_D2 import T6_D2 as NewChild +from BaboonDefinitions.Generated.testpkg.pkg0.v2_0_0.from_1_0_0__T6_D1 import Convert__T6_D1__From__1_0_0 +from BaboonDefinitions.Generated.testpkg.pkg0.v2_0_0.from_1_0_0__T6_D2 import Convert__T6_D2__From__1_0_0 +from BaboonTests.GeneratedFixtures.testpkg.pkg0.v1_0_0.T6_D1_Fixture import T6_D1_Fixture + + +class CollectionEvolutionTest(unittest.TestCase): + def convert(self, source): + conversions = AbstractBaboonConversions() + conversions.register(Convert__T6_D2__From__1_0_0(OldChild, NewChild)) + return Convert__T6_D1__From__1_0_0(Old, New).convert(None, conversions, source) + + def test_collection_swaps_preserve_elements(self): + source = T6_D1_Fixture.random().model_copy(update={ + "fSwapLstSet": [10, 20, 10], "fSwapSetLst": {30, 40}, + "fPrecex2": [50, 60], "fPrecex3": {70, 80}, + "fSwapPrecex1": [90, 100], "fSwapPrecex2": {110, 120}, + }) + result = self.convert(source) + self.assertEqual({10, 20}, result.fSwapLstSet) + self.assertEqual({30, 40}, set(result.fSwapSetLst)) + self.assertEqual([50, 60], result.fPrecex2) + self.assertEqual({70, 80}, result.fPrecex3) + self.assertEqual({90, 100}, result.fSwapPrecex1) + self.assertEqual({110, 120}, set(result.fSwapPrecex2)) + + def test_absent_optionals_become_empty_sets(self): + source = T6_D1_Fixture.random().model_copy(update={ + "fSwapOptSet0": None, "fSwapOptSet1": None, "fSwapOptSet2": None, + }) + result = self.convert(source) + for value in (result.fSwapOptSet0, result.fSwapOptSet1, result.fSwapOptSet2): + self.assertIsInstance(value, set) + self.assertEqual(set(), value) diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_conversion_identifiers.py b/test/py-stub/BaboonTests/RuntimeTests/test_conversion_identifiers.py new file mode 100644 index 000000000..b9c6d433f --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_conversion_identifiers.py @@ -0,0 +1,159 @@ +import unittest + +from BaboonDefinitions.Generated.baboon_conversions import AbstractBaboonConversions, AbstractConversion +from BaboonDefinitions.Generated.baboon_runtime_shared import BaboonAdtMemberMeta, BaboonGenerated + + +class Branch(BaboonGenerated, BaboonAdtMemberMeta): + baboon_domain_identifier = "test" + baboon_domain_version = "1.0.0" + baboon_type_identifier = "" + baboon_same_in_versions = ["1.0.0"] + baboon_min_reader_versions = {} + baboon_adt_type_identifier = "" + baboon_adt_type = object + + def __init__(self, identifier, owner): + self.baboon_type_identifier = identifier + self.baboon_adt_type_identifier = owner + + +class ManualConversion(AbstractConversion): + version_from = "1.0.0" + version_to = "2.0.0" + type_id = "old.branch" + + def __init__(self, result, events): + super().__init__(Branch, Branch) + self.result = result + self.events = events + + def do_convert(self, context, conversions, source): + self.events.append(("convert", source)) + return self.result + + +class RenamedConversion(ManualConversion): + @property + def _target_type_id(self): + return "new.branch" + + +class ConversionIdentifiersTest(unittest.TestCase): + def test_renamed_branch_uses_target_identifier_after_conversion(self): + source = Branch("old.branch", "old.root") + target = Branch("new.branch", "new.root") + conversion = RenamedConversion(target, []) + self.assertIs(target, conversion.convert(None, AbstractBaboonConversions(), source)) + self.assertEqual("old.branch", conversion.type_id) + + def test_wrong_source_is_rejected_before_conversion(self): + events = [] + conversion = RenamedConversion(Branch("new.branch", "new.root"), events) + with self.assertRaisesRegex(ValueError, "must be old.branch"): + conversion.convert(None, AbstractBaboonConversions(), Branch("other.branch", "other.root")) + self.assertEqual([], events) + + def test_wrong_target_is_rejected_after_conversion(self): + events = [] + source = Branch("old.branch", "old.root") + conversion = RenamedConversion(Branch("wrong.branch", "new.root"), events) + with self.assertRaisesRegex(ValueError, "must be new.branch"): + conversion.convert(None, AbstractBaboonConversions(), source) + self.assertEqual([("convert", source)], events) + + def test_legacy_source_validator_override_keeps_both_calls(self): + class TrackingConversion(ManualConversion): + def validate_baboon_type(self, value): + self.events.append(("validate", value)) + super().validate_baboon_type(value) + + source = Branch("old.branch", "old.root") + target = Branch("old.branch", "old.root") + events = [] + conversion = TrackingConversion(target, events) + self.assertIs(target, conversion.convert(None, AbstractBaboonConversions(), source)) + self.assertEqual([("validate", source), ("convert", source), ("validate", target)], events) + + def test_renamed_public_validator_accepts_both_endpoints(self): + conversion = RenamedConversion(Branch("new.branch", "new.root"), []) + conversion.validate_baboon_type(Branch("old.branch", "old.root")) + conversion.validate_baboon_type(Branch("new.branch", "new.root")) + + def test_renamed_custom_validator_keeps_both_super_calls(self): + class TrackingConversion(RenamedConversion): + def validate_baboon_type(self, value): + self.events.append(("validate", value)) + super().validate_baboon_type(value) + + source = Branch("old.branch", "old.root") + target = Branch("new.branch", "new.root") + events = [] + self.assertIs(target, TrackingConversion(target, events).convert(None, AbstractBaboonConversions(), source)) + self.assertEqual([("validate", source), ("convert", source), ("validate", target)], events) + + def test_renamed_custom_validator_can_reject_result(self): + for call_super in (False, True): + with self.subTest(call_super=call_super): + class RejectingConversion(RenamedConversion): + def validate_baboon_type(self, value): + if call_super: + super().validate_baboon_type(value) + if value is self.result: + raise ValueError("application result validation") + + source = Branch("old.branch", "old.root") + target = Branch("new.branch", "new.root") + with self.assertRaisesRegex(ValueError, "application result validation"): + RejectingConversion(target, []).convert(None, AbstractBaboonConversions(), source) + + def test_renamed_conversion_rejects_swapped_endpoints(self): + source = Branch("old.branch", "old.root") + target = Branch("new.branch", "new.root") + events = [] + with self.assertRaisesRegex(ValueError, "must be old.branch"): + RenamedConversion(target, events).convert(None, AbstractBaboonConversions(), target) + self.assertEqual([], events) + with self.assertRaisesRegex(ValueError, "must be new.branch"): + RenamedConversion(source, events).convert(None, AbstractBaboonConversions(), source) + self.assertEqual([("convert", source)], events) + + def test_legacy_validator_override_does_not_force_identifier_access(self): + class CustomValidator(ManualConversion): + @property + def type_id(self): + raise AssertionError("custom validator does not use metadata") + + def validate_baboon_type(self, value): + self.events.append(("validate", value)) + + source = Branch("old.branch", "old.root") + target = Branch("old.branch", "old.root") + events = [] + conversion = CustomValidator(target, events) + self.assertIs(target, conversion.convert(None, AbstractBaboonConversions(), source)) + self.assertEqual([("validate", source), ("convert", source), ("validate", target)], events) + + def test_parent_adt_identifier_remains_accepted(self): + class ParentConversion(RenamedConversion): + type_id = "old.root" + + @property + def _target_type_id(self): + return "new.root" + + target = Branch("new.branch", "new.root") + self.assertIs(target, ParentConversion(target, []).convert( + None, AbstractBaboonConversions(), Branch("old.branch", "old.root"))) + + def test_foreign_values_do_not_consult_identifier_hooks(self): + class ForeignConversion(ManualConversion): + @property + def type_id(self): + raise AssertionError("foreign source must skip metadata") + + @property + def _target_type_id(self): + raise AssertionError("foreign result must skip metadata") + + self.assertEqual(42, ForeignConversion(42, []).convert(None, AbstractBaboonConversions(), "input")) diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py b/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py index ce35e8de4..d8cac5758 100644 --- a/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py +++ b/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py @@ -7,9 +7,30 @@ from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext from BaboonDefinitions.Generated.my.ok.Inner import Inner from BaboonDefinitions.Generated.my.ok.domain_facade import DomainMyOkFacade +from BaboonDefinitions.Generated.my.ok.baboon_metadata import BaboonMetadata +from BaboonDefinitions.Generated.baboon_any_opaque import AnyMeta, AnyOpaqueJson +from BaboonDefinitions.Generated.baboon_service_wiring import BaboonRight class DomainFacadeTest(unittest.TestCase): + def test_generated_metadata_contract(self): + sample = Inner(x=42) + meta = BaboonMetadata() + self.assertEqual(sample.baboon_same_in_versions, meta.same_in_versions(sample.baboon_type_identifier)) + self.assertEqual(meta.unmodified_since(sample.baboon_type_identifier), meta.same_in_versions(sample.baboon_type_identifier)) + + def test_latest_json_round_trip(self): + facade = DomainMyOkFacade() + sample = Inner(x=42) + self.assertEqual(sample, facade.decode_from_json_latest(facade.encode_to_json(sample), Inner)) + + def test_decode_any_native_json(self): + sample = Inner(x=42) + meta = AnyMeta(7, sample.baboon_domain_identifier, sample.baboon_domain_version, sample.baboon_type_identifier) + result = DomainMyOkFacade().decode_any(AnyOpaqueJson(meta, {"x": 42})) + self.assertIsInstance(result, BaboonRight) + self.assertEqual(sample, result.value) + def test_round_trips_inner(self): facade = DomainMyOkFacade() sample = Inner(x=42) diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py b/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py new file mode 100644 index 000000000..7ffc2b627 --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py @@ -0,0 +1,41 @@ +import struct +import unittest +from io import BytesIO + +from BaboonDefinitions.Generated.baboon_codecs import BaboonBinCodecIndexed, BaboonCodecContext +from BaboonDefinitions.Generated.baboon_runtime_shared import LEDataInputStream + + +class TwoEntries(BaboonBinCodecIndexed): + def index_elements_count(self, ctx): + return 2 + + +class IndexConsumptionTest(unittest.TestCase): + def test_consumption_matches_materialization(self): + codec = TwoEntries() + for wire, count in ((b"\x00payload", 0), (b"\x01" + struct.pack(">(), vec![-2i64, 3]); + assert_eq!(new.wrapped, Some(i64::from(i32::MAX))); +} + +#[test] +fn nested_user_types_use_their_evolution_conversions() { + use baboon_rs_conversion_regression::rust::conversion::{from_1_0_0_nested::convert__nested__from__1_0_0, v1_0_0::{child::Child, nested::Nested}}; + let old = Nested { child: Child { values: Some(7) }, children: vec![Child { values: None }] }; + let new = convert__nested__from__1_0_0(&old); + assert_eq!(new.child.values, vec![7]); + assert!(new.children[0].values.is_empty()); +} + +#[test] +fn adt_branches_use_their_evolution_conversions() { + use baboon_rs_conversion_regression::rust::conversion::{ + from_1_0_0_event::convert__event__from__1_0_0, + v1_0_0::event::{Event, Payload}, + }; + let old = Event::Payload(Payload { values: Some(42) }); + let baboon_rs_conversion_regression::rust::conversion::event::Event::Payload(new) = convert__event__from__1_0_0(&old); + assert_eq!(new.values, vec![42]); +} + +#[test] +fn multiple_any_bearing_adt_branches_share_runtime_framing() { + use baboon_rs_conversion_regression::{ + any_opaque::{AnyMeta, AnyOpaque, AnyOpaqueUeba}, + baboon_runtime::{BaboonBinDecode, BaboonBinEncode, BaboonCodecContext}, + rust::anyadt::envelope::{Envelope, First, Second}, + }; + let any = AnyOpaque::Ueba(AnyOpaqueUeba::new( + AnyMeta::new(7, Some("domain".to_owned()), Some("1.0.0".to_owned()), Some("type".to_owned())).unwrap(), + vec![1, 2, 3], + )); + for value in [Envelope::First(First { value: any.clone() }), Envelope::Second(Second { values: vec![any] })] { + for context in [BaboonCodecContext::Compact, BaboonCodecContext::Indexed] { + let mut wire = Vec::new(); + value.encode_ueba(&context, &mut wire).unwrap(); + assert_eq!(Envelope::decode_ueba(&context, &mut std::io::Cursor::new(wire)).unwrap(), value); + } + } +} diff --git a/test/rs-stub-mcp-mux-overlay/tests/mcp_mux_tests.rs b/test/rs-stub-mcp-mux-overlay/tests/mcp_mux_tests.rs index ba7abe8af..fe0b5ffb9 100644 --- a/test/rs-stub-mcp-mux-overlay/tests/mcp_mux_tests.rs +++ b/test/rs-stub-mcp-mux-overlay/tests/mcp_mux_tests.rs @@ -34,6 +34,8 @@ use baboon_rs_stub::baboon_mcp_server::{ IBaboonRoutableMcpServer, McpJsonInvoke, json_rpc_error_codes, }; use baboon_rs_stub::baboon_runtime::BaboonCodecContext; +#[path = "runtime_dispatch_tests.rs"] +mod runtime_dispatch_tests; use baboon_rs_stub::baboon_service_wiring::BaboonMethodId; use baboon_rs_stub::mcp::mux::stub::baboon_service_rt::BaboonServiceRtDefault; diff --git a/test/rs-stub-mcp-mux-overlay/tests/runtime_dispatch_tests.rs b/test/rs-stub-mcp-mux-overlay/tests/runtime_dispatch_tests.rs new file mode 100644 index 000000000..c3e9596f7 --- /dev/null +++ b/test/rs-stub-mcp-mux-overlay/tests/runtime_dispatch_tests.rs @@ -0,0 +1,73 @@ +use baboon_rs_stub::baboon_mcp_server::*; +use baboon_rs_stub::baboon_runtime::BaboonCodecContext; +use baboon_rs_stub::baboon_service_wiring::{BaboonMethodId, BaboonWiringError}; + +struct Server { base: BaboonMcpServerBase } + +fn invoke(method: &BaboonMethodId, data: &str, _: (), _: &BaboonCodecContext) -> Result { + if method.method_name == "fail" { Err(BaboonWiringError::NoMatchingMethod(method.clone())) } + else { Ok(data.to_owned()) } +} + +fn server(names: &[&'static str]) -> Server { + Server { base: BaboonMcpServerBase { + server_info: McpServerInfo { name: "test", version: "1" }, + tools: names.iter().map(|name| McpToolEntry { + name, + method: BaboonMethodId { service_name: "test".to_owned(), method_name: (*name).to_owned() }, + description: Some("description"), + input_schema: serde_json::json!({"type":"object"}), + }).collect(), + }} +} + +impl IBaboonRoutableMcpServer<()> for Server { + fn server_info(&self) -> &McpServerInfo { &self.base.server_info } + fn tools(&self) -> &[McpToolEntry] { &self.base.tools } + fn route_tool_call(&self, method: &BaboonMethodId, data: &str, ctx: (), codec_ctx: &BaboonCodecContext) -> Result { + invoke(method, data, ctx, codec_ctx) + } +} + +fn request(method: &str, params: Option) -> JsonRpcRequest { + JsonRpcRequest { id: Some(serde_json::json!(1)), method: method.to_owned(), params } +} + +#[test] +fn single_and_mux_dispatch_have_identical_protocol_results() { + let single = server(&["echo", "fail"]); + let mux = AbstractMcpMuxer::new(McpServerInfo { name: "test", version: "1" }).with(Box::new(server(&["echo", "fail"]))).unwrap(); + let mut one_session = McpSession::new(); + let mut mux_session = McpSession::new(); + let requests = [ + request("tools/list", None), + request("tools/call", None), + request("initialize", None), + request("initialize", Some(serde_json::json!({"protocolVersion": MCP_PROTOCOL_VERSION}))), + JsonRpcRequest { id: None, method: "notifications/initialized".to_owned(), params: None }, + request("tools/list", None), + request("tools/call", None), + request("tools/call", Some(serde_json::json!({"name":"unknown"}))), + request("tools/call", Some(serde_json::json!({"name":"echo"}))), + request("tools/call", Some(serde_json::json!({"name":"echo", "arguments":{"a":1}}))), + request("tools/call", Some(serde_json::json!({"name":"fail"}))), + request("unknown", None), + ]; + for req in requests { + let a = single.base.handle_request(&req, &mut one_session, (), &BaboonCodecContext::Default, &invoke); + let b = mux.handle(&req, &mut mux_session, (), &BaboonCodecContext::Default); + assert_eq!(serde_json::to_string(&a).unwrap(), serde_json::to_string(&b).unwrap()); + assert_eq!(one_session.initialized, mux_session.initialized); + } +} + +#[test] +fn rejected_registration_does_not_publish_partial_routes() { + let mut mux = AbstractMcpMuxer::new(McpServerInfo { name: "test", version: "1" }); + mux.register(Box::new(server(&["original"]))).unwrap(); + assert!(matches!(mux.register(Box::new(server(&["new", "original"]))), Err(BaboonMcpWiringError::DuplicateTool(_)))); + let mut session = McpSession { initialized: true }; + let response = mux.handle(&request("tools/list", None), &mut session, (), &BaboonCodecContext::Default).unwrap(); + let names = response.result.unwrap()["tools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap().to_owned()).collect::>(); + assert_eq!(names, vec!["original"]); +} diff --git a/test/rs-stub/tests/any_field_framing_tests.rs b/test/rs-stub/tests/any_field_framing_tests.rs new file mode 100644 index 000000000..a6071ea84 --- /dev/null +++ b/test/rs-stub/tests/any_field_framing_tests.rs @@ -0,0 +1,52 @@ +use baboon_rs_stub::any_opaque::{any_field_codec::{decode_any_field, encode_any_field}, AnyMeta, AnyOpaque, AnyOpaqueUeba}; +use baboon_rs_stub::baboon_runtime::BaboonCodecContext; +use std::io::{Cursor, Write}; + +#[test] +fn all_kinds_round_trip_with_stable_framing() { + for kind in [0x00, 0x01, 0x02, 0x03, 0x06, 0x07] { + let meta = AnyMeta::new(kind, + (kind & 4 != 0).then(|| "domain".to_owned()), + (kind & 2 != 0).then(|| "1.0.0".to_owned()), + (kind & 1 != 0).then(|| "type".to_owned()), + ).unwrap(); + let original = AnyOpaqueUeba::new(meta, vec![1, 2, 3]); + let mut encoded = Vec::new(); + encode_any_field(&BaboonCodecContext::Default, &mut encoded, kind, None, None, None, &AnyOpaque::Ueba(original.clone())).unwrap(); + let decoded = decode_any_field(&mut Cursor::new(&encoded), kind).unwrap(); + assert_eq!(decoded, original); + if kind == 0 { + assert_eq!(encoded, vec![8, 0, 0, 0, 1, 0, 0, 0, 0, 1, 2, 3]); + } + } +} + +#[test] +fn native_payload_is_written_without_a_payload_copy() { + struct ObservingWriter { payload: *const u8, observed: bool } + impl Write for ObservingWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.observed |= bytes.as_ptr() == self.payload; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } + } + let inner = AnyOpaqueUeba::new(AnyMeta::new(0, None, None, None).unwrap(), vec![1, 2, 3]); + let mut writer = ObservingWriter { payload: inner.bytes.as_ptr(), observed: false }; + encode_any_field(&BaboonCodecContext::Default, &mut writer, 0, None, None, None, &AnyOpaque::Ueba(inner)).unwrap(); + assert!(writer.observed); +} + +#[test] +fn extension_bytes_are_skipped_and_length_errors_remain_distinct() { + let wire = vec![10, 0, 0, 0, 3, 0, 0, 0, 0, 99, 98, 1, 2, 3]; + assert_eq!(decode_any_field(&mut Cursor::new(wire), 0).unwrap().bytes, vec![1, 2, 3]); + let cases = [ + (vec![255, 255, 255, 255], "any: negative total-length -1"), + (vec![8, 0, 0, 0, 255, 255, 255, 255], "any: negative meta-length -1"), + (vec![4, 0, 0, 0, 1, 0, 0, 0], "any: total-length 4 smaller than 4 + meta-length 1"), + ]; + for (wire, message) in cases { + assert_eq!(decode_any_field(&mut Cursor::new(wire), 0).unwrap_err().to_string(), message); + } +} diff --git a/test/rs-stub/tests/lazy_codec_concurrency_tests.rs b/test/rs-stub/tests/lazy_codec_concurrency_tests.rs new file mode 100644 index 000000000..b62453a31 --- /dev/null +++ b/test/rs-stub/tests/lazy_codec_concurrency_tests.rs @@ -0,0 +1,32 @@ +use baboon_rs_stub::baboon_codecs_facade::LazyCodec; +use std::sync::{Arc, Barrier}; + +#[test] +fn concurrent_first_access_publishes_one_shared_value() { + let values = Arc::new((0..1000).map(|i| LazyCodec::new(move || { + std::thread::yield_now(); + i + })).collect::>()); + let start = Arc::new(Barrier::new(8)); + let threads = (0..8).map(|_| { + let values = Arc::clone(&values); + let start = Arc::clone(&start); + std::thread::spawn(move || { + let mut failures = 0; + for (i, value) in values.iter().enumerate() { + start.wait(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| value.get())); + match result { + Ok(v) => assert_eq!(*v, i), + Err(_) => failures += 1, + } + } + failures + }) + }).collect::>(); + let failures: usize = threads.into_iter().map(|thread| thread.join().unwrap()).sum(); + assert_eq!(failures, 0); + for value in values.iter() { + assert!(Arc::ptr_eq(&value.get(), &value.get())); + } +} diff --git a/test/rs-stub/tests/service_muxer_tests.rs b/test/rs-stub/tests/service_muxer_tests.rs new file mode 100644 index 000000000..5cdbd6df9 --- /dev/null +++ b/test/rs-stub/tests/service_muxer_tests.rs @@ -0,0 +1,50 @@ +use baboon_rs_stub::baboon_runtime::BaboonCodecContext; +use baboon_rs_stub::baboon_service_wiring::*; +use std::{cell::Cell, rc::Rc}; + +struct Echo(&'static str); +impl IBaboonJsonService for Echo { + fn service_name(&self) -> &str { self.0 } + fn invoke(&self, _: &BaboonMethodId, data: &str, _: &BaboonCodecContext) -> usize { data.len() } +} +impl IBaboonUebaService for Echo { + fn service_name(&self) -> &str { self.0 } + fn invoke(&self, _: &BaboonMethodId, data: &[u8], _: &BaboonCodecContext) -> usize { data.len() } +} +impl IBaboonJsonServiceCtx>, usize> for Echo { + fn service_name(&self) -> &str { self.0 } + fn invoke(&self, _: &BaboonMethodId, data: &str, ctx: Rc>, _: &BaboonCodecContext) -> usize { + ctx.set(ctx.get() + data.len()); ctx.get() + } +} +impl IBaboonUebaServiceCtx>, usize> for Echo { + fn service_name(&self) -> &str { self.0 } + fn invoke(&self, _: &BaboonMethodId, data: &[u8], ctx: Rc>, _: &BaboonCodecContext) -> usize { + ctx.set(ctx.get() + data.len()); ctx.get() + } +} +fn method(name: &str) -> BaboonMethodId { BaboonMethodId { service_name: name.to_owned(), method_name: "echo".to_owned() } } + +#[test] +fn wire_formats_preserve_registration_and_lookup_errors() { + let mut json = JsonMuxer::new().with(Box::new(Echo("b"))).unwrap().with(Box::new(Echo("a"))).unwrap(); + let mut bin = UebaMuxer::new().with(Box::new(Echo("b"))).unwrap().with(Box::new(Echo("a"))).unwrap(); + assert_eq!(json.service_names(), vec!["a", "b"]); + assert_eq!(bin.service_names(), json.service_names()); + assert_eq!(json.register(Box::new(Echo("a"))).unwrap_err().to_string(), "DuplicateService(a)"); + assert_eq!(bin.register(Box::new(Echo("a"))).unwrap_err().to_string(), "DuplicateService(a)"); + assert_eq!(json.invoke(&method("a"), "123", &BaboonCodecContext::Default).unwrap(), 3); + assert_eq!(bin.invoke(&method("a"), &[1, 2, 3], &BaboonCodecContext::Default).unwrap(), 3); + assert_eq!(json.invoke(&method("missing"), "", &BaboonCodecContext::Default).unwrap_err().to_string(), "NoMatchingService(missing.echo)"); + assert_eq!(bin.invoke(&method("missing"), &[], &BaboonCodecContext::Default).unwrap_err().to_string(), "NoMatchingService(missing.echo)"); +} + +#[test] +fn context_muxers_accept_non_send_owned_contexts() { + let json = JsonMuxerCtx::new().with(Box::new(Echo("a"))).unwrap(); + let bin = UebaMuxerCtx::new().with(Box::new(Echo("a"))).unwrap(); + let ctx = Rc::new(Cell::new(0)); + assert_eq!(json.invoke(&method("a"), "123", Rc::clone(&ctx), &BaboonCodecContext::Default).unwrap(), 3); + assert_eq!(bin.invoke(&method("a"), &[1, 2], Rc::clone(&ctx), &BaboonCodecContext::Default).unwrap(), 5); + assert_eq!(ctx.get(), 5); +} diff --git a/test/sc-stub/src/test/scala/runtime/AnyEnvelopeCodecSpec.scala b/test/sc-stub/src/test/scala/runtime/AnyEnvelopeCodecSpec.scala new file mode 100644 index 000000000..49ec8d080 --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/AnyEnvelopeCodecSpec.scala @@ -0,0 +1,55 @@ +package runtime + +import baboon.runtime.shared._ +import io.circe.Json +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} + +class AnyEnvelopeCodecSpec extends AnyFunSuite { + private val meta = AnyMeta(0.toByte, None, None, None) + private val payload = Array[Byte](1, 2) + + private def encode(value: AnyOpaque, kind: Byte): Array[Byte] = { + val buffer = new ByteArrayOutputStream() + val writer = new LEDataOutputStream(buffer) + BaboonAnyBinCodec.encode(BaboonCodecContext.Compact, writer, kind, None, None, None, value) + writer.close() + buffer.toByteArray + } + + private def decode(bytes: Array[Byte], kind: Byte): AnyOpaqueUeba = + BaboonAnyBinCodec.decode(new LEDataInputStream(new ByteArrayInputStream(bytes)), kind) + + test("binary envelope retains exact bytes") { + val expected = Array[Byte](7, 0, 0, 0, 1, 0, 0, 0, 0, 1, 2) + assert(java.util.Arrays.equals(expected, encode(AnyOpaqueUeba(meta, payload), 0.toByte))) + assert(decode(expected, 0.toByte) == AnyOpaqueUeba(meta, payload)) + } + + test("binary envelope skips future metadata extensions") { + val extended = Array[Byte](9, 0, 0, 0, 3, 0, 0, 0, 0, 42, 43, 1, 2) + assert(decode(extended, 0.toByte) == AnyOpaqueUeba(meta, payload)) + } + + test("JSON envelope retains content and checks kind") { + val content = Json.obj("x" -> Json.fromInt(1)) + val value = AnyOpaqueJson(meta, content) + val expected = Json.obj("$ak" -> Json.fromInt(0), "$c" -> content) + assert(BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0.toByte, None, None, None, value) == expected) + assert(BaboonAnyJsonCodec.decode(0.toByte, expected) == Right(value)) + assert(BaboonAnyJsonCodec.decode(1.toByte, expected).isLeft) + assert(BaboonAnyJsonCodec.decode(0.toByte, Json.obj("$ak" -> Json.fromInt(0))).isLeft) + } + + test("encoding retains kind and facade requirements") { + val binary = AnyOpaqueUeba(meta, payload) + intercept[BaboonCodecException.EncoderFailure](encode(binary, 1.toByte)) + intercept[BaboonCodecException.EncoderFailure] { + BaboonAnyJsonCodec.encode(BaboonCodecContext.Compact, 0.toByte, None, None, None, binary) + } + intercept[BaboonCodecException.EncoderFailure] { + encode(AnyOpaqueJson(meta, Json.fromInt(1)), 0.toByte) + } + } +} diff --git a/test/sc-stub/src/test/scala/runtime/ConversionValidationSpec.scala b/test/sc-stub/src/test/scala/runtime/ConversionValidationSpec.scala new file mode 100644 index 000000000..4c49111ee --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/ConversionValidationSpec.scala @@ -0,0 +1,83 @@ +package runtime + +import baboon.runtime.shared._ +import org.scalatest.funsuite.AnyFunSuite + +class ConversionValidationSpec extends AnyFunSuite { + private class Value(val baboonTypeIdentifier: String) extends BaboonGenerated { + override val baboonDomainVersion = "1.0.0" + override val baboonDomainIdentifier = "test" + override val baboonSameInVersions = List("1.0.0") + override val baboonForwardReadable = Map.empty[String, String] + override val baboonMinReaderVersions = Map.empty[String, String] + } + private class Branch(id: String, val baboonAdtTypeIdentifier: String) extends Value(id) with BaboonAdtMemberMeta { + override val baboonAdtType: Class[?] = classOf[Value] + } + private val conversions = new AbstractBaboonConversions { + override val versionsFrom = List("1.0.0") + override val versionTo = "2.0.0" + } + private class Legacy(val typeId: String, result: Value) extends AbstractConversion[Value, Value] { + var calls = 0 + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override def doConvert[C](context: Option[C], conversions: AbstractBaboonConversions, from: Value): Value = { + calls += 1 + result + } + } + private class Renamed(source: String, target: String, result: Value) extends Legacy(source, result) { + var targetReads = 0 + override protected def targetTypeId: String = { targetReads += 1; target } + } + + test("legacy converters retain same-identifier validation and call ordering") { + val good = new Legacy("same", new Value("same")) + assert(good.convert[Unit](None, conversions, new Value("same")).baboonTypeIdentifier == "same") + val wrongSource = new Legacy("same", new Value("same")) + intercept[IllegalArgumentException](wrongSource.convert[Unit](None, conversions, new Value("wrong"))) + assert(wrongSource.calls == 0) + val wrongResult = new Legacy("same", new Value("wrong")) + intercept[IllegalArgumentException](wrongResult.convert[Unit](None, conversions, new Value("same"))) + assert(wrongResult.calls == 1) + } + + test("renamed result uses target identifier without eager hook evaluation") { + val conversion = new Renamed("old", "new", new Value("new")) + assert(conversion.targetReads == 0) + intercept[IllegalArgumentException](conversion.convert[Unit](None, conversions, new Value("wrong"))) + assert(conversion.calls == 0 && conversion.targetReads == 0) + assert(conversion.convert[Unit](None, conversions, new Value("old")).baboonTypeIdentifier == "new") + assert(conversion.calls == 1 && conversion.targetReads > 0) + } + + test("renamed result rejects source and unrelated identifiers") { + List("old", "unrelated").foreach { + id => + val conversion = new Renamed("old", "new", new Value(id)) + intercept[IllegalArgumentException](conversion.convert[Unit](None, conversions, new Value("old"))) + assert(conversion.calls == 1) + } + } + + test("ADT validation accepts parent or exact branch and rejects neither") { + val parent = new Renamed("oldParent", "newParent", new Branch("newBranch", "newParent")) + assert(parent.convert[Unit](None, conversions, new Branch("oldBranch", "oldParent")).isInstanceOf[Branch]) + val exact = new Renamed("oldBranch", "newBranch", new Branch("newBranch", "otherParent")) + assert(exact.convert[Unit](None, conversions, new Branch("oldBranch", "otherParent")).isInstanceOf[Branch]) + val wrong = new Renamed("oldParent", "newParent", new Branch("wrongBranch", "wrongParent")) + intercept[IllegalArgumentException](wrong.convert[Unit](None, conversions, new Branch("oldBranch", "oldParent"))) + } + + test("foreign conversions do not evaluate metadata hooks") { + val conversion = new AbstractConversion[String, String] { + override def typeId: String = throw new AssertionError("source metadata must not be read") + override protected def targetTypeId: String = throw new AssertionError("target metadata must not be read") + override val versionFrom = "1.0.0" + override val versionTo = "2.0.0" + override def doConvert[C](context: Option[C], conversions: AbstractBaboonConversions, from: String): String = from.toUpperCase(java.util.Locale.ROOT) + } + assert(conversion.convert[Unit](None, conversions, "value") == "VALUE") + } +} diff --git a/test/sc-stub/src/test/scala/runtime/GeneratedMetadataReuseSpec.scala b/test/sc-stub/src/test/scala/runtime/GeneratedMetadataReuseSpec.scala new file mode 100644 index 000000000..6b42ba9fb --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/GeneratedMetadataReuseSpec.scala @@ -0,0 +1,29 @@ +package runtime + +import baboon.runtime.shared.Lazy +import org.scalatest.funsuite.AnyFunSuite + +class GeneratedMetadataReuseSpec extends AnyFunSuite { + test("generated codecs retain one deferred singleton wrapper") { + List(my.ok.Inner_JsonCodec, my.ok.Inner_UEBACodec).foreach { + codec => + val accessor = codec.getClass.getDeclaredMethod("LazyInstance") + accessor.setAccessible(true) + val first = accessor.invoke(codec).asInstanceOf[Lazy[AnyRef]] + val second = accessor.invoke(codec).asInstanceOf[Lazy[AnyRef]] + assert(first eq second) + assert(first.value eq codec) + } + } + + test("metadata methods retain immutable collections and instance forwarding") { + val value = my.ok.Inner(7) + assert(my.ok.Inner.baboonSameInVersions eq my.ok.Inner.baboonSameInVersions) + assert(my.ok.Inner.baboonForwardReadable eq my.ok.Inner.baboonForwardReadable) + assert(my.ok.Inner.baboonMinReaderVersions eq my.ok.Inner.baboonMinReaderVersions) + assert(value.baboonSameInVersions eq my.ok.Inner.baboonSameInVersions) + assert(value.baboonForwardReadable eq my.ok.Inner.baboonForwardReadable) + assert(value.baboonMinReaderVersions eq my.ok.Inner.baboonMinReaderVersions) + assert(value.baboonSameInVersions.contains(value.baboonDomainVersion)) + } +} diff --git a/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala b/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala new file mode 100644 index 000000000..d82fe950f --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala @@ -0,0 +1,42 @@ +package runtime + +import baboon.runtime.shared._ +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import org.scalatest.funsuite.AnyFunSuite + +class IndexCountSpec extends AnyFunSuite { + private val indexed = new BaboonBinCodecIndexed { + override def indexElementsCount(ctx: BaboonCodecContext): Short = 2 + } + + private def bytes(header: Int, entries: (Int, Int)*): Array[Byte] = { + val buffer = new ByteArrayOutputStream() + val writer = new LEDataOutputStream(buffer) + writer.writeByte(header) + entries.foreach { case (offset, length) => writer.writeInt(offset); writer.writeInt(length) } + writer.writeByte(42) + buffer.toByteArray + } + + test("count-only index reading preserves entries, validation and cursor consumption") { + val cases = List( + bytes(0), + bytes(1, 0 -> 1, 1 -> 2), + bytes(1, 0 -> 0, 1 -> 2), + bytes(1, -1 -> 1, 1 -> 2), + bytes(1, 0 -> 2, 1 -> 1), + bytes(1, 0 -> 1), + Array.emptyByteArray, + ) + cases.foreach { + input => + val fullStream = new ByteArrayInputStream(input) + val countStream = new ByteArrayInputStream(input) + val full = indexed.readIndex(BaboonCodecContext.Compact, new LEDataInputStream(fullStream)) + val count = indexed.readIndexCount(BaboonCodecContext.Compact, new LEDataInputStream(countStream)) + assert(full.map(_.size).left.map(e => (e.getClass, e.getMessage)) == count.left.map(e => (e.getClass, e.getMessage))) + assert(fullStream.available() == countStream.available()) + assert(fullStream.read() == countStream.read()) + } + } +} diff --git a/test/scala-stub-mcp-overlay/src/test/scala/McpProtocolOwnershipSpec.scala b/test/scala-stub-mcp-overlay/src/test/scala/McpProtocolOwnershipSpec.scala new file mode 100644 index 000000000..9ce3fa817 --- /dev/null +++ b/test/scala-stub-mcp-overlay/src/test/scala/McpProtocolOwnershipSpec.scala @@ -0,0 +1,73 @@ +import baboon.runtime.shared._ +import io.circe.Json +import org.scalatest.funsuite.AnyFunSuite + +class McpProtocolOwnershipSpec extends AnyFunSuite { + private val testInfo = McpServerInfo("test", "1") + private val method = BaboonMethodId("service", "call") + private def tool(name: String) = McpToolEntry(name, method, Json.obj()) + private class Server extends AbstractBaboonMcpServer[String] { + override val serverInfo: McpServerInfo = testInfo + var current: Seq[McpToolEntry] = Seq(tool("first")) + override def tools: Seq[McpToolEntry] = current + override protected def invokeJson(m: BaboonMethodId, data: String, ctx: String, codecCtx: BaboonCodecContext): Either[BaboonWiringError, String] = + if (ctx == "fail") Left(BaboonWiringError.NoMatchingMethod(m)) else Right(s"$ctx:$data") + override protected def describeWiringError(error: BaboonWiringError): String = "custom-server-error" + } + private def request(method: String, params: Option[Json]) = JsonRpcRequest(Some(JsonRpcId.LongId(7)), method, params) + private def call(name: String) = request("tools/call", Some(Json.obj("name" -> Json.fromString(name)))) + private val initialize = request("initialize", Some(Json.obj("protocolVersion" -> Json.fromString("ignored")))) + + test("standalone and mux protocol transcripts preserve sessions, IDs and error channels") { + val server = new Server + val mux = new AbstractMcpMuxer[String](testInfo, server) { + override protected def describeWiringError(error: BaboonWiringError): String = "custom-server-error" + } + val a = new McpSession + val b = new McpSession + val requests = List( + call("first"), + request("tools/list", None), + request("initialize", None), + initialize, + request("notifications/initialized", None), + request("tools/list", None), + call("first"), + call("absent"), + request("tools/call", None), + request("unknown", None), + ) + requests.foreach { + req => + assert(server.handle(req, a, "ctx", BaboonCodecContext.Compact) == mux.handle(req, b, "ctx", BaboonCodecContext.Compact)) + assert(a.initialized == b.initialized) + } + assert(server.handle(call("first"), a, "fail", BaboonCodecContext.Compact) == mux.handle(call("first"), b, "fail", BaboonCodecContext.Compact)) + assert(server.handle(call("first"), a, "fail", BaboonCodecContext.Compact).get.result.get.noSpaces.contains("custom-server-error")) + assert(server.handle(call("first"), new McpSession, "ctx", BaboonCodecContext.Compact).get.error.nonEmpty) + } + + test("standalone tools remain dynamic while mux registration retains its snapshot") { + val server = new Server + val mux = new AbstractMcpMuxer[String](testInfo, server) + val session = new McpSession + session.initialized = true + server.current = Seq(tool("second")) + assert(server.handle(call("second"), session, "ctx", BaboonCodecContext.Compact).get.error.isEmpty) + assert(server.handle(call("first"), session, "ctx", BaboonCodecContext.Compact).get.error.nonEmpty) + assert(mux.handle(call("first"), session, "ctx", BaboonCodecContext.Compact).get.error.isEmpty) + assert(mux.handle(call("second"), session, "ctx", BaboonCodecContext.Compact).get.error.nonEmpty) + intercept[BaboonMcpWiringException](mux.register(new Server)) + } + + test("mux overridden registration may dispatch during base construction") { + val server = new Server + val mux = new AbstractMcpMuxer[String](testInfo, server) { + override def register(value: IBaboonRoutableMcpServer[String]): Unit = { + assert(handle(initialize, new McpSession, "ctx", BaboonCodecContext.Compact).get.error.isEmpty) + super.register(value) + } + } + assert(mux.handle(initialize, new McpSession, "ctx", BaboonCodecContext.Compact).get.error.isEmpty) + } +} diff --git a/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift b/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift new file mode 100644 index 000000000..7f2810f0a --- /dev/null +++ b/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift @@ -0,0 +1,69 @@ +import Foundation +import XCTest +import BaboonRuntime + +final class RuntimePrimitiveCharacterizationTests: XCTestCase { + private struct Indexed: BaboonBinCodecIndexed { let indexElementsCount = 2 } + + func testIndexConsumptionFollowsHeaderRatherThanContext() throws { + for context in [BaboonCodecContext.compact, BaboonCodecContext.indexed] { + for header: UInt8 in [0, 1, 2, 3] { + let writer = BaboonBinWriter() + writer.writeU8(header) + if header & 1 != 0 { + for value: Int32 in [12, 13, 25, 7] { writer.writeI32(value) } + } + writer.writeU8(99) + let reader = BaboonBinReader(writer.toData()) + let entries = try Indexed().readIndex(context, reader) + XCTAssertEqual(entries.count, header & 1 == 0 ? 0 : 2) + let consumed = BaboonBinReader(writer.toData()) + XCTAssertEqual(try Indexed().consumeIndex(context, consumed), entries.count) + XCTAssertEqual(consumed.position, reader.position) + XCTAssertEqual(consumed.readU8(), 99) + XCTAssertEqual(reader.readU8(), 99) + } + } + } + + func testUuidMixedEndianAndSlices() throws { + let uuid = UUID(uuidString: "00112233-4455-6677-8899-aabbccddeeff")! + let golden: [UInt8] = [0x33, 0x22, 0x11, 0, 0x55, 0x44, 0x77, 0x66, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff] + let writer = BaboonBinWriter() + writer.writeUuid(uuid) + XCTAssertEqual(writer.toData(), Data(golden)) + let prefixed = Data([99, 98] + golden) + let reader = BaboonBinReader(prefixed.dropFirst(2)) + XCTAssertEqual(try reader.readUuid(), uuid) + XCTAssertEqual(reader.position, 16) + for value in [UUID(uuid: (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)), UUID(uuid: (255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255))] + (0..<100).map({ _ in UUID() }) { + let buffer = BaboonBinWriter() + buffer.writeUuid(value) + XCTAssertEqual(try BaboonBinReader(buffer.toData()).readUuid(), value) + } + let short = BaboonBinReader(Data(golden.dropLast())) + XCTAssertThrowsError(try short.readUuid()) { error in + guard case BaboonCodecError.truncated(let message) = error else { return XCTFail("Unexpected error: \(error)") } + XCTAssertEqual(message, "readUuid: need 16 bytes at pos 0, only 15 available") + } + XCTAssertEqual(short.position, 0) + } + + func testFixedReadCountsUnicodeScalarsAndPreservesFailurePosition() { + let cursor = BaboonIdentifierRepr.Cursor("Aé😀e\u{301}:suffix") + func read(_ count: Int, _ expected: String) { + guard case .right(let actual) = cursor.readFixed(count) else { return XCTFail("Read failed") } + XCTAssertEqual(actual, expected) + } + read(0, "") + read(3, "Aé😀") + XCTAssertEqual(cursor.position(), 7) + read(2, "e\u{301}") + XCTAssertEqual(cursor.position(), 10) + guard case .left(let message) = cursor.readFixed(99) else { return XCTFail("Expected short read") } + XCTAssertEqual(message, "expected 99 chars at 10 but only 7 remain") + XCTAssertEqual(cursor.position(), 10) + read(7, ":suffix") + XCTAssertTrue(cursor.atEnd()) + } +} diff --git a/test/swift-stub-mcp-mux-overlay/Tests/McpMuxTests/RuntimeDispatchCharacterizationTests.swift b/test/swift-stub-mcp-mux-overlay/Tests/McpMuxTests/RuntimeDispatchCharacterizationTests.swift new file mode 100644 index 000000000..3a514216c --- /dev/null +++ b/test/swift-stub-mcp-mux-overlay/Tests/McpMuxTests/RuntimeDispatchCharacterizationTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest +import BaboonRuntime + +private final class SyncProtocolProbe: IBaboonMcpServer { + typealias Ctx = Int + let serverInfo = McpServerInfo("probe", "1") + var tools: [McpToolEntry] + init(_ names: [String]) { tools = names.map { McpToolEntry($0, BaboonMethodId(serviceId: "probe", methodName: $0), [:], "description") } } + func invokeJson(_ method: BaboonMethodId, _ data: String, _ ctx: Int, _ codecCtx: BaboonCodecContext) throws -> String { + if method.methodName == "fail" { throw BaboonWiringException(.noMatchingMethod(method)) } + return data + } +} + +private struct AsyncProtocolProbe: IBaboonAsyncMcpServer { + typealias Ctx = Int + let delegate: SyncProtocolProbe + var serverInfo: McpServerInfo { delegate.serverInfo } + var tools: [McpToolEntry] { delegate.tools } + func invokeJson(_ method: BaboonMethodId, _ data: String, _ ctx: Int, _ codecCtx: BaboonCodecContext) async throws -> String { + await Task.yield() + return try delegate.invokeJson(method, data, ctx, codecCtx) + } +} + +final class RuntimeDispatchCharacterizationTests: XCTestCase { + private func wire(_ response: JsonRpcResponse?) throws -> Data { + guard let response else { return Data() } + var value: [String: Any] = ["id": response.id ?? NSNull()] + if let result = response.result { value["result"] = result } + if let error = response.error { value["error"] = ["code": error.code, "message": error.message] } + return try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + } + + @MainActor func testFourSurfacesHaveIdenticalProtocolResults() async throws { + let sync = SyncProtocolProbe(["echo", "fail"]) + let async = AsyncProtocolProbe(delegate: sync) + let mux = try AbstractMcpMuxer(sync.serverInfo, AnyRoutableMcpServer(sync)) + let asyncMux = try AbstractAsyncMcpMuxer(sync.serverInfo, AnyAsyncRoutableMcpServer(async)) + let sessions = (0..<4).map { _ in McpSession() } + let requests = [ + JsonRpcRequest(1, "tools/list", nil), JsonRpcRequest(2, "tools/call", nil), + JsonRpcRequest(3, "initialize", nil), JsonRpcRequest(4, "initialize", [:]), + JsonRpcRequest(5, "initialize", ["protocolVersion": "probe"]), + JsonRpcRequest(nil, "notifications/initialized", nil), JsonRpcRequest(6, "tools/list", nil), + JsonRpcRequest(7, "tools/call", [:]), JsonRpcRequest(8, "tools/call", ["name": "missing"]), + JsonRpcRequest(9, "tools/call", ["name": "echo", "arguments": ["b": 2, "a": 1]]), + JsonRpcRequest(10, "tools/call", ["name": "fail"]), JsonRpcRequest(11, "unknown", nil), + ] + let expectedCodes: [Int?] = [-32600, -32600, -32602, -32602, nil, nil, nil, -32602, -32602, nil, nil, -32601] + for (index, request) in requests.enumerated() { + let a = sync.handle(request, sessions[0], 0, .compact) + let b = await async.handle(request, sessions[1], 0, .compact) + let c = mux.handle(request, sessions[2], 0, .compact) + let d = await asyncMux.handle(request, sessions[3], 0, .compact) + XCTAssertEqual(a?.error?.code, expectedCodes[index]) + XCTAssertEqual(try wire(a), try wire(b)) + XCTAssertEqual(try wire(a), try wire(c)) + XCTAssertEqual(try wire(a), try wire(d)) + XCTAssertTrue(sessions.allSatisfy { $0.initialized == sessions[0].initialized }) + } + } + + func testDynamicConformerToolChangesAreVisible() { + let server = SyncProtocolProbe(["old"]) + let session = McpSession() + session.initialized = true + server.tools = SyncProtocolProbe(["new"]).tools + XCTAssertNil(server.handle(JsonRpcRequest(1, "tools/call", ["name": "new"]), session, 0, .compact)?.error) + XCTAssertEqual(server.handle(JsonRpcRequest(2, "tools/call", ["name": "old"]), session, 0, .compact)?.error?.code, -32602) + } + + func testRejectedRegistrationDoesNotPublishPartialTools() throws { + let first = SyncProtocolProbe(["existing"]) + let mux = try AbstractMcpMuxer(first.serverInfo, AnyRoutableMcpServer(first)) + XCTAssertThrowsError(try mux.register(AnyRoutableMcpServer(SyncProtocolProbe(["new", "existing"])))) + let session = McpSession() + session.initialized = true + XCTAssertEqual(mux.handle(JsonRpcRequest(1, "tools/call", ["name": "new"]), session, 0, .compact)?.error?.code, -32602) + } + + func testAsyncRejectedRegistrationDoesNotPublishPartialTools() async throws { + let first = AsyncProtocolProbe(delegate: SyncProtocolProbe(["existing"])) + let mux = try AbstractAsyncMcpMuxer(first.serverInfo, AnyAsyncRoutableMcpServer(first)) + XCTAssertThrowsError(try mux.register(AnyAsyncRoutableMcpServer(AsyncProtocolProbe(delegate: SyncProtocolProbe(["new", "existing"]))))) + let session = McpSession() + session.initialized = true + let response = await mux.handle(JsonRpcRequest(1, "tools/call", ["name": "new"]), session, 0, .compact) + XCTAssertEqual(response?.error?.code, -32602) + } +} diff --git a/test/ts-stub-mcp-overlay/src/dispatch-preparation.test.ts b/test/ts-stub-mcp-overlay/src/dispatch-preparation.test.ts new file mode 100644 index 000000000..0305f1fba --- /dev/null +++ b/test/ts-stub-mcp-overlay/src/dispatch-preparation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "vitest"; +import { BaboonCodecContext, type BaboonMethodId } from "./baboondefinitions/generated/BaboonSharedRuntime"; +import { + AbstractBaboonMcpServer, AbstractAsyncBaboonMcpServer, AbstractMcpMuxer, AbstractAsyncMcpMuxer, + McpSession, type BaboonEitherResult, type McpToolEntry, +} from "./baboondefinitions/generated/BaboonMcpRuntime"; + +function tool(name: string, methodName: string): McpToolEntry { + return { name, method: { serviceName: "Test", methodName }, inputSchema: { type: "object" } }; +} + +class SyncServer extends AbstractBaboonMcpServer { + readonly serverInfo = { name: "test", version: "1" }; + tools = [tool("first", "first"), tool("second", "second")]; + result: BaboonEitherResult = { tag: "Right", value: "ok" }; + readonly calls: string[] = []; + failure: Error | undefined; + protected invokeJson(method: BaboonMethodId, data: string): BaboonEitherResult { + this.calls.push(method.methodName + ":" + data); + if (this.failure !== undefined) throw this.failure; + return this.result; + } + protected describeWiringError(): string { return "custom description"; } +} + +class AsyncServer extends AbstractAsyncBaboonMcpServer { + constructor(readonly core: SyncServer) { super(); } + readonly serverInfo = { name: "test", version: "1" }; + get tools(): readonly McpToolEntry[] { return this.core.tools; } + protected async invokeJson(method: BaboonMethodId, data: string): Promise { + return this.core.routeToolCall(method, data, null, BaboonCodecContext.Default); + } + protected describeWiringError(): string { return "custom description"; } +} + +describe.each([false, true])("MCP async=%s", asyncMode => { + test.each([false, true])("preparation, sessions and channels; mux=%s", async mux => { + const core = new SyncServer(); + const service = asyncMode ? new AsyncServer(core) : core; + const endpoint = mux + ? (asyncMode ? new AbstractAsyncMcpMuxer(core.serverInfo, new AsyncServer(core)) : new AbstractMcpMuxer(core.serverInfo, core)) + : service; + const session = new McpSession(); + const invoke = (method: string, params: unknown, current = session) => endpoint.handle({ id: 1, method, params }, current, null, BaboonCodecContext.Default); + expect(await invoke("tools/list", {})).toMatchObject({ error: { code: -32600 } }); + expect(await invoke("initialize", {})).toMatchObject({ error: { code: -32602 } }); + expect(session.initialized).toBe(false); + expect(await invoke("initialize", { protocolVersion: "test" })).toMatchObject({ result: { protocolVersion: "2025-06-18" } }); + expect(await invoke("tools/list", {}, new McpSession())).toMatchObject({ error: { code: -32600 } }); + expect(await invoke("notifications/initialized", {})).toBeUndefined(); + expect(await invoke("tools/list", {})).toMatchObject({ result: { tools: [{ name: "first" }, { name: "second" }] } }); + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(await invoke("tools/call", { name: "missing", arguments: circular })).toMatchObject({ error: { code: -32602 } }); + expect(await invoke("tools/call", { name: "first", arguments: null })).toMatchObject({ result: { isError: false } }); + expect(core.calls).toEqual(["first:{}"]); + core.result = { tag: "Left", value: { tag: "NoMatchingMethod", method: { serviceName: "Test", methodName: "first" } } }; + expect(await invoke("tools/call", { name: "first" })).toMatchObject({ result: { isError: true } }); + expect(await invoke("missing", {})).toMatchObject({ error: { code: -32601 } }); + }); + + test("external tools remain dynamic and duplicate names retain last-entry routing", async () => { + const core = new SyncServer(); + const server = asyncMode ? new AsyncServer(core) : core; + const session = new McpSession(); session.initialized = true; + core.tools.push(tool("first", "last")); + await server.handle({ method: "tools/call", params: { name: "first" } }, session, null, BaboonCodecContext.Default); + expect(core.calls).toEqual(["last:{}"]); + core.tools = []; + expect(await server.handle({ method: "tools/call", params: { name: "first" } }, session, null, BaboonCodecContext.Default)) + .toMatchObject({ error: { code: -32602 } }); + }); + + test("duplicate registration preserves the existing partial-registration contract", async () => { + const core = new SyncServer(); + const extra = new SyncServer(); extra.tools = [tool("third", "third"), tool("first", "duplicate")]; + const mux = asyncMode ? new AbstractAsyncMcpMuxer(core.serverInfo, new AsyncServer(core)) : new AbstractMcpMuxer(core.serverInfo, core); + if (mux instanceof AbstractAsyncMcpMuxer) expect(() => mux.register(new AsyncServer(extra))).toThrow(); + else expect(() => mux.register(extra)).toThrow(); + const session = new McpSession(); session.initialized = true; + await mux.handle({ method: "tools/call", params: { name: "third" } }, session, null, BaboonCodecContext.Default); + expect(extra.calls).toEqual(["third:{}"]); + }); +}); + +test("invocation exceptions stay synchronous or become Promise rejections according to the public interface", async () => { + const core = new SyncServer(); core.failure = new Error("delegate failure"); + const session = new McpSession(); session.initialized = true; + const request = { method: "tools/call", params: { name: "first" } }; + expect(() => core.handle(request, session, null, BaboonCodecContext.Default)).toThrow("delegate failure"); + await expect(new AsyncServer(core).handle(request, session, null, BaboonCodecContext.Default)).rejects.toThrow("delegate failure"); +}); diff --git a/test/ts-stub-mcp-overlay/src/mcp.test.ts b/test/ts-stub-mcp-overlay/src/mcp.test.ts index 24bc62a4c..9669ad5fd 100644 --- a/test/ts-stub-mcp-overlay/src/mcp.test.ts +++ b/test/ts-stub-mcp-overlay/src/mcp.test.ts @@ -207,6 +207,17 @@ function initAndList(): { tools: Array<{ name: string; inputSchema: unknown; des // --------------------------------------------------------------------------- describe("MCP §1: initialize", () => { + test("tool schema snapshots do not mutate subsequent lists or cached routes", () => { + const server = makeServer(); + const first = server.tools; + const originalName = first[0].name; + const schema = first[0].inputSchema as Record; + schema["mutated"] = true; + (first[0].method as { methodName: string }).methodName = "mutated"; + expect(server.tools[0].name).toBe(originalName); + expect(server.tools[0].method.methodName).not.toBe("mutated"); + expect(server.tools[0].inputSchema).not.toHaveProperty("mutated"); + }); const server = makeServer(); const session = new McpSession(); diff --git a/test/ts-stub/ReviewModels/v1.baboon b/test/ts-stub/ReviewModels/v1.baboon new file mode 100644 index 000000000..5666277e2 --- /dev/null +++ b/test/ts-stub/ReviewModels/v1.baboon @@ -0,0 +1,49 @@ +model review.evolution +version "1.0.0" + + +data Child { + number: i64 + tags: set[str] + amounts: map[str, i64] +} + +root data NestedHolder { + child: Child +} + +root data CollectionHolder { + values: opt[i32] +} +root adt Shape { + data Circle { radius: i32 } +} + +enum Color { red green } +enum Tone { lightBlue } +root data ToneHolder { tone: Tone } + +data Leaf { value: i32 } + +root data Containers { + leaves: lst[Leaf] + named: map[str, Leaf] + numbered: map[i32, Leaf] + color: Color +} + +foreign NativeValue { typescript = "Date" } + +root data NativeHolder { value: NativeValue } + +data Manual { value: i32 } +root data ManualHolder { value: Manual } + +root data ScalarCollections : derived[json] { + values: lst[i32] + uniques: set[str] +} + +root adt OldRenamedShape { + data Circle { radius: i32 } +} diff --git a/test/ts-stub/ReviewModels/v2.baboon b/test/ts-stub/ReviewModels/v2.baboon new file mode 100644 index 000000000..7c7ffcd3d --- /dev/null +++ b/test/ts-stub/ReviewModels/v2.baboon @@ -0,0 +1,53 @@ +model review.evolution +version "2.0.0" + +import "1.0.0" { * } without { OldRenamedShape } + +data Child { + number: i64 + tags: set[str] + amounts: map[str, i64] +} + +root data NestedHolder { + child: Child +} + +root data CollectionHolder { + values: lst[i32] +} +root adt Shape { + data Circle { radius: i32 } +} + +enum Color { red green } +enum Tone { deepBlue : was[lightBlue] } +root data ToneHolder { tone: Tone } + +data Leaf { value: i32 } + +root data Containers { + leaves: lst[Leaf] + named: map[str, Leaf] + numbered: map[i32, Leaf] + color: Color +} + +foreign NativeValue { typescript = "Date" } + +root data NativeHolder { value: NativeValue } + +data Manual { value: str } +root data ManualHolder { value: Manual } + +root data ScalarCollections : derived[json] { + values: lst[i32] + uniques: set[str] +} + +root adt RenamedShape : was[OldRenamedShape] { + data Circle { + radius: i64 + label: opt[str] + } +} diff --git a/test/ts-stub/package-lock.json b/test/ts-stub/package-lock.json index 902e3872d..4f470c4af 100644 --- a/test/ts-stub/package-lock.json +++ b/test/ts-stub/package-lock.json @@ -8,6 +8,7 @@ "name": "baboon-ts-stub", "version": "0.1.0", "devDependencies": { + "@types/node": "24.13.5", "typescript": "~5.7.0", "vitest": "~3.0.0" } @@ -818,6 +819,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@vitest/expect": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", @@ -1394,6 +1405,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", diff --git a/test/ts-stub/package.json b/test/ts-stub/package.json index 2e8d65dc1..410639312 100644 --- a/test/ts-stub/package.json +++ b/test/ts-stub/package.json @@ -8,6 +8,7 @@ "test": "vitest run" }, "devDependencies": { + "@types/node": "24.13.5", "typescript": "~5.7.0", "vitest": "~3.0.0" } diff --git a/test/ts-stub/src/runtime-tests/BinStorage.test.ts b/test/ts-stub/src/runtime-tests/BinStorage.test.ts new file mode 100644 index 000000000..610497f76 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/BinStorage.test.ts @@ -0,0 +1,71 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, test } from "vitest"; +import { BaboonBinReader, BaboonBinWriter, BinTools } from "../generated/BaboonSharedRuntime"; + +describe("binary storage", () => { + test("fixed-width writes retain little-endian bytes across growth", () => { + const writer = new BaboonBinWriter(1); + BinTools.writeI8(writer, -128); + BinTools.writeU8(writer, 255); + BinTools.writeI16(writer, -32768); + BinTools.writeU16(writer, 65535); + BinTools.writeI32(writer, -2147483648); + BinTools.writeU32(writer, 4294967295); + BinTools.writeI64(writer, -9223372036854775808n); + BinTools.writeU64(writer, 18446744073709551615n); + BinTools.writeF32(writer, -0); + BinTools.writeF64(writer, Infinity); + expect(Buffer.from(writer.toBytes()).toString("hex")).toBe( + "80ff0080ffff00000080ffffffff0000000000000080ffffffffffffffff00000080000000000000f07f", + ); + expect(writer.position()).toBe(42); + }); + + test("float special values and integer wrapping retain their values", () => { + const writer = new BaboonBinWriter(3); + BinTools.writeI8(writer, 255); + BinTools.writeU16(writer, -1); + BinTools.writeF32(writer, NaN); + BinTools.writeF64(writer, -0); + BinTools.writeF64(writer, -Infinity); + const reader = new BaboonBinReader(writer.toBytes()); + expect(reader.readI8()).toBe(-1); + expect(reader.readU16()).toBe(65535); + expect(reader.readF32()).toBeNaN(); + expect(Object.is(reader.readF64(), -0)).toBe(true); + expect(reader.readF64()).toBe(-Infinity); + expect(reader.remaining()).toBe(0); + }); + + test("exported bytes are independent of writer storage", () => { + const writer = new BaboonBinWriter(2); + BinTools.writeU16(writer, 0x1234); + const first = writer.toBytes(); + first[0] = 0; + BinTools.writeU32(writer, 0xabcdef01); + expect([...writer.toBytes()]).toEqual([0x34, 0x12, 1, 0xef, 0xcd, 0xab]); + expect([...first]).toEqual([0, 0x12]); + }); + + for (const input of [new Uint8Array([9, 1, 2, 3, 8]), Buffer.from([9, 1, 2, 3, 8])]) { + test(`readBytes owns its result for ${input.constructor.name} offset views`, () => { + const reader = new BaboonBinReader(input.subarray(1, 4)); + const bytes = reader.readBytes(2); + expect(bytes.constructor).toBe(Uint8Array); + expect([...bytes]).toEqual([1, 2]); + bytes[0] = 7; + expect(input[1]).toBe(1); + input[2] = 6; + expect(bytes[1]).toBe(2); + expect(reader.position()).toBe(2); + expect(reader.readByte()).toBe(3); + }); + } + + test("readBytes preserves truncation and cursor advancement", () => { + const reader = new BaboonBinReader(new Uint8Array([1, 2])); + expect([...reader.readBytes(4)]).toEqual([1, 2]); + expect(reader.position()).toBe(4); + expect(reader.remaining()).toBe(-2); + }); +}); diff --git a/test/ts-stub/src/runtime-tests/CollectionEncoding.test.ts b/test/ts-stub/src/runtime-tests/CollectionEncoding.test.ts new file mode 100644 index 000000000..d7f1b0055 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/CollectionEncoding.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { BaboonCodecContext } from "../generated-review/BaboonSharedRuntime"; +import { ScalarCollections, ScalarCollections_JsonCodec } from "../generated-review/review/evolution/ScalarCollections"; + +test("identity collection encoding materializes sparse values and preserves set order", () => { + const values = new Array(3); values[1] = 42; + const encoded = ScalarCollections_JsonCodec.instance.encode(BaboonCodecContext.Default, + new ScalarCollections(values, new Set(["b", "a", "b"]))); + expect(encoded).toStrictEqual({ values: [undefined, 42, undefined], uniques: ["b", "a"] }); + expect(JSON.stringify(encoded)).toBe('{"values":[null,42,null],"uniques":["b","a"]}'); +}); + +test("identity collection encoding retains custom iterator and getter order", () => { + const events: string[] = []; + const values = [1, 2]; + Object.defineProperty(values, "0", { get: () => { events.push("get"); return 1; } }); + values[Symbol.iterator] = function* (): Generator { + events.push("start"); yield this[0]; events.push("middle"); yield this[1]; events.push("end"); + return undefined; + }; + expect(ScalarCollections_JsonCodec.instance.encode(BaboonCodecContext.Default, new ScalarCollections(values, new Set()))) + .toStrictEqual({ values: [1, 2], uniques: [] }); + expect(events).toEqual(["start", "get", "middle", "end"]); +}); diff --git a/test/ts-stub/src/runtime-tests/CollectionEvolution.test.ts b/test/ts-stub/src/runtime-tests/CollectionEvolution.test.ts new file mode 100644 index 000000000..5da9b7ce8 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/CollectionEvolution.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { Child as OldChild } from "../generated-review/review/evolution/v1_0_0/Child"; +import { NestedHolder as OldNestedHolder } from "../generated-review/review/evolution/v1_0_0/NestedHolder"; +import { CollectionHolder as OldCollectionHolder } from "../generated-review/review/evolution/v1_0_0/CollectionHolder"; +import { Child } from "../generated-review/review/evolution/Child"; +import { convert__nested_holder__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_nested-holder"; +import { convert__collection_holder__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_collection-holder"; +import { Shape_Circle as OldCircle } from "../generated-review/review/evolution/v1_0_0/Shape"; +import { Shape_Circle } from "../generated-review/review/evolution/Shape"; +import { Leaf as OldLeaf } from "../generated-review/review/evolution/v1_0_0/Leaf"; +import { Leaf } from "../generated-review/review/evolution/Leaf"; +import { Containers as OldContainers } from "../generated-review/review/evolution/v1_0_0/Containers"; +import { Color as OldColor } from "../generated-review/review/evolution/v1_0_0/Color"; +import { Color } from "../generated-review/review/evolution/Color"; +import { Tone as OldTone } from "../generated-review/review/evolution/v1_0_0/Tone"; +import { Tone } from "../generated-review/review/evolution/Tone"; +import { convert__tone__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_tone"; +import { NativeHolder as OldNativeHolder } from "../generated-review/review/evolution/v1_0_0/NativeHolder"; +import { Manual as OldManual } from "../generated-review/review/evolution/v1_0_0/Manual"; +import { ManualHolder as OldManualHolder } from "../generated-review/review/evolution/v1_0_0/ManualHolder"; +import { convert__shape__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_shape"; +import { convert__containers__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_containers"; +import { convert__native_holder__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_native-holder"; +import { convert__manual_holder__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_manual-holder"; +import { OldRenamedShape_Circle } from "../generated-review/review/evolution/v1_0_0/OldRenamedShape"; +import { RenamedShape_Circle } from "../generated-review/review/evolution/RenamedShape"; +import { convert__old_renamed_shape__from__1_0_0 } from "../generated-review/review/evolution/from_1_0_0_old-renamed-shape"; + +describe("typed schema evolution", () => { + test("renamed ADTs convert branch payload changes with the new constructor", () => { + const result = convert__old_renamed_shape__from__1_0_0(new OldRenamedShape_Circle(7)); + expect(result).toBeInstanceOf(RenamedShape_Circle); + expect(result.radius).toBe(7n); + expect(result.label).toBeUndefined(); + }); + test("renames enum values using the configured representation", () => { + expect(convert__tone__from__1_0_0(OldTone.lightBlue)).toBe(Tone.deepBlue); + }); + test("retains ADT branch prototypes", () => { + const result = convert__shape__from__1_0_0(new OldCircle(7)); + expect(result).toBeInstanceOf(Shape_Circle); + expect(result.radius).toBe(7); + }); + + test("recurses into lists and both map key representations", () => { + const result = convert__containers__from__1_0_0(new OldContainers( + [new OldLeaf(1)], new Map([["x", new OldLeaf(2)]]), new Map([[3, new OldLeaf(4)]]), OldColor.red)); + expect(result.leaves[0]).toBeInstanceOf(Leaf); + expect(result.named.get("x")).toBeInstanceOf(Leaf); + expect(result.numbered.get(3)).toBeInstanceOf(Leaf); + expect(result.color).toBe(Color.red); + }); + + test("preserves unchanged foreign values without serializing", () => { + const value = new Date("2020-01-01T00:00:00Z"); + expect(convert__native_holder__from__1_0_0(new OldNativeHolder(value)).value).toBe(value); + }); + + test("fails explicitly when a nested conversion requires implementation", () => { + expect(() => convert__manual_holder__from__1_0_0(new OldManualHolder(new OldManual(7)))) + .toThrow("Custom conversion required: Manual -> Manual"); + }); + test("retains nested prototypes, bigint, sets and maps", () => { + const source = new OldNestedHolder(new OldChild(9007199254740993n, new Set(["a", "b"]), new Map([["x", 42n]]))); + const result = convert__nested_holder__from__1_0_0(source); + expect(result.child).toBeInstanceOf(Child); + expect(result.child.number).toBe(9007199254740993n); + expect(result.child.tags).toEqual(new Set(["a", "b"])); + expect(result.child.amounts).toEqual(new Map([["x", 42n]])); + expect(result.child.baboonDomainVersion()).toBe("2.0.0"); + }); + + test("turns a present optional into a singleton list", () => { + expect(convert__collection_holder__from__1_0_0(new OldCollectionHolder(42)).values).toEqual([42]); + }); + + test("turns an absent optional into an empty list", () => { + expect(convert__collection_holder__from__1_0_0(new OldCollectionHolder(undefined)).values).toEqual([]); + }); +}); diff --git a/test/ts-stub/src/runtime-tests/RecordEvolution.test.ts b/test/ts-stub/src/runtime-tests/RecordEvolution.test.ts new file mode 100644 index 000000000..a3e9d2d95 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/RecordEvolution.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { Leaf as OldLeaf } from "../generated-review-records/review/evolution/v1_0_0/Leaf"; +import { Leaf } from "../generated-review-records/review/evolution/Leaf"; +import { Containers as OldContainers } from "../generated-review-records/review/evolution/v1_0_0/Containers"; +import { Color as OldColor } from "../generated-review-records/review/evolution/v1_0_0/Color"; +import { convert__containers__from__1_0_0 } from "../generated-review-records/review/evolution/from_1_0_0_containers"; + +test("record mode recursively converts string keys while retaining numeric Maps", () => { + const result = convert__containers__from__1_0_0(new OldContainers( + [], { x: new OldLeaf(2) }, new Map([[3, new OldLeaf(4)]]), OldColor.red)); + expect(result.named.x).toBeInstanceOf(Leaf); + expect(result.named.x.value).toBe(2); + expect(result.numbered.get(3)).toBeInstanceOf(Leaf); + expect(result.numbered.get(3)?.value).toBe(4); +}); diff --git a/test/ts-stub/src/runtime-tests/ServiceRegistry.test.ts b/test/ts-stub/src/runtime-tests/ServiceRegistry.test.ts new file mode 100644 index 000000000..6411b6830 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/ServiceRegistry.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vitest"; +import { BaboonCodecContext, BaboonWiringException, JsonMuxer, JsonMuxerCtx, UebaMuxer, UebaMuxerCtx } from "../generated/BaboonSharedRuntime"; + +test("JSON service registries preserve order, duplicate errors and context forwarding", () => { + const ctx = BaboonCodecContext.Indexed; + const service = { serviceName: "one", invoke: (_method: unknown, data: string, codecCtx: BaboonCodecContext) => { + expect(codecCtx).toBe(ctx); return data; + } }; + const mux = new JsonMuxer(service); + mux.register({ ...service, serviceName: "two" }); + expect(mux.serviceNames()).toEqual(["one", "two"]); + expect(() => mux.register(service)).toThrow(BaboonWiringException); + expect(() => mux.invoke({ serviceName: "missing", methodName: "x" }, "{}", ctx)).toThrow(BaboonWiringException); + expect(mux.invoke({ serviceName: "one", methodName: "x" }, "{}", ctx)).toBe("{}"); + const contextual = new JsonMuxerCtx({ serviceName: "one", invoke: (_method, data, state, codecCtx) => { + expect(state).toBe(42); expect(codecCtx).toBe(ctx); return data; + } }); + expect(contextual.invoke({ serviceName: "one", methodName: "x" }, "{}", 42, ctx)).toBe("{}"); + expect(contextual.serviceNames()).toEqual(["one"]); +}); + +test("UEBA service registries retain the supplied bytes and context", () => { + const data = new Uint8Array([1, 2]); + const ctx = BaboonCodecContext.Compact; + const mux = new UebaMuxer({ serviceName: "one", invoke: (_method, bytes, codecCtx) => { + expect(codecCtx).toBe(ctx); return bytes; + } }); + expect(mux.invoke({ serviceName: "one", methodName: "x" }, data, ctx)).toBe(data); + const contextual = new UebaMuxerCtx({ serviceName: "one", invoke: (_method, bytes, state, codecCtx) => { + expect(state).toBe(42); expect(codecCtx).toBe(ctx); return bytes; + } }); + expect(contextual.invoke({ serviceName: "one", methodName: "x" }, data, 42, ctx)).toBe(data); + expect(contextual.serviceNames()).toEqual(["one"]); +}); From bb2c782747dd1bf553a20f3b9b486a5b16296032 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 17 Sep 2026 19:40:00 +0100 Subject: [PATCH 20/48] refactor(translators): share product, service and metadata planning --- .../tests/EvolutionMetadataPlanTest.scala | 58 +++++++++++++++++++ .../baboon/tests/ServiceMethodPlanTest.scala | 27 +++++---- .../translator/EvolutionMetadataPlan.scala | 25 ++++++++ .../baboon/translator/ServiceMethodPlan.scala | 18 ++++++ .../csharp/CSBaboonTranslator.scala | 29 ++++------ .../translator/csharp/CSDefnTranslator.scala | 4 +- .../csharp/CSServiceMethodPlan.scala | 17 ------ .../csharp/CSServiceWiringTranslator.scala | 25 ++++---- .../translator/dart/DtBaboonTranslator.scala | 38 ++++-------- .../translator/java/JvBaboonTranslator.scala | 29 ++++------ .../translator/java/JvDefnTranslator.scala | 6 +- .../translator/java/JvServiceMethodPlan.scala | 18 ------ .../java/JvServiceWiringTranslator.scala | 23 ++++---- .../kotlin/KtBaboonTranslator.scala | 29 ++++------ .../translator/kotlin/KtDefnTranslator.scala | 4 +- .../kotlin/KtServiceMethodPlan.scala | 20 ------- .../kotlin/KtServiceWiringTranslator.scala | 19 +++--- .../python/PyBaboonTranslator.scala | 38 ++++-------- .../translator/rust/RsBaboonTranslator.scala | 31 ++++------ .../translator/scl/ScBaboonTranslator.scala | 29 ++++------ .../translator/scl/ScDefnTranslator.scala | 4 +- .../translator/scl/ScServiceMethodPlan.scala | 20 ------- .../scl/ScServiceWiringTranslator.scala | 15 +++-- .../translator/swift/SwBaboonTranslator.scala | 38 ++++-------- .../typescript/TsBaboonTranslator.scala | 34 +++++------ .../followup.md | 24 ++++++++ 26 files changed, 301 insertions(+), 321 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/EvolutionMetadataPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceMethodPlan.scala delete mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala delete mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala delete mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala delete mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala create mode 100644 docs/research/20260916-backend-runtime-review/followup.md diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala new file mode 100644 index 000000000..6cabf2d0d --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala @@ -0,0 +1,58 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.EvolutionMetadataPlan +import io.septimalmind.baboon.translator.EvolutionMetadataPlan.{ReaderVersion, SameIn} +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.collections.nonempty.NEList +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class EvolutionMetadataPlanTest extends AnyFlatSpec with Matchers { + private val pkg = Pkg(NEList("metadata")) + private val typeA = TypeId.User(pkg, Owner.Toplevel, TypeName("A")) + private val z = TypeId.User(pkg, Owner.Toplevel, TypeName("Z")) + private val v1 = Version.parse("1.0.0") + private val v2 = Version.parse("2.0.0") + private val v10 = Version.parse("10.0.0") + private val evolution = BaboonEvolution( + pkg, + v10, + Map.empty, + Map.empty, + Map(v2 -> Map( + z -> UnmodifiedSince(z, v2, NEList(v1, v2, v10)), + typeA -> UnmodifiedSince(typeA, v2, NEList(v2)), + TypeId.Builtins.str -> UnmodifiedSince(TypeId.Builtins.str, v2, NEList(v1, v2)), + )), + Map(v2 -> Map( + z -> ForwardReadable(z, v2, NEList(v2 -> ForwardCompatTier.Identical, v10 -> ForwardCompatTier.JsonAdditive)), + typeA -> ForwardReadable(typeA, v2, NEList(v2 -> ForwardCompatTier.Identical)), + )), + ) + + "Evolution metadata preparation" should "sort type rows while preserving version order and builtin rows" in { + val plan = EvolutionMetadataPlan(evolution, v2) + plan.sameIn shouldBe List( + SameIn(TypeId.Builtins.str, List("1.0.0", "2.0.0")), + SameIn(typeA, List("2.0.0")), + SameIn(z, List("1.0.0", "2.0.0", "10.0.0")), + ) + plan.forwardReadable.map(_.typeId) shouldBe List(typeA, z) + plan.forwardReadable.last.versions shouldBe List( + ReaderVersion("2.0.0", "identical"), + ReaderVersion("10.0.0", "json-additive"), + ) + } + + it should "retain sparse tables without synthesizing missing type entries" in { + val plan = EvolutionMetadataPlan(evolution, v2) + plan.forwardReadable.exists(_.typeId == TypeId.Builtins.str) shouldBe false + val empty = evolution.copy(typesUnchangedSince = Map(v2 -> Map.empty), typesForwardReadable = Map(v2 -> Map.empty)) + EvolutionMetadataPlan(empty, v2) shouldBe EvolutionMetadataPlan(Nil, Nil) + } + + it should "fail when the requested version is absent instead of creating empty metadata" in { + intercept[NoSuchElementException](EvolutionMetadataPlan(evolution, v1)) + intercept[NoSuchElementException](EvolutionMetadataPlan(evolution.copy(typesForwardReadable = Map.empty), v2)) + } +} diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala index c7aa72c7c..9dd74a504 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ServiceMethodPlanTest.scala @@ -1,10 +1,9 @@ package io.septimalmind.baboon.tests -import io.septimalmind.baboon.translator.ResolvedServiceResult -import io.septimalmind.baboon.translator.csharp.CSServiceMethodPlan -import io.septimalmind.baboon.translator.java.JvServiceMethodPlan -import io.septimalmind.baboon.translator.kotlin.KtServiceMethodPlan -import io.septimalmind.baboon.translator.scl.ScServiceMethodPlan +import io.septimalmind.baboon.translator.{ResolvedServiceResult, ServiceMethodPlan} +import io.septimalmind.baboon.translator.csharp.CSTypes +import io.septimalmind.baboon.translator.java.JvTypeTranslator +import io.septimalmind.baboon.translator.kotlin.KtTypeTranslator import io.septimalmind.baboon.typer.model.{TypeId, TypeRef, Typedef} import io.septimalmind.baboon.typer.model.Typedef.MethodName import izumi.fundamentals.platform.strings.TextTree.* @@ -19,10 +18,11 @@ class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { "Kotlin method facts" should "retain keyword escaping and defer type resolution until its output is used" in { var resolvedTypes = List.empty[TypeRef] - val plan = new KtServiceMethodPlan( + val plan = new ServiceMethodPlan( method, tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + KtTypeTranslator.escapeKtKeyword(method.name.name), ) plan.methodName shouldBe "`when`" plan.hasError shouldBe true @@ -38,10 +38,11 @@ class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { "Scala method facts" should "retain raw method spelling and configured error suppression" in { var resolvedTypes = List.empty[TypeRef] - val plan = new ScServiceMethodPlan( + val plan = new ServiceMethodPlan( method, tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, ResolvedServiceResult(noErrors = true, Some("Either"), Some("[$error, $success]"), None), + method.name.name, ) plan.methodName shouldBe "when" plan.hasError shouldBe false @@ -54,8 +55,8 @@ class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { it should "keep absent output and error types unresolved" in { val empty = method.copy(out = None, err = None) val result = ResolvedServiceResult(noErrors = false, Some("Either"), Some("[$error, $success]"), None) - val sc = new ScServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result) - val kt = new KtServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result) + val sc = new ServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result, empty.name.name) + val kt = new ServiceMethodPlan(empty, _ => throw new AssertionError("unused type resolved"), result, KtTypeTranslator.escapeKtKeyword(empty.name.name)) sc.output shouldBe None sc.error shouldBe None sc.hasError shouldBe false @@ -66,12 +67,13 @@ class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { "Java method facts" should "escape declarations and resolve requested types once" in { var resolvedTypes = List.empty[TypeRef] - val plan = new JvServiceMethodPlan( + val plan = new ServiceMethodPlan( method.copy(name = MethodName("class")), tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + JvTypeTranslator.escapeJvKeyword("class"), ) - plan.declarationName shouldBe "class_" + plan.methodName shouldBe "class_" plan.hasError shouldBe true plan.hasOutput shouldBe true resolvedTypes shouldBe Nil @@ -84,10 +86,11 @@ class ServiceMethodPlanTest extends AnyFlatSpec with Matchers { "C# method facts" should "retain PascalCase and resolve each requested type once" in { var resolvedTypes = List.empty[TypeRef] - val plan = new CSServiceMethodPlan( + val plan = new ServiceMethodPlan( method, tpe => { resolvedTypes = resolvedTypes :+ tpe; q"Type" }, ResolvedServiceResult(noErrors = false, Some("Either"), Some("<$error, $success>"), None), + CSTypes.escapeCsKeyword(method.name.name.capitalize), ) plan.methodName shouldBe "When" plan.hasError shouldBe true diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/EvolutionMetadataPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/EvolutionMetadataPlan.scala new file mode 100644 index 000000000..02df1a064 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/EvolutionMetadataPlan.scala @@ -0,0 +1,25 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.{BaboonEvolution, TypeId, Version} + +final case class EvolutionMetadataPlan( + sameIn: List[EvolutionMetadataPlan.SameIn], + forwardReadable: List[EvolutionMetadataPlan.ForwardReadable], +) + +object EvolutionMetadataPlan { + final case class SameIn(typeId: TypeId, versions: List[String]) + final case class ReaderVersion(version: String, tier: String) + final case class ForwardReadable(typeId: TypeId, versions: List[ReaderVersion]) + + def apply(evolution: BaboonEvolution, version: Version): EvolutionMetadataPlan = { + val sameIn = evolution.typesUnchangedSince(version).toList.sortBy(_._1.toString).map { + case (id, unchanged) => SameIn(id, unchanged.sameIn.toList.map(_.v.toString)) + } + val forwardReadable = evolution.typesForwardReadable(version).toList.sortBy(_._1.toString).map { + case (id, readable) => + ForwardReadable(id, readable.readable.toList.map { case (v, tier) => ReaderVersion(v.v.toString, tier.wireName) }) + } + EvolutionMetadataPlan(sameIn, forwardReadable) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceMethodPlan.scala new file mode 100644 index 000000000..a81bf7844 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceMethodPlan.scala @@ -0,0 +1,18 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} + +final class ServiceMethodPlan[A]( + val method: Typedef.MethodDef, + resolveType: TypeRef => A, + result: ResolvedServiceResult, + val methodName: String, +) { + val hasError: Boolean = method.err.isDefined && !result.noErrors + val hasOutput: Boolean = method.out.isDefined + + // Disabled transports and error modes must not resolve types they do not use. + lazy val input: A = resolveType(method.sig) + lazy val output: Option[A] = method.out.map(resolveType) + lazy val error: Option[A] = method.err.map(resolveType) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala index 786c9f3d3..e3e531e72 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSBaboonTranslator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue import io.septimalmind.baboon.translator.csharp.CSDefnTranslator.OutputOrigin import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSPackageId -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -314,24 +314,17 @@ class CSBaboonTranslator[F[+_, +_]: Error2]( val basename = csFiles.basename(domain, lineage.evolution) val pkg = trans.toCsPkg(domain.id, domain.version, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q"""_unmodified.Add("${tid.toString}", new $csList<$csString> { ${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")} });""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q"""_unmodified.Add("${tid.toString}", new $csList<$csString> { ${versions.map(s => q"\"$s\"").join(", ")} });""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s"""{ "${v.v.toString}", "${tier.wireName}" }""" }.mkString(", ") - q"""_forwardReadable.Add("${tid.toString}", new $csDictionary<$csString, $csString> { $pairs });""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s"""{ "$v", "$tier" }""" }.mkString(", ") + q"""_forwardReadable.Add("${tid.toString}", new $csDictionary<$csString, $csString> { $pairs });""" + } val metaTree = q"""public sealed class BaboonMeta : $iBaboonMeta diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala index dc7df5e12..4da8254c4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala @@ -3,7 +3,7 @@ package io.septimalmind.baboon.translator.csharp import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.CSTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue -import io.septimalmind.baboon.translator.IdentifierFieldKind +import io.septimalmind.baboon.translator.{IdentifierFieldKind, ServiceMethodPlan} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.{CSPackageId, CSType, CSTypeOrigin} @@ -507,7 +507,7 @@ object CSDefnTranslator { } val methods = service.methods.map { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = new ServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved, CSTypes.escapeCsKeyword(m.name.name.capitalize)) val out = plan.output val err = plan.error val csFqName: CSValue => String = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala deleted file mode 100644 index a2a6223f3..000000000 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceMethodPlan.scala +++ /dev/null @@ -1,17 +0,0 @@ -package io.septimalmind.baboon.translator.csharp - -import io.septimalmind.baboon.translator.ResolvedServiceResult -import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} -import izumi.fundamentals.platform.strings.TextTree - -final class CSServiceMethodPlan( - val method: Typedef.MethodDef, - resolveType: TypeRef => TextTree[CSValue], - result: ResolvedServiceResult, -) { - val methodName: String = CSTypes.escapeCsKeyword(method.name.name.capitalize) - val hasError: Boolean = method.err.isDefined && !result.noErrors - lazy val input: TextTree[CSValue] = resolveType(method.sig) - lazy val output: Option[TextTree[CSValue]] = method.out.map(resolveType) - lazy val error: Option[TextTree[CSValue]] = method.err.map(resolveType) -} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala index b7069655a..add59dcaa 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.csharp import io.septimalmind.baboon.CompilerTarget.CSTarget -import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceResultResolver} +import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceMethodPlan, ServiceResultResolver} import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSTypeOrigin import io.septimalmind.baboon.typer.model.* @@ -28,6 +28,9 @@ object CSServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "cs", target.language.serviceResult, target.language.pragmas) + private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[CSValue]] = + new ServiceMethodPlan(method, trans.asCsRef(_, domain, evo), resolved, CSTypes.escapeCsKeyword(method.name.name.capitalize)) + private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "cs", target.language.serviceContext, target.language.pragmas) @@ -227,7 +230,7 @@ object CSServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val retType = clientRet(plan.output) @@ -451,13 +454,13 @@ object CSServiceWiringTranslator { |}""".stripMargin } - private def noErrorsInvocation(plan: CSServiceMethodPlan): TextTree[CSValue] = + private def noErrorsInvocation(plan: ServiceMethodPlan[TextTree[CSValue]]): TextTree[CSValue] = plan.method.out match { case Some(_) => q"var result = ${awaitKw}impl.${plan.methodName}(${ctxArgPass}decoded);" case None => q"${awaitKw}impl.${plan.methodName}(${ctxArgPass}decoded);" } - private def errorsInvocation(plan: CSServiceMethodPlan): TextTree[CSValue] = plan.output match { + private def errorsInvocation(plan: ServiceMethodPlan[TextTree[CSValue]]): TextTree[CSValue] = plan.output match { case Some(outType) => if (plan.hasError) { val errType = plan.error.get @@ -508,7 +511,7 @@ object CSServiceWiringTranslator { } private def errorsAsyncInvocation( - plan: CSServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[CSValue]], wireType: TextTree[CSValue], emptyWire: TextTree[CSValue], encodeFlatMap: TextTree[CSValue], @@ -611,7 +614,7 @@ object CSServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -653,7 +656,7 @@ object CSServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -747,7 +750,7 @@ object CSServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") @@ -832,7 +835,7 @@ object CSServiceWiringTranslator { * `rt.Pure` / `rt.Fail` boundaries, identical to the sync arm's results. */ private def generateErrorsJsonCaseAsync( - plan: CSServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[CSValue]], inRef: TextTree[CSValue], decodeIn: TextTree[CSValue], ): TextTree[CSValue] = { @@ -887,7 +890,7 @@ object CSServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new CSServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") @@ -971,7 +974,7 @@ object CSServiceWiringTranslator { * [[generateErrorsJsonCaseAsync]] for the await-then-thread rationale. */ private def generateErrorsUebaCaseAsync( - plan: CSServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[CSValue]], inRef: TextTree[CSValue], decodeIn: TextTree[CSValue], ): TextTree[CSValue] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala index ccca0478f..d11987346 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.DtTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.dart.DtTypes.* -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -65,14 +65,7 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[DtDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[DtDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[DtDefnTranslator.Output]] = { @@ -202,24 +195,17 @@ class DtBaboonTranslator[F[+_, +_]: Error2]( val basename = dtFiles.basename(domain, lineage.evolution) val pkg = trans.toDtPkg(domain.id, domain.version, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q"""'${tid.toString}': [${version.sameIn.map(_.v.toString).map(s => q"'$s'").toList.join(", ")}],""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q"""'${tid.toString}': [${versions.map(s => q"'$s'").join(", ")}],""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s"'${v.v.toString}': '${tier.wireName}'" }.mkString(", ") - q"""'${tid.toString}': {$pairs},""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s"'$v': '$tier'" }.mkString(", ") + q"""'${tid.toString}': {$pairs},""" + } val metaTree = q"""class BaboonMetadata { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala index 0ed1241ad..b80a7dd90 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.JvTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.java.JvTypes.* -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -163,24 +163,17 @@ class JvBaboonTranslator[F[+_, +_]: Error2]( val basename = jvFiles.basename(domain, lineage.evolution) val pkg = trans.toJvPkg(domain.id, domain.version, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q"""unmodified.put("${tid.toString}", $jvList.of(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}));""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q"""unmodified.put("${tid.toString}", $jvList.of(${versions.map(s => q"\"$s\"").join(", ")}));""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s"""java.util.Map.entry("${v.v.toString}", "${tier.wireName}")""" }.mkString(", ") - q"""forwardReadable.put("${tid.toString}", $jvMap.ofEntries($pairs));""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s"""java.util.Map.entry("$v", "$tier")""" }.mkString(", ") + q"""forwardReadable.put("${tid.toString}", $jvMap.ofEntries($pairs));""" + } val metaTree = q"""public final class BaboonMetadata implements $baboonMeta { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala index 925f4f782..7cdd570dd 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala @@ -3,7 +3,7 @@ package io.septimalmind.baboon.translator.java import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.JvTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue -import io.septimalmind.baboon.translator.IdentifierFieldKind +import io.septimalmind.baboon.translator.{IdentifierFieldKind, ServiceMethodPlan} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.java.JvValue.JvType import io.septimalmind.baboon.typer.EnumWireStyle @@ -595,7 +595,7 @@ object JvDefnTranslator { } val methods = service.methods.map { m => - val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val plan = new ServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved, JvTypeTranslator.escapeJvKeyword(m.name.name)) val in = plan.input val out = plan.output val err = plan.error @@ -606,7 +606,7 @@ object JvDefnTranslator { val outStr = out.map(_.mapRender(jvFqName)).getOrElse("void") val errStr = err.map(_.mapRender(jvFqName)) val retStr = resolved.renderReturnType(outStr, errStr, "void") - val javaMethodName = plan.declarationName + val javaMethodName = plan.methodName val retTree: TextTree[JvValue] = if (target.language.asyncServices) { val inner = if (retStr == "void") "Void" else retStr diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala deleted file mode 100644 index bd02013b6..000000000 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceMethodPlan.scala +++ /dev/null @@ -1,18 +0,0 @@ -package io.septimalmind.baboon.translator.java - -import io.septimalmind.baboon.translator.ResolvedServiceResult -import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} -import izumi.fundamentals.platform.strings.TextTree - -final class JvServiceMethodPlan( - val method: Typedef.MethodDef, - resolveType: TypeRef => TextTree[JvValue], - result: ResolvedServiceResult, -) { - val hasError: Boolean = method.err.isDefined && !result.noErrors - val hasOutput: Boolean = method.out.isDefined - val declarationName: String = JvTypeTranslator.escapeJvKeyword(method.name.name) - lazy val input: TextTree[JvValue] = resolveType(method.sig) - lazy val output: Option[TextTree[JvValue]] = method.out.map(resolveType) - lazy val error: Option[TextTree[JvValue]] = method.err.map(resolveType) -} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala index 566c3e32b..c5a0139e3 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.java import io.septimalmind.baboon.CompilerTarget.JvTarget -import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceResultResolver} +import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceMethodPlan, ServiceResultResolver} import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree @@ -25,6 +25,9 @@ object JvServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "java", target.language.serviceResult, target.language.pragmas) + private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[JvValue]] = + new ServiceMethodPlan(method, trans.asJvRef(_, domain, evo), resolved, JvTypeTranslator.escapeJvKeyword(method.name.name)) + private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "java", target.language.serviceContext, target.language.pragmas) @@ -179,7 +182,7 @@ object JvServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val uebaMethod = if (hasUeba) Some(generateClientUebaMethod(svcName, plan, inRef)) else None @@ -277,7 +280,7 @@ object JvServiceWiringTranslator { private def generateClientUebaMethod( svcName: String, - plan: JvServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[JvValue]], inRef: TextTree[JvValue], ): TextTree[JvValue] = { val m = plan.method @@ -335,7 +338,7 @@ object JvServiceWiringTranslator { private def generateClientJsonMethod( svcName: String, - plan: JvServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[JvValue]], inRef: TextTree[JvValue], ): TextTree[JvValue] = { val m = plan.method @@ -677,7 +680,7 @@ object JvServiceWiringTranslator { private def ct(error: String, success: String): String = renderContainer(error, success) - private def synchronousInvocation(plan: JvServiceMethodPlan): TextTree[JvValue] = { + private def synchronousInvocation(plan: ServiceMethodPlan[TextTree[JvValue]]): TextTree[JvValue] = { val m = plan.method if (plan.hasError) { q"""try { @@ -709,7 +712,7 @@ object JvServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") @@ -786,7 +789,7 @@ object JvServiceWiringTranslator { // CompletableFuture-around-F double-wrap. Decode failures short-circuit with // a single completedFuture(rt.fail(…)). private def generateErrorsJsonCaseAsync( - plan: JvServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[JvValue]], inRef: TextTree[JvValue], decodeIn: TextTree[JvValue], ): TextTree[JvValue] = { @@ -840,7 +843,7 @@ object JvServiceWiringTranslator { } private def asynchronousInvocation( - plan: JvServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[JvValue]], bindOutput: TextTree[JvValue], encode: TextTree[JvValue], ): TextTree[JvValue] = { @@ -874,7 +877,7 @@ object JvServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new JvServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") @@ -948,7 +951,7 @@ object JvServiceWiringTranslator { // Async errors-mode UEBA case (one `switch` arm). See // [[generateErrorsJsonCaseAsync]] for the chain-then-thread rationale. private def generateErrorsUebaCaseAsync( - plan: JvServiceMethodPlan, + plan: ServiceMethodPlan[TextTree[JvValue]], inRef: TextTree[JvValue], decodeIn: TextTree[JvValue], ): TextTree[JvValue] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala index 8a01a6263..b39ee385a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtBaboonTranslator.scala @@ -6,7 +6,7 @@ import io.septimalmind.baboon.CompilerTarget.KtTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.translator.kotlin.KtValue.KtPackageId -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -180,24 +180,17 @@ class KtBaboonTranslator[F[+_, +_]: Error2]( val basename = ktFiles.basename(domain, lineage.evolution) val pkg = trans.toKtPkg(domain.id, domain.version, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q"""unmodified["${tid.toString}"] = listOf(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")})""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q"""unmodified["${tid.toString}"] = listOf(${versions.map(s => q"\"$s\"").join(", ")})""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}" to "${tier.wireName}"""" }.mkString(", ") - q"""forwardReadable["${tid.toString}"] = mapOf($pairs)""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s""""$v" to "$tier"""" }.mkString(", ") + q"""forwardReadable["${tid.toString}"] = mapOf($pairs)""" + } val metaTree = q"""object BaboonMetadata : $baboonMeta { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala index 825473617..9a6997731 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala @@ -3,7 +3,7 @@ package io.septimalmind.baboon.translator.kotlin import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.KtTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue -import io.septimalmind.baboon.translator.IdentifierFieldKind +import io.septimalmind.baboon.translator.{IdentifierFieldKind, ServiceMethodPlan} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.kotlin.KtValue.KtType import io.septimalmind.baboon.typer.EnumWireStyle @@ -433,7 +433,7 @@ object KtDefnTranslator { } val methods = service.methods.map { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = new ServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved, KtTypeTranslator.escapeKtKeyword(m.name.name)) val in = plan.input val out = plan.output val err = plan.error diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala deleted file mode 100644 index 14f050127..000000000 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceMethodPlan.scala +++ /dev/null @@ -1,20 +0,0 @@ -package io.septimalmind.baboon.translator.kotlin - -import io.septimalmind.baboon.translator.ResolvedServiceResult -import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} -import izumi.fundamentals.platform.strings.TextTree - -final class KtServiceMethodPlan( - method: Typedef.MethodDef, - resolveType: TypeRef => TextTree[KtValue], - result: ResolvedServiceResult, -) { - val methodName: String = KtTypeTranslator.escapeKtKeyword(method.name.name) - val hasError: Boolean = method.err.isDefined && !result.noErrors - - // Keep type resolution demand-driven: a disabled transport or error mode - // must not start resolving types that its renderer never used. - lazy val input: TextTree[KtValue] = resolveType(method.sig) - lazy val output: Option[TextTree[KtValue]] = method.out.map(resolveType) - lazy val error: Option[TextTree[KtValue]] = method.err.map(resolveType) -} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala index 23d667348..e6f060829 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.kotlin import io.septimalmind.baboon.CompilerTarget.KtTarget -import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceResultResolver} +import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceMethodPlan, ServiceResultResolver} import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree @@ -52,6 +52,9 @@ object KtServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "kotlin", target.language.serviceResult, target.language.pragmas) + private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[KtValue]] = + new ServiceMethodPlan(method, trans.asKtRef(_, domain, evo), resolved, KtTypeTranslator.escapeKtKeyword(method.name.name)) + private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "kotlin", target.language.serviceContext, target.language.pragmas) @@ -365,7 +368,7 @@ object KtServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -407,7 +410,7 @@ object KtServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -587,7 +590,7 @@ object KtServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") @@ -696,7 +699,7 @@ object KtServiceWiringTranslator { inRef: TextTree[KtValue], decodeIn: TextTree[KtValue], ): TextTree[KtValue] = { - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val hasErrType = plan.hasError val decodeStep = @@ -773,7 +776,7 @@ object KtServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") @@ -878,7 +881,7 @@ object KtServiceWiringTranslator { inRef: TextTree[KtValue], decodeIn: TextTree[KtValue], ): TextTree[KtValue] = { - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val hasErrType = plan.hasError val decodeStep = @@ -962,7 +965,7 @@ object KtServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val plan = new KtServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved) + val plan = methodPlan(m) val inTypeRef = plan.input val outTypeRef = plan.output val retType = outTypeRef.getOrElse(q"Unit") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala index bfd072304..ee736c06a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerTarget.PyTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.python.PyTypes.* import io.septimalmind.baboon.translator.python.PyValue.PyModuleId -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.{BaboonFamily, BaboonLineage, Domain, DomainMember, EvolutionStep} import izumi.distage.Subcontext import izumi.functional.bio.{Error2, F} @@ -417,24 +417,17 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( private def generateMeta(domain: Domain, lineage: BaboonLineage): Out[List[PyDefnTranslator.Output]] = { val basename = pyFileTools.basename(domain, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q""""${tid.toString}": [${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}]""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q""""${tid.toString}": [${versions.map(s => q"\"$s\"").join(", ")}]""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") - q""""${tid.toString}": {$pairs}""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s""""$v": "$tier"""" }.mkString(", ") + q""""${tid.toString}": {$pairs}""" + } val metaTree = q"""class BaboonMetadata($baboonMeta): @@ -472,14 +465,7 @@ class PyBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[PyDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[PyDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index c9e0440b2..a78654ff1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.RsTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.rust.RsDefnTranslator.{escapeRustKeyword, escapeRustModuleName, escapeRustTypeName, toSnakeCaseRaw} -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -106,14 +106,7 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[RsDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[RsDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[RsDefnTranslator.Output]] = { @@ -230,26 +223,26 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( val types = collectTypes(domain) val (jsonFn, binFn, metaName) = factoryNames(domain) - val fwdByTypeId = lineage.evolution.typesForwardReadable(domain.version).map { case (tid, fr) => (tid.toString, fr) } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val fwdByTypeId = metadata.forwardReadable.map { case EvolutionMetadataPlan.ForwardReadable(tid, readers) => (tid.toString, readers) }.toMap def fwdPairs(typeId: String): String = { fwdByTypeId - .get(typeId).toList.flatMap(_.readable.toList).map { - case (v, tier) => s"""("${v.v.toString}".to_string(), "${tier.wireName}".to_string())""" + .get(typeId).toList.flatten.map { + case EvolutionMetadataPlan.ReaderVersion(v, tier) => s"""("$v".to_string(), "$tier".to_string())""" }.mkString(", ") } - val minReadersByTypeId = lineage.evolution - .typesForwardReadable(domain.version).keys.map { - tid => (tid.toString, lineage.evolution.minReaders(domain.version, tid)) - }.toMap + val minReadersByTypeId = metadata.forwardReadable.map { + entry => (entry.typeId.toString, lineage.evolution.minReaders(domain.version, entry.typeId)) + }.toMap def minReaderPairs(typeId: String): String = { minReadersByTypeId .get(typeId).toList.flatMap(_.toList.sortBy(_._1.weight)).map { case (tier, v) => s"""("${tier.wireName}".to_string(), "${v.v.toString}".to_string())""" }.mkString(", ") } - val sameInByTypeId = lineage.evolution.typesUnchangedSince(domain.version).map { - case (tid, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) - } + val sameInByTypeId = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => (tid.toString, versions) + }.toMap val perType: List[TextTree[RsValue]] = types.map { case (fullPath, dynBase, typeId, _) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala index cbb94db64..44f3b941a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.ScTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.scl.ScTypes.* -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources, scl} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources, scl} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -162,24 +162,17 @@ class ScBaboonTranslator[F[+_, +_]: Error2]( val basename = scFiles.basename(domain, lineage.evolution) val pkg = trans.toScPkg(domain.id, domain.version, lineage.evolution) - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q"""unmodified.put("${tid.toString}", $scList(${version.sameIn.map(_.v.toString).map(s => q"\"$s\"").toList.join(", ")}))""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q"""unmodified.put("${tid.toString}", $scList(${versions.map(s => q"\"$s\"").join(", ")}))""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}" -> "${tier.wireName}"""" }.mkString(", ") - q"""forwardReadable.put("${tid.toString}", ${ScTypes.scMap.fullyQualified}($pairs))""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s""""$v" -> "$tier"""" }.mkString(", ") + q"""forwardReadable.put("${tid.toString}", ${ScTypes.scMap.fullyQualified}($pairs))""" + } val metaTree = q"""object BaboonMetadata extends $baboonMeta { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala index 228f88fcf..41c0859c0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala @@ -3,7 +3,7 @@ package io.septimalmind.baboon.translator.scl import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.ScTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue -import io.septimalmind.baboon.translator.IdentifierFieldKind +import io.septimalmind.baboon.translator.{IdentifierFieldKind, ServiceMethodPlan} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} import io.septimalmind.baboon.translator.scl.ScValue.ScType import io.septimalmind.baboon.typer.EnumWireStyle @@ -423,7 +423,7 @@ object ScDefnTranslator { } val methods = service.methods.map { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = new ServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved, m.name.name) val in = plan.input val out = plan.output val err = plan.error diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala deleted file mode 100644 index ceca1fe94..000000000 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceMethodPlan.scala +++ /dev/null @@ -1,20 +0,0 @@ -package io.septimalmind.baboon.translator.scl - -import io.septimalmind.baboon.translator.ResolvedServiceResult -import io.septimalmind.baboon.typer.model.{TypeRef, Typedef} -import izumi.fundamentals.platform.strings.TextTree - -final class ScServiceMethodPlan( - method: Typedef.MethodDef, - resolveType: TypeRef => TextTree[ScValue], - result: ResolvedServiceResult, -) { - val methodName: String = method.name.name - val hasError: Boolean = method.err.isDefined && !result.noErrors - - // Keep type resolution demand-driven: a disabled transport or error mode - // must not start resolving types that its renderer never used. - lazy val input: TextTree[ScValue] = resolveType(method.sig) - lazy val output: Option[TextTree[ScValue]] = method.out.map(resolveType) - lazy val error: Option[TextTree[ScValue]] = method.err.map(resolveType) -} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala index d7bf9eb00..6fcce7276 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala @@ -1,7 +1,7 @@ package io.septimalmind.baboon.translator.scl import io.septimalmind.baboon.CompilerTarget.ScTarget -import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceResultResolver} +import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceMethodPlan, ServiceResultResolver} import io.septimalmind.baboon.translator.scl.ScTypes.* import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree @@ -27,6 +27,9 @@ object ScServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "scala", target.language.serviceResult, target.language.pragmas) + private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[ScValue]] = + new ServiceMethodPlan(method, trans.asScRef(_, domain, evo), resolved, method.name.name) + private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "scala", target.language.serviceContext, target.language.pragmas) @@ -210,7 +213,7 @@ object ScServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = methodPlan(m) val outRefOpt = plan.output val outFq = outRefOpt.map(renderFq).getOrElse("Unit") val retType = if (resolved.noErrors) outFq else ct(bweFq, outFq) @@ -548,7 +551,7 @@ object ScServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") val encodeOutput = m.out match { @@ -589,7 +592,7 @@ object ScServiceWiringTranslator { val svcName = service.id.name.name val cases = service.methods.map { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = methodPlan(m) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") val encodeOutput = m.out match { @@ -673,7 +676,7 @@ object ScServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") @@ -777,7 +780,7 @@ object ScServiceWiringTranslator { val cases = service.methods.map { m => - val plan = new ScServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved) + val plan = methodPlan(m) val inRef = plan.input val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"br") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala index 04a9ac32b..5fa9d3e58 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwBaboonTranslator.scala @@ -5,7 +5,7 @@ import io.septimalmind.baboon.CompilerProduct import io.septimalmind.baboon.CompilerTarget.SwTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.swift.SwTypes.* -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.IzCollections.* @@ -66,14 +66,7 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[SwDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[SwDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[SwDefnTranslator.Output]] = { @@ -258,24 +251,17 @@ class SwBaboonTranslator[F[+_, +_]: Error2]( val domainFileSuffix = trans.toSnakeCase(domainSuffix) val metadataClassName = s"BaboonMetadata_$domainSuffix$versionSuffix" - val entries = lineage.evolution - .typesUnchangedSince(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, version) => - q""""${tid.toString}": [${version.sameIn.map(_.v.toString).map(s => q""""$s"""").toList.join(", ")}],""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val entries = metadata.sameIn.map { + case EvolutionMetadataPlan.SameIn(tid, versions) => + q""""${tid.toString}": [${versions.map(s => q""""$s"""").join(", ")}],""" + } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version) - .toList - .sortBy(_._1.toString) - .map { - case (tid, fr) => - val pairs = fr.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") - q""""${tid.toString}": [$pairs],""" - } + val forwardEntries = metadata.forwardReadable.map { + case EvolutionMetadataPlan.ForwardReadable(tid, readers) => + val pairs = readers.map { case EvolutionMetadataPlan.ReaderVersion(v, tier) => s""""$v": "$tier"""" }.mkString(", ") + q""""${tid.toString}": [$pairs],""" + } val metaTree = q"""public class $metadataClassName { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index 70341c8c4..e9fcf8ecd 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -6,7 +6,7 @@ import io.septimalmind.baboon.CompilerTarget.TsTarget import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, TranslationIssue} import io.septimalmind.baboon.translator.typescript.TsTypes.{tsBaboonAnyOpaqueModule, tsBaboonIdReprModule, tsBaboonRuntimeShared, tsCrossLangFixtureModule, tsFixtureShared} import io.septimalmind.baboon.translator.typescript.TsValue.{TsModuleId, TsType} -import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, McpServerGeneratorHook, OutputFile, Sources} +import io.septimalmind.baboon.translator.{BaboonAbstractTranslator, DomainProductTranslator, EvolutionMetadataPlan, McpServerGeneratorHook, OutputFile, Sources} import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* import izumi.functional.bio.{Error2, F} @@ -75,14 +75,7 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( p: CompilerProduct, translate: DomainMember.User => F[NEList[BaboonIssue], List[TsDefnTranslator.Output]], ): F[NEList[BaboonIssue], List[TsDefnTranslator.Output]] = { - if (target.output.products.contains(p)) { - F.flatTraverseAccumErrors(domain.defs.meta.nodes.toList) { - case (_, defn: DomainMember.User) => translate(defn) - case _ => F.pure(List.empty) - } - } else { - F.pure(List.empty) - } + DomainProductTranslator.translate(domain, target.output.products, p, translate) } private def translateDomain(domain: Domain, lineage: BaboonLineage): Out[List[TsDefnTranslator.Output]] = { @@ -257,18 +250,17 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( // Meta class: the real per-type sameIn / forward-readable tables for this version // (user types only; unknown type ids resolve to empty, never to fabricated own-version data). - val sameInEntries = lineage.evolution - .typesUnchangedSince(domain.version).toList.collect { - case (tid: TypeId.User, u) => (tid.toString, u.sameIn.toList.map(_.v.toString)) - }.sortBy(_._1).map { - case (tid, vs) => q""""$tid": [${vs.map(v => s"'$v'").mkString(", ")}],""" - } - val forwardEntries = lineage.evolution - .typesForwardReadable(domain.version).toList.collect { - case (tid: TypeId.User, fr) => (tid.toString, fr.readable.toList.map { case (v, tier) => (v.v.toString, tier.wireName) }) - }.sortBy(_._1).map { - case (tid, pairs) => q""""$tid": { ${pairs.map { case (v, t) => s"'$v': '$t'" }.mkString(", ")} },""" - } + val metadata = EvolutionMetadataPlan(lineage.evolution, domain.version) + val sameInEntries = metadata.sameIn.collect { + case EvolutionMetadataPlan.SameIn(tid: TypeId.User, versions) => (tid.toString, versions) + }.map { + case (tid, vs) => q""""$tid": [${vs.map(v => s"'$v'").mkString(", ")}],""" + } + val forwardEntries = metadata.forwardReadable.collect { + case EvolutionMetadataPlan.ForwardReadable(tid: TypeId.User, readers) => (tid.toString, readers) + }.map { + case (tid, pairs) => q""""$tid": { ${pairs.map { case EvolutionMetadataPlan.ReaderVersion(v, t) => s"'$v': '$t'" }.mkString(", ")} },""" + } q"""class ${verClassName}JsonCodecs extends AbstractBaboonJsonCodecs { | constructor() { diff --git a/docs/research/20260916-backend-runtime-review/followup.md b/docs/research/20260916-backend-runtime-review/followup.md new file mode 100644 index 000000000..77d29655d --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/followup.md @@ -0,0 +1,24 @@ +# Shared planning follow-up + +Baseline: `4aef4b50`. The user authorized proposals 1–3 as a validated, separate commit, followed by a Java/Kotlin pilot of proposal 4. Proposal 5 (UEBA field-layout planning) is not included. + +## Batch 1: product traversal, service facts and evolution metadata + +- Reuse `DomainProductTranslator` in Python, Rust, TypeScript, Dart and Swift. Preserve member order, accumulated errors and disabled-product laziness. +- Replace the four backend-local service-method plan classes with `ServiceMethodPlan[A]`. Keep naming/escaping in each backend and retain independently lazy, once-per-plan type resolution. +- Prepare ordered evolution metadata through `EvolutionMetadataPlan` in all nine backends. Keep builtin rows in the shared plan; TypeScript's user-only projection and Rust's existing fallback/min-reader behavior stay local. Preserve version-list order and wire tier names. + +### Verification + +- Before edits: all 8 existing traversal/service-plan tests passed. +- Captured baseline generated output for 51 configurations: regular, wrapped, JSON-only and binary-only across all nine languages plus Kotlin KMP; no-errors and abstract-context services in C#/Scala/Kotlin/Java; async services in C#/Kotlin/Java. +- Added metadata-plan checks for stable type ordering, numerical version order, builtin retention, sparse tables and absent-version failure. +- Post-change JVM/Scala.js compilation and all 11 focused tests passed. +- All 42,180 generated files across the 51 configurations are byte-identical to the baseline, with no added or removed files. +- Cross-language serialization acceptance passed all 200 checks. +- Translator/runtime production code decreased by 103 non-comment, nonblank lines (`cloc` 2.10: 69,445 → 69,342). Runtime resources are unchanged; this is policy consolidation, not a measured performance improvement. +- Fresh `mdl --seq --keep-run-dir --simple-log :build :test :ci` run `20260917-184854-024193517` passed all 205 actions, including 846 compiler tests (no failures/cancellations), all backend matrices, native portability, editor grammars, 200 serialization checks and 162 RPC checks. + +## Batch 2: recursive conversion planning + +Not started. The pilot will share source/target structural decisions beneath the existing `FieldOp` model, while Java/Kotlin retain their numeric conversions, collection constructors/traversal and custom conversion hooks. No wire-format or runtime-contract changes are intended. From 1ef604019ac9d93853f321907127db6564fc5bfc Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 17 Sep 2026 21:32:44 +0100 Subject: [PATCH 21/48] refactor(conversions): share Java and Kotlin value conversion planning --- .../tests/ValueConversionPlanTest.scala | 80 ++++++++++++ .../translator/ValueConversionPlan.scala | 55 +++++++++ .../java/JvConversionTranslator.scala | 115 ++++++------------ .../kotlin/KtConversionTranslator.scala | 115 ++++++------------ .../followup.md | 33 ++++- 5 files changed, 239 insertions(+), 159 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ValueConversionPlanTest.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ValueConversionPlan.scala diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ValueConversionPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ValueConversionPlanTest.scala new file mode 100644 index 000000000..9629da7c6 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ValueConversionPlanTest.scala @@ -0,0 +1,80 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.translator.ValueConversionPlan +import io.septimalmind.baboon.translator.ValueConversionPlan.* +import io.septimalmind.baboon.typer.model.{Owner, Pkg, TypeId, TypeName, TypeRef} +import izumi.fundamentals.collections.nonempty.NEList +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ValueConversionPlanTest extends AnyFlatSpec with Matchers { + private val narrow = TypeRef.Scalar(TypeId.Builtins.i16) + private val wide = TypeRef.Scalar(TypeId.Builtins.i64) + private val pkg = Pkg(NEList("conversion")) + private val oldTpe = TypeRef.Scalar(TypeId.User(pkg, Owner.Toplevel, TypeName("Old"))) + private val newTpe = TypeRef.Scalar(TypeId.User(pkg, Owner.Toplevel, TypeName("New"))) + + private def unary(id: TypeId.BuiltinCollection, element: TypeRef): TypeRef.Constructor = TypeRef.Constructor(id, NEList(element)) + private def dictionary(key: TypeRef, value: TypeRef): TypeRef.Constructor = TypeRef.Constructor(TypeId.Builtins.map, NEList(key, value)) + + "Value conversion planning" should "retain both scalar identifiers without choosing a backend numeric conversion" in { + ValueConversionPlan(narrow, wide) shouldBe Scalar(narrow, wide) + ValueConversionPlan(oldTpe, newTpe) shouldBe Scalar(oldTpe, newTpe) + } + + it should "retain scalar conversion hooks even when the type identifier is unchanged" in { + ValueConversionPlan(oldTpe, oldTpe) shouldBe Scalar(oldTpe, oldTpe) + ValueConversionPlan(unary(TypeId.Builtins.lst, oldTpe), unary(TypeId.Builtins.lst, oldTpe)) shouldBe MapList(Scalar(oldTpe, oldTpe)) + } + + it should "plan scalar wrapping independently of the backend collection representation" in { + ValueConversionPlan(narrow, unary(TypeId.Builtins.opt, wide)) shouldBe WrapOptional(Scalar(narrow, wide)) + ValueConversionPlan(narrow, unary(TypeId.Builtins.lst, wide)) shouldBe WrapList(Scalar(narrow, wide)) + ValueConversionPlan(narrow, unary(TypeId.Builtins.set, wide)) shouldBe WrapSet(Scalar(narrow, wide)) + } + + it should "map optional, list and set elements recursively" in { + ValueConversionPlan(unary(TypeId.Builtins.opt, narrow), unary(TypeId.Builtins.opt, wide)) shouldBe MapOptional(Scalar(narrow, wide)) + ValueConversionPlan(unary(TypeId.Builtins.set, narrow), unary(TypeId.Builtins.set, wide)) shouldBe MapSet(Scalar(narrow, wide)) + ValueConversionPlan(unary(TypeId.Builtins.lst, narrow), unary(TypeId.Builtins.lst, unary(TypeId.Builtins.opt, wide))) shouldBe + MapList(WrapOptional(Scalar(narrow, wide))) + } + + it should "plan map keys and nested values using their separate source and target types" in { + val source = dictionary(narrow, unary(TypeId.Builtins.lst, unary(TypeId.Builtins.opt, oldTpe))) + val target = dictionary(wide, unary(TypeId.Builtins.lst, unary(TypeId.Builtins.opt, newTpe))) + ValueConversionPlan(source, target) shouldBe MapEntries(Scalar(narrow, wide), MapList(MapOptional(Scalar(oldTpe, newTpe)))) + } + + it should "copy opaque values only when their variant and underlying type are unchanged" in { + for { + variant <- List(TypeRef.AnyVariant.Global, TypeRef.AnyVariant.ThisDom, TypeRef.AnyVariant.Current) + underlying <- List(None, Some(oldTpe)) + } { + val opaque = TypeRef.Any(variant, underlying) + ValueConversionPlan(opaque, opaque) shouldBe CopyOpaque + ValueConversionPlan(unary(TypeId.Builtins.lst, opaque), unary(TypeId.Builtins.lst, opaque)) shouldBe MapList(CopyOpaque) + } + } + + it should "reject opaque variant, underlying and nonopaque transitions" in { + val opaque = TypeRef.Any(TypeRef.AnyVariant.Global, Some(oldTpe)) + for ((source, target) <- List( + (opaque, opaque.copy(variant = TypeRef.AnyVariant.Current)), + (opaque, opaque.copy(underlying = Some(newTpe))), + (opaque, narrow), + (narrow, opaque), + (opaque, unary(TypeId.Builtins.opt, opaque)), + )) { + intercept[IllegalStateException](ValueConversionPlan(source, target)) + } + } + + it should "retain invariant failures for unsupported constructor transitions" in { + intercept[IllegalStateException](ValueConversionPlan(unary(TypeId.Builtins.lst, narrow), narrow)) + intercept[IllegalStateException](ValueConversionPlan(narrow, dictionary(narrow, wide))) + val unknown = unary(TypeId.BuiltinCollection(TypeName("unknown")), narrow) + intercept[IllegalStateException](ValueConversionPlan(narrow, unknown)) + intercept[IllegalStateException](ValueConversionPlan(unary(TypeId.Builtins.lst, narrow), unknown)) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ValueConversionPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ValueConversionPlan.scala new file mode 100644 index 000000000..b5c523adb --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ValueConversionPlan.scala @@ -0,0 +1,55 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.{TypeId, TypeRef} + +sealed trait ValueConversionPlan + +object ValueConversionPlan { + final case class Scalar(source: TypeRef.Scalar, target: TypeRef.Scalar) extends ValueConversionPlan + case object CopyOpaque extends ValueConversionPlan + final case class WrapOptional(element: ValueConversionPlan) extends ValueConversionPlan + final case class WrapList(element: ValueConversionPlan) extends ValueConversionPlan + final case class WrapSet(element: ValueConversionPlan) extends ValueConversionPlan + final case class MapOptional(element: ValueConversionPlan) extends ValueConversionPlan + final case class MapList(element: ValueConversionPlan) extends ValueConversionPlan + final case class MapSet(element: ValueConversionPlan) extends ValueConversionPlan + final case class MapEntries(key: ValueConversionPlan, value: ValueConversionPlan) extends ValueConversionPlan + + def apply(source: TypeRef, target: TypeRef): ValueConversionPlan = (source, target) match { + case (s: TypeRef.Scalar, t: TypeRef.Scalar) => Scalar(s, t) + case (s: TypeRef.Scalar, t: TypeRef.Constructor) => + val element = apply(s, t.args.head) + t.id match { + case TypeId.Builtins.opt => WrapOptional(element) + case TypeId.Builtins.lst => WrapList(element) + case TypeId.Builtins.set => WrapSet(element) + case _ => throw new IllegalStateException(s"Unsupported constructor type: ${t.id}") + } + case (s: TypeRef.Constructor, _: TypeRef.Scalar) => + throw new IllegalStateException(s"Unsupported scalar to constructor conversion: ${s.id}") + case (s: TypeRef.Constructor, t: TypeRef.Constructor) => + t.id match { + case TypeId.Builtins.opt => MapOptional(apply(s.args.head, t.args.head)) + case TypeId.Builtins.lst => MapList(apply(s.args.head, t.args.head)) + case TypeId.Builtins.set => MapSet(apply(s.args.head, t.args.head)) + case TypeId.Builtins.map => MapEntries(apply(s.args.head, t.args.head), apply(s.args.last, t.args.last)) + case _ => throw new IllegalStateException(s"Unsupported constructor type: ${t.id}") + } + // `any` payload is opaque; we never auto-convert between `any` and a non-`any` + // type (validator forbids it). Identical (variant, underlying) pairs copy the + // reference as-is: the wire bytes carry their own meta header, independent of + // the containing DTO's schema version. Variant or underlying changes are + // breaking per spec §Evolution and should have been rejected by + // `BaboonRules.incompatibleAdditions`; retain the defensive invariant checks. + case (s: TypeRef.Any, t: TypeRef.Any) => + if (s == t) CopyOpaque + else + throw new IllegalStateException( + s"BUG: conversion of `any` field across variant or underlying change is breaking and should have been rejected by BaboonRules.incompatibleAdditions: $s -> $t" + ) + case (other, _: TypeRef.Any) => + throw new IllegalStateException(s"BUG: cannot auto-convert field of type $other to `any` (not allowed by evolution rules)") + case (_: TypeRef.Any, other) => + throw new IllegalStateException(s"BUG: cannot auto-convert `any` field to type $other (not allowed by evolution rules)") + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala index b356ba295..17c4e5746 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala @@ -2,6 +2,7 @@ package io.septimalmind.baboon.translator.java import distage.Id import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.ValueConversionPlan import io.septimalmind.baboon.translator.java.JvBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.translator.java.JvValue.JvPackageId @@ -36,77 +37,33 @@ class JvConversionTranslator[F[+_, +_]: Error2]( private val srcVer = srcDom.version type Out[T] = F[NEList[BaboonIssue], T] - private def transfer(newTpe: TypeRef, oldRef: TextTree[JvValue], depth: Int, maybeOldTpe: Option[TypeRef] = None): TextTree[JvValue] = { - import io.septimalmind.baboon.translator.FQNSymbol.* - - val oldTpe = maybeOldTpe.getOrElse(newTpe) - val newTypeRefTree = trans.asJvRef(newTpe, domain, evo) - val oldTypeRefTree = trans.asJvRef(oldTpe, srcDom, evo).fullyQualified + private def transfer(tpe: TypeRef, oldRef: TextTree[JvValue], depth: Int): TextTree[JvValue] = + transfer(tpe, oldRef, depth, tpe) - (newTpe, oldTpe) match { - case (c: TypeRef.Constructor, s: TypeRef.Scalar) => - val headTransfer = transfer(c.args.head, oldRef, depth + 1, Some(s)) - c.id match { - case TypeId.Builtins.opt => - q"$jvOptional.of($headTransfer)" - case TypeId.Builtins.lst => - q"$jvList.of($headTransfer)" - case TypeId.Builtins.set => - q"$jvSet.of($headTransfer)" - case _ => - throw new IllegalStateException(s"Unsupported constructor type: ${c.id}") - } + private def transfer(newTpe: TypeRef, oldRef: TextTree[JvValue], depth: Int, oldTpe: TypeRef): TextTree[JvValue] = + renderTransfer(ValueConversionPlan(oldTpe, newTpe), oldRef, depth) - case (ns: TypeRef.Scalar, os: TypeRef.Scalar) => - transferScalar(oldRef, newTypeRefTree, oldTypeRefTree, ns, os) - case (TypeRef.Scalar(_), c: TypeRef.Constructor) => - throw new IllegalStateException(s"Unsupported scalar to constructor conversion: ${c.id}") - case (cn: TypeRef.Constructor, co: TypeRef.Constructor) => - transferConstructor(oldRef, depth, cn, co) - // `any` payload is opaque (bytes / JsonNode); we never auto-convert between `any` and a - // non-`any` type (validator forbids it). When both old and new are `any` AND their (variant, - // underlying) pair is identical, copy the reference as-is — the surface ADT is the same - // `AnyOpaque` and the wire bytes carry their own meta header, independent of the schema - // version of the containing DTO. Variant or underlying changes are breaking per spec - // §Evolution and should have been rejected by `BaboonRules.incompatibleAdditions`; we emit a - // defensive throw in case that layer is ever loosened. - case (newA: TypeRef.Any, oldA: TypeRef.Any) => - if (newA == oldA) oldRef - else - throw new IllegalStateException( - s"BUG: conversion of `any` field across variant or underlying change is breaking and should have been rejected by BaboonRules.incompatibleAdditions: $oldA -> $newA" - ) - case (_: TypeRef.Any, other) => - throw new IllegalStateException(s"BUG: cannot auto-convert field of type $other to `any` (not allowed by evolution rules)") - case (other, _: TypeRef.Any) => - throw new IllegalStateException(s"BUG: cannot auto-convert `any` field to type $other (not allowed by evolution rules)") - } - } + private def renderTransfer(plan: ValueConversionPlan, oldRef: TextTree[JvValue], depth: Int): TextTree[JvValue] = { + import io.septimalmind.baboon.translator.FQNSymbol.* + import ValueConversionPlan.* - private def transferConstructor( - oldRef: TextTree[JvValue], - depth: Int, - cn: TypeRef.Constructor, - co: TypeRef.Constructor, - ): TextTree[JvValue] = { val tmp = q"e${depth.toString}" - cn match { - case c: TypeRef.Constructor if c.id == TypeId.Builtins.lst => - q"$oldRef.stream().map($tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))}).toList()" - - case c: TypeRef.Constructor if c.id == TypeId.Builtins.map => - val keyRef = c.args.head - val valueRef = c.args.last - val kv = q"$tmp.getKey()" - val vv = q"$tmp.getValue()" - - q"$oldRef.entrySet().stream().collect(java.util.stream.Collectors.toMap($tmp -> ${transfer(keyRef, kv, depth + 1, Some(co.args.head))}, $tmp -> ${transfer(valueRef, vv, depth + 1, Some(co.args.last))}))" - case c: TypeRef.Constructor if c.id == TypeId.Builtins.set => - q"$oldRef.stream().map($tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))}).collect(java.util.stream.Collectors.toSet())" - case c: TypeRef.Constructor if c.id == TypeId.Builtins.opt => - q"$oldRef.map($tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))})" - case c => - throw new IllegalStateException(s"Unsupported constructor type: ${c.id}") + plan match { + case Scalar(source, target) => + val newTypeRefTree = trans.asJvRef(target, domain, evo) + val oldTypeRefTree = trans.asJvRef(source, srcDom, evo).fullyQualified + transferScalar(oldRef, newTypeRefTree, oldTypeRefTree, target, source) + case CopyOpaque => oldRef + case WrapOptional(element) => q"$jvOptional.of(${renderTransfer(element, oldRef, depth + 1)})" + case WrapList(element) => q"$jvList.of(${renderTransfer(element, oldRef, depth + 1)})" + case WrapSet(element) => q"$jvSet.of(${renderTransfer(element, oldRef, depth + 1)})" + case MapOptional(element) => q"$oldRef.map($tmp -> ${renderTransfer(element, tmp, depth + 1)})" + case MapList(element) => q"$oldRef.stream().map($tmp -> ${renderTransfer(element, tmp, depth + 1)}).toList()" + case MapSet(element) => q"$oldRef.stream().map($tmp -> ${renderTransfer(element, tmp, depth + 1)}).collect(java.util.stream.Collectors.toSet())" + case MapEntries(key, value) => + val kv = q"$tmp.getKey()" + val vv = q"$tmp.getValue()" + q"$oldRef.entrySet().stream().collect(java.util.stream.Collectors.toMap($tmp -> ${renderTransfer(key, kv, depth + 1)}, $tmp -> ${renderTransfer(value, vv, depth + 1)}))" } } @@ -239,7 +196,7 @@ class JvConversionTranslator[F[+_, +_]: Error2]( oldId => val oldT = trans.asJvType(oldId, srcDom, evo).fullyQualified val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) - q"if (from instanceof $oldT x) { return ${transfer(TypeRef.Scalar(newId), q"x", 1, Some(TypeRef.Scalar(oldId)))}; }" + q"if (from instanceof $oldT x) { return ${transfer(TypeRef.Scalar(newId), q"x", 1, TypeRef.Scalar(oldId))}; }" } val classDef = q"""public final class $className @@ -297,7 +254,7 @@ class JvConversionTranslator[F[+_, +_]: Error2]( } case _ => throw new IllegalStateException("WrapIntoCollection target must be a constructor type") } - case o: FieldOp.ExpandPrecision => transfer(o.newTpe, q"from.$fld()", 1, Some(o.oldTpe)) + case o: FieldOp.ExpandPrecision => transfer(o.newTpe, q"from.$fld()", 1, o.oldTpe) case o: FieldOp.SwapCollectionType => swapCollType(q"from.$fld()", o, 0) case o: FieldOp.Rename => transfer(o.targetField.tpe, q"from.${o.sourceFieldName.name}()", 1) case o: FieldOp.Redef => @@ -315,7 +272,7 @@ class JvConversionTranslator[F[+_, +_]: Error2]( case _ => throw new IllegalStateException("WrapIntoCollection target must be a constructor type") } case m: FieldOp.ExpandPrecision => - transfer(m.newTpe, srcFieldRef, 1, Some(m.oldTpe)) + transfer(m.newTpe, srcFieldRef, 1, m.oldTpe) case m: FieldOp.SwapCollectionType => swapCollType(srcFieldRef, m, 0) } @@ -355,30 +312,30 @@ class JvConversionTranslator[F[+_, +_]: Error2]( (oldId, newId) match { case (TypeId.Builtins.opt, TypeId.Builtins.lst) => - q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).stream().toList()""" + q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).stream().toList()""" case (TypeId.Builtins.opt, TypeId.Builtins.set) => - q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).stream().collect(java.util.stream.Collectors.toSet())""" + q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).stream().collect(java.util.stream.Collectors.toSet())""" case (TypeId.Builtins.opt, TypeId.Builtins.opt) => - q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))})""" + q"""$fieldRef.map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)})""" case (TypeId.Builtins.lst, TypeId.Builtins.lst) => - q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).toList()""" + q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).toList()""" case (TypeId.Builtins.lst, TypeId.Builtins.set) => - q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).collect(java.util.stream.Collectors.toSet())""" + q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).collect(java.util.stream.Collectors.toSet())""" case (TypeId.Builtins.set, TypeId.Builtins.lst) => - q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).toList()""" + q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).toList()""" case (TypeId.Builtins.set, TypeId.Builtins.set) => - q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))}).collect(java.util.stream.Collectors.toSet())""" + q"""$fieldRef.stream().map(e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)}).collect(java.util.stream.Collectors.toSet())""" case (TypeId.Builtins.map, TypeId.Builtins.map) => val kv = q"entry.getKey()" val vv = q"entry.getValue()" - q"""$fieldRef.entrySet().stream().collect(java.util.stream.Collectors.toMap(entry -> ${transfer(newArgs.head, kv, 1, Some(oldArgs.head))}, entry -> ${transfer( + q"""$fieldRef.entrySet().stream().collect(java.util.stream.Collectors.toMap(entry -> ${transfer(newArgs.head, kv, 1, oldArgs.head)}, entry -> ${transfer( newArgs.last, vv, 1, - Some(oldArgs.last), + oldArgs.last, )}))""" case _ => throw new IllegalStateException("Unsupported collection swap") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala index 75718b852..fffc9a2fc 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala @@ -2,6 +2,7 @@ package io.septimalmind.baboon.translator.kotlin import distage.Id import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.translator.ValueConversionPlan import io.septimalmind.baboon.translator.kotlin.KtBaboonTranslator.RenderedConversion import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.translator.kotlin.KtValue.KtPackageId @@ -37,77 +38,33 @@ class KtConversionTranslator[F[+_, +_]: Error2]( private val srcVer = srcDom.version type Out[T] = F[NEList[BaboonIssue], T] - private def transfer(newTpe: TypeRef, oldRef: TextTree[KtValue], depth: Int, maybeOldTpe: Option[TypeRef] = None): TextTree[KtValue] = { - import io.septimalmind.baboon.translator.FQNSymbol.* - - val oldTpe = maybeOldTpe.getOrElse(newTpe) - val newTypeRefTree = trans.asKtRef(newTpe, domain, evo) - val oldTypeRefTree = trans.asKtRef(oldTpe, srcDom, evo).fullyQualified + private def transfer(tpe: TypeRef, oldRef: TextTree[KtValue], depth: Int): TextTree[KtValue] = + transfer(tpe, oldRef, depth, tpe) - (newTpe, oldTpe) match { - case (c: TypeRef.Constructor, s: TypeRef.Scalar) => - val headTransfer = transfer(c.args.head, oldRef, depth + 1, Some(s)) - c.id match { - case TypeId.Builtins.opt => - q"$headTransfer" - case TypeId.Builtins.lst => - q"listOf($headTransfer)" - case TypeId.Builtins.set => - q"setOf($headTransfer)" - case _ => - throw new IllegalStateException(s"Unsupported constructor type: ${c.id}") - } + private def transfer(newTpe: TypeRef, oldRef: TextTree[KtValue], depth: Int, oldTpe: TypeRef): TextTree[KtValue] = + renderTransfer(ValueConversionPlan(oldTpe, newTpe), oldRef, depth) - case (ns: TypeRef.Scalar, os: TypeRef.Scalar) => - transferScalar(oldRef, newTypeRefTree, oldTypeRefTree, ns, os) - case (TypeRef.Scalar(_), c: TypeRef.Constructor) => - throw new IllegalStateException(s"Unsupported scalar to constructor conversion: ${c.id}") - case (cn: TypeRef.Constructor, co: TypeRef.Constructor) => - transferConstructor(oldRef, depth, cn, co) - // `any` payload is opaque (bytes / JsonElement); we never auto-convert between `any` and a - // non-`any` type (validator forbids it). When both old and new are `any` AND their (variant, - // underlying) pair is identical, copy the reference as-is — the surface ADT is the same - // `AnyOpaque` and the wire bytes carry their own meta header, independent of the schema - // version of the containing DTO. Variant or underlying changes are breaking per spec - // §Evolution and should have been rejected by `BaboonRules.incompatibleAdditions`; we emit a - // defensive throw in case that layer is ever loosened. - case (newA: TypeRef.Any, oldA: TypeRef.Any) => - if (newA == oldA) oldRef - else - throw new IllegalStateException( - s"BUG: conversion of `any` field across variant or underlying change is breaking and should have been rejected by BaboonRules.incompatibleAdditions: $oldA -> $newA" - ) - case (_: TypeRef.Any, other) => - throw new IllegalStateException(s"BUG: cannot auto-convert field of type $other to `any` (not allowed by evolution rules)") - case (other, _: TypeRef.Any) => - throw new IllegalStateException(s"BUG: cannot auto-convert `any` field to type $other (not allowed by evolution rules)") - } - } + private def renderTransfer(plan: ValueConversionPlan, oldRef: TextTree[KtValue], depth: Int): TextTree[KtValue] = { + import io.septimalmind.baboon.translator.FQNSymbol.* + import ValueConversionPlan.* - private def transferConstructor( - oldRef: TextTree[KtValue], - depth: Int, - cn: TypeRef.Constructor, - co: TypeRef.Constructor, - ): TextTree[KtValue] = { val tmp = q"e${depth.toString}" - cn match { - case c: TypeRef.Constructor if c.id == TypeId.Builtins.lst => - q"$oldRef.map { $tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))} }" - - case c: TypeRef.Constructor if c.id == TypeId.Builtins.map => - val keyRef = c.args.head - val valueRef = c.args.last - val kv = q"$tmp.key" - val vv = q"$tmp.value" - - q"$oldRef.map { $tmp -> (${transfer(keyRef, kv, depth + 1, Some(co.args.head))}) to (${transfer(valueRef, vv, depth + 1, Some(co.args.last))}) }.toMap()" - case c: TypeRef.Constructor if c.id == TypeId.Builtins.set => - q"$oldRef.map { $tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))} }.toSet()" - case c: TypeRef.Constructor if c.id == TypeId.Builtins.opt => - q"$oldRef?.let { $tmp -> ${transfer(c.args.head, tmp, depth + 1, Some(co.args.head))} }" - case c => - throw new IllegalStateException(s"Unsupported constructor type: ${c.id}") + plan match { + case Scalar(source, target) => + val newTypeRefTree = trans.asKtRef(target, domain, evo) + val oldTypeRefTree = trans.asKtRef(source, srcDom, evo).fullyQualified + transferScalar(oldRef, newTypeRefTree, oldTypeRefTree, target, source) + case CopyOpaque => oldRef + case WrapOptional(element) => renderTransfer(element, oldRef, depth + 1) + case WrapList(element) => q"listOf(${renderTransfer(element, oldRef, depth + 1)})" + case WrapSet(element) => q"setOf(${renderTransfer(element, oldRef, depth + 1)})" + case MapOptional(element) => q"$oldRef?.let { $tmp -> ${renderTransfer(element, tmp, depth + 1)} }" + case MapList(element) => q"$oldRef.map { $tmp -> ${renderTransfer(element, tmp, depth + 1)} }" + case MapSet(element) => q"$oldRef.map { $tmp -> ${renderTransfer(element, tmp, depth + 1)} }.toSet()" + case MapEntries(key, value) => + val kv = q"$tmp.key" + val vv = q"$tmp.value" + q"$oldRef.map { $tmp -> (${renderTransfer(key, kv, depth + 1)}) to (${renderTransfer(value, vv, depth + 1)}) }.toMap()" } } @@ -237,7 +194,7 @@ class KtConversionTranslator[F[+_, +_]: Error2]( oldId => val oldT = trans.asKtType(oldId, srcDom, evo).fullyQualified val newId = c.branchMapping.getOrElse(oldId.name.name, oldId) - q"is $oldT -> ${transfer(TypeRef.Scalar(newId), q"x", 1, Some(TypeRef.Scalar(oldId)))}" + q"is $oldT -> ${transfer(TypeRef.Scalar(newId), q"x", 1, TypeRef.Scalar(oldId))}" } :+ q"else -> throw $javaIllegalArgumentException(\"Bad input: \" + from)" val classDef = q""" @@ -289,7 +246,7 @@ class KtConversionTranslator[F[+_, +_]: Error2]( case TypeRef.Constructor(TypeId.Builtins.opt, _) => q"_from.$fld" case _ => q"listOf(_from.$fld)" } - case o: FieldOp.ExpandPrecision => transfer(o.newTpe, q"_from.$fld", 1, Some(o.oldTpe)) + case o: FieldOp.ExpandPrecision => transfer(o.newTpe, q"_from.$fld", 1, o.oldTpe) case o: FieldOp.SwapCollectionType => swapCollType(q"_from.$fld", o, 0) case o: FieldOp.Rename => val srcName = KtTypeTranslator.escapeKtKeyword(o.sourceFieldName.name) @@ -305,7 +262,7 @@ class KtConversionTranslator[F[+_, +_]: Error2]( case _ => q"listOf($srcFieldRef)" } case m: FieldOp.ExpandPrecision => - transfer(m.newTpe, srcFieldRef, 1, Some(m.oldTpe)) + transfer(m.newTpe, srcFieldRef, 1, m.oldTpe) case m: FieldOp.SwapCollectionType => swapCollType(srcFieldRef, m, 0) } @@ -345,30 +302,30 @@ class KtConversionTranslator[F[+_, +_]: Error2]( (oldId, newId) match { case (TypeId.Builtins.opt, TypeId.Builtins.lst) => - q"""listOfNotNull($fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} })""" + q"""listOfNotNull($fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} })""" case (TypeId.Builtins.opt, TypeId.Builtins.set) => - q"""setOfNotNull($fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} })""" + q"""setOfNotNull($fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} })""" case (TypeId.Builtins.opt, TypeId.Builtins.opt) => - q"""$fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} }""" + q"""$fieldRef?.let { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} }""" case (TypeId.Builtins.lst, TypeId.Builtins.lst) => - q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} }""" + q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} }""" case (TypeId.Builtins.lst, TypeId.Builtins.set) => - q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} }.toSet()""" + q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} }.toSet()""" case (TypeId.Builtins.set, TypeId.Builtins.lst) => - q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} }""" + q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} }""" case (TypeId.Builtins.set, TypeId.Builtins.set) => - q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, Some(oldArgs.head))} }.toSet()""" + q"""$fieldRef.map { e -> ${transfer(newArgs.head, tmp, depth, oldArgs.head)} }.toSet()""" case (TypeId.Builtins.map, TypeId.Builtins.map) => val keyRef = q"k" val valRef = q"v" - q"""$fieldRef.map { (k, v) -> (${transfer(newArgs.head, keyRef, 1, Some(oldArgs.head))}) to (${transfer( + q"""$fieldRef.map { (k, v) -> (${transfer(newArgs.head, keyRef, 1, oldArgs.head)}) to (${transfer( newArgs.last, valRef, 1, - Some(oldArgs.last), + oldArgs.last, )}) }.toMap()""" case _ => throw new IllegalStateException("Unsupported collection swap") diff --git a/docs/research/20260916-backend-runtime-review/followup.md b/docs/research/20260916-backend-runtime-review/followup.md index 77d29655d..5a40bc6ac 100644 --- a/docs/research/20260916-backend-runtime-review/followup.md +++ b/docs/research/20260916-backend-runtime-review/followup.md @@ -21,4 +21,35 @@ Baseline: `4aef4b50`. The user authorized proposals 1–3 as a validated, separa ## Batch 2: recursive conversion planning -Not started. The pilot will share source/target structural decisions beneath the existing `FieldOp` model, while Java/Kotlin retain their numeric conversions, collection constructors/traversal and custom conversion hooks. No wire-format or runtime-contract changes are intended. +Completed as the Java/Kotlin pilot, after committing batch 1 as `bb2c7827`. + +- Added `ValueConversionPlan` beneath the existing `FieldOp` model, used only by Java and Kotlin in this pilot. +- Centralized recursive scalar/container decisions, map key/value planning and opaque-`any` invariants. Scalar leaves retain both type identifiers, even when equal, so renderers still invoke user-defined conversion hooks. +- Kept numeric conversions, foreign-type handling, collection constructors/traversal and field-operation-specific wrapping/swapping in the backend renderers. Removed optional source-type overrides in favor of explicit source types and a same-type overload. +- Moved the duplicated opaque-value rationale and invariant checks into the shared planner. No runtime resources, wire formats or runtime contracts changed. + +### Verification + +- Baseline: batch 1's complete CI and generated corpus. +- JVM/Scala.js compilation and all 8 new planner tests passed. Tests cover scalar source/target retention, hooks for equal identifiers, wrapping, nested lists/options/sets, independent map keys/values, opaque identity and rejected invariant violations. +- Repeated output parity passed: all 42,180 files across the same 51 configurations are byte-identical to batch 1, with no added or removed files. +- The pilot removes another 21 non-comment, nonblank production lines (translator total: 40,859 → 40,838); no runtime resources changed. +- Fresh full CI passed all 205 actions: serial Kotlin/serialization run `20260917-194510-533675770` passed 33 actions, then `mdl --par --continue --keep-run-dir --simple-log :build :test :ci` run `20260917-195957-573111324` passed the remaining 172 and restored only those same-source results. No production source changed between phases. +- Final results: 854 compiler tests across 138 suites (no failures/cancellations), all backend matrices, 200 serialization checks and 162 RPC checks passed. +- Commit closeout: the three production source files exactly matched their entries in the source JAR packaged for that validated build. Reran JVM/Scala.js compilation and all 19 traversal/service/metadata/value-plan tests successfully. Pre-commit `mdl --seq --continue --keep-run-dir --simple-log :build :test :ci` run `20260917-213004-450743262` restored the 205 passing actions for the unchanged implementation; it did not rerun the full matrix. + +## Outcomes and measured savings + +Counts below use `cloc` 2.10 with `--skip-uniqueness`, excluding blank lines and comments but counting identical files separately. Baselines were exported from Git; the final row counts the completed working tree. Scope is production translators plus embedded runtimes, not the whole repository or generated fixture output. + +| State | Translator code | Runtime code | Total | +|---|---:|---:|---:| +| Before the earlier backend/runtime cleanup (`69bbb4c4`) | 43,352 | 28,130 | 71,482 | +| Start of proposals 1–4 (`4aef4b50`) | 40,962 | 28,483 | 69,445 | +| Proposals 1–3 committed (`bb2c7827`) | 40,859 | 28,483 | 69,342 | +| Proposal 4 complete | 40,838 | 28,483 | 69,321 | + +- This round removes 124 production lines: 103 from proposals 1–3 and 21 from proposal 4. Across the earlier cleanup and this follow-up, the net reduction is 2,161 lines (3.0%). Tests and documentation are excluded from these savings. +- Product traversal is shared by eight backends; service-method facts by four; evolution metadata preparation by all nine; recursive value-conversion planning by Java and Kotlin in this pilot. +- Added 11 behavior-level planner tests in this round (three metadata and eight conversion-plan tests), while retaining the existing traversal/service checks. +- Generated output is byte-identical across the 51 compared configurations, so this round provides no generated-code size reduction. Runtime contracts and wire formats are unchanged. Execution time, memory use and maintenance effort were not benchmarked; the structural gain is fewer independent implementations of shared policy. From 5089e8c0ce7512dbb612478600c45b77cc456d05 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 17 Sep 2026 23:03:44 +0100 Subject: [PATCH 22/48] Share UEBA layout planning and enforce validation in release builds --- .mdl/defs/tests.md | 11 ++ .../baboon/tests/UebaLayoutPlanTest.scala | 98 +++++++++++++ .../baboon-runtime/cs/BaboonCodecs.cs | 27 ++-- .../baboon-runtime/dart/baboon_runtime.dart | 4 + .../baboon-runtime/java/BaboonBinCodec.java | 4 + .../baboon-runtime/python/baboon_codecs.py | 10 +- .../baboon-runtime/rust/baboon_runtime.rs | 21 +-- .../baboon-runtime/swift/baboon_runtime.swift | 33 +++-- .../typescript/BaboonSharedRuntime.ts | 14 ++ .../baboon/translator/UebaLayoutPlan.scala | 44 ++++++ .../translator/UebaLengthCheckRenderer.scala | 27 ++++ .../csharp/CSUEBACodecGenerator.scala | 53 +++---- .../dart/DtUEBACodecGenerator.scala | 58 +++----- .../java/JvUEBACodecGenerator.scala | 56 +++----- .../kotlin/KtUEBACodecGenerator.scala | 58 +++----- .../python/PyUEBACodecGenerator.scala | 50 +++---- .../rust/RsUEBACodecGenerator.scala | 75 +++++----- .../translator/scl/ScUEBACodecGenerator.scala | 60 +++----- .../swift/SwUEBACodecGenerator.scala | 59 +++----- .../typescript/TsUEBACodecGenerator.scala | 72 +++++----- .../ueba-layout.md | 123 ++++++++++++++++ .../BaboonTests/IndexConsumptionTests.cs | 75 ++++++++++ .../test/runtime/index_validation_test.dart | 43 ++++++ .../java/runtime/IndexConsumptionTest.java | 14 ++ .../kotlin/runtime/IndexValidationTest.kt | 32 +++++ test/kt-stub/gradle.properties | 2 + .../kotlin/runtime/IndexValidationTest.kt | 36 +++++ .../RuntimeTests/test_index_consumption.py | 8 +- test/rs-stub/tests/index_validation_tests.rs | 29 ++++ .../test/scala/runtime/IndexCountSpec.scala | 31 ++++ ...untimePrimitiveCharacterizationTests.swift | 18 +++ .../src/runtime-tests/UebaLayout.test.ts | 50 +++++++ .../HighMarkerSpec.scala | 19 +++ test/ueba-layout-regression/HighMarkerTest.kt | 23 +++ test/ueba-layout-regression/high.baboon | 134 ++++++++++++++++++ test/ueba-layout-regression/run.sh | 21 +++ 36 files changed, 1118 insertions(+), 374 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/UebaLayoutPlanTest.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLayoutPlan.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLengthCheckRenderer.scala create mode 100644 docs/research/20260916-backend-runtime-review/ueba-layout.md create mode 100644 test/dt-stub/test/runtime/index_validation_test.dart create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/IndexValidationTest.kt create mode 100644 test/kt-stub/gradle.properties create mode 100644 test/kt-stub/src/test/kotlin/runtime/IndexValidationTest.kt create mode 100644 test/rs-stub/tests/index_validation_tests.rs create mode 100644 test/ts-stub/src/runtime-tests/UebaLayout.test.ts create mode 100644 test/ueba-layout-regression/HighMarkerSpec.scala create mode 100644 test/ueba-layout-regression/HighMarkerTest.kt create mode 100644 test/ueba-layout-regression/high.baboon create mode 100644 test/ueba-layout-regression/run.sh diff --git a/.mdl/defs/tests.md b/.mdl/defs/tests.md index e48e2c516..c55a85316 100644 --- a/.mdl/defs/tests.md +++ b/.mdl/defs/tests.md @@ -3550,6 +3550,7 @@ Run complete test suite (orchestrator action). ```bash dep action.test-sbt-basic +dep action.test-ueba-layout-regression dep action.test-graphql dep action.test-openapi dep action.test-cs-regular @@ -3685,6 +3686,16 @@ dep action.test-cs-adt-capture ret success:bool=true ``` +# action: test-ueba-layout-regression + +Verify wrapped UEBA branch ordinals across the signed-byte boundary in Scala and Kotlin. + +```bash +dep action.build +bash test/ueba-layout-regression/run.sh "${action.build.binary}" +ret success:bool=true +``` + # action: test-acceptance Run cross-language serialization acceptance tests (full cartesian product). diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/UebaLayoutPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/UebaLayoutPlanTest.scala new file mode 100644 index 000000000..4d16f156b --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/UebaLayoutPlanTest.scala @@ -0,0 +1,98 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.parser.model.{InputPointer, RawNodeMeta} +import io.septimalmind.baboon.translator.UebaLayoutPlan +import io.septimalmind.baboon.translator.UebaLayoutPlan.FieldLayout +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.collections.nonempty.{NEList, NESet} +import izumi.fundamentals.graphs.{DG, GraphMeta} +import izumi.fundamentals.graphs.struct.AdjacencyPredList +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class UebaLayoutPlanTest extends AnyFlatSpec with Matchers { + private val pkg = Pkg(NEList("layout")) + private def id(name: String): TypeId.User = TypeId.User(pkg, Owner.Toplevel, TypeName(name)) + private def field(name: String): Field = Field(FieldName(name), TypeRef.Scalar(id(name)), None) + + private def domain(definitions: List[Typedef.User], lengths: Map[TypeRef, BinReprLen]): Domain = { + val members: Map[TypeId, DomainMember] = definitions.map { + definition => + definition.id -> DomainMember.User(false, definition, Set.empty, RawNodeMeta(InputPointer.Undefined)) + }.toMap + Domain( + pkg, + Version.parse("1.0.0"), + DG.fromPred(AdjacencyPredList(members.keys.map(_ -> Set.empty[TypeId]).toMap), GraphMeta(members)), + Set.empty, + Map.empty, + Set.empty, + lengths.map { case (ref, length) => ref -> RefMeta(length) }, + Map.empty, + Set.empty, + Map.empty, + Map.empty, + Nil, + ) + } + + "UEBA layout planning" should "preserve field order and every length constraint" in { + val expected = List( + FieldLayout(field("zFixed"), BinReprLen.Fixed(4)), + FieldLayout(field("unknown"), BinReprLen.Unknown()), + FieldLayout(field("alternatives"), BinReprLen.Alternatives(NESet(1, 5, 9))), + FieldLayout(field("bounded"), BinReprLen.Range(1, Some(17))), + FieldLayout(field("unbounded"), BinReprLen.Range(1, None)), + FieldLayout(field("aFixed"), BinReprLen.Fixed(0)), + ) + val dto = Typedef.Dto(id("Record"), expected.map(_.field), Nil) + val plan = new UebaLayoutPlan(domain(List(dto), expected.map(f => f.field.tpe -> f.length).toMap)) + plan.fields(dto) shouldBe expected + plan.indexedFields(dto) shouldBe expected.slice(1, 5).map(_.field) + } + + it should "produce no index entries for empty or fixed-only records" in { + val empty = Typedef.Dto(id("Empty"), Nil, Nil) + val fixed = Typedef.Dto(id("Fixed"), List(field("value")), Nil) + val plan = new UebaLayoutPlan(domain(List(empty, fixed), Map(fixed.fields.head.tpe -> BinReprLen.Fixed(4)))) + plan.fields(empty) shouldBe Nil + plan.indexedFields(empty) shouldBe Nil + plan.indexedFields(fixed) shouldBe Nil + } + + it should "prepare the same strict length checks for every backend" in { + import UebaLayoutPlan.LengthCheck.* + UebaLayoutPlan.lengthChecks(BinReprLen.Fixed(0)) shouldBe List(EqualTo(0)) + UebaLayoutPlan.lengthChecks(BinReprLen.Fixed(4)) shouldBe List(EqualTo(4)) + UebaLayoutPlan.lengthChecks(BinReprLen.Unknown()) shouldBe List(AtLeast(1), AtMost(Int.MaxValue)) + UebaLayoutPlan.lengthChecks(BinReprLen.Range(4, None)) shouldBe List(AtLeast(4), AtMost(Int.MaxValue)) + UebaLayoutPlan.lengthChecks(BinReprLen.Range(1, Some(17))) shouldBe List(AtLeast(1), AtMost(17)) + UebaLayoutPlan.lengthChecks(BinReprLen.Range(0, Some(17))) shouldBe List(AtLeast(1), AtMost(17)) + val alternatives = NESet(1, 5, 9) + UebaLayoutPlan.lengthChecks(BinReprLen.Alternatives(alternatives)) shouldBe List(AtLeast(1), OneOf(alternatives.toList)) + } + + it should "fail when required field metadata is missing" in { + val dto = Typedef.Dto(id("Missing"), List(field("value")), Nil) + intercept[NoSuchElementException](new UebaLayoutPlan(domain(List(dto), Map.empty)).fields(dto)) + } + + it should "retain declaration ordinals while excluding contracts and services" in { + val parent = id("Choice") + val first = Typedef.Dto(TypeId.User(pkg, Owner.Adt(parent), TypeName("Z")), Nil, Nil) + val last = Typedef.Dto(TypeId.User(pkg, Owner.Adt(parent), TypeName("A")), Nil, Nil) + val contract = Typedef.Contract(id("Contract"), Nil, Nil) + val service = Typedef.Service(id("Service"), Nil) + val adt = Typedef.Adt(parent, NEList(contract.id, first.id, service.id, last.id), Nil, Nil) + val plan = new UebaLayoutPlan(domain(List(adt, first, last, contract, service), Map.empty)) + plan.adtBranchIndex(parent, first.id) shouldBe 0 + plan.adtBranchIndex(parent, last.id) shouldBe 1 + intercept[NoSuchElementException](plan.adtBranchIndex(parent, contract.id)) + intercept[NoSuchElementException](plan.adtBranchIndex(parent, id("Absent"))) + } + + it should "reject a branch owner that is not an ADT" in { + val dto = Typedef.Dto(id("Record"), Nil, Nil) + intercept[IllegalStateException](new UebaLayoutPlan(domain(List(dto), Map.empty)).adtBranchIndex(dto.id, id("Branch"))) + } +} diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs index 95f2d5be6..e85291f5f 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecs.cs @@ -5,7 +5,6 @@ using System.Linq; using System; using System.Collections.Immutable; -using System.Diagnostics; using Newtonsoft.Json.Linq; // ReSharper disable UnusedTypeParameter @@ -218,8 +217,9 @@ private ushort ReadIndexEntries(BaboonCodecContext ctx, BinaryReader wire, List< { var offset = wire.ReadUInt32(); var len = wire.ReadUInt32(); - Debug.Assert(len > 0); - Debug.Assert(offset >= prevoffset + prevlen); + if (len == 0 || len > int.MaxValue) throw new InvalidDataException($"Invalid UEBA index length: {len}"); + if (offset > int.MaxValue || (ulong)offset < (ulong)prevoffset + prevlen) + throw new InvalidDataException($"Invalid UEBA index offset: {offset}"); if (entries != null) entries.Add(new BaboonIndexEntry(offset, len)); count++; left = (ushort) (left - 1); @@ -233,24 +233,25 @@ private ushort ReadIndexEntries(BaboonCodecContext ctx, BinaryReader wire, List< void WriteIndexFixedLenField(BinaryWriter writer, int expected, Action doWrite) { - var before = (uint) writer.BaseStream.Position; + var before = writer.BaseStream.Position; doWrite(); - var after = (uint) writer.BaseStream.Position; + var after = writer.BaseStream.Position; var length = after - before; - Debug.Assert(length == expected); - Debug.Assert(after >= before, $"Got after={after}, before={before}"); + if (length != expected || after < before) + throw new InvalidDataException($"Invalid UEBA field length: {length}, expected {expected}"); } uint WriteIndexVarLenField(BinaryWriter writer, BinaryWriter fakeWriter, Action doWrite) { - var before = (uint) fakeWriter.BaseStream.Position; + var before = fakeWriter.BaseStream.Position; doWrite(); - var after = (uint) fakeWriter.BaseStream.Position; + var after = fakeWriter.BaseStream.Position; var length = after - before; - writer.Write(before); - writer.Write(length); - Debug.Assert(after >= before, $"Got after={after}, before={before}"); - return length; + if (before < 0 || before > int.MaxValue || length <= 0 || length > int.MaxValue) + throw new InvalidDataException($"Invalid UEBA index entry: offset={before}, length={length}"); + writer.Write((int)before); + writer.Write((int)length); + return (uint)length; } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart index efb8817e6..e15204e46 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_runtime.dart @@ -195,9 +195,13 @@ mixin BaboonBinCodecIndexed { if (!hasIndex) return []; final count = indexElementsCount; final entries = []; + var previousEnd = 0; for (var i = 0; i < count; i++) { final offset = reader.readI32(); final length = reader.readI32(); + if (length <= 0) throw FormatException('Invalid UEBA index length: $length'); + if (offset < previousEnd) throw FormatException('Invalid UEBA index offset: $offset'); + previousEnd = offset + length; entries.add(BaboonIndexEntry(offset, length)); } return entries; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java index 67c5da2b3..0d27ab173 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonBinCodec.java @@ -72,10 +72,14 @@ private static int readIndexEntries(LEDataInputStream input, int expectedElement byte header = input.readByte(); boolean hasIndex = (header & 1) != 0; int count = 0; + long previousEnd = 0; if (hasIndex) { for (int i = 0; i < expectedElements; i++) { int offset = input.readInt(); int length = input.readInt(); + if (length <= 0) throw new IllegalArgumentException("Invalid UEBA index length: " + length); + if (offset < previousEnd) throw new IllegalArgumentException("Invalid UEBA index offset: " + offset); + previousEnd = (long)offset + length; if (index != null) index.add(new int[]{offset, length}); count++; } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py index 723ec6c05..b8762639b 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs.py @@ -249,11 +249,13 @@ def _read_index(self, ctx: BaboonCodecContext, wire: 'LEDataInputStream', entrie if is_indexed: left = self.index_elements_count(ctx) while left > 0: - offset = wire.read_u32() - length = wire.read_u32() + offset = wire.read_i32() + length = wire.read_i32() - assert length > 0, "Length must be positive" - assert offset >= prev_offset + prev_len, f"Offset violation: {offset} < {prev_offset + prev_len}" + if length <= 0: + raise ValueError(f"Invalid UEBA index length: {length}") + if offset < prev_offset + prev_len: + raise ValueError(f"Invalid UEBA index offset: {offset}") if entries is not None: entries.append(BaboonIndexEntry(offset=offset, length=length)) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs index f779563fe..d0691f706 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs @@ -159,24 +159,17 @@ pub trait BaboonBinCodecIndexed { let header = bin_tools::read_byte(reader)?; let is_indexed = (header & 0x01) != 0; let mut result = Vec::new(); - let mut prev_offset: u32 = 0; - let mut prev_len: u32 = 0; + let mut previous_end: i64 = 0; if is_indexed { let mut left = Self::index_elements_count(ctx) as usize; while left > 0 { - let offset = bin_tools::read_i32(reader)? as u32; - let len = bin_tools::read_i32(reader)? as u32; - assert!(len > 0, "Length must be positive"); - assert!( - offset >= prev_offset + prev_len, - "Offset violation: {} not >= {}", - offset, - prev_offset + prev_len - ); - result.push(BaboonIndexEntry { offset, length: len }); + let offset = bin_tools::read_i32(reader)?; + let len = bin_tools::read_i32(reader)?; + if len <= 0 { return Err(format!("Invalid UEBA index length: {}", len).into()); } + if i64::from(offset) < previous_end { return Err(format!("Invalid UEBA index offset: {}", offset).into()); } + previous_end = i64::from(offset) + i64::from(len); + result.push(BaboonIndexEntry { offset: offset as u32, length: len as u32 }); left -= 1; - prev_offset = offset; - prev_len = len; } } Ok((header, result)) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift index 9b0e10d99..13077f8d1 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift @@ -283,28 +283,34 @@ public struct BaboonIndexEntry { extension BaboonBinCodecIndexed { public func consumeIndex(_ ctx: BaboonCodecContext, _ reader: BaboonBinReader) throws -> Int { - let header = reader.readU8() - if header & 1 == 0 { return 0 } - let count = indexElementsCount - for _ in 0.. [BaboonIndexEntry] { + var entries: [BaboonIndexEntry] = [] + _ = try readIndexEntries(reader) { offset, length in + entries.append(BaboonIndexEntry(offset: offset, length: length)) + } + return entries + } + + private func readIndexEntries(_ reader: BaboonBinReader, _ consume: (Int32, Int32) -> Void) throws -> Int { + guard reader.remaining >= 1 else { throw BaboonCodecError.truncated("Missing UEBA index header") } let header = reader.readU8() - let hasIndex = (header & 1) != 0 - if !hasIndex { return [] } + if header & 1 == 0 { return 0 } let count = indexElementsCount - var entries: [BaboonIndexEntry] = [] + let indexEntryBytes = 2 * MemoryLayout.size + var previousEnd: Int64 = 0 for _ in 0..= indexEntryBytes else { throw BaboonCodecError.truncated("Truncated UEBA index entry") } let offset = reader.readI32() let length = reader.readI32() - entries.append(BaboonIndexEntry(offset: offset, length: length)) + guard length > 0 else { throw BaboonCodecError.invalidInput("Invalid UEBA index length: \(length)") } + guard Int64(offset) >= previousEnd else { throw BaboonCodecError.invalidInput("Invalid UEBA index offset: \(offset)") } + previousEnd = Int64(offset) + Int64(length) + consume(offset, length) } - return entries + return count } } @@ -529,6 +535,7 @@ public class BaboonBinWriter { public class BaboonBinReader { private let data: Data private var pos: Int = 0 + fileprivate var remaining: Int { data.count - pos } public init(_ data: Data) { self.data = data diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index f005d1ba6..3d95ba0f7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -431,6 +431,20 @@ const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); export class BinTools { + static consumeIndex(reader: BaboonBinReader, expectedElements: number): number { + const header = reader.readByte(); + if ((header & 1) === 0) return 0; + let previousEnd = 0; + for (let i = 0; i < expectedElements; i++) { + const offset = reader.readI32(); + const length = reader.readI32(); + if (length <= 0) throw new BaboonDecoderFailure("Invalid UEBA index length: " + length); + if (offset < previousEnd) throw new BaboonDecoderFailure("Invalid UEBA index offset: " + offset); + previousEnd = offset + length; + } + return expectedElements; + } + // --- Writers --- static writeBool(writer: BaboonBinWriter, value: boolean): void { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLayoutPlan.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLayoutPlan.scala new file mode 100644 index 000000000..3762158ff --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLayoutPlan.scala @@ -0,0 +1,44 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.* + +final class UebaLayoutPlan(domain: Domain) { + import UebaLayoutPlan.FieldLayout + + def fields(dto: Typedef.Dto): List[FieldLayout] = + dto.fields.map(field => FieldLayout(field, domain.refMeta(field.tpe).len)) + + def indexedFields(dto: Typedef.Dto): List[Field] = + fields(dto).collect { case FieldLayout(field, _: BinReprLen.Variable) => field } + + def adtBranchIndex(adtId: TypeId.User, branchId: TypeId.User): Int = { + val adt = domain.defs.meta.nodes(adtId) match { + case DomainMember.User(_, value: Typedef.Adt, _, _) => value + case other => throw new IllegalStateException(s"BUG: expected ADT $adtId while planning UEBA branch $branchId, got $other") + } + adt.dataMembers(domain).zipWithIndex.find(_._1 == branchId).get._2 + } +} + +object UebaLayoutPlan { + val MaxIndexValue: Int = Int.MaxValue + final case class FieldLayout(field: Field, length: BinReprLen) + + sealed trait LengthCheck + object LengthCheck { + final case class EqualTo(bytes: Int) extends LengthCheck + final case class AtLeast(bytes: Int) extends LengthCheck + final case class AtMost(bytes: Int) extends LengthCheck + final case class OneOf(bytes: List[Int]) extends LengthCheck + } + + def lengthChecks(length: BinReprLen): List[LengthCheck] = { + import LengthCheck.* + length match { + case BinReprLen.Fixed(bytes) => List(EqualTo(bytes)) + case BinReprLen.Unknown() => List(AtLeast(1), AtMost(MaxIndexValue)) + case BinReprLen.Alternatives(bytes) => List(AtLeast(1), OneOf(bytes.toList)) + case BinReprLen.Range(min, max) => List(AtLeast(math.max(1, min)), AtMost(max.getOrElse(MaxIndexValue))) + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLengthCheckRenderer.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLengthCheckRenderer.scala new file mode 100644 index 000000000..5c47b3025 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/UebaLengthCheckRenderer.scala @@ -0,0 +1,27 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.translator.UebaLayoutPlan.LengthCheck.* +import io.septimalmind.baboon.typer.model.BinReprLen +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* + +object UebaLengthCheckRenderer { + def render[V]( + length: BinReprLen, + equalTo: Int => TextTree[V], + oneOf: List[Int] => TextTree[V], + enforce: TextTree[V] => TextTree[V], + ): TextTree[V] = { + UebaLayoutPlan + .lengthChecks(length).map { + check => + val condition = check match { + case EqualTo(bytes) => equalTo(bytes) + case AtLeast(bytes) => q"length >= ${bytes.toString}" + case AtMost(bytes) => q"length <= ${bytes.toString}" + case OneOf(bytes) => oneOf(bytes) + } + enforce(condition) + }.joinN() + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala index d4dd79d26..8dfc08900 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSTypeOrigin import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.translator.{AnyFieldPlan, UebaLayoutPlan, UebaLengthCheckRenderer} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* import izumi.fundamentals.platform.strings.TextTree.style.c.* @@ -20,6 +20,7 @@ class CSUEBACodecGenerator( evo: BaboonEvolution, csTypeInfo: CSTypeInfo, ) extends CSCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, @@ -103,7 +104,7 @@ class CSUEBACodecGenerator( val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").join("\n") q"""$comment |return ${varlens.size.toString};""".stripMargin @@ -321,22 +322,9 @@ class CSUEBACodecGenerator( val fdec = dtoDec(name, fields.map { case (a, b, _) => (a, b) }) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == d.id) - .get - ._2 - } - val enc = d.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, d.id) q"""writer.Write((byte)${idx.toString}); |$fenc""".stripMargin @@ -345,10 +333,10 @@ class CSUEBACodecGenerator( val dec = d.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, d.id) q"""var marker = wire.ReadByte(); - |$debug.Assert(marker == ${idx.toString}); + |if (marker != ${idx.toString}) throw new $csArgumentException("Unexpected UEBA ADT branch marker: " + marker); |return DecodeBranch(ctx, wire);""".stripMargin case _ => fdec } @@ -361,7 +349,7 @@ class CSUEBACodecGenerator( | |if (ctx.UseIndices) |{ - | $debug.Assert(indexCount == IndexElementsCount(ctx)); + | if (indexCount != IndexElementsCount(ctx)) throw new $csArgumentException("Unexpected UEBA index count: " + indexCount); |} | |return new $name( @@ -371,14 +359,14 @@ class CSUEBACodecGenerator( } private def fieldsOf(d: Typedef.Dto) = { - d.fields.map { - f => + layout.fields(d).map { + case UebaLayoutPlan.FieldLayout(f, length) => val fieldRef = q"value.${escapeCsKeyword(f.name.name.capitalize)}" val enc = mkEncoder(f.tpe, fieldRef, q"writer") val fakeEnc = mkEncoder(f.tpe, fieldRef, q"fakeWriter") val dec = mkDecoder(f.tpe, q"wire") - val w = domain.refMeta(f.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""// ${f.toString} |w.WriteIndexFixedLenField(fakeWriter, ${bytes.toString}, () => @@ -388,18 +376,7 @@ class CSUEBACodecGenerator( """.stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"$debug.Assert(length > 0, $$\"Got length={length}\");" - - case BinReprLen.Alternatives(variants) => - q"$debug.Assert(new $csSet() { ${variants.mkString(", ")} }.Contains(length), $$\"Got length={length}\");" - case BinReprLen.Range(min, max) => - ( - Seq(q"$debug.Assert(length >= ${min.toString}, $$\"Got length={length}\");") ++ - max.toSeq.map(m => q"$debug.Assert(length <= ${m.toString}, $$\"Got length={length}\");") - ).joinCN() - } + val sanityChecks = lengthChecks(v) q"""// ${f.toString} |{ @@ -416,6 +393,14 @@ class CSUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[CSValue] = + UebaLengthCheckRenderer.render[CSValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"new $csSet() { ${bytes.mkString(", ")} }.Contains(length)", + enforce = condition => q"""if (!($condition)) throw new $csArgumentException("Invalid UEBA field length: " + length);""", + ) + private def mkAnyDecoder(a: TypeRef.Any, wref: TextTree[CSValue]): TextTree[CSValue] = { val expectedHex = "0x%02x".format(AnyFieldPlan.forField(a, domain).kind & 0xFF) q"$baboonAnyBinCodec.Decode($wref, ($csByte)$expectedHex)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala index f5f1f27fd..b8737b106 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala @@ -1,5 +1,6 @@ package io.septimalmind.baboon.translator.dart +import io.septimalmind.baboon.translator.{UebaLayoutPlan, UebaLengthCheckRenderer} import io.septimalmind.baboon.CompilerTarget.DtTarget import io.septimalmind.baboon.parser.model.RawMemberMeta import io.septimalmind.baboon.translator.dart.DtCodecTranslator.CodecMeta @@ -19,6 +20,7 @@ class DtUEBACodecGenerator( evo: BaboonEvolution, dtDomainTreeTools: DtDomainTreeTools, ) extends DtCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, @@ -77,7 +79,7 @@ class DtUEBACodecGenerator( val isEncoderEnabled = domain.version == evo.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").joinN() q"""$comment |return ${varlens.size.toString};""".stripMargin @@ -293,22 +295,9 @@ class DtUEBACodecGenerator( val fdec = dtoDec(name, fields.map(_._2)) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dto.id) - .get - ._2 - } - val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""writer.writeU8(${idx.toString}); |$fenc""".stripMargin @@ -317,9 +306,9 @@ class DtUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""final marker = reader.readU8(); - |assert(marker == ${idx.toString}); + |if (marker != ${idx.toString}) throw FormatException("Unexpected UEBA ADT branch marker: " + marker.toString()); |return decodeBranch(ctx, reader);""".stripMargin case _ => fdec } @@ -334,7 +323,7 @@ class DtUEBACodecGenerator( } q"""final index = readIndex(ctx, reader); - |if (ctx.useIndices) assert(index.length == indexElementsCount); + |if (ctx.useIndices && index.length != indexElementsCount) throw FormatException("Unexpected UEBA index count: " + index.length.toString()); |return $name( | $fieldAssignments |); @@ -342,8 +331,8 @@ class DtUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[DtValue], TextTree[DtValue], TextTree[DtValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val dartName = trans.escapeDartKeyword(field.name.name) val fieldRef = q"value.$dartName" val enc = mkEncoder(field.tpe, fieldRef, q"writer", 0) @@ -351,7 +340,7 @@ class DtUEBACodecGenerator( val decoder = mkDecoder(field.tpe) val decodeTree = q"$dartName: $decoder" - val w = domain.refMeta(field.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""{ | // ${field.toString} @@ -359,33 +348,22 @@ class DtUEBACodecGenerator( | ${bufferEnc.shift(2).trim} | final after = buffer.position; | final length = after - before; - | assert(length == ${bytes.toString}); + | ${lengthChecks(BinReprLen.Fixed(bytes)).shift(2).trim} |}""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"""assert(after >= before, 'Got after=$$after, before=$$before');""" - - case BinReprLen.Alternatives(variants) => - q"""assert({${variants.mkString(", ")}}.contains(length), 'Got length=$$length');""" - - case BinReprLen.Range(min, max) => - ( - Seq(q"""assert(length >= ${min.toString}, 'Got length=$$length');""") ++ - max.toSeq.map(m => q"""assert(length <= ${m.toString}, 'Got length=$$length');""") - ).joinN() - } + val sanityChecks = lengthChecks(v) q"""{ | // ${field.toString} | final before = buffer.position; + | if (before > ${UebaLayoutPlan.MaxIndexValue.toString}) throw FormatException("UEBA index offset exceeds i32"); | writer.writeI32(before); | ${bufferEnc.shift(2).trim} | final after = buffer.position; | final length = after - before; - | writer.writeI32(length); | ${sanityChecks.shift(2).trim} + | writer.writeI32(length); |}""".stripMargin } @@ -393,6 +371,14 @@ class DtUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[DtValue] = + UebaLengthCheckRenderer.render[DtValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"{${bytes.mkString(", ")}}.contains(length)", + enforce = condition => q"""if (!($condition)) throw FormatException("Invalid UEBA field length: " + length.toString());""", + ) + private def mkDecoder(tpe: TypeRef): TextTree[DtValue] = { tpe match { case TypeRef.Scalar(id) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala index b19d214c5..a4cf662b0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.java.JvDomainTreeTools.MetaField import io.septimalmind.baboon.translator.java.JvTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.translator.{AnyFieldPlan, UebaLayoutPlan, UebaLengthCheckRenderer} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -18,6 +18,7 @@ class JvUEBACodecGenerator( evo: BaboonEvolution, jvDomainTreeTools: JvDomainTreeTools, ) extends JvCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, @@ -83,7 +84,7 @@ class JvUEBACodecGenerator( val isEncoderEnabled = target.language.enableDeprecatedEncoders || domain.version == evo.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").joinN() q"""$comment |return (short) ${varlens.size.toString};""".stripMargin @@ -305,22 +306,9 @@ class JvUEBACodecGenerator( val fdec = dtoDec(name, fields.map(_._2)) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dto.id) - .get - ._2 - } - val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""output.writeByte(${idx.toString}); |$fenc""".stripMargin @@ -329,9 +317,9 @@ class JvUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""int marker = input.readByte() & 0xFF; - |assert marker == ${idx.toString}; + |if (marker != ${idx.toString}) throw new $javaIllegalArgumentException("Unexpected UEBA ADT branch marker: " + marker); |return decodeBranch(ctx, input);""".stripMargin case _ => fdec } @@ -340,7 +328,7 @@ class JvUEBACodecGenerator( private def dtoDec(name: JvValue.JvType, fields: List[TextTree[JvValue]]): TextTree[JvValue] = { q"""var indexCount = $baboonBinCodec.consumeIndex(ctx, input, (int) indexElementsCount(ctx)); - |if (ctx.useIndices()) assert indexCount == (int) indexElementsCount(ctx); + |if (ctx.useIndices() && indexCount != (int) indexElementsCount(ctx)) throw new $javaIllegalArgumentException("Unexpected UEBA index count: " + indexCount); |return new $name( | ${fields.join(",\n").shift(2).trim} |); @@ -348,8 +336,8 @@ class JvUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[JvValue], TextTree[JvValue], TextTree[JvValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val javaName = JvTypeTranslator.escapeJvKeyword(field.name.name) val fieldRef = q"value.$javaName()" val enc = mkEncoder(field.tpe, fieldRef, q"output") @@ -357,7 +345,7 @@ class JvUEBACodecGenerator( val decoder = mkDecoder(field.tpe) val decodeTree = q"/* ${field.name.name} */ $decoder" - val w = domain.refMeta(field.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""{ | // ${field.toString} @@ -365,23 +353,11 @@ class JvUEBACodecGenerator( | ${fakeEnc.shift(2).trim} | int after = writeMemoryStream.size(); | int length = after - before; - | assert length == ${bytes.toString}; + | ${lengthChecks(BinReprLen.Fixed(bytes)).shift(2).trim} |}""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"""assert after >= before : "Got after=" + after + ", before=" + before;""" - - case BinReprLen.Alternatives(variants) => - q"""assert java.util.Set.of(${variants.mkString(", ")}).contains(length) : "Got length=" + length;""" - - case BinReprLen.Range(min, max) => - ( - Seq(q"""assert length >= ${min.toString} : "Got length=" + length;""") ++ - max.toSeq.map(m => q"""assert length <= ${m.toString} : "Got length=" + length;""") - ).joinN() - } + val sanityChecks = lengthChecks(v) q"""{ | // ${field.toString} @@ -399,6 +375,14 @@ class JvUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[JvValue] = + UebaLengthCheckRenderer.render[JvValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"java.util.Set.of(${bytes.mkString(", ")}).contains(length)", + enforce = condition => q"""if (!($condition)) throw new $javaIllegalArgumentException("Invalid UEBA field length: " + length);""", + ) + private def mkDecoder(tpe: TypeRef): TextTree[JvValue] = { tpe match { case TypeRef.Scalar(id) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala index 35ef1b008..8c69bcd1f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.kotlin.KtDomainTreeTools.MetaField import io.septimalmind.baboon.translator.kotlin.KtTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.translator.{AnyFieldPlan, UebaLayoutPlan, UebaLengthCheckRenderer} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -19,6 +19,7 @@ class KtUEBACodecGenerator( ktDomainTreeTools: KtDomainTreeTools, ktTypes: KtTypes, ) extends KtCodecTranslator { + private val layout = new UebaLayoutPlan(domain) import ktTypes.* private val scalarCodecs = new KtScalarCodecEmitter(ktTypes) @@ -90,7 +91,7 @@ class KtUEBACodecGenerator( val isEncoderEnabled = target.language.enableDeprecatedEncoders || domain.version == evo.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").joinN() q"""$comment |return ${varlens.size.toString}.toShort()""".stripMargin @@ -306,22 +307,9 @@ class KtUEBACodecGenerator( val fdec = dtoDec(name, fields.map(_._2)) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dto.id) - .get - ._2 - } - val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""writer.writeByte(${idx.toString}) |$fenc""".stripMargin @@ -330,9 +318,9 @@ class KtUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) - q"""val marker = wire.readByte().toInt() - |assert(marker == ${idx.toString}) + val idx = layout.adtBranchIndex(id, dto.id) + q"""val marker = wire.readByte().toInt() and 0xFF + |require(marker == ${idx.toString}) { "Unexpected UEBA ADT branch marker: " + marker } |return decodeBranch(ctx, wire)""".stripMargin case _ => fdec } @@ -341,7 +329,7 @@ class KtUEBACodecGenerator( private def dtoDec(name: KtValue.KtType, fields: List[TextTree[KtValue]]): TextTree[KtValue] = { q"""val index = this.readIndex(ctx, wire) - |if (ctx.useIndices) assert(index.size == indexElementsCount(ctx).toInt()) + |if (ctx.useIndices) require(index.size == indexElementsCount(ctx).toInt()) { "Unexpected UEBA index count: " + index.size } |return $name( | ${fields.join(",\n").shift(2).trim} |) @@ -349,8 +337,8 @@ class KtUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[KtValue], TextTree[KtValue], TextTree[KtValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val ktFieldName = KtTypeTranslator.escapeKtKeyword(field.name.name) val fieldRef = q"instance.$ktFieldName" val enc = mkEncoder(field.tpe, fieldRef, q"writer") @@ -358,7 +346,7 @@ class KtUEBACodecGenerator( val decoder = mkDecoder(field.tpe) val decodeTree = q"$ktFieldName = $decoder" - val w = domain.refMeta(field.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""run { | // ${field.toString} @@ -366,23 +354,11 @@ class KtUEBACodecGenerator( | ${fakeEnc.shift(2).trim} | val after = $bufferSizeExpr | val length = after - before - | assert(length == ${bytes.toString}) + | ${lengthChecks(BinReprLen.Fixed(bytes)).shift(2).trim} |}""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"assert(after >= before) { \"Got after=$$after, before=$$before\" }" - - case BinReprLen.Alternatives(variants) => - q"assert(setOf(${variants.mkString(", ")}).contains(length)) { \"Got length=$$length\" }" - - case BinReprLen.Range(min, max) => - ( - Seq(q"assert(length >= ${min.toString}) { \"Got length=$$length\" }") ++ - max.toSeq.map(m => q"assert(length <= ${m.toString}) { \"Got length=$$length\" }") - ).joinN() - } + val sanityChecks = lengthChecks(v) q"""run { | // ${field.toString} @@ -400,6 +376,14 @@ class KtUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[KtValue] = + UebaLengthCheckRenderer.render[KtValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"setOf(${bytes.mkString(", ")}).contains(length)", + enforce = condition => q"""require($condition) { "Invalid UEBA field length: " + length }""", + ) + private def mkDecoder(tpe: TypeRef): TextTree[KtValue] = { tpe match { case TypeRef.Scalar(id) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala index e3973b710..6b7833634 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala @@ -1,5 +1,6 @@ package io.septimalmind.baboon.translator.python +import io.septimalmind.baboon.translator.{UebaLayoutPlan, UebaLengthCheckRenderer} import io.septimalmind.baboon.CompilerTarget.PyTarget import io.septimalmind.baboon.parser.model.RawMemberMeta import io.septimalmind.baboon.translator.python.PyKeywords.escapePyKeyword @@ -19,6 +20,7 @@ class PyUEBACodecGenerator( pyTarget: PyTarget, domain: Domain, ) extends PyCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, pyRef: PyValue.PyType, @@ -56,7 +58,7 @@ class PyUEBACodecGenerator( val isEncoderEnabled = pyTarget.language.enableDeprecatedEncoders || domain.version == evolution.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"# ${f.toString}").joinN() q"""$comment |return ${varlens.size.toString}""".stripMargin @@ -232,7 +234,7 @@ class PyUEBACodecGenerator( q"""index_count = self.consume_index(ctx, wire) | |if ctx.use_indices: - | assert index_count == self.index_elements_count(ctx) + | if index_count != self.index_elements_count(ctx): raise ValueError("Unexpected UEBA index count: " + str(index_count)) | |return ${name.name}( | ${fieldsDecoders.join(",\n").shift(4).trim} @@ -241,14 +243,6 @@ class PyUEBACodecGenerator( } private def genDtoBodies(name: PyType, dto: Typedef.Dto): (TextTree[PyValue], TextTree[PyValue]) = { - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id).asInstanceOf[DomainMember.User] - .defn.asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex.find(_._1 == dto.id).get._2 - } - val fields = fieldsOf(dto) val noIndex = Seq( @@ -275,7 +269,7 @@ class PyUEBACodecGenerator( val enc = dto.id.owner match { case Owner.Adt(id) if pyTarget.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""wire.write_byte(${idx.toString}) |$fieldsEncoders""".stripMargin case _ => fieldsEncoders @@ -283,9 +277,9 @@ class PyUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if pyTarget.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""marker = wire.read_byte() - |assert marker == ${idx.toString} + |if marker != ${idx.toString}: raise ValueError("Unexpected UEBA ADT branch marker: " + str(marker)) |return self.decode_branch(ctx, wire)""".stripMargin case _ => fieldsDecoders } @@ -294,37 +288,25 @@ class PyUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[PyValue], TextTree[PyValue], TextTree[PyValue])] = { - dto.fields.map { - f => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(f, length) => val fieldRef = q"value.${escapePyKeyword(f.name.name)}" val encoder = mkEncoder(f.tpe, fieldRef, q"wire") val fakeEnc = mkEncoder(f.tpe, fieldRef, q"fake_writer") val dec = mkDecoder(f.tpe) - val w = domain.refMeta(f.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""# ${f.toString} |before = write_memory_stream.tell() |${fakeEnc.trim} |after = write_memory_stream.tell() |length = after - before - |assert length == ${bytes.toString} + |${lengthChecks(BinReprLen.Fixed(bytes)).trim} |""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"assert after >= before, f\"Got after={after}, before={before}\"" - - case BinReprLen.Alternatives(variants) => - q"assert length in {${variants.mkString(", ")}}, f\"Got length={length}\"" - - case BinReprLen.Range(min, max) => - List( - Some(q"assert length >= ${min.toString}, f\"Got length={length}\" "), - max.map(m => q"assert length <= ${m.toString}, $$\"Got length={length}\""), - ).flatten.joinN() - } + val sanityChecks = lengthChecks(v) q"""# ${f.toString} |before = write_memory_stream.tell() @@ -340,6 +322,14 @@ class PyUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[PyValue] = + UebaLengthCheckRenderer.render[PyValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"length in {${bytes.mkString(", ")}}", + enforce = condition => q"""if not ($condition): raise ValueError("Invalid UEBA field length: " + str(length))""", + ) + private def mkEncoder(tpe: TypeRef, ref: TextTree[PyValue], writerRef: TextTree[PyValue]): TextTree[PyValue] = { tpe match { case TypeRef.Scalar(id) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala index c5eaec193..6559822c6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala @@ -1,6 +1,7 @@ package io.septimalmind.baboon.translator.rust import io.septimalmind.baboon.CompilerTarget.RsTarget +import io.septimalmind.baboon.translator.{UebaLayoutPlan, UebaLengthCheckRenderer} import io.septimalmind.baboon.parser.model.RawMemberMeta import io.septimalmind.baboon.translator.rust.RsDefnTranslator.{escapeRustTypeName, toSnakeCase} import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} @@ -16,6 +17,7 @@ class RsUEBACodecGenerator( evo: BaboonEvolution, enquiries: BaboonEnquiries, ) extends RsCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate(defn: DomainMember.User, rsRef: RsValue.RsType, srcRef: RsValue.RsType): Option[TextTree[RsValue]] = { if (isActive(defn.id)) { @@ -81,7 +83,7 @@ class RsUEBACodecGenerator( private def genIndexedImpl(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { val indexCount = defn.defn match { case d: Typedef.Dto => - d.fields.count(f => domain.refMeta(f.tpe).len.isVariable) + layout.indexedFields(d).size case _: Typedef.Enum => 0 case _: Typedef.Adt => 0 case _ => 0 @@ -94,18 +96,14 @@ class RsUEBACodecGenerator( |}""".stripMargin } - private def adtBranchIndex(adtId: TypeId.User, dtoId: TypeId): Int = { - domain.defs.meta - .nodes(adtId) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dtoId) - .get - ._2 - } + private def lengthChecks(length: BinReprLen): TextTree[RsValue] = + UebaLengthCheckRenderer.render[RsValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"[${bytes.mkString(", ")}].contains(&length)", + enforce = + condition => q"""if !($condition) { return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid UEBA field length: {}", length))); }""", + ) private def genDtoCodec(defn: DomainMember.User, name: RsValue.RsType, dto: Typedef.Dto): TextTree[RsValue] = { // Compact mode encoder: fields written directly to writer @@ -122,25 +120,28 @@ class RsUEBACodecGenerator( // Indexed mode encoder: fields written to buffer, index entries to main writer // Uses &mut buffer directly (not a long-lived binding) so borrow is dropped between calls, // allowing buffer.len() reads for variable-length field index entries. - val indexedEncFields = dto.fields.map { - f => - val fieldRef = q"value.${toSnakeCase(f.name.name)}" - val actualRef = if (needsBox(f.tpe)) q"(*$fieldRef)" else fieldRef - val fakeEnc = mkEncoder(f.tpe, actualRef, q"&mut buffer") - val isVariable = domain.refMeta(f.tpe).len.isVariable - - if (isVariable) { - q"""{ - | let before = buffer.len(); - | crate::baboon_runtime::bin_tools::write_i32(writer, before as i32)?; - | $fakeEnc - | let after = buffer.len(); - | let length = after - before; - | crate::baboon_runtime::bin_tools::write_i32(writer, length as i32)?; - |}""".stripMargin - } else { - fakeEnc - } + val indexedEncFields = layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(f, length) => + val fieldRef = q"value.${toSnakeCase(f.name.name)}" + val actualRef = if (needsBox(f.tpe)) q"(*$fieldRef)" else fieldRef + val fakeEnc = mkEncoder(f.tpe, actualRef, q"&mut buffer") + val indexOffset = + if (length.isVariable) + q"crate::baboon_runtime::bin_tools::write_i32(writer, i32::try_from(before).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?)?;" + else q"" + val indexLength = + if (length.isVariable) + q"crate::baboon_runtime::bin_tools::write_i32(writer, i32::try_from(length).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?)?;" + else q"" + q"""{ + | let before = buffer.len(); + | $indexOffset + | $fakeEnc + | let after = buffer.len(); + | let length = after - before; + | ${lengthChecks(length).shift(4).trim} + | $indexLength + |}""".stripMargin } val decFields = dto.fields.map { @@ -162,7 +163,7 @@ class RsUEBACodecGenerator( val encPrefix = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id, dto.id) + val idx = layout.adtBranchIndex(id, dto.id) Some(q"crate::baboon_runtime::bin_tools::write_byte(writer, ${idx.toString})?;") case _ => None } @@ -179,7 +180,7 @@ class RsUEBACodecGenerator( | use crate::baboon_runtime::BaboonBinDecode; | let (_header, index) = ::read_index(ctx, reader)?; | if ctx.use_indices() { - | assert_eq!(index.len(), ::index_elements_count(ctx) as usize); + | if index.len() != ::index_elements_count(ctx) as usize { return Err("Unexpected UEBA index count".into()); } | } | ${decFields.joinN().shift(8).trim} | Ok(${name.asName} { @@ -193,14 +194,14 @@ class RsUEBACodecGenerator( val decBody = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id, dto.id) + val idx = layout.adtBranchIndex(id, dto.id) q"""let marker = crate::baboon_runtime::bin_tools::read_byte(reader)?; - |assert_eq!(marker, ${idx.toString}, "Expected ADT branch marker ${idx.toString}, got {}", marker); + |if marker != ${idx.toString} { return Err(format!("Unexpected UEBA ADT branch marker: {}", marker).into()); } |Self::decode_ueba_branch(ctx, reader)""".stripMargin case _ => q"""let (_header, index) = ::read_index(ctx, reader)?; |if ctx.use_indices() { - | assert_eq!(index.len(), ::index_elements_count(ctx) as usize); + | if index.len() != ::index_elements_count(ctx) as usize { return Err("Unexpected UEBA index count".into()); } |} |${decFields.joinN().shift(0).trim} |Ok(${name.asName} { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala index 34d21db36..556221384 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala @@ -7,7 +7,7 @@ import io.septimalmind.baboon.translator.scl.ScDomainTreeTools.MetaField import io.septimalmind.baboon.translator.scl.ScTypes.* import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.translator.{AnyFieldPlan, UebaLayoutPlan, UebaLengthCheckRenderer} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -18,6 +18,7 @@ class ScUEBACodecGenerator( evo: BaboonEvolution, scDomainTreeTools: ScDomainTreeTools, ) extends ScCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, @@ -84,7 +85,7 @@ class ScUEBACodecGenerator( val isEncoderEnabled = target.language.enableDeprecatedEncoders || domain.version == evo.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").joinN() q"""$comment |${varlens.size.toString}""".stripMargin @@ -287,22 +288,9 @@ class ScUEBACodecGenerator( val fdec = dtoDec(name, fields.map(_._2)) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dto.id) - .get - ._2 - } - val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""writer.writeByte(${idx.toString}) |$fenc""".stripMargin @@ -311,10 +299,10 @@ class ScUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) - q"""val marker = wire.readByte() - |assert(marker == ${idx.toString}) - |decodeBranch(ctx, wire)""".stripMargin + val idx = layout.adtBranchIndex(id, dto.id) + q"""val marker = wire.readByte() & 0xFF + |if (marker != ${idx.toString}) Left(new $javaIllegalArgumentException("Unexpected UEBA ADT branch marker: " + marker)) + |else decodeBranch(ctx, wire)""".stripMargin case _ => fdec } (enc, dec) @@ -323,7 +311,7 @@ class ScUEBACodecGenerator( private def dtoDec(name: ScValue.ScType, fields: List[TextTree[ScValue]]): TextTree[ScValue] = { q"""for { | index <- this.readIndexCount(ctx, wire) - | _ <- $scTry(if (ctx.useIndices) assert(index == indexElementsCount(ctx)) else ()).toEither + | _ <- $scTry(if (ctx.useIndices) require(index == indexElementsCount(ctx), "Unexpected UEBA index count: " + index) else ()).toEither | result <- $scTry { | $name( | ${fields.join(",\n").shift(6).trim} @@ -334,8 +322,8 @@ class ScUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[ScValue], TextTree[ScValue], TextTree[ScValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val escapedName = escapeScKeyword(field.name.name) val fieldRef = q"value.$escapedName" val enc = mkEncoder(field.tpe, fieldRef, q"writer") @@ -343,7 +331,7 @@ class ScUEBACodecGenerator( val decoder = mkDecoder(field.tpe) val decodeTree = q"$escapedName = $decoder" - val w = domain.refMeta(field.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""{ | // ${field.toString} @@ -351,23 +339,11 @@ class ScUEBACodecGenerator( | ${fakeEnc.shift(2).trim} | val after = writeMemoryStream.size() | val length = after - before - | assert(length == ${bytes.toString}) + | ${lengthChecks(BinReprLen.Fixed(bytes)).shift(2).trim} |}""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"assert(after >= before, s\"Got after={after}, before={before}\")" - - case BinReprLen.Alternatives(variants) => - q"assert($scSet(${variants.mkString(", ")}).contains(length), s\"Got length={length}\")" - - case BinReprLen.Range(min, max) => - ( - Seq(q"assert(length >= ${min.toString}, s\"Got length={length}\")") ++ - max.toSeq.map(m => q"assert(length <= ${m.toString}, $$\"Got length={length}\")") - ).joinN() - } + val sanityChecks = lengthChecks(v) q"""{ | // ${field.toString} @@ -385,6 +361,14 @@ class ScUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[ScValue] = + UebaLengthCheckRenderer.render[ScValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"$scSet(${bytes.mkString(", ")}).contains(length)", + enforce = condition => q"""require($condition, "Invalid UEBA field length: " + length)""", + ) + private def mkDecoder(tpe: TypeRef): TextTree[ScValue] = { tpe match { case TypeRef.Scalar(id) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala index e7d4f8434..ac3c827b8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala @@ -8,7 +8,7 @@ import io.septimalmind.baboon.translator.swift.SwTypes.* import io.septimalmind.baboon.translator.swift.SwValue.SwType import io.septimalmind.baboon.typer.EnumWireStyle import io.septimalmind.baboon.typer.model.* -import io.septimalmind.baboon.translator.AnyFieldPlan +import io.septimalmind.baboon.translator.{AnyFieldPlan, UebaLayoutPlan, UebaLengthCheckRenderer} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -19,6 +19,7 @@ class SwUEBACodecGenerator( evo: BaboonEvolution, swDomainTreeTools: SwDomainTreeTools, ) extends SwCodecTranslator { + private val layout = new UebaLayoutPlan(domain) override def translate( defn: DomainMember.User, @@ -63,7 +64,7 @@ class SwUEBACodecGenerator( val isEncoderEnabled = domain.version == evo.latest val indexBody = defn.defn match { case d: Typedef.Dto => - val varlens = d.fields.filter(f => domain.refMeta(f.tpe).len.isVariable) + val varlens = layout.indexedFields(d) val comment = varlens.map(f => q"// ${f.toString}").joinN() q"""$comment |return ${varlens.size.toString}""".stripMargin @@ -254,22 +255,9 @@ class SwUEBACodecGenerator( val fdec = dtoDec(name, fields.map(_._2)) - def adtBranchIndex(id: TypeId.User) = { - domain.defs.meta - .nodes(id) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dto.id) - .get - ._2 - } - val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""writer.writeU8(${idx.toString}) |$fenc""".stripMargin @@ -278,9 +266,9 @@ class SwUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id) + val idx = layout.adtBranchIndex(id, dto.id) q"""let marker = reader.readU8() - |assert(marker == ${idx.toString}) + |guard marker == ${idx.toString} else { throw BaboonCodecError.invalidInput("Unexpected UEBA ADT branch marker: " + String(marker)) } |return try decodeBranch(ctx, reader)""".stripMargin case _ => fdec } @@ -295,15 +283,15 @@ class SwUEBACodecGenerator( } q"""let indexCount = try consumeIndex(ctx, reader) - |if ctx.useIndices { assert(indexCount == indexElementsCount) } + |if ctx.useIndices && indexCount != indexElementsCount { throw BaboonCodecError.invalidInput("Unexpected UEBA index count: " + String(indexCount)) } |return ${name.asDeclName}( | $fieldAssignments |)""".stripMargin } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[SwValue], TextTree[SwValue], TextTree[SwValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val escaped = trans.escapeSwiftKeyword(field.name.name) val fieldRef = q"value.$escaped" val enc = mkEncoder(field.tpe, fieldRef, q"writer") @@ -311,7 +299,7 @@ class SwUEBACodecGenerator( val (decoder, mayThrow) = mkDecoder(field.tpe) val decodeTree = if (mayThrow) q"$escaped: try $decoder" else q"$escaped: $decoder" - val w = domain.refMeta(field.tpe).len match { + val w = length match { case BinReprLen.Fixed(bytes) => q"""do { | // ${field.toString} @@ -319,33 +307,22 @@ class SwUEBACodecGenerator( | ${bufferEnc.shift(4).trim} | let after = buffer.position | let length = after - before - | assert(length == ${bytes.toString}) + | ${lengthChecks(BinReprLen.Fixed(bytes)).shift(4).trim} |}""".stripMargin case v: BinReprLen.Variable => - val sanityChecks = v match { - case BinReprLen.Unknown() => - q"""assert(after >= before, "Got after=\\(after), before=\\(before)")""" - - case BinReprLen.Alternatives(variants) => - q"""assert([${variants.mkString(", ")}].contains(length), "Got length=\\(length)")""" - - case BinReprLen.Range(min, max) => - ( - Seq(q"""assert(length >= ${min.toString}, "Got length=\\(length)")""") ++ - max.toSeq.map(m => q"""assert(length <= ${m.toString}, "Got length=\\(length)")""") - ).joinN() - } + val sanityChecks = lengthChecks(v) q"""do { | // ${field.toString} | let before = buffer.position + | guard before <= ${UebaLayoutPlan.MaxIndexValue.toString} else { fatalError("UEBA index offset exceeds i32") } | writer.writeI32(Int32(before)) | ${bufferEnc.shift(4).trim} | let after = buffer.position | let length = after - before - | writer.writeI32(Int32(length)) | ${sanityChecks.shift(4).trim} + | writer.writeI32(Int32(length)) |}""".stripMargin } @@ -353,6 +330,14 @@ class SwUEBACodecGenerator( } } + private def lengthChecks(length: BinReprLen): TextTree[SwValue] = + UebaLengthCheckRenderer.render[SwValue]( + length, + equalTo = bytes => q"length == ${bytes.toString}", + oneOf = bytes => q"[${bytes.mkString(", ")}].contains(length)", + enforce = condition => q"""guard $condition else { fatalError("Invalid UEBA field length: " + String(length)) }""", + ) + // Returns (expression, mayThrow). `mayThrow` is true when the expression contains a top-level // throwing call that needs `try` at the parent site (e.g. inside a closure body or ternary). private def mkDecoder(tpe: TypeRef): (TextTree[SwValue], Boolean) = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala index 0feb16201..1258ef6d5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala @@ -1,6 +1,7 @@ package io.septimalmind.baboon.translator.typescript import io.septimalmind.baboon.CompilerTarget.TsTarget +import io.septimalmind.baboon.translator.{UebaLayoutPlan, UebaLengthCheckRenderer} import io.septimalmind.baboon.translator.typescript.TsTypes.* import io.septimalmind.baboon.translator.typescript.TsValue.TsType import io.septimalmind.baboon.typer.{BaboonEnquiries, EnumWireStyle} @@ -17,6 +18,7 @@ class TsUEBACodecGenerator( tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, ) extends TsCodecTranslator { + private val layout = new UebaLayoutPlan(domain) private val scalarOps = new TsScalarCodecOps(target) override def translate(defn: DomainMember.User, tsRef: TsValue.TsType, srcRef: TsValue.TsType): Option[TextTree[TsValue]] = { if (isActive(defn.id)) { @@ -110,19 +112,6 @@ class TsUEBACodecGenerator( ) } - private def adtBranchIndex(adtId: TypeId.User, dtoId: TypeId): Int = { - domain.defs.meta - .nodes(adtId) - .asInstanceOf[DomainMember.User] - .defn - .asInstanceOf[Typedef.Adt] - .dataMembers(domain) - .zipWithIndex - .find(_._1 == dtoId) - .get - ._2 - } - private def genBranchDecoder( name: TsType, d: Typedef.Dto, @@ -137,19 +126,13 @@ class TsUEBACodecGenerator( } private def dtoDec(dto: Typedef.Dto, name: TsType, fields: List[TextTree[TsValue]]): TextTree[TsValue] = { - val indexCount = dto.fields.count(f => domain.refMeta(f.tpe).len.isVariable) + val indexCount = layout.indexedFields(dto).size val ctorFields = dto.fields.map { f => q"${typeTranslator.escapeTsKeyword(f.name.name)}," } - q"""const header = $tsBinTools.readByte(reader); - |const useIndices = header === 0x01; - |if (useIndices) { - | for (let i = 0; i < ${indexCount.toString}; i++) { - | $tsBinTools.readI32(reader); - | $tsBinTools.readI32(reader); - | } - |} + q"""const indexCount = $tsBinTools.consumeIndex(reader, ${indexCount.toString}); + |if (ctx.useIndices && indexCount !== ${indexCount.toString}) throw new $tsBaboonDecoderFailure("Unexpected UEBA index count: " + indexCount); |${fields.joinN().trim} |return new $name( | ${ctorFields.joinN().shift(4).trim} @@ -157,32 +140,41 @@ class TsUEBACodecGenerator( } private def fieldsOf(dto: Typedef.Dto): List[(TextTree[TsValue], TextTree[TsValue], TextTree[TsValue])] = { - dto.fields.map { - field => + layout.fields(dto).map { + case UebaLayoutPlan.FieldLayout(field, length) => val fieldName = field.name.name val escapedFieldName = typeTranslator.escapeTsKeyword(fieldName) - val isVariable = domain.refMeta(field.tpe).len.isVariable // Property access uses the escaped getter name; local var name is also escaped to avoid // emitting `const default = ...` or `const class = ...` which are TS syntax errors. val enc = mkEncoder(field.tpe, q"value.$escapedFieldName", "writer") val fakeEnc = mkEncoder(field.tpe, q"value.$escapedFieldName", "buffer") val dec = q"const $escapedFieldName = ${mkDecoder(field.tpe)};" - val w = if (isVariable) { - q"""{ - | const before = buffer.position(); - | $tsBinTools.writeI32(writer, before); - | $fakeEnc - | const after = buffer.position(); - | $tsBinTools.writeI32(writer, after - before); - |}""".stripMargin - } else { - fakeEnc - } + val indexOffset = + if (length.isVariable) q"""if (before > ${UebaLayoutPlan.MaxIndexValue.toString}) throw new $tsBaboonEncoderFailure("UEBA index offset exceeds i32"); + |$tsBinTools.writeI32(writer, before);""".stripMargin else q"" + val indexLength = if (length.isVariable) q"$tsBinTools.writeI32(writer, length);" else q"" + val w = q"""{ + | const before = buffer.position(); + | $indexOffset + | $fakeEnc + | const after = buffer.position(); + | const length = after - before; + | ${lengthChecks(length).shift(4).trim} + | $indexLength + |}""".stripMargin (enc, dec, w) } } + private def lengthChecks(length: BinReprLen): TextTree[TsValue] = + UebaLengthCheckRenderer.render[TsValue]( + length, + equalTo = bytes => q"length === ${bytes.toString}", + oneOf = bytes => q"[${bytes.mkString(", ")}].includes(length)", + enforce = condition => q"""if (!($condition)) throw new $tsBaboonEncoderFailure("Invalid UEBA field length: " + length);""", + ) + private def genDtoCodec(name: TsValue.TsType, dto: Typedef.Dto): (TextTree[TsValue], TextTree[TsValue]) = { val fields = fieldsOf(dto) val noIndex = Seq( @@ -191,7 +183,7 @@ class TsUEBACodecGenerator( ).filterNot(_.isEmpty).joinN() val fenc = - q"""if (ctx === $tsBaboonCodecContext.Indexed) { + q"""if (ctx.useIndices) { | $tsBinTools.writeByte(writer, 0x01); | const buffer = new $tsBaboonBinWriter(); | ${fields.map(_._3).joinN().shift(4).trim} @@ -204,7 +196,7 @@ class TsUEBACodecGenerator( val enc = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id, dto.id) + val idx = layout.adtBranchIndex(id, dto.id) q"""$tsBinTools.writeByte(writer, ${idx.toString}) |$fenc""".stripMargin @@ -213,9 +205,9 @@ class TsUEBACodecGenerator( val dec = dto.id.owner match { case Owner.Adt(id) if target.language.wrappedAdtBranchCodecs => - val idx = adtBranchIndex(id, dto.id) + val idx = layout.adtBranchIndex(id, dto.id) q"""const marker = $tsBinTools.readByte(reader) - |if (marker !== ${idx.toString}) { throw new Error("Expected ADT branch marker ${idx.toString}, got " + marker); } + |if (marker !== ${idx.toString}) { throw new $tsBaboonDecoderFailure("Expected ADT branch marker ${idx.toString}, got " + marker); } |return this.decodeBranch(ctx, reader)""".stripMargin case _ => fdec } diff --git a/docs/research/20260916-backend-runtime-review/ueba-layout.md b/docs/research/20260916-backend-runtime-review/ueba-layout.md new file mode 100644 index 000000000..9f71e6a1e --- /dev/null +++ b/docs/research/20260916-backend-runtime-review/ueba-layout.md @@ -0,0 +1,123 @@ +# Proposal 5: shared UEBA layout and release validation + +Baseline: `1ef60401`. The initial scope was a C#/Scala pilot followed by all +remaining backends. The user then requested the same strict checks everywhere, +including release builds. This is therefore both a refactor and a deliberate +tightening of validation, not an output-preserving refactor as a whole. + +## Implementation + +- `UebaLayoutPlan` centralizes ordered field metadata, indexed-field selection, + ADT branch ordinals and length constraints for all nine generators. It reuses + `BinReprLen`; it does not cache layouts or change the wire representation. +- `UebaLengthCheckRenderer` shares predicate traversal and numeric comparisons. + Membership syntax and error reporting remain language-specific. +- Indexed encoding validates fixed lengths, allowed alternatives and range + bounds. Variable-length index entries require positive lengths and signed-i32 + offsets/lengths, matching the existing Scala/Kotlin reader domain. +- Index readers reject negative/out-of-range values, zero lengths and overlapping + entries using widened arithmetic. Gaps remain valid. Decoders enforce the + expected index count when indexing is required and validate wrapped ADT markers. +- These checks no longer depend on assertions/debug configuration. Swift's + non-throwing encoder reports an invariant violation with unconditional + `fatalError`; Swift decoding throws. Rust uses its existing `Result` contracts. + Other backends use their ordinary exception/error mechanisms. +- Swift's read/consume paths now share one checked index loop, with a count-only + path that avoids constructing index entries. TypeScript uses a common runtime + index-consumption helper instead of duplicating the loop in generated DTOs. + +Scope excludes wholesale primitive-decoder hardening, validation of compact +encoder field lengths, and checking index entries against actual decoded payload +boundaries. Timestamp formats and timestamp runtime implementations are unchanged. + +## Reproduced defects corrected + +- C# Release accepted invalid index entries, wrong fixed field lengths, missing + required indices and corrupt wrapped markers. Added tests failed before the + correction because expected exceptions were absent. +- Scala's corrupt wrapped marker escaped its `Either` contract as an + `AssertionError`; it now returns `Left`. +- TypeScript ignored malformed index entries and missing required indices, + accepted a custom codec with the wrong fixed length, and treated custom + `useIndices=true` contexts as compact because it compared context identity. + All four regression tests failed against the baseline for those reasons. +- Actual generated Scala and Kotlin wrapped codecs failed to decode branch 128 + from the valid bytes `[0x80, 0]`: signed byte reads were compared to unsigned + ordinals. Both failures were reproduced independently before correcting the + reads. A persistent model and harness test ordinals 0, 127 and 128 and reject a + mismatched marker; the harness is included in `mdl :test`. + +## Measurements + +`cloc` 2.10, `--skip-uniqueness`, production translators plus embedded runtimes; +tests, documentation, comments and blank lines excluded: + +| State | Translator code | Runtime code | Total | +|---|---:|---:|---:| +| Baseline | 40,838 | 28,483 | 69,321 | +| Completed implementation | 40,804 | 28,506 | 69,310 | + +Net reduction: **11 production lines** (34 fewer translator lines, 23 additional +runtime lines). The substantive change is replacing independent layout policies +with one shared policy while adding enforcement. Generated output intentionally +changes. Execution time and memory consumption were not benchmarked; additional +validation is not claimed as a performance improvement. + +The same 51-configuration corpus still contains 42,180 generated files, with no +added or removed paths. Its source bytes increased from 134,966,493 to 138,105,295 +(+3,138,802; 2.3%) because more checks are emitted. This corpus includes generated +tests, fixtures and repeated runtime copies; it is not a deployed binary size. + +## Validation + +- Before editing: JVM/Scala.js compilation and the 19 existing shared-planner + tests passed. Captured 42,180 generated files across 51 configurations. +- The initial C#/Scala layout-only extraction preserved every generated file. +- C#/Scala enforcement stage: C# Release passed 594 tests in each ADT mode; + Scala passed 519 regular tests (166 expected cancellations) and 685 wrapped + tests. Six new behavior-level layout-planner tests passed on the compiler. +- The later predicate-renderer extraction was checked independently: all 42,180 + files across the 51 configurations were byte-identical before and after it. +- The first cross-language run exposed two implementation errors: generated Rust + encoder errors needed explicit `io::Error` conversion, and Swift encoder checks + attempted to throw through a non-throwing API. Both were corrected before final + validation. +- Standalone probes against the final generated runtimes passed with Dart AOT + (assertions verified disabled), Swift `-O`, and Python `-O`: all five invalid + index shapes were rejected, including both materializing and count-only paths + where available. Swift's executable was launched inside the project's FHS + toolchain environment, avoiding the host/toolchain libc mismatch. +- Checked-in focused tests also passed with Rust `--release` (2 tests), Scala + `-Xdisable-assertions` (4), Java `-DenableAssertions=false` (4), Python `-O` (3), + and Kotlin JVM/KMP's Gradle `enableAssertions=false` (2 each). The four TypeScript + regressions passed against the final generated code. +- The fresh serialization acceptance run passed all 200 combinations. The + subsequent Kotlin JVM regular compile saturated its default 512 MiB heap: + `jstat` measured over 1,500 full collections and roughly 200 seconds in full + GC, with old-generation occupancy above 99%. That invocation was stopped; + the Kotlin fixture project now explicitly allows a 2 GiB compiler heap. + The same configuration is copied into the branch-ordinal regression project. +- Kotlin regular/wrapped then passed in 41/40 seconds, and both manual lanes + passed. The first integrated run of the new ordinal harness caught a fatal + Scala discarded-value warning in its test helper; the helper now explicitly + discards the assertion result instead of relying on implicit Unit conversion. + +### Final CI result + +`mdl --par --continue --keep-run-dir --simple-log :build :test :ci` completed +successfully in run `20260917-225441-561283027`: **206 actions passed**, with 181 +executed in that run and 25 restored from the preparatory runs +`20260917-223329-892845374` and `20260917-225126-144050939`. No production source +changed between these runs. The only intervening implementation changes were the +explicit Kotlin fixture heap and the new ordinal test's discarded-value fix; +the affected Kotlin suites and ordinal harness were rerun successfully. + +- JVM/Scala.js build and **860 compiler tests in 139 suites** passed, with no + failed, canceled or aborted compiler tests/suites. +- All backend regular/wrapped, manual, wiring and MCP lanes passed, as did native + smoke and editor-grammar checks. C# Release ran 594 tests in each ADT mode; + Scala ran 674 regular tests and 683 wrapped tests. +- Serialization acceptance: **200/200**. Service acceptance: **162/162**. +- The integrated ordinal harness passed on Scala and Kotlin. +- Focused release/optimized checks, Scala formatting checks, shell syntax checks + and `git diff --check` passed. diff --git a/test/cs-stub/BaboonTests/IndexConsumptionTests.cs b/test/cs-stub/BaboonTests/IndexConsumptionTests.cs index 165a2cb50..345a99cd3 100644 --- a/test/cs-stub/BaboonTests/IndexConsumptionTests.cs +++ b/test/cs-stub/BaboonTests/IndexConsumptionTests.cs @@ -3,6 +3,7 @@ using System.IO; using Baboon.Runtime.Shared; using NUnit.Framework; +using Testpkg.Pkg0; namespace ConversionsTest { @@ -63,5 +64,79 @@ public void RejectsTruncatedPairs(bool consumeOnly) else codec.ReadIndex(BaboonCodecContext.Compact, reader); }); } + + [TestCase(0, 0, 1, 1)] + [TestCase(-1, 1, 1, 1)] + [TestCase(0, -1, 1, 1)] + [TestCase(0, 2, 1, 1)] + [TestCase(int.MaxValue, 1, 0, 1)] + public void RejectsInvalidEntriesInRelease(int firstOffset, int firstLength, int secondOffset, int secondLength) + { + using var data = new MemoryStream(); + using (var writer = new BinaryWriter(data, System.Text.Encoding.UTF8, true)) + { + writer.Write((byte)1); + writer.Write(firstOffset); + writer.Write(firstLength); + writer.Write(secondOffset); + writer.Write(secondLength); + } + IBaboonBinCodecIndexed codec = new TwoEntries(); + foreach (var consumeOnly in new[] { false, true }) + { + using var reader = new BinaryReader(new MemoryStream(data.ToArray())); + Assert.Throws(() => + { + if (consumeOnly) codec.ConsumeIndex(BaboonCodecContext.Compact, reader); + else codec.ReadIndex(BaboonCodecContext.Compact, reader); + }); + } + } + + [TestCase(0)] + [TestCase(2)] + public void RejectsIncorrectFixedLengthsInRelease(int actual) + { + IBaboonBinCodecIndexed codec = new TwoEntries(); + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + Assert.Throws(() => codec.WriteIndexFixedLenField(writer, 1, () => writer.Write(new byte[actual]))); + } + + [Test] + public void RejectsEmptyVariableFieldsInRelease() + { + IBaboonBinCodecIndexed codec = new TwoEntries(); + using var buffer = new MemoryStream(); + using var index = new MemoryStream(); + using var writer = new BinaryWriter(index); + using var fakeWriter = new BinaryWriter(buffer); + Assert.Throws(() => codec.WriteIndexVarLenField(writer, fakeWriter, () => { })); + } + + [Test] + public void GeneratedDecoderRejectsMissingRequiredIndexInRelease() + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + var codec = T1_E2_RET_UEBACodec.Instance; + codec.Encode(BaboonCodecContext.Compact, writer, new T1_E2_RET(T1_E2.A, null)); + buffer.Position = 0; + using var reader = new BinaryReader(buffer); + Assert.Catch(() => codec.Decode(BaboonCodecContext.Indexed, reader)); + } + + [Test] + public void GeneratedBranchDecoderRejectsCorruptPrefixInRelease() + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + var codec = T5_A1.B1_UEBACodec.Instance; + codec.Encode(BaboonCodecContext.Compact, writer, new T5_A1.B1("x")); + var bytes = buffer.ToArray(); + bytes[0] = 255; + using var reader = new BinaryReader(new MemoryStream(bytes)); + Assert.Catch(() => codec.Decode(BaboonCodecContext.Compact, reader)); + } } } diff --git a/test/dt-stub/test/runtime/index_validation_test.dart b/test/dt-stub/test/runtime/index_validation_test.dart new file mode 100644 index 000000000..bafd5cc33 --- /dev/null +++ b/test/dt-stub/test/runtime/index_validation_test.dart @@ -0,0 +1,43 @@ +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:test/test.dart'; + +class TwoEntries with BaboonBinCodecIndexed { + @override + int get indexElementsCount => 2; +} + +void main() { + test('invalid entries throw normal errors without relying on assertions', () { + for (final entries in [ + [0, 0, 1, 1], + [-1, 1, 1, 1], + [0, -1, 1, 1], + [0, 2, 1, 1], + [2147483647, 1, 0, 1] + ]) { + final writer = BaboonBinWriter()..writeU8(1); + for (final value in entries) { + writer.writeI32(value); + } + expect( + () => TwoEntries().readIndex( + BaboonCodecContext.compact, BaboonBinReader(writer.toBytes())), + throwsFormatException); + } + }); + + test('valid entries retain gaps and leave the payload unconsumed', () { + final writer = BaboonBinWriter()..writeU8(1); + for (final value in [1, 2, 5, 3]) { + writer.writeI32(value); + } + writer.writeU8(42); + final reader = BaboonBinReader(writer.toBytes()); + final entries = TwoEntries().readIndex(BaboonCodecContext.compact, reader); + expect(entries.map((entry) => [entry.offset, entry.length]), [ + [1, 2], + [5, 3] + ]); + expect(reader.readU8(), 42); + }); +} diff --git a/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java b/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java index 10c314f4a..b549c1022 100644 --- a/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java +++ b/test/jv-stub/src/test/java/runtime/IndexConsumptionTest.java @@ -11,6 +11,20 @@ import static org.junit.jupiter.api.Assertions.*; public class IndexConsumptionTest { + @Test + public void rejectsInvalidIndexEntriesWithoutAssertions() throws Exception { + int[][] cases = {{0, 0, 1, 1}, {-1, 1, 1, 1}, {0, -1, 1, 1}, {0, 2, 1, 1}, {Integer.MAX_VALUE, 1, 0, 1}}; + for (int[] entries : cases) { + var buffer = new ByteArrayOutputStream(); + var writer = new LEDataOutputStream(buffer); + writer.writeByte(1); + for (int value : entries) writer.writeInt(value); + var bytes = buffer.toByteArray(); + assertThrows(IllegalArgumentException.class, () -> BaboonBinCodec.readIndex(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(bytes)), 2)); + assertThrows(IllegalArgumentException.class, () -> BaboonBinCodec.consumeIndex(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(bytes)), 2)); + } + } + @Test public void streamCopyOverloadsAreObservableToPublicSubclasses() throws Exception { class ObservingOutput extends LEDataOutputStream { diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/IndexValidationTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/IndexValidationTest.kt new file mode 100644 index 000000000..af0b3bd84 --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/IndexValidationTest.kt @@ -0,0 +1,32 @@ +package runtime + +import baboon.runtime.shared.* +import kotlin.test.* +import testpkg.pkg0.T1_E2 +import testpkg.pkg0.T1_E2_RET +import testpkg.pkg0.T1_E2_RET_UEBACodec + +class IndexValidationTest { + private val codec = object : BaboonBinCodecIndexed { + override fun indexElementsCount(ctx: BaboonCodecContext): Short = 2 + } + + @Test fun generatedDecoderRejectsMissingRequiredIndex() { + val writer = BaboonBinaryWriter() + T1_E2_RET_UEBACodec.encode(BaboonCodecContext.Compact, writer, T1_E2_RET(T1_E2.A, null)) + assertFailsWith { + T1_E2_RET_UEBACodec.decode(BaboonCodecContext.Indexed, BaboonBinaryReader(writer.toByteArray())) + } + } + + @Test fun invalidEntriesThrowNormalErrors() { + for (entries in listOf(listOf(0, 0, 1, 1), listOf(-1, 1, 1, 1), listOf(0, -1, 1, 1), listOf(0, 2, 1, 1), listOf(Int.MAX_VALUE, 1, 0, 1))) { + val writer = BaboonBinaryWriter() + writer.writeByte(1) + entries.forEach { writer.writeInt(it) } + assertFailsWith { + codec.readIndex(BaboonCodecContext.Compact, BaboonBinaryReader(writer.toByteArray())) + } + } + } +} diff --git a/test/kt-stub/gradle.properties b/test/kt-stub/gradle.properties new file mode 100644 index 000000000..a7996a4ce --- /dev/null +++ b/test/kt-stub/gradle.properties @@ -0,0 +1,2 @@ +# Compiling the full generated fixture corpus exhausts the default 512 MiB heap. +kotlin.daemon.jvmargs=-Xmx2g diff --git a/test/kt-stub/src/test/kotlin/runtime/IndexValidationTest.kt b/test/kt-stub/src/test/kotlin/runtime/IndexValidationTest.kt new file mode 100644 index 000000000..7672327cd --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/IndexValidationTest.kt @@ -0,0 +1,36 @@ +package runtime + +import baboon.runtime.shared.* +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.* +import testpkg.pkg0.T1_E2 +import testpkg.pkg0.T1_E2_RET +import testpkg.pkg0.T1_E2_RET_UEBACodec + +class IndexValidationTest { + private val codec = object : BaboonBinCodecIndexed { + override fun indexElementsCount(ctx: BaboonCodecContext): Short = 2 + } + + @Test fun generatedDecoderRejectsMissingRequiredIndex() { + val buffer = ByteArrayOutputStream() + T1_E2_RET_UEBACodec.encode(BaboonCodecContext.Compact, LEDataOutputStream(buffer), T1_E2_RET(T1_E2.A, null)) + assertFailsWith { + T1_E2_RET_UEBACodec.decode(BaboonCodecContext.Indexed, LEDataInputStream(ByteArrayInputStream(buffer.toByteArray()))) + } + } + + @Test fun invalidEntriesThrowNormalErrors() { + for (entries in listOf(listOf(0, 0, 1, 1), listOf(-1, 1, 1, 1), listOf(0, -1, 1, 1), listOf(0, 2, 1, 1), listOf(Int.MAX_VALUE, 1, 0, 1))) { + val bytes = ByteBuffer.allocate(1 + entries.size * Int.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN) + bytes.put(1) + entries.forEach { bytes.putInt(it) } + assertFailsWith { + codec.readIndex(BaboonCodecContext.Compact, LEDataInputStream(ByteArrayInputStream(bytes.array()))) + } + } + } +} diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py b/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py index 7ffc2b627..27d6e3eaf 100644 --- a/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py +++ b/test/py-stub/BaboonTests/RuntimeTests/test_index_consumption.py @@ -25,14 +25,12 @@ def test_consumption_matches_materialization(self): self.assertEqual(b"payload", consumed.stream.read()) def test_both_paths_reject_invalid_indices(self): - if not __debug__: - self.skipTest("index validation uses assertions") codec = TwoEntries() - for fields in ((0, 0, 0, 1), (0, 3, 2, 1)): + for fields in ((0, 0, 0, 1), (-1, 1, 1, 1), (0, -1, 1, 1), (0, 3, 2, 1), (2147483647, 1, 0, 1)): for read in (codec.read_index, codec.consume_index): with self.subTest(fields=fields, read=read.__name__): - with self.assertRaises(AssertionError): - read(BaboonCodecContext.Compact, LEDataInputStream(BytesIO(b"\x01" + struct.pack(" u16 { 2 } +} + +#[test] +fn invalid_entries_return_errors_instead_of_panicking() { + for entries in [[0, 0, 1, 1], [-1, 1, 1, 1], [0, -1, 1, 1], [0, 2, 1, 1], [i32::MAX, 1, 0, 1]] { + let mut bytes = vec![1u8]; + for value in entries { bytes.extend_from_slice(&value.to_le_bytes()); } + assert!(TwoEntries::read_index(&BaboonCodecContext::Compact, &mut Cursor::new(bytes)).is_err()); + } +} + +#[test] +fn valid_entries_preserve_gaps_and_leave_payload_unconsumed() { + let mut bytes = vec![1u8]; + for value in [1i32, 2, 5, 3] { bytes.extend_from_slice(&value.to_le_bytes()); } + bytes.push(42); + let mut input = Cursor::new(bytes); + let (_, entries) = TwoEntries::read_index(&BaboonCodecContext::Compact, &mut input).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!((entries[0].offset, entries[0].length), (1, 2)); + assert_eq!((entries[1].offset, entries[1].length), (5, 3)); + assert_eq!(input.position(), 17); +} diff --git a/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala b/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala index d82fe950f..9e6a34e9e 100644 --- a/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/IndexCountSpec.scala @@ -3,6 +3,7 @@ package runtime import baboon.runtime.shared._ import java.io.{ByteArrayInputStream, ByteArrayOutputStream} import org.scalatest.funsuite.AnyFunSuite +import testpkg.pkg0.{T1_E2, T1_E2_RET, T1_E2_RET_UEBACodec, T5_A1} class IndexCountSpec extends AnyFunSuite { private val indexed = new BaboonBinCodecIndexed { @@ -39,4 +40,34 @@ class IndexCountSpec extends AnyFunSuite { assert(fullStream.read() == countStream.read()) } } + + test("both index readers reject zero, negative and overlapping entries through the error channel") { + val invalid = List( + bytes(1, 0 -> 0, 1 -> 1), + bytes(1, -1 -> 1, 1 -> 1), + bytes(1, 0 -> -1, 1 -> 1), + bytes(1, 0 -> 2, 1 -> 1), + bytes(1, Int.MaxValue -> 1, 0 -> 1), + ) + invalid.foreach { + input => + assert(indexed.readIndex(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(input))).isLeft) + assert(indexed.readIndexCount(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(input))).isLeft) + } + } + + test("generated decoder rejects a missing required index through its error channel") { + val buffer = new ByteArrayOutputStream() + T1_E2_RET_UEBACodec.encode(BaboonCodecContext.Compact, new LEDataOutputStream(buffer), T1_E2_RET(T1_E2.A, None)) + val result = T1_E2_RET_UEBACodec.decode(BaboonCodecContext.Indexed, new LEDataInputStream(new ByteArrayInputStream(buffer.toByteArray))) + assert(result.isLeft) + } + + test("generated branch decoder rejects a corrupt prefix through its error channel") { + val buffer = new ByteArrayOutputStream() + T5_A1.B1_UEBACodec.encode(BaboonCodecContext.Compact, new LEDataOutputStream(buffer), T5_A1.B1("x")) + val input = buffer.toByteArray + input(0) = 255.toByte + assert(T5_A1.B1_UEBACodec.decode(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(input))).isLeft) + } } diff --git a/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift b/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift index 7f2810f0a..ec41288d5 100644 --- a/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift +++ b/test/sw-stub/Tests/RuntimeTests/RuntimePrimitiveCharacterizationTests.swift @@ -5,6 +5,24 @@ import BaboonRuntime final class RuntimePrimitiveCharacterizationTests: XCTestCase { private struct Indexed: BaboonBinCodecIndexed { let indexElementsCount = 2 } + func testInvalidIndexEntriesThrowInRelease() throws { + let cases: [[Int32]] = [[0, 0, 1, 1], [-1, 1, 1, 1], [0, -1, 1, 1], [0, 2, 1, 1], [Int32.max, 1, 0, 1]] + for entries in cases { + let writer = BaboonBinWriter() + writer.writeU8(1) + for value in entries { writer.writeI32(value) } + XCTAssertThrowsError(try Indexed().readIndex(.compact, BaboonBinReader(writer.toData()))) + XCTAssertThrowsError(try Indexed().consumeIndex(.compact, BaboonBinReader(writer.toData()))) + } + } + + func testTruncatedIndexThrowsWithoutTrapping() throws { + for bytes: [UInt8] in [[], [1], [1, 0], [1, 0, 0, 0, 0]] { + XCTAssertThrowsError(try Indexed().readIndex(.compact, BaboonBinReader(Data(bytes)))) + XCTAssertThrowsError(try Indexed().consumeIndex(.compact, BaboonBinReader(Data(bytes)))) + } + } + func testIndexConsumptionFollowsHeaderRatherThanContext() throws { for context in [BaboonCodecContext.compact, BaboonCodecContext.indexed] { for header: UInt8 in [0, 1, 2, 3] { diff --git a/test/ts-stub/src/runtime-tests/UebaLayout.test.ts b/test/ts-stub/src/runtime-tests/UebaLayout.test.ts new file mode 100644 index 000000000..31f603bc1 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/UebaLayout.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { BaboonBinReader, BaboonBinWriter, BaboonCodecContext, BaboonDecoderFailure, BaboonEncoderFailure, BaboonEnvelopeVersion, ForwardWritePolicy, Lazy } from "../generated/BaboonSharedRuntime"; +import { T1_E2, T1_E2_UEBACodec } from "../generated/testpkg/pkg0/T1_E2"; +import { T1_E2_RET, T1_E2_RET_UEBACodec } from "../generated/testpkg/pkg0/T1_E2_RET"; + +describe("UEBA layout validation", () => { + const value = new T1_E2_RET(T1_E2.A, undefined); + + test("rejects invalid index entries before decoding an otherwise valid payload", () => { + for (const [offset, length] of [[0, 0], [-1, 1], [0, -1]]) { + const writer = new BaboonBinWriter(); + writer.writeByte(1); + writer.writeI32(offset); + writer.writeI32(length); + writer.writeByte(0); + writer.writeByte(0); + expect(() => T1_E2_RET_UEBACodec.instance.decode(BaboonCodecContext.Compact, new BaboonBinReader(writer.toBytes()))).toThrow(BaboonDecoderFailure); + } + }); + + test("rejects a missing index when the context requires one", () => { + const writer = new BaboonBinWriter(); + T1_E2_RET_UEBACodec.instance.encode(BaboonCodecContext.Compact, value, writer); + expect(() => T1_E2_RET_UEBACodec.instance.decode(BaboonCodecContext.Indexed, new BaboonBinReader(writer.toBytes()))).toThrow(BaboonDecoderFailure); + }); + + test("honors the index flag on custom contexts", () => { + const ctx = BaboonCodecContext.custom(true, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V1, undefined); + const writer = new BaboonBinWriter(); + T1_E2_RET_UEBACodec.instance.encode(ctx, value, writer); + expect(writer.toBytes()[0]).toBe(1); + expect(T1_E2_RET_UEBACodec.instance.decode(ctx, new BaboonBinReader(writer.toBytes()))).toEqual(value); + }); + + test("rejects a custom codec that violates its fixed wire length", () => { + class WrongLength extends T1_E2_UEBACodec { + override encode(_ctx: BaboonCodecContext, _value: T1_E2, writer: BaboonBinWriter): void { + writer.writeByte(0); + writer.writeByte(0); + } + } + const saved = T1_E2_UEBACodec.lazyInstance; + try { + T1_E2_UEBACodec.lazyInstance = new Lazy(() => new WrongLength()); + expect(() => T1_E2_RET_UEBACodec.instance.encode(BaboonCodecContext.Indexed, value, new BaboonBinWriter())).toThrow(BaboonEncoderFailure); + } finally { + T1_E2_UEBACodec.lazyInstance = saved; + } + }); +}); diff --git a/test/ueba-layout-regression/HighMarkerSpec.scala b/test/ueba-layout-regression/HighMarkerSpec.scala new file mode 100644 index 000000000..d6acf8a1d --- /dev/null +++ b/test/ueba-layout-regression/HighMarkerSpec.scala @@ -0,0 +1,19 @@ +import baboon.runtime.shared._ +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import org.scalatest.funsuite.AnyFunSuite +import ueba.layout.high.Choice + +class HighMarkerSpec extends AnyFunSuite { + test("wrapped branch ordinals cross the signed-byte boundary") { + def check[T](value: T, codec: BaboonBinCodec[T], ordinal: Int): Unit = { + val buffer = new ByteArrayOutputStream() + codec.encode(BaboonCodecContext.Compact, new LEDataOutputStream(buffer), value) + assert(buffer.toByteArray.toList == List(ordinal.toByte, 0.toByte)) + val _ = assert(codec.decode(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(buffer.toByteArray))) == Right(value)) + } + check(Choice.B0(), Choice.B0_UEBACodec, 0) + check(Choice.B127(), Choice.B127_UEBACodec, 127) + check(Choice.B128(), Choice.B128_UEBACodec, 128) + assert(Choice.B128_UEBACodec.decode(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(Array[Byte](0, 0)))).isLeft) + } +} diff --git a/test/ueba-layout-regression/HighMarkerTest.kt b/test/ueba-layout-regression/HighMarkerTest.kt new file mode 100644 index 000000000..0c61dba99 --- /dev/null +++ b/test/ueba-layout-regression/HighMarkerTest.kt @@ -0,0 +1,23 @@ +import baboon.runtime.shared.* +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.test.* +import ueba.layout.high.Choice + +class HighMarkerTest { + @Test fun unsignedOrdinalsRoundTrip() { + fun check(value: T, codec: BaboonBinCodec, ordinal: Int) { + val buffer = ByteArrayOutputStream() + codec.encode(BaboonCodecContext.Compact, LEDataOutputStream(buffer), value) + assertContentEquals(byteArrayOf(ordinal.toByte(), 0), buffer.toByteArray()) + val decoded = codec.decode(BaboonCodecContext.Compact, LEDataInputStream(ByteArrayInputStream(buffer.toByteArray()))) + assertEquals(value::class, decoded::class) + } + check(Choice.B0(), Choice.B0_UEBACodec, 0) + check(Choice.B127(), Choice.B127_UEBACodec, 127) + check(Choice.B128(), Choice.B128_UEBACodec, 128) + assertFailsWith { + Choice.B128_UEBACodec.decode(BaboonCodecContext.Compact, LEDataInputStream(ByteArrayInputStream(byteArrayOf(0, 0)))) + } + } +} diff --git a/test/ueba-layout-regression/high.baboon b/test/ueba-layout-regression/high.baboon new file mode 100644 index 000000000..c287d0d97 --- /dev/null +++ b/test/ueba-layout-regression/high.baboon @@ -0,0 +1,134 @@ +model ueba.layout.high +version "1.0.0" + +root adt Choice { + data B0 {} + data B1 {} + data B2 {} + data B3 {} + data B4 {} + data B5 {} + data B6 {} + data B7 {} + data B8 {} + data B9 {} + data B10 {} + data B11 {} + data B12 {} + data B13 {} + data B14 {} + data B15 {} + data B16 {} + data B17 {} + data B18 {} + data B19 {} + data B20 {} + data B21 {} + data B22 {} + data B23 {} + data B24 {} + data B25 {} + data B26 {} + data B27 {} + data B28 {} + data B29 {} + data B30 {} + data B31 {} + data B32 {} + data B33 {} + data B34 {} + data B35 {} + data B36 {} + data B37 {} + data B38 {} + data B39 {} + data B40 {} + data B41 {} + data B42 {} + data B43 {} + data B44 {} + data B45 {} + data B46 {} + data B47 {} + data B48 {} + data B49 {} + data B50 {} + data B51 {} + data B52 {} + data B53 {} + data B54 {} + data B55 {} + data B56 {} + data B57 {} + data B58 {} + data B59 {} + data B60 {} + data B61 {} + data B62 {} + data B63 {} + data B64 {} + data B65 {} + data B66 {} + data B67 {} + data B68 {} + data B69 {} + data B70 {} + data B71 {} + data B72 {} + data B73 {} + data B74 {} + data B75 {} + data B76 {} + data B77 {} + data B78 {} + data B79 {} + data B80 {} + data B81 {} + data B82 {} + data B83 {} + data B84 {} + data B85 {} + data B86 {} + data B87 {} + data B88 {} + data B89 {} + data B90 {} + data B91 {} + data B92 {} + data B93 {} + data B94 {} + data B95 {} + data B96 {} + data B97 {} + data B98 {} + data B99 {} + data B100 {} + data B101 {} + data B102 {} + data B103 {} + data B104 {} + data B105 {} + data B106 {} + data B107 {} + data B108 {} + data B109 {} + data B110 {} + data B111 {} + data B112 {} + data B113 {} + data B114 {} + data B115 {} + data B116 {} + data B117 {} + data B118 {} + data B119 {} + data B120 {} + data B121 {} + data B122 {} + data B123 {} + data B124 {} + data B125 {} + data B126 {} + data B127 {} + data B128 {} +} diff --git a/test/ueba-layout-regression/run.sh b/test/ueba-layout-regression/run.sh new file mode 100644 index 000000000..7b4d335cf --- /dev/null +++ b/test/ueba-layout-regression/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +compiler=$(realpath "$1") +repo=$(cd "$(dirname "$0")/../.." && pwd) +output="$repo/target/ueba-layout-regression" + +mkdir -p "$output/scala/src/test/scala" "$output/kotlin/src/test/kotlin" +cp "$repo/test/sc-stub/build.sbt" "$output/scala/" +cp "$repo/test/kt-stub/build.gradle.kts" "$repo/test/kt-stub/settings.gradle.kts" "$repo/test/kt-stub/gradle.properties" "$output/kotlin/" +cp "$repo/test/ueba-layout-regression/HighMarkerSpec.scala" "$output/scala/src/test/scala/" +cp "$repo/test/ueba-layout-regression/HighMarkerTest.kt" "$output/kotlin/src/test/kotlin/" + +"$compiler" --model-dir "$repo/test/ueba-layout-regression" \ + :scala --output "$output/scala/src/main/scala" \ + --sc-wrapped-adt-branch-codecs=true --generate-ueba-codecs-by-default=true \ + :kotlin --output "$output/kotlin/src/main/kotlin" \ + --kt-wrapped-adt-branch-codecs=true --generate-ueba-codecs-by-default=true + +(cd "$output/scala" && sbt -batch test) +(cd "$output/kotlin" && gradle --console=plain --no-daemon test) From 9f804aab9d00667466ebc84663c85b051f0ab30b Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Fri, 18 Sep 2026 15:40:12 +0100 Subject: [PATCH 23/48] Increase Kotlin KMP fixture compiler heap --- test/kt-stub-kmp/gradle.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 test/kt-stub-kmp/gradle.properties diff --git a/test/kt-stub-kmp/gradle.properties b/test/kt-stub-kmp/gradle.properties new file mode 100644 index 000000000..4387bb910 --- /dev/null +++ b/test/kt-stub-kmp/gradle.properties @@ -0,0 +1 @@ +kotlin.daemon.jvmargs=-Xmx2g From f5c0a0368c60e88e413e51ba0c268bbbdc5ec7a5 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 20 Sep 2026 13:33:00 +0100 Subject: [PATCH 24/48] feat(forward-compat): resolve forward readability per wire format; declared renames are UEBA-readable UEBA identifies a field, an enum member or an ADT branch by position and never writes its name; JSON identifies them by name and is blind to position. The two formats therefore forgive disjoint sets of changes and neither contains the other, so the single linear tier chain could not describe a step: a declared rename is byte-identical in UEBA and broken in JSON, which is the exact mirror of a mid-position insert. The chain collapsed every rename to "unreadable", discarding a legal UEBA forward read. A step (and a chain, by meet) now carries a ForwardGuarantee: an independent UEBA axis (Full | PrefixAnyMode | PrefixCompact | absent) and a JSON flag. Dependencies propagate per axis too, since a nested value must be UEBA byte-identical to keep the outer sequential read in sync but only JSON-readable to keep the outer JSON read alive. `baboonMinReaderVersions` keeps its four capability keys and each is now resolved from its own axis, so a rename lowers `prefix-compact` and `prefix-any-mode` and leaves `json-additive` alone, while a mid-position insert does the reverse. `identical` still means byte-identical in both formats and still equals the type's sameIn head. No wire change was needed. A rename sits at the strongest UEBA tier, and both the v1 and v2 binary envelopes already carry a bound the writer computes per format. Only `baboonForwardReadable` gains values: `ueba-identical`, `ueba-prefix-any-mode`, `ueba-prefix-compact`, where the prefix means the guarantee is UEBA-only. That map is informational; no runtime reads it. Renames are taken from the declared `was` annotation, never inferred, so the claim is semantics-preserving by construction. `prevName` survives into later versions, so it is honoured only while it still names a field of the version being compared; without that filter a type carried its rename forever and every later step was misclassified. A new field that takes a name over from a different field via `was` also disqualifies the JSON axis, which closes a pre-existing hole where a declared name swap was reported JSON-readable while handing the old reader the wrong value. ADT branch renames and type renames remain unclassified: they change the TypeId, so the type drops out of the version intersection and out of its dependents' dependency sets. Recorded under known gaps. Coverage: ForwardCompatComparatorTest gains two cases over new isolated fixtures (rename, rename-then-append, enum member rename, and hosts of both) plus the per-format minReaders bounds; ForwardCompatRenameSpec proves it end to end over a new shared model, including that the UEBA payload matches the 1.0.0 layout byte for byte and that JSON refuses under either read policy. Swift Package.swift gains the targets for the new fixture domain. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/EvolutionMetadataPlanTest.scala | 4 +- .../tests/ForwardCompatComparatorTest.scala | 123 +++++++++--- .../baboon/typer/BaboonComparator.scala | 177 ++++++++++-------- .../baboon/typer/model/BaboonEvolution.scala | 21 ++- .../baboon/typer/model/ForwardCompat.scala | 128 ++++++++++--- .../septimalmind/baboon/util/Metadata.scala | 5 +- .../fwd-e2e-rename-ok/fwd-rename-1.baboon | 20 ++ .../fwd-e2e-rename-ok/fwd-rename-2.baboon | 12 ++ .../test/resources/fwd-compat-ok/fwd-1.baboon | 30 +++ .../test/resources/fwd-compat-ok/fwd-2.baboon | 17 ++ .../test/resources/fwd-compat-ok/fwd-3.baboon | 6 + docs/forward-compat.md | 111 ++++++++--- .../runtime/ForwardCompatRenameSpec.scala | 89 +++++++++ test/sw-stub/Package.swift | 15 ++ 14 files changed, 594 insertions(+), 164 deletions(-) create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-1.baboon create mode 100644 baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-2.baboon create mode 100644 test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala index 6cabf2d0d..86823d759 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/EvolutionMetadataPlanTest.scala @@ -25,8 +25,8 @@ class EvolutionMetadataPlanTest extends AnyFlatSpec with Matchers { TypeId.Builtins.str -> UnmodifiedSince(TypeId.Builtins.str, v2, NEList(v1, v2)), )), Map(v2 -> Map( - z -> ForwardReadable(z, v2, NEList(v2 -> ForwardCompatTier.Identical, v10 -> ForwardCompatTier.JsonAdditive)), - typeA -> ForwardReadable(typeA, v2, NEList(v2 -> ForwardCompatTier.Identical)), + z -> ForwardReadable(z, v2, NEList(v2 -> ForwardGuarantee.identical, v10 -> ForwardGuarantee.jsonOnly)), + typeA -> ForwardReadable(typeA, v2, NEList(v2 -> ForwardGuarantee.identical)), )), ) diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala index 965fbe823..46db92699 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ForwardCompatComparatorTest.scala @@ -15,6 +15,16 @@ final class ForwardCompatComparatorTest extends ForwardCompatComparatorTestBase[ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { import ForwardCompatTier.* + // `ForwardCompatTier` values are the capability KEYS published in + // `baboonMinReaderVersions`; a step or a chain carries a `ForwardGuarantee`, + // which resolves the two wire formats independently. + private val GIdentical = ForwardGuarantee.identical + private val GPrefixAnyMode = ForwardGuarantee(Some(UebaRead.PrefixAnyMode), json = true) + private val GPrefixCompact = ForwardGuarantee(Some(UebaRead.PrefixCompact), json = true) + private val GJsonAdditive = ForwardGuarantee.jsonOnly + private val GUebaIdentical = ForwardGuarantee(Some(UebaRead.Full), json = false) + private val GUebaPrefixCompact = ForwardGuarantee(Some(UebaRead.PrefixCompact), json = false) + private def loadFwd(loader: BaboonLoader[F]): F[NEList[BaboonIssue], BaboonFamily] = { val root = IzResources .getPath("fwd-compat-ok") @@ -32,7 +42,7 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT family.domains.toMap.values.find(_.pkg.toString == "fwdcompat.model").get.evolution } - private def runOf(evo: BaboonEvolution, version: String, typeName: String): List[(String, ForwardCompatTier)] = { + private def runOf(evo: BaboonEvolution, version: String, typeName: String): List[(String, ForwardGuarantee)] = { val entries = evo.typesForwardReadable(Version.parse(version)).collect { case (id: TypeId.User, fr) if id.name.name == typeName => fr }.toList @@ -49,42 +59,103 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT val evo = evolutionOf(family) // fixed-length append: readable in both UEBA modes (top-level framed reads) - assert(runOf(evo, "1.0.0", "AppendFixed") == List(("1.0.0", Identical), ("1.1.0", PrefixAnyMode), ("1.2.0", PrefixAnyMode))) + assert(runOf(evo, "1.0.0", "AppendFixed") == List(("1.0.0", GIdentical), ("1.1.0", GPrefixAnyMode), ("1.2.0", GPrefixAnyMode))) // variable-length appends: compact blobs only, composed across two steps - assert(runOf(evo, "1.0.0", "AppendVar") == List(("1.0.0", Identical), ("1.1.0", PrefixCompact), ("1.2.0", PrefixCompact))) - assert(runOf(evo, "1.1.0", "AppendVar") == List(("1.1.0", Identical), ("1.2.0", PrefixCompact))) + assert(runOf(evo, "1.0.0", "AppendVar") == List(("1.0.0", GIdentical), ("1.1.0", GPrefixCompact), ("1.2.0", GPrefixCompact))) + assert(runOf(evo, "1.1.0", "AppendVar") == List(("1.1.0", GIdentical), ("1.2.0", GPrefixCompact))) // mid-position insertion: JSON only; run ends when a field is removed in 1.2.0 - assert(runOf(evo, "1.0.0", "MidInsert") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive))) - assert(runOf(evo, "1.1.0", "MidInsert") == List(("1.1.0", Identical))) + assert(runOf(evo, "1.0.0", "MidInsert") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive))) + assert(runOf(evo, "1.1.0", "MidInsert") == List(("1.1.0", GIdentical))) // field removal / field type change: not forward-readable - assert(runOf(evo, "1.0.0", "Removed") == List(("1.0.0", Identical))) - assert(runOf(evo, "1.0.0", "Changed") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "Removed") == List(("1.0.0", GIdentical))) + assert(runOf(evo, "1.0.0", "Changed") == List(("1.0.0", GIdentical))) // own structure unchanged but a dependency appends: capped at JSON - assert(runOf(evo, "1.0.0", "HostOfAppend") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "HostOfAppend") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) // fully unchanged closure: byte-identical throughout - assert(runOf(evo, "1.0.0", "HostOfUnchanged") == List(("1.0.0", Identical), ("1.1.0", Identical), ("1.2.0", Identical))) - assert(runOf(evo, "1.0.0", "Stable") == List(("1.0.0", Identical), ("1.1.0", Identical), ("1.2.0", Identical))) + assert(runOf(evo, "1.0.0", "HostOfUnchanged") == List(("1.0.0", GIdentical), ("1.1.0", GIdentical), ("1.2.0", GIdentical))) + assert(runOf(evo, "1.0.0", "Stable") == List(("1.0.0", GIdentical), ("1.1.0", GIdentical), ("1.2.0", GIdentical))) // enum gains a member: unreadable (new values would throw), host follows - assert(runOf(evo, "1.0.0", "EnumGrows") == List(("1.0.0", Identical))) - assert(runOf(evo, "1.0.0", "EnumGrowsHost") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "EnumGrows") == List(("1.0.0", GIdentical))) + assert(runOf(evo, "1.0.0", "EnumGrowsHost") == List(("1.0.0", GIdentical))) // enum members reordered: JSON encodes names, UEBA discriminants are positional - assert(runOf(evo, "1.0.0", "EnumReorder") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) - assert(runOf(evo, "1.0.0", "EnumReorderHost") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "EnumReorder") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) + assert(runOf(evo, "1.0.0", "EnumReorderHost") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) // recursive DTO appending a field: self-dependency demotes PREFIX to JSON - assert(runOf(evo, "1.0.0", "Recur") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "Recur") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) // ADT branch appends a field: the branch itself is prefix-readable, // the ADT (whose decode nests the branch) is capped at JSON - assert(runOf(evo, "1.0.0", "L") == List(("1.0.0", Identical), ("1.1.0", PrefixCompact), ("1.2.0", PrefixCompact))) - assert(runOf(evo, "1.0.0", "Sum") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) + assert(runOf(evo, "1.0.0", "L") == List(("1.0.0", GIdentical), ("1.1.0", GPrefixCompact), ("1.2.0", GPrefixCompact))) + assert(runOf(evo, "1.0.0", "Sum") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) + } + } + + "resolve the two wire formats independently: renames are UEBA-only, repositions are JSON-only" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + + // A declared field rename never reaches the UEBA wire, so the bytes are + // unchanged; the JSON key moved, so the old codec cannot find the value. + assert(runOf(evo, "1.0.0", "RenameField") == List(("1.0.0", GIdentical), ("1.1.0", GUebaIdentical), ("1.2.0", GUebaIdentical))) + + // Rename then variable-length append: the axes weaken independently, so + // the chain keeps a compact-mode UEBA prefix read and no JSON at all. + assert(runOf(evo, "1.0.0", "RenameThenAppend") == List(("1.0.0", GIdentical), ("1.1.0", GUebaIdentical), ("1.2.0", GUebaPrefixCompact))) + assert(runOf(evo, "1.1.0", "RenameThenAppend") == List(("1.1.0", GIdentical), ("1.2.0", GPrefixCompact))) + + // Enum members are positional u8 discriminants in UEBA and names in JSON. + assert(runOf(evo, "1.0.0", "EnumRenamed") == List(("1.0.0", GIdentical), ("1.1.0", GUebaIdentical), ("1.2.0", GUebaIdentical))) + + // A nested value that is UEBA byte-identical keeps its dependents' UEBA + // axis alive; only their JSON axis dies with it. + assert(runOf(evo, "1.0.0", "EnumRenamedHost") == List(("1.0.0", GIdentical), ("1.1.0", GUebaIdentical), ("1.2.0", GUebaIdentical))) + assert(runOf(evo, "1.0.0", "HostOfRename") == List(("1.0.0", GIdentical), ("1.1.0", GUebaIdentical), ("1.2.0", GUebaIdentical))) + + // The mirror image, already supported: a mid-position insert keeps JSON + // and loses UEBA. Neither tier implies the other. + assert(runOf(evo, "1.0.0", "MidInsert") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive))) + } + } + + "publish per-format bounds: a renamed field lowers the UEBA bounds and leaves the JSON bound alone" in { + (loader: BaboonLoader[F]) => + for { + family <- loadFwd(loader) + } yield { + val evo = evolutionOf(family) + val v1 = Version.parse("1.0.0") + val v2 = Version.parse("1.1.0") + val v3 = Version.parse("1.2.0") + + def idOf(typeName: String): TypeId = { + evo.typesForwardReadable(v1).keys.collectFirst { case id: TypeId.User if id.name.name == typeName => id: TypeId }.get + } + + // This is the bound the binary envelope publishes. A 1.0.0 reader can decode + // 1.2.0 UEBA bytes for a renamed field; a 1.0.0 JSON reader cannot, and the + // `json-additive` bound correctly stops at the rename. + assert(evo.minReaders(v3, idOf("RenameField")) == Map(Identical -> v2, PrefixAnyMode -> v1, PrefixCompact -> v1, JsonAdditive -> v2)) + assert(evo.minReaders(v3, idOf("EnumRenamed")) == Map(Identical -> v2, PrefixAnyMode -> v1, PrefixCompact -> v1, JsonAdditive -> v2)) + assert(evo.minReaders(v3, idOf("HostOfRename")) == Map(Identical -> v2, PrefixAnyMode -> v1, PrefixCompact -> v1, JsonAdditive -> v2)) + + // Rename then variable-length append: only the compact-mode UEBA bound reaches + // back past the rename. `prefix-any-mode` stops at the writer because the + // appended field is variable-length, and `json-additive` stops at the rename. + assert(evo.minReaders(v3, idOf("RenameThenAppend")) == Map(Identical -> v3, PrefixAnyMode -> v3, PrefixCompact -> v1, JsonAdditive -> v2)) + + // The mirror image: a mid-position insert lowers only the JSON bound. + assert(evo.minReaders(v2, idOf("MidInsert")) == Map(Identical -> v2, PrefixAnyMode -> v2, PrefixCompact -> v2, JsonAdditive -> v1)) } } @@ -118,10 +189,10 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT assert(twins("Stable").contains(v2)) // forward tiers for the new fixtures (order-sensitive computation, unaffected by hashing) - assert(runOf(evo, "1.0.0", "SumReorder") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) - assert(runOf(evo, "1.0.0", "SumReorderHost") == List(("1.0.0", Identical), ("1.1.0", JsonAdditive), ("1.2.0", JsonAdditive))) - assert(runOf(evo, "1.0.0", "MapCarrier") == List(("1.0.0", Identical))) - assert(runOf(evo, "1.0.0", "MapSwapHost") == List(("1.0.0", Identical))) + assert(runOf(evo, "1.0.0", "SumReorder") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) + assert(runOf(evo, "1.0.0", "SumReorderHost") == List(("1.0.0", GIdentical), ("1.1.0", GJsonAdditive), ("1.2.0", GJsonAdditive))) + assert(runOf(evo, "1.0.0", "MapCarrier") == List(("1.0.0", GIdentical))) + assert(runOf(evo, "1.0.0", "MapSwapHost") == List(("1.0.0", GIdentical))) } } @@ -181,7 +252,7 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT assert(fr.typeId == id) assert(fr.in == version) // head is always the own version at IDENTICAL - assert(fr.readable.head == ((version, Identical))) + assert(fr.readable.head == ((version, GIdentical))) // versions strictly ascending val vs = fr.readable.toList.map(_._1) assert(vs == vs.sorted(Version.ordering) && vs.distinct == vs) @@ -203,15 +274,15 @@ abstract class ForwardCompatComparatorTestBase[F[+_, +_]: Error2: TagKK: BaboonT case (id, fr) => val twins = evo.typesUnchangedSince(version)(id).higherTwins(version).toSet fr.readable.toList.drop(1).foreach { - case (v, tier) => - if (tier == Identical) { + case (v, guarantee) => + if (guarantee == GIdentical) { assert(twins.contains(v), s"$id@$version: forward-Identical $v must be a sameIn twin") } } } } - assert(runOf(evo, "1.0.0", "EnumReorderHost").tail.forall(_._2 == JsonAdditive)) + assert(runOf(evo, "1.0.0", "EnumReorderHost").tail.forall(_._2 == GJsonAdditive)) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index 210ceef60..b59fd7b4c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -174,12 +174,12 @@ object BaboonComparator { domainVersions: NEMap[Version, Domain], ascendingVersions: List[Version], ): Map[Version, Map[TypeId, ForwardReadable]] = { - val steps: Map[Version, Map[TypeId, ForwardCompatTier]] = + val steps: Map[Version, Map[TypeId, ForwardGuarantee]] = ascendingVersions .sliding(2) .collect { case from :: to :: Nil => - (from, stepForwardTiers(domainVersions(from), domainVersions(to))) + (from, stepForwardGuarantees(domainVersions(from), domainVersions(to))) } .toMap @@ -197,44 +197,45 @@ object BaboonComparator { val tail = successor match { case Some((stepTiers, nextRuns)) => (stepTiers.get(id), nextRuns.get(id)) match { - case (Some(stepTier), Some(nextRun)) => - nextRun.readable.toList.map { case (v, t) => (v, ForwardCompatTier.min(t, stepTier)) } + case (Some(stepGuarantee), Some(nextRun)) => + nextRun.readable.toList + .map { case (v, g) => (v, g.meet(stepGuarantee)) } + .takeWhile(_._2.nonEmpty) case _ => List.empty } case None => List.empty } - (id, ForwardReadable(id, version, NEList.unsafeFrom((version, ForwardCompatTier.Identical: ForwardCompatTier) :: tail))) + (id, ForwardReadable(id, version, NEList.unsafeFrom((version, ForwardGuarantee.identical) :: tail))) } acc.updated(version, entries) } } - /** Per-step (prev -> last) forward-readability tier for every type kept under - * the same TypeId in both versions. Absent key = not forward-readable. - * Combines the type's own structural tier with a fixpoint over its + /** Per-step (prev -> last) forward-readability for every type kept under the same + * TypeId in both versions. Absent key = not forward-readable in either format. + * Combines the type's own structural guarantee with a fixpoint over its * codec-relevant dependencies in the OLD version (the types the old decoder - * actually touches): PREFIX_* requires all dependencies byte-identical; - * JSON_ADDITIVE requires all dependencies at least JSON-readable. + * actually touches). The two axes propagate independently: the outer UEBA read + * stays in sync only while every nested value is UEBA byte-identical, and the + * outer JSON read survives only while every nested value is JSON-readable. */ - private def stepForwardTiers(prev: Domain, last: Domain): Map[TypeId, ForwardCompatTier] = { - import ForwardCompatTier.* - + private def stepForwardGuarantees(prev: Domain, last: Domain): Map[TypeId, ForwardGuarantee] = { val kept = prev.defs.meta.nodes.keySet.intersect(last.defs.meta.nodes.keySet) - val own: Map[TypeId, Option[ForwardCompatTier]] = kept.map { + val own: Map[TypeId, ForwardGuarantee] = kept.map { id => - val tier = (prev.defs.meta.nodes(id), last.defs.meta.nodes(id)) match { + val guarantee = (prev.defs.meta.nodes(id), last.defs.meta.nodes(id)) match { case (_: DomainMember.Builtin, _: DomainMember.Builtin) => - Some(Identical) + ForwardGuarantee.identical case (o: DomainMember.User, n: DomainMember.User) => - ownForwardTier(o.defn, n.defn, prev, last) + ownForwardGuarantee(o.defn, n.defn, prev, last) case _ => - None + ForwardGuarantee.none } - (id, tier) + (id, guarantee) }.toMap // codec-relevant direct dependencies in the OLD version: what the old decoder touches @@ -261,40 +262,64 @@ object BaboonComparator { while (changed) { changed = false current = current.map { - case (id, tier) => - val next = tier.flatMap { - ownTier => - val depTiers = deps(id).toList.map(dep => current.getOrElse(dep, None)) - if (depTiers.exists(_.isEmpty)) { - None - } else if (depTiers.forall(_.contains(Identical))) { - Some(ownTier) - } else { - // some dependency is readable but not byte-identical: JSON-only - Some(ForwardCompatTier.min(ownTier, JsonAdditive)) - } - } - if (next != tier) changed = true + case (id, guarantee) => + val depGuarantees = deps(id).toList.map(dep => current.getOrElse(dep, ForwardGuarantee.none)) + val next = ForwardGuarantee( + // a nested value that merely prefix-reads would leave the outer cursor + // mid-value; only byte-identical nesting keeps the outer read in sync + ueba = if (depGuarantees.forall(_.ueba.contains(UebaRead.Full))) guarantee.ueba else None, + json = guarantee.json && depGuarantees.forall(_.json), + ) + if (next != guarantee) changed = true (id, next) } } - current.collect { case (id, Some(tier)) => (id, tier) } + current.filter(_._2.nonEmpty) } - /** The type's OWN structural forward tier for one step, ignoring dependencies. - * Order-sensitive where the UEBA wire is (field order, enum/ADT member order). + /** The type's OWN structural forward-readability for one step, ignoring + * dependencies, resolved separately for the two wire formats. + * + * UEBA identifies members by position and never puts a name on the wire, so a + * rename declared with `was` is invisible to it. JSON identifies members by + * name and is blind to position, so it forgives exactly the opposite set of + * changes. Neither subsumes the other. + * + * Renames of a TYPE (rather than of a field or an enum member) are not handled + * here: they change the TypeId, so the renamed type is absent from the + * intersection the caller iterates and from its dependents' dependency sets, + * which collapses both axes. That needs rename-aware dependency resolution. */ - private def ownForwardTier(o: Typedef.User, n: Typedef.User, prev: Domain, last: Domain): Option[ForwardCompatTier] = { - import ForwardCompatTier.* + private def ownForwardGuarantee(o: Typedef.User, n: Typedef.User, prev: Domain, last: Domain): ForwardGuarantee = { (o, n) match { case (d1: Typedef.Dto, d2: Typedef.Dto) => - val f1 = d1.fields.map(f => (f.name, f.tpe)) - val f2 = d2.fields.map(f => (f.name, f.tpe)) - if (f1 == f2) { - Some(Identical) - } else if (f2.startsWith(f1)) { - val appended = d2.fields.drop(f1.size) + // `prevName` is carried forward by every later version, so it only denotes a + // rename performed by THIS step while it still names a field of the old type. + val oldFieldNames = d1.fields.map(_.name).toSet + def renamedAtThisStep(nf: Field): Option[FieldName] = + nf.prevName.filter(prev => prev != nf.name && oldFieldNames.contains(prev)) + + // JSON: every old key must still carry that same old field's value. A new + // field that took the name over from a different field via `was` would hand + // the old reader the wrong value, so it does not count. + val json = d1.fields.forall { + of => d2.fields.exists(nf => nf.name == of.name && nf.tpe == of.tpe && renamedAtThisStep(nf).isEmpty) + } + + // UEBA: the position is the identity. A name change at a position is + // invisible on the wire, and sound exactly when it is a declared rename of + // the field that occupied that position. + val prefixAligned = d2.fields.size >= d1.fields.size && d1.fields.zip(d2.fields).forall { + case (of, nf) => of.tpe == nf.tpe && (of.name == nf.name || renamedAtThisStep(nf).contains(of.name)) + } + + val ueba = if (!prefixAligned) { + None + } else if (d2.fields.size == d1.fields.size) { + Some(UebaRead.Full) + } else { + val appended = d2.fields.drop(d1.fields.size) val allFixed = appended.forall { f => last.refMeta(f.tpe).len match { @@ -302,50 +327,54 @@ object BaboonComparator { case _ => false } } - Some(if (allFixed) PrefixAnyMode else PrefixCompact) - } else if (f1.toSet.subsetOf(f2.toSet)) { - // additions at arbitrary positions and/or reordering: JSON readers - // look fields up by key; UEBA is positional and breaks - Some(JsonAdditive) - } else { - None + Some(if (allFixed) UebaRead.PrefixAnyMode else UebaRead.PrefixCompact) } + ForwardGuarantee(ueba, json) + case (e1: Typedef.Enum, e2: Typedef.Enum) => - val m1 = e1.members.toList.map(m => (m.name, m.const)) - val m2 = e2.members.toList.map(m => (m.name, m.const)) - if (m1 == m2) { - Some(Identical) - } else if (e1.members.toList.map(_.name).toSet == e2.members.toList.map(_.name).toSet) { - // same member set, different order or consts: JSON encodes names; - // UEBA discriminants are positional and break - Some(JsonAdditive) - } else { - // added members are unreadable by the old decoder when actually sent; - // removed/renamed members shift UEBA discriminants - None + val m1 = e1.members.toList + val m2 = e2.members.toList + + // As for fields, `prevName` survives into later versions and only denotes a + // rename performed by THIS step. + val oldMemberNames = m1.map(_.name).toSet + def renamedAtThisStep(m: EnumMember): Option[String] = + m.prevName.filter(prev => prev != m.name && oldMemberNames.contains(prev)) + + // JSON encodes the member name. An added member is unreadable once it is + // actually sent, so the name sets must match exactly; a member that has + // taken over another member's name would decode to the wrong constant. + val json = m1.map(_.name).toSet == m2.map(_.name).toSet && + m2.forall(m => renamedAtThisStep(m).isEmpty) + + // UEBA encodes the positional index, so a rename in place is invisible. + // Explicit `const` values never reach the wire, but they stay in the + // comparison so a const-only change keeps its existing classification. + val uebaFull = m1.size == m2.size && m1.zip(m2).forall { + case (a, b) => a.const == b.const && (a.name == b.name || renamedAtThisStep(b).contains(a.name)) } + ForwardGuarantee(Option.when(uebaFull)(UebaRead.Full), json) + case (a1: Typedef.Adt, a2: Typedef.Adt) => val b1 = a1.dataMembers(prev) val b2 = a2.dataMembers(last) - if (b1 == b2) { - Some(Identical) - } else if (b2.toSet.subsetOf(b1.toSet)) { - // branches only removed: the new writer emits only branches the old - // reader knows by name (JSON); UEBA branch indices are positional and shift - Some(JsonAdditive) - } else { - None - } + + // JSON tags a branch by its name: the new writer must only emit branches + // the old reader already knows. + val json = b2.toSet.subsetOf(b1.toSet) + // UEBA encodes the positional branch index. A branch rename is a type + // rename, excluded above. + ForwardGuarantee(Option.when(b1 == b2)(UebaRead.Full), json) case (f1: Typedef.Foreign, f2: Typedef.Foreign) => // hand-written codecs: only byte-level sameness is derivable - if (f1 == f2) Some(Identical) else None + if (f1 == f2) ForwardGuarantee.identical else ForwardGuarantee.none case _ => // Contract/Service carry no codecs; kind changes are unreadable - None + ForwardGuarantee.none } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala index 6deea1a58..65806ee0d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/BaboonEvolution.scala @@ -11,23 +11,28 @@ case class BaboonEvolution( typesForwardReadable: Map[Version, Map[TypeId, ForwardReadable]], ) { /** Inverse of [[typesForwardReadable]], from the WRITER's point of view: for a type - * as encoded by `version`, the oldest reader version that can decode it at each - * tier or better. Readers form a contiguous range per tier (a shorter chain is a - * sub-chain), so one lower bound per tier is exact. The `Identical` bound equals - * the type's `sameIn` head. + * as encoded by `version`, the oldest reader version that can decode it under each + * capability. Readers form a contiguous range per capability (a shorter chain is a + * sub-chain), so one lower bound per capability is exact. The `Identical` bound + * equals the type's `sameIn` head. + * + * Capabilities are resolved independently, so a chain that is UEBA-readable but + * not JSON-readable (a declared rename) publishes the `prefix-*` bounds and + * withholds `json-additive`, and a chain that is JSON-readable but not + * UEBA-readable (a reorder or a mid-position insert) does the reverse. */ def minReaders(version: Version, id: TypeId): Map[ForwardCompatTier, Version] = { val readers = typesForwardReadable.toList.collect { case (readerVersion, types) if readerVersion <= version => - types.get(id).flatMap(_.tierFor(version)).map(tier => (readerVersion, tier)) + types.get(id).flatMap(_.tierFor(version)).map(guarantee => (readerVersion, guarantee)) }.flatten ForwardCompatTier.all.flatMap { - tier => + cap => readers - .collect { case (readerVersion, readerTier) if readerTier.weight >= tier.weight => readerVersion } + .collect { case (readerVersion, guarantee) if guarantee.grants(cap) => readerVersion } .minOption(Version.ordering) - .map(v => (tier, v)) + .map(v => (cap, v)) }.toMap } override def toString: String = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala index ccb035289..cd580eb59 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/ForwardCompat.scala @@ -2,49 +2,129 @@ package io.septimalmind.baboon.typer.model import izumi.fundamentals.collections.nonempty.NEList -/** Forward-readability guarantee: whether a codec generated for an OLDER version - * can decode data produced by a NEWER version's codec, for any value the newer - * version can emit. Tiers are linearly ordered by strength; each tier implies - * every guarantee of the tiers below it. +/** How much of a NEWER version's UEBA encoding an OLDER codec consumes correctly. * - * See docs/drafts/20260911-0937-forward-compat-metadata.md for the design and - * the soundness argument. + * UEBA is positional and name-blind: field, enum-member and ADT-branch identities + * are indices, never strings. A declared rename therefore leaves the bytes + * untouched, while a reordering or a mid-position insert shifts every following + * value. This is the mirror image of JSON, where the name IS the identity. + */ +sealed abstract class UebaRead(val weight: Int) + +object UebaRead { + + /** The old decoder consumes the whole value: the UEBA encoding is byte-identical. */ + case object Full extends UebaRead(3) + + /** The old fields are a positional prefix of the new ones and every appended + * field is fixed-length, so compact and indexed blobs both prefix-read. + */ + case object PrefixAnyMode extends UebaRead(2) + + /** The old fields are a positional prefix of the new ones but some appended + * field is variable-length, so only compact blobs prefix-read: an indexed + * blob carries an extra index entry and the index has no on-wire count. + */ + case object PrefixCompact extends UebaRead(1) + + implicit val ordering: Ordering[UebaRead] = Ordering.by(_.weight) + + def min(a: UebaRead, b: UebaRead): UebaRead = if (a.weight <= b.weight) a else b +} + +/** Forward-readability of one evolution step, or of a chain of them, resolved + * independently per wire format. + * + * The two formats forgive different things and neither subsumes the other, so a + * single linear scale cannot describe a step. A declared rename is `ueba = Full` + * with `json = false`; a reorder or mid-position insert is the exact opposite. + * Chains compose by [[meet]], which weakens each axis on its own. + */ +final case class ForwardGuarantee(ueba: Option[UebaRead], json: Boolean) { + + def isEmpty: Boolean = ueba.isEmpty && !json + + def nonEmpty: Boolean = !isEmpty + + /** Byte-identical in BOTH formats. `ueba = Full` fixes the field count and the + * positional types, and `json` additionally forbids every rename, so the two + * together can only hold when the member lists are equal. + */ + def identical: Boolean = ueba.contains(UebaRead.Full) && json + + def meet(o: ForwardGuarantee): ForwardGuarantee = + ForwardGuarantee( + ueba = for { a <- ueba; b <- o.ueba } yield UebaRead.min(a, b), + json = json && o.json, + ) + + /** Whether this guarantee is strong enough to publish `cap` as a bound. */ + def grants(cap: ForwardCompatTier): Boolean = cap match { + case ForwardCompatTier.Identical => identical + case ForwardCompatTier.PrefixAnyMode => ueba.exists(_.weight >= UebaRead.PrefixAnyMode.weight) + case ForwardCompatTier.PrefixCompact => ueba.nonEmpty + case ForwardCompatTier.JsonAdditive => json + } + + /** Monotonically non-increasing under [[meet]]; used for ordering and invariants. */ + def weight: Int = ueba.fold(0)(_.weight) * 2 + (if (json) 1 else 0) + + /** Published in `baboonForwardReadable` and in the `forwardReadable` block of + * `baboon-meta.json`. A `ueba-` prefix means the guarantee holds for UEBA only + * and that the JSON encoding of this step is NOT readable by the older codec. + */ + def wireName: String = (ueba, json) match { + case (Some(UebaRead.Full), true) => "identical" + case (Some(UebaRead.Full), false) => "ueba-identical" + case (Some(UebaRead.PrefixAnyMode), true) => "prefix-any-mode" + case (Some(UebaRead.PrefixAnyMode), false) => "ueba-prefix-any-mode" + case (Some(UebaRead.PrefixCompact), true) => "prefix-compact" + case (Some(UebaRead.PrefixCompact), false) => "ueba-prefix-compact" + case (None, true) => "json-additive" + case (None, false) => "none" + } +} + +object ForwardGuarantee { + val identical: ForwardGuarantee = ForwardGuarantee(Some(UebaRead.Full), json = true) + val none: ForwardGuarantee = ForwardGuarantee(None, json = false) + val jsonOnly: ForwardGuarantee = ForwardGuarantee(None, json = true) +} + +/** A capability a writer can publish a lower bound for, in `baboonMinReaderVersions`. + * + * These keys are already per-format and the runtimes consume them that way: the + * binary envelope asks for `prefix-compact` or `prefix-any-mode` depending on the + * payload's index mode, the JSON envelope asks for `json-additive`. None of them + * implies any of the others. */ sealed abstract class ForwardCompatTier(val weight: Int, val wireName: String) object ForwardCompatTier { - /** Byte-identical encoding: both wire formats, any nesting position. */ + + /** Byte-identical in both formats; equals the type's `sameIn` head. */ case object Identical extends ForwardCompatTier(4, "identical") - /** UEBA: prefix-read of compact AND indexed blobs (all appended fields are - * fixed-length, so the index shape is unchanged); sound only for top-level - * framed reads where the caller discards the cursor. JSON: any position. - */ + /** UEBA: the old decoder reads compact AND indexed blobs. Says nothing about JSON. */ case object PrefixAnyMode extends ForwardCompatTier(3, "prefix-any-mode") - /** UEBA: prefix-read of compact blobs only (some appended field is - * variable-length; an indexed blob would desync the reader's index parse); - * top-level framed reads only. JSON: any position. - */ + /** UEBA: the old decoder reads compact blobs. Says nothing about JSON. */ case object PrefixCompact extends ForwardCompatTier(2, "prefix-compact") - /** JSON only (tolerant readers ignore unknown keys), any nesting position. */ + /** JSON: the old decoder reads the payload, at any field position. Says nothing about UEBA. */ case object JsonAdditive extends ForwardCompatTier(1, "json-additive") implicit val ordering: Ordering[ForwardCompatTier] = Ordering.by(_.weight) - def min(a: ForwardCompatTier, b: ForwardCompatTier): ForwardCompatTier = - if (a.weight <= b.weight) a else b - val all: List[ForwardCompatTier] = List(Identical, PrefixAnyMode, PrefixCompact, JsonAdditive) } /** For a type as seen by version `in`: the ascending contiguous list of versions - * whose blobs the `in`-version codec can read, with the strongest guarantee tier - * for each. Invariants: `readable.head == (in, Identical)`; versions strictly - * ascending; tiers non-increasing. + * whose blobs the `in`-version codec can read, with the guarantee for each. + * Invariants: `readable.head == (in, ForwardGuarantee.identical)`; versions + * strictly ascending; guarantees non-increasing on both axes. */ -case class ForwardReadable(typeId: TypeId, in: Version, readable: NEList[(Version, ForwardCompatTier)]) { - def tierFor(version: Version): Option[ForwardCompatTier] = +case class ForwardReadable(typeId: TypeId, in: Version, readable: NEList[(Version, ForwardGuarantee)]) { + def tierFor(version: Version): Option[ForwardGuarantee] = readable.toList.collectFirst { case (v, t) if v == version => t } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala index df73e8a6a..679fb205d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/util/Metadata.scala @@ -24,11 +24,12 @@ object BaboonDomainCodecs { implicit def nelist[T: Encoder]: Encoder[NEList[T]] = Encoder.encodeList[T].contramap(_.toList) - implicit lazy val forwardCompatTierEncoder: Encoder[ForwardCompatTier] = Encoder.encodeString.contramap(_.wireName) + implicit lazy val forwardCompatTierEncoder: Encoder[ForwardCompatTier] = Encoder.encodeString.contramap(_.wireName) + implicit lazy val forwardGuaranteeEncoder: Encoder[ForwardGuarantee] = Encoder.encodeString.contramap(_.wireName) implicit lazy val forwardReadableEncoder: Encoder[ForwardReadable] = Encoder.encodeJson.contramap { fr => Json.fromValues(fr.readable.toList.map { - case (v, tier) => Json.obj("v" -> versionEncoder(v), "tier" -> forwardCompatTierEncoder(tier)) + case (v, guarantee) => Json.obj("v" -> versionEncoder(v), "tier" -> forwardGuaranteeEncoder(guarantee)) }) } diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-1.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-1.baboon new file mode 100644 index 000000000..ba576070c --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-1.baboon @@ -0,0 +1,20 @@ +// Forward-compatibility end-to-end fixture: a declared FIELD RENAME +// (docs/forward-compat.md, "Per-format readability"). +// +// UEBA identifies fields by position and never puts a name on the wire, so the +// rename below leaves the bytes untouched and a 1.0.0 codec reads a 2.0.0 blob. +// JSON identifies fields by name, so the same step is unreadable there. This is +// the one case where the two formats' guarantees are incomparable, and the +// fixture pins both halves. +// +// A pure rename is an auto-derivable conversion (FieldOp.Rename), so this model +// is safe to keep in the shared model-dir: no stub carries a manual conversion. + +model fwde2e.rename + +version "1.0.0" + +root data RenamedHolder { + a: i32 + b: str +} diff --git a/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-2.baboon b/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-2.baboon new file mode 100644 index 000000000..bc6b02f9d --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/fwd-e2e-rename-ok/fwd-rename-2.baboon @@ -0,0 +1,12 @@ +model fwde2e.rename + +version "2.0.0" + +import "1.0.0" { * } without { + RenamedHolder +} + +root data RenamedHolder { + a: i32 + r: str was b +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon index c946a7034..15c3f41f4 100644 --- a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-1.baboon @@ -113,3 +113,33 @@ data MapCarrier { root data MapSwapHost { c: MapCarrier } + +// v2 renames a field in place. UEBA identifies fields by position and never puts +// a name on the wire, so the bytes are unchanged; JSON keys by name, so the old +// codec cannot find the value -> UEBA-only readability. +root data RenameField { + a: i32 + b: str +} + +// v2 renames, v3 appends a variable-length field: the chain meets to a UEBA +// prefix read with JSON already gone. +root data RenameThenAppend { + a: i32 +} + +enum EnumRenamed { + A + B +} + +// dep enum renames a member: the positional discriminants are unchanged, so the +// host keeps its UEBA axis and loses only JSON +root data EnumRenamedHost { + e: EnumRenamed +} + +// own structure unchanged, dep renames a field: same asymmetry one level up +root data HostOfRename { + dep: RenameField +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon index 5f963ff67..2738bd423 100644 --- a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-2.baboon @@ -14,6 +14,9 @@ import "1.0.0" { * } without { EnumReorder Recur Sum + RenameField + RenameThenAppend + EnumRenamed } root data AppendFixed { @@ -80,3 +83,17 @@ root adt SumReorder { data MapCarrier { mc: map[str, i32] } + +root data RenameField { + a: i32 + r: str was b +} + +root data RenameThenAppend { + r: i32 was a +} + +enum EnumRenamed { + A + B2 : was[B] +} diff --git a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon index 9219b51e2..058f615a7 100644 --- a/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon +++ b/baboon-compiler/src/test/resources/fwd-compat-ok/fwd-3.baboon @@ -5,6 +5,7 @@ version "1.2.0" import "1.1.0" { * } without { AppendVar MidInsert + RenameThenAppend } root data AppendVar { @@ -17,3 +18,8 @@ root data MidInsert { a: i32 m: i32 } + +root data RenameThenAppend { + r: i32 + s: str +} diff --git a/docs/forward-compat.md b/docs/forward-compat.md index ba8e34405..172d99258 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -14,37 +14,77 @@ encoded data the V-version codec can decode, each with a **guarantee tier**. data-dependent luck (e.g. "works unless the new enum member is sent") never qualifies. -## Tiers +## Per-format readability -Linearly ordered; every tier implies all guarantees of the tiers below it. +The two wire formats forgive different things, and neither set contains the +other. UEBA identifies a field, an enum member or an ADT branch by its +**position** and never writes its name, so a declared rename leaves the bytes +untouched. JSON identifies the same things by **name** and is blind to +position, so it forgives insertion and reordering and breaks on a rename. A +single linear scale cannot describe that, so each step is resolved on two +independent axes and chains compose by weakening each axis on its own. -| tier (wire name) | guarantee | +| UEBA axis | guarantee | |---|---| -| `identical` | byte-identical encoding: both formats, any nesting position | -| `prefix-any-mode` | UEBA: prefix-read of compact **and** indexed blobs (all appended fields fixed-length); JSON: any position | -| `prefix-compact` | UEBA: prefix-read of **compact** blobs only (some appended field is variable-length); JSON: any position | -| `json-additive` | JSON only, any nesting position | +| `Full` | the old decoder consumes the whole value: byte-identical UEBA encoding | +| `PrefixAnyMode` | prefix-read of compact **and** indexed blobs (every appended field is fixed-length) | +| `PrefixCompact` | prefix-read of **compact** blobs only (some appended field is variable-length) | | *(absent)* | no guarantee | -What earns each tier (per evolution step, composed over contiguous chains by -taking the minimum): - -- `identical` — own fields ordered-equal and every codec-relevant dependency - identical. -- `prefix-*` — the old field sequence is a strict prefix of the new one - (names and types equal, additions only at the tail), **and every other type - in the old version's dependency closure is byte-identical**. Any nested - change would desync the outer sequential read. -- `json-additive` — all old fields present with identical types (additions at - any position, reordering allowed), dependencies at least `json-additive`. - Sound because every generated JSON decoder is a tolerant reader (key lookup; - unknown keys ignored — verified in all 9 backends, including serde without - `deny_unknown_fields` and pydantic's default `extra='ignore'`). - -Never forward-readable: field removals or type changes (forward reads narrow), -field renames (JSON key changes), enum member additions/reordering (positional -`u8` discriminants / unknown names), ADT branch additions, foreign-type -changes, type renames. +| JSON axis | guarantee | +|---|---| +| readable | every old key still carries that same field's value, at any position | +| *(absent)* | no guarantee | + +What earns each axis, per evolution step: + +- UEBA `Full` — the positional type sequence is unchanged, and every position + whose name changed is a rename declared with `was` naming exactly the field + that occupied it. +- UEBA `Prefix*` — the old positional sequence is a prefix of the new one under + the same rule, with additions only at the tail. `PrefixAnyMode` when every + appended field is fixed-length, `PrefixCompact` otherwise. +- JSON readable — every old field still exists under its own name with the same + type, and no new field has taken that name over from a different field via + `was`. Additions at any position and reordering are fine. Sound because every + generated JSON decoder is a tolerant reader (key lookup; unknown keys ignored, + verified in all 9 backends, including serde without `deny_unknown_fields` and + pydantic's default `extra='ignore'`). + +Dependencies propagate per axis too: a nested value must be UEBA byte-identical +for the outer sequential read to stay in sync, and JSON-readable for the outer +JSON read to survive. A dependency that merely prefix-reads would leave the +outer cursor mid-value. + +Never forward-readable in either format: field removals, field type changes +(forward reads narrow), enum member additions, ADT branch additions, +foreign-type changes, type renames. + +### Wire names + +`baboonForwardReadable` and the `forwardReadable` block of `baboon-meta.json` +name the resolved pair. A `ueba-` prefix means the guarantee holds for UEBA +only and that the JSON encoding of that step is **not** readable: + +| name | UEBA | JSON | typical cause | +|---|---|---|---| +| `identical` | Full | yes | nothing changed | +| `ueba-identical` | Full | no | a declared rename | +| `prefix-any-mode` | PrefixAnyMode | yes | fixed-length tail append | +| `ueba-prefix-any-mode` | PrefixAnyMode | no | rename plus fixed-length tail append | +| `prefix-compact` | PrefixCompact | yes | variable-length tail append | +| `ueba-prefix-compact` | PrefixCompact | no | rename plus variable-length tail append | +| `json-additive` | absent | yes | mid-position insert, reorder | + +`baboonMinReaderVersions` is keyed by capability, not by the pair, and its four +keys are unchanged. Each is resolved from its own axis, so a rename lowers +`prefix-compact` and `prefix-any-mode` while leaving `json-additive` alone, and +a mid-position insert does the reverse. `identical` still means byte-identical +in both formats and still equals the type's `sameIn` head. + +**No envelope change was needed for any of this.** A rename sits at the +strongest UEBA tier, and both the v1 and the v2 binary envelopes already carry +a bound the writer computes per format. ## The prefix-* client contract @@ -467,6 +507,13 @@ Two consequences of scheme 2: `$rv`: a facade registering only 1.0.0 decodes 2.0.0 envelopes under `Tolerant` exactly where `$rv` allows, refuses under `Lossless`, and refuses when no bound was published; `$rv` elision and `readMeta` round-trip. +- `test/sc-stub/.../ForwardCompatRenameSpec.scala` — the per-format split over the + shared `fwd-e2e-rename-ok` model: the renamed field's UEBA payload matches the + 1.0.0 layout byte for byte while the JSON key moves; the writer publishes + `prefix-compact` and `prefix-any-mode` back to 1.0.0 and holds `json-additive` + at 2.0.0; a 1.0.0 reader decodes the 2.0.0 UEBA envelope under both v1-Tolerant + and v2; the default Strict v1 writer still publishes nothing, so that reader + refuses; and JSON refuses under either read policy. - `test/sc-stub/.../BinEnvelopeV2Spec.scala` and `test/ts-stub/.../BinEnvelopeV2.test.ts` — envelope v2: a hand-assembled v2 envelope decodes under `Tolerant` and is refused under `Lossless` (fail-first: @@ -501,6 +548,11 @@ Two consequences of scheme 2: ## Known gaps +- **ADT branch renames and type renames are not classified.** They change the + TypeId, so the renamed type is absent both from the version intersection the + comparator walks and from its dependents' dependency sets, which collapses + both axes. Field renames and enum member renames keep their owner's TypeId and + are handled. Lifting this needs rename-aware dependency resolution. - **`prefix-any-mode` has no runtime end-to-end test.** The tier requires an appended *fixed-length* field, and every fixed-length scalar is non-defaultable, so such a step needs a hand-written conversion — which the @@ -516,6 +568,9 @@ Closed gaps, kept for the record: other seven runtimes failed fast. All ten now fail fast (encoder failure), `baboonMinReaderVersions` has no default in any runtime's base type, and each of the three has a regression test with a hand-written value lacking the tiers. +- *Per-format range splitting* used to be out of scope, so a declared rename was + classified unreadable in both formats even though UEBA bytes do not move. Each + step is now resolved per format; see "Per-format readability" above. - *Timestamp kind byte round trips* (https://github.com/7mind/baboon/issues/91): the C# `RpDateTime` keeps carrying its `DateTimeKind` on the wire (Local when the offset matches the writer's zone) — this is intentional and unchanged, as @@ -530,5 +585,5 @@ Closed gaps, kept for the record: UEBA format evolution (on-wire index count + body length + skipping decoder) that would make appended fields unconditionally UEBA-safe; an `opt`-removal -tier (decodes as `None` but silently lossy); per-format range splitting for -renames (byte-invisible to UEBA, breaking for JSON). +tier (decodes as `None` but silently lossy); JSON alias emission that would make +renames readable there too, at the cost of duplicate keys in the payload. diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala new file mode 100644 index 000000000..a4d04b63e --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala @@ -0,0 +1,89 @@ +// NOTE: This test references generated symbols (fwde2e.rename.*) which are copied into +// this stub only by `mdl :build :test-gen-regular-adt` (rsync + codegen into +// target/test-regular/sc-stub/). Run from the codegen'd copy. +// +// Per-format forward readability (docs/forward-compat.md, "Per-format readability"). +// A declared field rename is the one change where the two wire formats disagree in +// opposite directions: UEBA identifies a field by position and never writes its name, +// so the bytes do not move; JSON identifies it by name, so the old key is simply gone. +package runtime + +import baboon.runtime.shared._ +import org.scalatest.funsuite.AnyFunSuite + +import java.io.ByteArrayOutputStream + +class ForwardCompatRenameSpec extends AnyFunSuite { + + private val writer: BaboonCodecsFacade = fwde2e.rename.DomainFwde2eRenameFacade + + // a reader deployed before the rename: knows fwde2e.rename 1.0.0 only + private def oldReader(policy: ForwardReadPolicy): BaboonCodecsFacade = { + val f = new BaboonCodecsFacade {} + f.register( + BaboonDomainVersion("fwde2e.rename", "1.0.0"), + fwde2e.rename.v1_0_0.BaboonCodecsJson, + fwde2e.rename.v1_0_0.BaboonCodecsUeba, + fwde2e.rename.v1_0_0.BaboonMetadata, + ) + f.forwardReadPolicy = policy + f + } + + private val v1Tolerant = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Tolerant, BaboonEnvelopeVersion.V1, None) + private val v2Compact = BaboonCodecContext.Custom(useIndices = false, ForwardWritePolicy.Strict, BaboonEnvelopeVersion.V2, None) + + private val current = fwde2e.rename.RenamedHolder(42, "hi") + private val legacy = fwde2e.rename.v1_0_0.RenamedHolder(42, "hi") + + private def bytesOf(write: LEDataOutputStream => Unit): Array[Byte] = { + val baos = new ByteArrayOutputStream() + write(new LEDataOutputStream(baos)) + baos.toByteArray + } + + test("the rename leaves the UEBA payload byte-identical and moves the JSON key") { + // The 1.0.0 codec ships without an encoder (deprecated encoders are off by + // default), so the byte claim is pinned against the layout 1.0.0 defines: + // mode byte, i32 `a`, then the length-prefixed str that 1.0.0 calls `b`. + val expected = Array[Byte](0x00, 0x2A, 0x00, 0x00, 0x00, 0x02, 0x68, 0x69) + val payload = bytesOf(dos => fwde2e.rename.RenamedHolder_UEBACodec.instance.encode(BaboonCodecContext.Compact, dos, current)) + assert(payload.sameElements(expected), "UEBA never puts a field name on the wire") + + val newJson = fwde2e.rename.RenamedHolder_JsonCodec.instance.encode(BaboonCodecContext.Compact, current) + assert(newJson.hcursor.downField("r").as[String].toOption.contains("hi")) + assert(newJson.hcursor.downField("b").focus.isEmpty, "the old JSON key is gone") + } + + test("the writer publishes the UEBA bounds across the rename and withholds the JSON one") { + val bounds = fwde2e.rename.RenamedHolder.baboonMinReaderVersions + assert(bounds("prefix-compact") == "1.0.0") + assert(bounds("prefix-any-mode") == "1.0.0") + assert(bounds("json-additive") == "2.0.0") + assert(bounds("identical") == "2.0.0") + // the byte-identical bound is unchanged: a rename is not byte-identical in both formats + assert(fwde2e.rename.RenamedHolder.baboonSameInVersions == List("2.0.0")) + assert(fwde2e.rename.v1_0_0.RenamedHolder.baboonForwardReadable == Map("1.0.0" -> "identical", "2.0.0" -> "ueba-identical")) + } + + test("a 1.0.0 codec decodes 2.0.0 UEBA bytes for the renamed field, under v1 and under v2") { + val v1env = writer.encodeToBin(v1Tolerant, current).toTry.get + assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromBin(v1env).toTry.get == legacy) + + val v2env = writer.encodeToBin(v2Compact, current).toTry.get + assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromBin(v2env).toTry.get == legacy) + // v2 carries the byte-identical bound separately, so a Lossless reader still refuses + assert(oldReader(ForwardReadPolicy.Lossless).decodeFromBin(v2env).isLeft) + } + + test("the default Strict v1 writer publishes no lowered bound, so the old reader refuses") { + val strict = writer.encodeToBin(BaboonCodecContext.Compact, current).toTry.get + assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromBin(strict).isLeft) + } + + test("JSON is not readable across the rename under either policy") { + val json = writer.encodeToJson(current).toTry.get + assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromJson(json).toTry.get.isEmpty) + assert(oldReader(ForwardReadPolicy.Lossless).decodeFromJson(json).toTry.get.isEmpty) + } +} diff --git a/test/sw-stub/Package.swift b/test/sw-stub/Package.swift index 164d626b3..4f8acf343 100644 --- a/test/sw-stub/Package.swift +++ b/test/sw-stub/Package.swift @@ -86,6 +86,16 @@ let package = Package( dependencies: ["BaboonRuntime", "Fwde2eChain_v1_0_0", "Fwde2eChain_v2_0_0"], path: "Sources/Fwde2eChain" ), + .target( + name: "Fwde2eRename_v1_0_0", + dependencies: ["BaboonRuntime"], + path: "Sources/Fwde2eRename_v1_0_0" + ), + .target( + name: "Fwde2eRename", + dependencies: ["BaboonRuntime", "Fwde2eRename_v1_0_0"], + path: "Sources/Fwde2eRename" + ), // ----- Single-version fixtures ---------------------------------------------------------- .target( @@ -327,6 +337,11 @@ let package = Package( dependencies: ["BaboonRuntime", "Fwde2eChain"], path: "Tests/BaboonTests/Fwde2eChain" ), + .testTarget( + name: "Fwde2eRenameTests", + dependencies: ["BaboonRuntime", "Fwde2eRename"], + path: "Tests/BaboonTests/Fwde2eRename" + ), .testTarget( name: "MyOkExtractedContractsTests", dependencies: ["BaboonRuntime", "MyOkExtractedContracts"], From 2b016c26d0a34dd584d81550fdb195476eab16bf Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 20 Sep 2026 16:38:00 +0100 Subject: [PATCH 25/48] fix(evolution): make declared `was` renames sound across a version chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `was` annotation survives into every later version of a type, while the comparator only ever diffs adjacent version pairs. Two defects followed. 1. A carried-forward annotation was rejected as a typo. After `r: str was b` in 1.1.0, touching the type again in 1.2.0 failed with `InvalidFieldRename` because 1.1.0 no longer has a `b`. The per-pair check is gone; `evolve` now validates ancestry once per package against every earlier version of the type's lineage (following `Domain.renames`), and each pair honours a `prevName` only while it still names a member of the version it compares against. 2. A rename whose target name the previous version also used was mishandled. `keptMembers = names1.intersect(names2)` did not exclude renamed names, so a declared name swap was simultaneously "kept" and "renamed" and produced two conflicting ops for one target field; `BaboonValidator`'s `RemovedDtoFields` check had the same defect from the other side. Worse, a swap that preserves field order leaves both `shallowId` (sorted) and `deepId` (positional) intact, so the type was classified unchanged and `BaboonRules` took the transfer-everything fast path — the declared move was ignored outright. `hasEffectiveRename` now forces such a type into `shallowModified`. That fast path also built its ops from the SOURCE fields. `Field` equality includes `prevName`, so dropping a stale annotation left every target field unmapped and crashed all nine conversion translators with a `NoSuchElementException` at `ops(f)`. It builds from the target defn now. `RenameSoundnessTest` covers all of it: four cases fail with the comparator and validator reverted, and the annotation-dropped case fails with only `BaboonRules` reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon/tests/RenameSoundnessTest.scala | 267 ++++++++++++++++++ .../parser/model/issues/EvolutionIssue.scala | 4 +- .../baboon/typer/BaboonComparator.scala | 257 +++++++++++------ .../baboon/typer/BaboonRules.scala | 9 +- .../baboon/validator/BaboonValidator.scala | 9 +- docs/forward-compat.md | 16 ++ 6 files changed, 467 insertions(+), 95 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala new file mode 100644 index 000000000..30c87a476 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala @@ -0,0 +1,267 @@ +package io.septimalmind.baboon.tests + +import io.septimalmind.baboon.parser.BaboonParser +import io.septimalmind.baboon.parser.model.FSPath +import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, EvolutionIssue} +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.typer.BaboonFamilyManager +import io.septimalmind.baboon.typer.model.Conversion.{CopyEnumByName, DtoConversion, FieldOp} +import io.septimalmind.baboon.typer.model.{BaboonFamily, Conversion, DomainMember, EvolutionStep, Field, Pkg, Typedef, Version} +import izumi.functional.bio.{Error2, F} +import izumi.fundamentals.collections.nonempty.{NEList, NEString} +import izumi.reflect.TagKK + +/** Soundness of declared `was` renames across a version chain. + * + * Two independent hazards, both rooted in the fact that a `was` annotation is carried forward by + * every later version of a type while the per-pair diffs only ever see two adjacent versions: + * + * 1. a carried-forward annotation must not be mistaken for a typo (its ancestry is validated + * once per package, against every earlier version, rather than per version pair); + * 2. a rename whose target name the previous version also used — a name swap, or a rename onto + * the name of a field being dropped — must produce rename ops rather than being silently + * accounted for as a positional transfer. A swap that preserves field order leaves both + * structural signatures untouched, so the type has to be classified as locally modified on + * the strength of the annotation alone. + */ +final class RenameSoundnessTest extends RenameSoundnessTestBase[Either] + +abstract class RenameSoundnessTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + + private def makeInput(name: String, body: String): BaboonParser.Input = + BaboonParser.Input(FSPath.parse(NEString.unsafeFrom(name)), body) + + private def load(manager: BaboonFamilyManager[F], bodies: (String, String)*): F[NEList[BaboonIssue], BaboonFamily] = + manager.load(bodies.toList.map { case (n, b) => makeInput(n, b) }) + + private def conversionsOf(family: BaboonFamily, pkg: String, from: String, to: String): List[Conversion] = { + val lineage = family.domains.toMap(Pkg(NEList.unsafeFrom(pkg.split('.').toList))) + lineage.evolution.rules(EvolutionStep(Version.parse(from), Version.parse(to))).conversions + } + + private def dtoConversion(family: BaboonFamily, pkg: String, from: String, to: String, name: String): DtoConversion = { + val found = conversionsOf(family, pkg, from, to).collect { + case c: DtoConversion if c.targetTpe.name.name == name => c + } + assert(found.size == 1, s"expected exactly one DtoConversion for $name in $from->$to, got $found") + found.head + } + + private def fieldsOf(family: BaboonFamily, pkg: String, version: String, name: String): List[Field] = { + val lineage = family.domains.toMap(Pkg(NEList.unsafeFrom(pkg.split('.').toList))) + val domain = lineage.versions.toMap(Version.parse(version)) + domain.defs.meta.nodes.values.collect { + case DomainMember.User(_, d: Typedef.Dto, _, _) if d.id.name.name == name => d.fields + }.toList match { + case one :: Nil => one + case other => fail(s"expected exactly one dto named $name in $version, got $other") + } + } + + private def renames(c: DtoConversion): Set[(String, String)] = c.ops.collect { + case r: FieldOp.Rename => (r.sourceFieldName.name, r.targetField.name.name) + }.toSet + + private def transfers(c: DtoConversion): Set[String] = c.ops.collect { + case t: FieldOp.Transfer => t.targetField.name.name + }.toSet + + "declared renames" should { + + "convert a name swap that also reorders the fields" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.swapreordered + |version "1.0.0" + |root data S { a: i32 b: i32 } + |""".stripMargin + val v2 = """model rsnd.swapreordered + |version "1.1.0" + |import "1.0.0" { * } without { S } + |root data S { b: i32 was a a: i32 was b } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val c = dtoConversion(family, "rsnd.swapreordered", "1.0.0", "1.1.0", "S") + assert(renames(c) == Set(("a", "b"), ("b", "a")), s"ops: ${c.ops}") + assert(transfers(c).isEmpty, s"ops: ${c.ops}") + assert(c.removed.isEmpty, s"removed: ${c.removed}") + } + } + + "convert a name swap that leaves both structural signatures intact" in { + (manager: BaboonFamilyManager[F]) => + // `shallowId` sorts `name:type` and `deepId` is positional: with the field ORDER preserved, + // neither signature notices this swap. Only the annotations say the values move. + val v1 = """model rsnd.swapinplace + |version "1.0.0" + |root data S { a: i32 b: i32 } + |""".stripMargin + val v2 = """model rsnd.swapinplace + |version "1.1.0" + |import "1.0.0" { * } without { S } + |root data S { a: i32 was b b: i32 was a } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val c = dtoConversion(family, "rsnd.swapinplace", "1.0.0", "1.1.0", "S") + assert(renames(c) == Set(("b", "a"), ("a", "b")), s"ops: ${c.ops}") + assert(transfers(c).isEmpty, s"ops: ${c.ops}") + } + } + + "treat a field whose name a rename takes over as removed" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.takeover + |version "1.0.0" + |root data S { a: i32 b: str } + |""".stripMargin + val v2 = """model rsnd.takeover + |version "1.1.0" + |import "1.0.0" { * } without { S } + |root data S { b: i32 was a } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val c = dtoConversion(family, "rsnd.takeover", "1.0.0", "1.1.0", "S") + assert(renames(c) == Set(("a", "b")), s"ops: ${c.ops}") + assert(transfers(c).isEmpty, s"ops: ${c.ops}") + assert(c.removed.map(_.name.name) == Set("b"), s"removed: ${c.removed}") + } + } + + "swap enum members by their declared previous names" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.enumswap + |version "1.0.0" + |enum E { A B } + |root data H { e: E } + |""".stripMargin + val v2 = """model rsnd.enumswap + |version "1.1.0" + |import "1.0.0" { * } without { E H } + |enum E { B : was[A] A : was[B] } + |root data H { e: E } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val mappings = conversionsOf(family, "rsnd.enumswap", "1.0.0", "1.1.0").collect { + case c: CopyEnumByName if c.targetTpe.name.name == "E" => c.memberMapping + } + assert(mappings == List(Map("A" -> "B", "B" -> "A")), s"mappings: $mappings") + } + } + + "accept an annotation carried forward into a later version" in { + (manager: BaboonFamilyManager[F]) => + // `r` was renamed from `b` in 1.1.0; the annotation survives into 1.2.0, where 1.1.0 has no + // `b` any more. That is not a typo, and it is not a rename at the 1.1.0 -> 1.2.0 step. + val v1 = """model rsnd.carried + |version "1.0.0" + |root data T { a: i32 b: str } + |""".stripMargin + val v2 = """model rsnd.carried + |version "1.1.0" + |import "1.0.0" { * } without { T } + |root data T { a: i32 r: str was b } + |""".stripMargin + val v3 = """model rsnd.carried + |version "1.2.0" + |import "1.1.0" { * } without { T } + |root data T { a: i32 r: str was b z: opt[i32] } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2, "v3.baboon" -> v3).map { + family => + val first = dtoConversion(family, "rsnd.carried", "1.0.0", "1.1.0", "T") + assert(renames(first) == Set(("b", "r")), s"ops: ${first.ops}") + + val second = dtoConversion(family, "rsnd.carried", "1.1.0", "1.2.0", "T") + assert(renames(second).isEmpty, s"ops: ${second.ops}") + assert(transfers(second) == Set("a", "r"), s"ops: ${second.ops}") + } + } + + "accept a stale annotation being dropped again" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.dropped + |version "1.0.0" + |root data T { a: i32 b: str } + |""".stripMargin + val v2 = """model rsnd.dropped + |version "1.1.0" + |import "1.0.0" { * } without { T } + |root data T { a: i32 r: str was b } + |""".stripMargin + val v3 = """model rsnd.dropped + |version "1.2.0" + |import "1.1.0" { * } without { T } + |root data T { a: i32 r: str } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2, "v3.baboon" -> v3).map { + family => + val c = dtoConversion(family, "rsnd.dropped", "1.1.0", "1.2.0", "T") + assert(renames(c).isEmpty, s"ops: ${c.ops}") + assert(transfers(c) == Set("a", "r"), s"ops: ${c.ops}") + + // Field ops address the TARGET, and `Field` equality includes `prevName`: 1.1.0's `r` + // still carries `was b` while 1.2.0's does not. Ops keyed off the source fields would + // leave every target field unmatched in the code generators. + assert( + c.ops.map(_.targetField).toSet == fieldsOf(family, "rsnd.dropped", "1.2.0", "T").toSet, + s"ops: ${c.ops}", + ) + } + } + + "reject a field rename from a name no earlier version ever had" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.fieldtypo + |version "1.0.0" + |root data T { a: i32 b: str } + |""".stripMargin + val v2 = """model rsnd.fieldtypo + |version "1.1.0" + |import "1.0.0" { * } without { T } + |root data T { a: i32 r: str was qqq } + |""".stripMargin + F.attempt(load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2)).map { + case Left(issues) => + assert( + issues.toList.exists { + case BaboonIssue.Evolution(_: EvolutionIssue.InvalidFieldRename) => true + case _ => false + }, + s"expected InvalidFieldRename; got $issues", + ) + case Right(_) => + fail("expected InvalidFieldRename, but the model compiled clean") + } + } + + "reject an enum member rename from a name no earlier version ever had" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.enumtypo + |version "1.0.0" + |enum E { A B } + |root data H { e: E } + |""".stripMargin + val v2 = """model rsnd.enumtypo + |version "1.1.0" + |import "1.0.0" { * } without { E H } + |enum E { A B2 : was[Q] } + |root data H { e: E } + |""".stripMargin + F.attempt(load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2)).map { + case Left(issues) => + assert( + issues.toList.exists { + case BaboonIssue.Evolution(_: EvolutionIssue.InvalidEnumMemberRename) => true + case _ => false + }, + s"expected InvalidEnumMemberRename; got $issues", + ) + case Right(_) => + fail("expected InvalidEnumMemberRename, but the model compiled clean") + } + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala index 6e082dccf..ba85fba73 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala @@ -134,7 +134,7 @@ object EvolutionIssue { (issue: InvalidFieldRename) => { s"""Invalid field rename in type ${issue.typeId.toString}: | Field '${issue.newFieldName.name}' was declared as renamed from '${issue.prevFieldName.name}', - | but '${issue.prevFieldName.name}' does not exist in the previous version. + | but no earlier version of this type has a field named '${issue.prevFieldName.name}'. |""".stripMargin } @@ -142,7 +142,7 @@ object EvolutionIssue { (issue: InvalidEnumMemberRename) => { s"""Invalid enum member rename in type ${issue.typeId.toString}: | Member '${issue.newMemberName}' was declared as renamed from '${issue.prevMemberName}', - | but '${issue.prevMemberName}' does not exist in the previous version. + | but no earlier version of this type has a member named '${issue.prevMemberName}'. |""".stripMargin } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index b59fd7b4c..af6ea9c46 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -46,6 +46,7 @@ object BaboonComparator { ) for { + _ <- validateRenameAncestry(versions) indexedDiffs <- if (sortedVersions.size == 1) { F.pure(List.empty) } else { @@ -378,6 +379,92 @@ object BaboonComparator { } } + /** + * True when the new version of `id` declares a `was` rename that still names a field or enum + * member of the previous version. Such a rename is honoured by the per-pair diffs, so the type + * must not be reported as structurally unchanged. + */ + private def hasEffectiveRename(last: Domain, prev: Domain, id: TypeId): Boolean = { + (last.defs.meta.nodes.get(id), prev.defs.meta.nodes.get(id)) match { + case (Some(DomainMember.User(_, nd: Typedef.Dto, _, _)), Some(DomainMember.User(_, od: Typedef.Dto, _, _))) => + val oldNames = od.fields.map(_.name).toSet + nd.fields.exists(f => f.prevName.exists(was => was != f.name && oldNames.contains(was))) + case (Some(DomainMember.User(_, ne: Typedef.Enum, _, _)), Some(DomainMember.User(_, oe: Typedef.Enum, _, _))) => + val oldNames = oe.members.map(_.name).toSet + ne.members.exists(m => m.prevName.exists(was => was != m.name && oldNames.contains(was))) + case _ => + false + } + } + + private def renameableNames(domain: Domain, id: TypeId.User): Set[String] = { + domain.defs.meta.nodes.get(id) match { + case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) => dto.fields.map(_.name.name).toSet + case Some(DomainMember.User(_, enm: Typedef.Enum, _, _)) => enm.members.map(_.name).toSet + case _ => Set.empty + } + } + + /** + * A `was` annotation survives into every later version of a type, so an individual version pair + * cannot tell a carried-forward annotation apart from a typo: at the 1.1.0 -> 1.2.0 step the + * field renamed in 1.1.0 still says `was b` while 1.1.0 no longer has a `b`. Ancestry is + * therefore validated here, where every version of the package is visible, and the per-pair + * diffs honour a `prevName` only while it still names a member of the version they compare + * against. + */ + private def validateRenameAncestry( + versions: NEMap[Version, Domain] + ): F[NEList[BaboonIssue], Unit] = { + val ascending = versions.keySet.toList.sorted(Version.ordering) + // names carried by a type's lineage in all versions strictly preceding the keyed one + val history = mutable.HashMap.empty[(Version, TypeId.User), Set[String]] + + val issues = ascending.zipWithIndex.flatMap { + case (version, idx) => + val domain = versions(version) + + domain.defs.meta.nodes.values.toList.flatMap { + case DomainMember.User(_, defn: Typedef.User, _, _) => + val ancestralNames = if (idx == 0) { + Set.empty[String] + } else { + val prevVersion = ascending(idx - 1) + val prevId = domain.renames.getOrElse(defn.id, defn.id) + history.getOrElse((prevVersion, prevId), Set.empty[String]) ++ + renameableNames(versions(prevVersion), prevId) + } + history.put((version, defn.id), ancestralNames) + + if (ancestralNames.isEmpty) { + // the type has no ancestor to have been renamed from; no pair diff looks at it either + List.empty + } else { + defn match { + case dto: Typedef.Dto => + dto.fields.flatMap { + field => + field.prevName + .filterNot(prev => ancestralNames.contains(prev.name)) + .map(prev => EvolutionIssue.InvalidFieldRename(dto.id, field.name, prev)) + } + case enm: Typedef.Enum => + enm.members.toList.flatMap { + member => + member.prevName + .filterNot(ancestralNames.contains) + .map(prev => EvolutionIssue.InvalidEnumMemberRename(enm.id, member.name, prev)) + } + case _ => List.empty + } + } + case _ => List.empty + } + } + + F.traverseAccumErrors(issues)(issue => F.fail(BaboonIssue.of(issue))).map(_ => ()) + } + override def compare( last: Domain, prev: Domain, @@ -415,11 +502,18 @@ object BaboonComparator { val added = newTypes.diff(oldTypes).diff(renamedNewIds) val removed = oldTypes.diff(newTypes).diff(renamedOldIds) + // A `was` rename that still names a member of the previous version is a real change even when + // both structural signatures stay put: `shallowId` sorts `name:type` and `deepId` is + // positional, so a declared name swap is invisible to both. Such a type must be classified as + // locally modified so that it reaches `diffDtos`/`diffEnums` and the rename becomes a + // conversion op instead of a positional transfer. + val declaredRenames = kept.filter(id => hasEffectiveRename(last, prev, id)) + val unmodified = kept.filter { id => last.typeMeta(id).shallowId == prev.typeMeta(id).shallowId && last.typeMeta(id).deepId == prev.typeMeta(id).deepId - } + }.diff(declaredRenames) val changed = kept.diff(unmodified) @@ -435,14 +529,12 @@ object BaboonComparator { // same dependencies, different local structure val shallowModified = partiallyModified.filter { id => - last.typeMeta(id).shallowId != prev.typeMeta(id).shallowId + last.typeMeta(id).shallowId != prev.typeMeta(id).shallowId || + declaredRenames.contains(id) } // same local structure, different dependencies - val deepModified = partiallyModified.filter { - id => - last.typeMeta(id).deepId != prev.typeMeta(id).deepId - } + val deepModified = partiallyModified.diff(shallowModified) assert(shallowModified.intersect(deepModified).isEmpty) assert(shallowModified.intersect(fullyModified).isEmpty) @@ -541,45 +633,38 @@ object BaboonComparator { val names1 = members1.keySet val names2 = members2.keySet - val invalidRenames = e2.members.toList.flatMap { + + // `prevName` is carried forward by every later version of a type, so here it denotes a rename + // only while it still names a member of the version we compare against. Ancestry of a stale + // annotation is validated once per package in `evolve`. + val renamedMembers = e2.members.toList.flatMap { newMember => newMember.prevName.flatMap { prevName => - if (!members1.contains(prevName)) { - Some(EvolutionIssue.InvalidEnumMemberRename(e2.id, newMember.name, prevName)) - } else { - None - } + members1.get(prevName).map(oldMember => (newMember.name, (oldMember, newMember))) }.toList - } + }.toMap - for { - _ <- F.traverseAccumErrors(invalidRenames)(issue => F.fail(BaboonIssue.of(issue))) - } yield { - val renamedMembers = e2.members.toList.flatMap { - newMember => - newMember.prevName.flatMap { - prevName => - members1.get(prevName).map(oldMember => (newMember.name, (oldMember, newMember))) - }.toList - }.toMap + val renamedNewNames = renamedMembers.keySet + val renamedOldNames = renamedMembers.values.map(_._1.name).toSet - val renamedNewNames = renamedMembers.keySet - val renamedOldNames = renamedMembers.values.map(_._1.name).toSet + // A renamed member is accounted for by its rename op alone: a declared name swap or a rename + // onto a name the previous version also used would otherwise be both "kept" and "renamed". + val survivors1 = names1.diff(renamedOldNames) + val survivors2 = names2.diff(renamedNewNames) - val removedMembers = names1.diff(names2).diff(renamedOldNames) - val addedMembers = names2.diff(names1).diff(renamedNewNames) - val keptMembers = names1.intersect(names2) + val removedMembers = survivors1.diff(survivors2) + val addedMembers = survivors2.diff(survivors1) + val keptMembers = survivors1.intersect(survivors2) - val ops = List( - removedMembers.map(id => EnumOp.RemoveBranch(members1(id))), - addedMembers.map(id => EnumOp.AddBranch(members2(id))), - keptMembers.map(id => EnumOp.KeepBranch(members2(id))), - renamedMembers.values.map { case (_, newMember) => EnumOp.KeepBranch(newMember) }, - ).flatten + val ops = List( + removedMembers.map(id => EnumOp.RemoveBranch(members1(id))), + addedMembers.map(id => EnumOp.AddBranch(members2(id))), + keptMembers.map(id => EnumOp.KeepBranch(members2(id))), + renamedMembers.values.map { case (_, newMember) => EnumOp.KeepBranch(newMember) }, + ).flatten - TypedefDiff.EnumDiff(ops) - } + F.pure(TypedefDiff.EnumDiff(ops)) } private def diffAdts( @@ -675,71 +760,63 @@ object BaboonComparator { val names1 = members1.keySet val names2 = members2.keySet - val invalidRenames = d2.fields.flatMap { + // `prevName` is carried forward by every later version of a type, so here it denotes a rename + // only while it still names a field of the version we compare against. Ancestry of a stale + // annotation is validated once per package in `evolve`. + val renamedFields: Map[FieldName, (Field, Field)] = d2.fields.flatMap { newField => newField.prevName.flatMap { prevName => - if (!members1.contains(prevName)) { - Some(EvolutionIssue.InvalidFieldRename(d2.id, newField.name, prevName)) - } else { - None - } + members1.get(prevName).map(oldField => (newField.name, (oldField, newField))) } - } - - for { - _ <- F.traverseAccumErrors(invalidRenames)(issue => F.fail(BaboonIssue.of(issue))) - } yield { - val renamedFields: Map[FieldName, (Field, Field)] = d2.fields.flatMap { - newField => - newField.prevName.flatMap { - prevName => - members1.get(prevName).map(oldField => (newField.name, (oldField, newField))) - } - }.toMap - - val renamedNewNames = renamedFields.keySet - val renamedOldNames = renamedFields.values.map(_._1.name).toSet + }.toMap - val removedMembers = names1.diff(names2).diff(renamedOldNames) - val addedMembers = names2.diff(names1).diff(renamedNewNames) + val renamedNewNames = renamedFields.keySet + val renamedOldNames = renamedFields.values.map(_._1.name).toSet - val keptMembers = names1.intersect(names2) + // A renamed field is accounted for by its rename op alone: a declared name swap or a rename + // onto a name the previous version also used would otherwise be both "kept" and "renamed", + // producing two conflicting ops for one target field. + val survivors1 = names1.diff(renamedOldNames) + val survivors2 = names2.diff(renamedNewNames) - val keptFields = keptMembers.map(name => (members1(name), members2(name))) - val changedFields = keptFields.filter { - case (f1, f2) => - f1.tpe != f2.tpe - } - val unchangedFields = keptFields.filter { - case (f1, f2) => - f1.tpe == f2.tpe - }.map { - case (_, f2) => - val directRefs = enquiries.explode(f2.tpe) - val modification = - figureOutModification(changes, directRefs) + val removedMembers = survivors1.diff(survivors2) + val addedMembers = survivors2.diff(survivors1) + val keptMembers = survivors1.intersect(survivors2) - DtoOp.KeepField(f2, modification) - } + val keptFields = keptMembers.map(name => (members1(name), members2(name))) + val changedFields = keptFields.filter { + case (f1, f2) => + f1.tpe != f2.tpe + } + val unchangedFields = keptFields.filter { + case (f1, f2) => + f1.tpe == f2.tpe + }.map { + case (_, f2) => + val directRefs = enquiries.explode(f2.tpe) + val modification = + figureOutModification(changes, directRefs) + + DtoOp.KeepField(f2, modification) + } - val renamedFieldOps = renamedFields.values.map { - case (oldField, newField) => - val directRefs = enquiries.explode(newField.tpe) - val modification = figureOutModification(changes, directRefs) - DtoOp.RenameField(oldField, newField, modification) - } + val renamedFieldOps = renamedFields.values.map { + case (oldField, newField) => + val directRefs = enquiries.explode(newField.tpe) + val modification = figureOutModification(changes, directRefs) + DtoOp.RenameField(oldField, newField, modification) + } - val ops = List( - removedMembers.map(id => DtoOp.RemoveField(members1(id))), - addedMembers.map(id => DtoOp.AddField(members2(id))), - changedFields.map(id => DtoOp.ChangeField(id._1, id._2.tpe)), - unchangedFields, - renamedFieldOps, - ).flatten + val ops = List( + removedMembers.map(id => DtoOp.RemoveField(members1(id))), + addedMembers.map(id => DtoOp.AddField(members2(id))), + changedFields.map(id => DtoOp.ChangeField(id._1, id._2.tpe)), + unchangedFields, + renamedFieldOps, + ).flatten - TypedefDiff.DtoDiff(ops) - } + F.pure(TypedefDiff.DtoDiff(ops)) } private def diffServices( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala index d38414a1b..d52ab2ac7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala @@ -76,11 +76,16 @@ object BaboonRules { .contains(id) || (isRenamed && hasDiff) defn match { - case d: Typedef.Dto if sameLocalStruct => + case _: Typedef.Dto if sameLocalStruct => + // Field ops describe how to populate the TARGET, and `Field` equality includes + // `prevName`, which may differ between the two versions even when the local + // structure does not (a stale `was` annotation being dropped, for instance). + // Keying the ops off the source fields would then leave a target field unmapped. + val targetDto = last.defs.meta.nodes(targetTpe).asInstanceOf[DomainMember.User].defn.asInstanceOf[Typedef.Dto] F.pure( DtoConversion( id, - d.fields.map(f => FieldOp.Transfer(f)), + targetDto.fields.map(f => FieldOp.Transfer(f)), Set.empty, targetTpe, ) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/validator/BaboonValidator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/validator/BaboonValidator.scala index f332e091e..0f90ecc59 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/validator/BaboonValidator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/validator/BaboonValidator.scala @@ -1026,7 +1026,14 @@ object BaboonValidator { case f: FieldOp.Rename => f.sourceFieldName case f: FieldOp.Redef => f.sourceFieldName }.toSet - removals = oldFieldNames.diff(newFieldNames).diff(renamedSources) + renamedTargets = c.ops.collect { + case f: FieldOp.Rename => f.targetField.name + case f: FieldOp.Redef => f.targetField.name + }.toSet + // A renamed field is accounted for by its rename op on both ends, so neither its + // source nor its target name participates in the removal set. Subtracting only the + // sources would hide the removal of an old field whose name a rename took over. + removals = oldFieldNames.diff(renamedSources).diff(newFieldNames.diff(renamedTargets)) _ <- F.when(removals != removedFields)( F.fail( BaboonIssue.of( diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 172d99258..769512b9a 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -571,6 +571,22 @@ Closed gaps, kept for the record: - *Per-format range splitting* used to be out of scope, so a declared rename was classified unreadable in both formats even though UEBA bytes do not move. Each step is now resolved per format; see "Per-format readability" above. +- *Declared renames were unsound in two ways*, both caused by a `was` annotation + surviving into every later version of a type while a version pair only ever + sees two adjacent versions. (1) A carried-forward annotation was rejected as a + typo: after `r: str was b` in 1.1.0, editing the type again in 1.2.0 failed + with `InvalidFieldRename` because 1.1.0 no longer has a `b`. Ancestry is now + validated once per package against every earlier version, and each pair + honours a `prevName` only while it still names a member of the version it + compares against. (2) A rename whose target name the previous version also used + — a name swap, or a rename onto the name of a field being dropped — was counted + as both "kept" and "renamed", yielding two conflicting ops for one target + field; and a swap that preserves field order leaves `shallowId` (sorted) and + `deepId` (positional) both intact, so the type was classified unchanged and the + declared move was ignored outright. A type declaring an effective rename is now + classified as locally modified, and the kept/removed/added sets subtract the + rename's source and target names on both ends, in the comparator and in the + conversion validator alike. Covered by `RenameSoundnessTest`. - *Timestamp kind byte round trips* (https://github.com/7mind/baboon/issues/91): the C# `RpDateTime` keeps carrying its `DateTimeKind` on the wire (Local when the offset matches the writer's zone) — this is intentional and unchanged, as From 979eaf75c48dec9e8c964693c8327c7f578ab6b1 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 20 Sep 2026 18:33:17 +0100 Subject: [PATCH 26/48] fix(evolution): make declared `was` renames sound for ADT branches and types Follow-up to 2b016c26, which fixed the field and enum-member arms and left `diffAdts` alone on the mistaken reasoning that a branch-level rename could not reach the defective code. Both shapes are reachable (#92). 1. A branch rename onto a name the previous version also used hid the removal of the branch that used to carry that name: `removed = members1 \ members2 \ renamedOld` cancels out, `AdtBranchRemoved` never fired, and an old value of the dropped branch was silently converted into the renamed one. Both `diffAdts` arms now subtract the rename's two ends before intersecting, as `diffDtos`/`diffEnums` already do. The type-level kept/added/removed sets had the same defect one level up, which additionally derived a conversion from the old type onto the id the rename took over; they get the same treatment. 2. A declared swap of two names cannot be honoured at all -- both declared sources keep their own TypeIds in the new version, so there is nothing to map one onto the other and the classification would compare each id against its own namesake. It was previously discarded without a diagnostic; it now fails with `EvolutionIssue.RenameSourceStillPresent`, reported per direction. This applies to any declared type rename whose source is still defined. 3. A `was` clause naming something that never existed is now an error at every level, not only for fields and enum members: a type-level `was[Other]` naming a type no earlier version declares, and a field clause on a type introduced by the version carrying it, both fail rather than being discarded in silence. Two exemptions, each forced by observed behaviour rather than convenience: - The oldest version of a package. `BaboonSchemeRenderer` emits one version at a time and the rendered source must load back (`SchemeRoundtripTest`), so a lone version containing a rename has to stay legal -- and with no earlier version the clause makes no checkable claim. `RenameSoundnessTest` now pins this directly instead of relying on the renderer to catch a regression. - An ancestor that was only ever an excluded declaration. A type no root reaches is dropped from `defs` and survives only as an id in `excludedIds`, so its members are not observable; `m20-was-propagation` renames a branch of an `adt` that `root adt Outer` absorbs structurally. Existence is therefore judged against declared ids (retained plus excluded) while member names are judged only against shapes some version actually retained. `RenameSoundnessTest` covers all of it (15 cases); the two ADT cases fail with the comparator reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon/tests/RenameSoundnessTest.scala | 160 +++++++++++++++++- .../parser/model/issues/EvolutionIssue.scala | 24 +++ .../baboon/typer/BaboonComparator.scala | 106 +++++++++--- docs/forward-compat.md | 33 ++++ 4 files changed, 302 insertions(+), 21 deletions(-) diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala index 30c87a476..2a2c64b45 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala @@ -5,8 +5,8 @@ import io.septimalmind.baboon.parser.model.FSPath import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, EvolutionIssue} import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule import io.septimalmind.baboon.typer.BaboonFamilyManager -import io.septimalmind.baboon.typer.model.Conversion.{CopyEnumByName, DtoConversion, FieldOp} -import io.septimalmind.baboon.typer.model.{BaboonFamily, Conversion, DomainMember, EvolutionStep, Field, Pkg, Typedef, Version} +import io.septimalmind.baboon.typer.model.Conversion.{CopyEnumByName, CustomConversionRequired, DtoConversion, FieldOp, RemovedTypeNoConversion} +import io.septimalmind.baboon.typer.model.{BaboonFamily, Conversion, DerivationFailure, DomainMember, EvolutionStep, Field, Pkg, Typedef, Version} import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.nonempty.{NEList, NEString} import izumi.reflect.TagKK @@ -212,6 +212,162 @@ abstract class RenameSoundnessTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul } } + "treat an ADT branch whose name a rename takes over as removed" in { + (manager: BaboonFamilyManager[F]) => + // The model says the branch now called `X` IS the old `Y`, and the old `X` is gone. The old + // `X` therefore has nowhere to go: the ADT conversion cannot be derived, and no conversion + // may be emitted that maps the old `X` onto the new one. + val v1 = """model rsnd.branchtakeover + |version "1.0.0" + |root adt A { + | data X { a: i32 } + | data Y { a: i32 } + |} + |""".stripMargin + val v2 = """model rsnd.branchtakeover + |version "1.1.0" + |import "1.0.0" { * } without { A } + |root adt A { + | data X : was[Y] { a: i32 } + |} + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val convs = conversionsOf(family, "rsnd.branchtakeover", "1.0.0", "1.1.0") + + assert( + convs.exists { + case c: CustomConversionRequired => + c.sourceTpe.name.name == "A" && c.reason.isInstanceOf[DerivationFailure.AdtBranchRemoved] + case _ => false + }, + s"expected AdtBranchRemoved for A; got $convs", + ) + assert( + convs.exists { + case c: RemovedTypeNoConversion => c.sourceTpe.name.name == "X" + case _ => false + }, + s"expected the old X branch to be reported removed; got $convs", + ) + assert( + !convs.exists { + case c: DtoConversion => c.sourceTpe.name.name == "X" && c.targetTpe.name.name == "X" + case _ => false + }, + s"the old X must not be converted into the new X; got $convs", + ) + assert( + convs.exists { + case c: DtoConversion => c.sourceTpe.name.name == "Y" && c.targetTpe.name.name == "X" + case _ => false + }, + s"expected the declared Y -> X branch conversion; got $convs", + ) + } + } + + "reject a declared swap of two ADT branch names" in { + (manager: BaboonFamilyManager[F]) => + // Both declared sources still exist under their own TypeIds in the new version, so there is + // nothing to map one onto the other. Discarding the annotations silently, as the rename + // filter did on its own, loses the declaration without a word. + val v1 = """model rsnd.branchswap + |version "1.0.0" + |root adt A { + | data X { a: i32 } + | data Y { b: str } + |} + |""".stripMargin + val v2 = """model rsnd.branchswap + |version "1.1.0" + |import "1.0.0" { * } without { A } + |root adt A { + | data X : was[Y] { b: str } + | data Y : was[X] { a: i32 } + |} + |""".stripMargin + F.attempt(load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2)).map { + case Left(issues) => + val renames = issues.toList.collect { + case BaboonIssue.Evolution(i: EvolutionIssue.RenameSourceStillPresent) => + (i.typeId.name.name, i.prevTypeId.name.name) + }.toSet + assert(renames == Set(("X", "Y"), ("Y", "X")), s"expected both directions reported; got $issues") + case Right(_) => + fail("expected RenameSourceStillPresent, but the model compiled clean") + } + } + + "reject a type rename from a type no earlier version ever defined" in { + (manager: BaboonFamilyManager[F]) => + val v1 = """model rsnd.typetypo + |version "1.0.0" + |root data T { a: i32 } + |""".stripMargin + val v2 = """model rsnd.typetypo + |version "1.1.0" + |import "1.0.0" { * } + |root data U : was[Ghost] { a: i32 } + |""".stripMargin + F.attempt(load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2)).map { + case Left(issues) => + assert( + issues.toList.exists { + case BaboonIssue.Evolution(i: EvolutionIssue.InvalidTypeRename) => + i.typeId.name.name == "U" && i.prevTypeId.name.name == "Ghost" + case _ => false + }, + s"expected InvalidTypeRename for U was[Ghost]; got $issues", + ) + case Right(_) => + fail("expected InvalidTypeRename, but the model compiled clean") + } + } + + "reject a field rename on a type that has no earlier version" in { + (manager: BaboonFamilyManager[F]) => + // `Fresh` is introduced in 1.1.0 and is not a rename target, so `was b` names nothing. + val v1 = """model rsnd.freshtype + |version "1.0.0" + |root data T { a: i32 } + |""".stripMargin + val v2 = """model rsnd.freshtype + |version "1.1.0" + |import "1.0.0" { * } + |root data Fresh { r: str was b } + |""".stripMargin + F.attempt(load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2)).map { + case Left(issues) => + assert( + issues.toList.exists { + case BaboonIssue.Evolution(i: EvolutionIssue.InvalidFieldRename) => i.typeId.name.name == "Fresh" + case _ => false + }, + s"expected InvalidFieldRename for Fresh; got $issues", + ) + case Right(_) => + fail("expected InvalidFieldRename, but the model compiled clean") + } + } + + "accept a `was` clause in a single-version model" in { + (manager: BaboonFamilyManager[F]) => + // `BaboonSchemeRenderer` emits one version at a time and the result must load back, so a + // lone version carrying a rename is a real artifact. There is nothing there for the clause + // to have been renamed away from, so the clause carries no claim to check. + val v1 = """model rsnd.single + |version "1.0.0" + |root data T { a: i32 r: str was b } + |enum E { B2 : was[B] } + |root data H { e: E } + |""".stripMargin + load(manager, "v1.baboon" -> v1).map { + family => + assert(family.domains.toMap.keySet.exists(_.toString == "rsnd.single")) + } + } + "reject a field rename from a name no earlier version ever had" in { (manager: BaboonFamilyManager[F]) => val v1 = """model rsnd.fieldtypo diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala index ba85fba73..d1fe0d985 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/EvolutionIssue.scala @@ -31,6 +31,12 @@ object EvolutionIssue { case class InvalidEnumMemberRename(typeId: TypeId, newMemberName: String, prevMemberName: String) extends EvolutionIssue + /** The `was` clause names a type no earlier version of the package ever defined. */ + case class InvalidTypeRename(typeId: TypeId, prevTypeId: TypeId) extends EvolutionIssue + + /** The `was` clause names a type this version still defines, so nothing was renamed away. */ + case class RenameSourceStillPresent(typeId: TypeId, prevTypeId: TypeId) extends EvolutionIssue + implicit val brokenComparisonPrinter: IssuePrinter[BrokenComparison] = new BugPrinter[BrokenComparison] { override def errorMessage(bug: BrokenComparison): String = { @@ -138,6 +144,24 @@ object EvolutionIssue { |""".stripMargin } + implicit val invalidTypeRenamePrinter: IssuePrinter[InvalidTypeRename] = + (issue: InvalidTypeRename) => { + s"""Invalid type rename: ${issue.typeId.toString} + | was declared as renamed from ${issue.prevTypeId.toString}, + | but no earlier version of this package defines that type. + |""".stripMargin + } + + implicit val renameSourceStillPresentPrinter: IssuePrinter[RenameSourceStillPresent] = + (issue: RenameSourceStillPresent) => { + s"""Invalid type rename: ${issue.typeId.toString} + | was declared as renamed from ${issue.prevTypeId.toString}, + | but that type still exists in this version. + | A rename moves a type off its old identity; declare the old type as removed, + | or point the `was` clause at a type this version no longer defines. + |""".stripMargin + } + implicit val invalidEnumMemberRenamePrinter: IssuePrinter[InvalidEnumMemberRename] = (issue: InvalidEnumMemberRename) => { s"""Invalid enum member rename in type ${issue.typeId.toString}: diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index af6ea9c46..c874d70f2 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -419,25 +419,57 @@ object BaboonComparator { val ascending = versions.keySet.toList.sorted(Version.ordering) // names carried by a type's lineage in all versions strictly preceding the keyed one val history = mutable.HashMap.empty[(Version, TypeId.User), Set[String]] + // Every type id DECLARED by a version strictly preceding the one being validated. A type that + // no root reaches is dropped from `defs` and only its id survives, in `excludedIds` -- it was + // still declared, and a `was` clause naming it is not a typo. + val everDeclared = mutable.HashSet.empty[TypeId] + // The subset whose shape we actually saw, i.e. that some earlier version retained. Only for + // those can we judge whether a field or member name existed. + val everRetained = mutable.HashSet.empty[TypeId] val issues = ascending.zipWithIndex.flatMap { case (version, idx) => val domain = versions(version) - domain.defs.meta.nodes.values.toList.flatMap { + // A declared type rename must name a type this package used to define. `was` clauses are + // carried forward by later versions just like field ones, so "used to define" spans every + // earlier version, not only the immediately preceding one. + // + // The oldest version is exempt: there is nothing there to have been renamed away, so a + // `was` clause carries no claim this pass could check. Single-version models are a real + // artifact -- `BaboonSchemeRenderer` emits one version at a time and the result must load + // back -- so rejecting them would break rendering a version that contains a rename. + val renameIssues = if (idx == 0) { + List.empty + } else { + domain.renames.toList.collect { + case (newId, oldId) if !everDeclared.contains(oldId) => + EvolutionIssue.InvalidTypeRename(newId, oldId) + } + } + + val memberIssues = domain.defs.meta.nodes.values.toList.flatMap { case DomainMember.User(_, defn: Typedef.User, _, _) => + val ancestorId = domain.renames.getOrElse(defn.id, defn.id) val ancestralNames = if (idx == 0) { Set.empty[String] } else { val prevVersion = ascending(idx - 1) - val prevId = domain.renames.getOrElse(defn.id, defn.id) - history.getOrElse((prevVersion, prevId), Set.empty[String]) ++ - renameableNames(versions(prevVersion), prevId) + history.getOrElse((prevVersion, ancestorId), Set.empty[String]) ++ + renameableNames(versions(prevVersion), ancestorId) } history.put((version, defn.id), ancestralNames) - if (ancestralNames.isEmpty) { - // the type has no ancestor to have been renamed from; no pair diff looks at it either + // The package has earlier versions but none of them declared this type's lineage: the + // type is introduced here and is not a rename target, so any `was` clause on it names + // something that never existed. The oldest version is exempt, as above. + val neverExisted = idx > 0 && !everDeclared.contains(ancestorId) + // We saw the ancestor's shape, so `ancestralNames` is authoritative. When the ancestor + // was only ever an excluded (unreachable) declaration we cannot see its members at all, + // and staying quiet is the only honest option. + val shapeKnown = everRetained.contains(ancestorId) + + if (!neverExisted && !shapeKnown) { List.empty } else { defn match { @@ -460,6 +492,12 @@ object BaboonComparator { } case _ => List.empty } + + everRetained ++= domain.defs.meta.nodes.keySet + everDeclared ++= domain.defs.meta.nodes.keySet + everDeclared ++= domain.excludedIds + + renameIssues ++ memberIssues } F.traverseAccumErrors(issues)(issue => F.fail(BaboonIssue.of(issue))).map(_ => ()) @@ -477,6 +515,16 @@ object BaboonComparator { case (newId, oldId) => newTypes.contains(newId) && oldTypes.contains(oldId) && !newTypes.contains(oldId) } + // A declared rename whose source is still defined in the new version cannot be honoured: both + // ids keep their own identity, so there is nothing to map one onto the other and the + // classification below would compare each id against its own namesake. This covers a declared + // branch/type name swap. Rejecting is the only sound answer -- silently discarding the + // annotation, as the filter above does on its own, loses the declaration without a word. + val renamesOntoLiveTypes = last.renames.toList.collect { + case (newId, oldId) if newTypes.contains(newId) && oldTypes.contains(oldId) && newTypes.contains(oldId) => + EvolutionIssue.RenameSourceStillPresent(newId, oldId) + } + val explicitSources = explicitRenames.values.toSet // Moving an ADT changes its branches' owner IDs even when their names stay the same. val branchRenames = explicitRenames.toList.flatMap { @@ -498,9 +546,16 @@ object BaboonComparator { val renamedNewIds = validRenames.keySet.asInstanceOf[Set[TypeId]] val renamedOldIds = validRenames.values.toSet.asInstanceOf[Set[TypeId]] - val kept = newTypes.intersect(oldTypes) - val added = newTypes.diff(oldTypes).diff(renamedNewIds) - val removed = oldTypes.diff(newTypes).diff(renamedOldIds) + // A renamed type is accounted for by its rename alone. Subtracting the rename's ends before + // intersecting matters when a rename takes over the id of a type that also exists on the other + // side: the old type of that id is genuinely gone, and pairing it with its own namesake would + // both hide its removal and derive a conversion the rename says is wrong. + val survivingNew = newTypes.diff(renamedNewIds) + val survivingOld = oldTypes.diff(renamedOldIds) + + val kept = survivingNew.intersect(survivingOld) + val added = survivingNew.diff(survivingOld) + val removed = survivingOld.diff(survivingNew) // A `was` rename that still names a member of the previous version is a real change even when // both structural signatures stay put: `shallowId` sorts `name:type` and `deepId` is @@ -560,6 +615,7 @@ object BaboonComparator { ) for { + _ <- F.traverseAccumErrors(renamesOntoLiveTypes)(issue => F.fail(BaboonIssue.of(issue))) // Compute diffs for types that kept the same ID keptDiffs <- F.traverseAccumErrors(changed.toList) { id => @@ -687,11 +743,18 @@ object BaboonComparator { case (newId, oldId) if newId.name != oldId.name => (oldId.name.name, newId.name.name) } - val names1 = members1ByName.keySet - val names2 = members2ByName.keySet - val removedNames = names1.diff(names2).diff(renamedByName.keySet) - val addedNames = names2.diff(names1).diff(renamedByName.values.toSet) - val keptNames = names1.intersect(names2) + val names1 = members1ByName.keySet + val names2 = members2ByName.keySet + + // A renamed branch is accounted for by its rename op alone: a rename onto a name the + // previous version also used would otherwise be both "kept" and "renamed", and the removal + // of the branch that used to carry that name would be cancelled out. + val survivors1 = names1.diff(renamedByName.keySet) + val survivors2 = names2.diff(renamedByName.values.toSet) + + val removedNames = survivors1.diff(survivors2) + val addedNames = survivors2.diff(survivors1) + val keptNames = survivors1.intersect(survivors2) val keptMembers = keptNames.map { name => @@ -721,11 +784,16 @@ object BaboonComparator { val members1 = a1.members.toSet val members2 = a2.members.toSet - val renamedOld = branchRenames.values.toSet - val renamedNew = branchRenames.keySet - val removedMembers = members1.diff(members2).diff(renamedOld) - val addedMembers = members2.diff(members1).diff(renamedNew) - val keptMembers = members1.intersect(members2).map { + val renamedOld = branchRenames.values.toSet + val renamedNew = branchRenames.keySet + + // See the by-name arm: a renamed branch takes no part in the kept/removed/added sets. + val survivors1 = members1.diff(renamedOld) + val survivors2 = members2.diff(renamedNew) + + val removedMembers = survivors1.diff(survivors2) + val addedMembers = survivors2.diff(survivors1) + val keptMembers = survivors1.intersect(survivors2).map { ref => val modification = figureOutModification(changes, Set(ref)) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 769512b9a..de488434d 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -587,6 +587,39 @@ Closed gaps, kept for the record: classified as locally modified, and the kept/removed/added sets subtract the rename's source and target names on both ends, in the comparator and in the conversion validator alike. Covered by `RenameSoundnessTest`. +- *The same two hazards existed for ADT branch renames* (a branch declared + `data X : was[Y]`) and were fixed in the same way + (https://github.com/7mind/baboon/issues/92). A branch rename onto a name the + previous version also used hid the removal of the branch that used to carry + that name, so `AdtBranchRemoved` never fired and an old value of the dropped + branch was silently reinterpreted as the renamed one; both `diffAdts` arms and + the type-level kept/added/removed sets now subtract a rename's two ends before + intersecting, which also stops a conversion being derived from the old type + onto the id the rename took over. A declared *swap* of two branch names cannot + be honoured at all — both declared sources keep their own TypeIds in the new + version, so there is nothing to map one onto the other — and was previously + discarded with no diagnostic; it is now rejected with + `EvolutionIssue.RenameSourceStillPresent`, reported per direction. This applies + to any declared type rename whose source is still defined in the new version, + not only to ADT branches. +- *A `was` clause naming something that never existed is now an error at every + level.* Field and enum-member clauses were already checked; a type-level + `was[Other]` naming a type no earlier version of the package declares was + discarded in silence, and so was a field clause on a type introduced by the + version that carries it. Both now fail, with `InvalidTypeRename` and + `InvalidFieldRename`/`InvalidEnumMemberRename` respectively. "Earlier version" + means any strictly-preceding one, so an annotation carried forward by later + versions still passes. Two cases are deliberately exempt: + - *the oldest version of a package*, where a clause makes no checkable claim. + `BaboonSchemeRenderer` emits one version at a time and the rendered source + must load back (`SchemeRoundtripTest`), so a lone version containing a rename + has to stay legal. + - *an ancestor that was only ever an excluded declaration.* A type no root + reaches is dropped from `defs` and survives only as an id in `excludedIds`, + so its members are not observable — `m20-was-propagation` renames a branch of + an `adt` that `root adt Outer` absorbs structurally. Existence is therefore + judged against declared ids (retained plus excluded) while member names are + judged only against shapes some version actually retained. - *Timestamp kind byte round trips* (https://github.com/7mind/baboon/issues/91): the C# `RpDateTime` keeps carrying its `DateTimeKind` on the wire (Local when the offset matches the writer's zone) — this is intentional and unchanged, as From 65c4513a299841ef63327f50661d6e666a6ec552 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Sun, 20 Sep 2026 19:43:17 +0100 Subject: [PATCH 27/48] feat(evolution): resolve type renames for conversions and forward-compat The documented gap said a type rename collapsed both forward-readability axes for the renamed type and its dependents. Measuring it turned up something larger: it also broke CONVERSION DERIVATION. With `Leaf` renamed to `Renamed` and `Host { l: Leaf }` keeping its own name, the field read as an incompatible type change, so `Host` needed a hand-written conversion -- while the compiler emitted the `Leaf -> Renamed` conversion it needed right next to it. No fixture caught this because `conv-test` renames the holder too, degrading the step to remove+add. One root cause: `TypeRef` equality was not rename-aware across a step. Neither wire format puts a nested value's type name on the wire (UEBA is positional; JSON tags a DTO field by the FIELD name), verified in the generators rather than assumed, so a reference to a renamed type denotes the same bytes as the reference that replaced it. Forward-compat: - `stepForwardGuarantees` pairs a renamed type with its counterpart and keys the step by the OLD id, so hosts resolve it as a dependency. The renamed type still gets no bound of its own -- `computeForwardReadable` finds no successor under that id -- which is the right answer while the envelope identifies a payload by typeId and no runtime resolves a renamed one. - `ownForwardGuarantee` compares field types modulo renames. For an ADT the axes part company: renames resolve on the UEBA side (branch INDEX) and deliberately not on the JSON side (branch NAME), so a branch rename is `ueba-identical`. Conversions: - `DtoOp.KeepField` and `FieldOp.Transfer` carry `sourceTpe`, the field's type as the old version spells it; it differs from the target only for rename-affected fields, so every existing path is unchanged. All nine translators already had a source-type parameter -- C# carried a comment noting the hole ("no `oldTpe` for a plain `Transfer` op") -- and each needed one line. Adding the field does not break translator consumers at compile time, so a missed backend would have emitted a bad old-type reference silently. `rename-nested-ok` therefore goes in the shared model-dir (with the hand-written Swift targets) so all nine codegen lanes compile it. Also fixes an unrelated Python defect the fixture exposed: conversion locals were annotated with the TARGET field's type resolved in the SOURCE domain, naming the old version's class for a new-version value in every nested user-typed field, and crashing outright once that type was a rename target. Full gate `mdl --seq :build :test` green, 200/200 lanes; JVM suite 877/877. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/IdentifierConversionTest.scala | 4 +- .../baboon/tests/RenameSoundnessTest.scala | 71 ++++++++++- .../baboon/diff/OpDiffFormatter.scala | 2 +- .../csharp/CSConversionTranslator.scala | 2 +- .../dart/DtConversionTranslator.scala | 2 +- .../java/JvConversionTranslator.scala | 2 +- .../kotlin/KtConversionTranslator.scala | 2 +- .../python/PyConversionTranslator.scala | 8 +- .../rust/RsConversionTranslator.scala | 4 +- .../scl/ScConversionTranslator.scala | 2 +- .../swift/SwConversionTranslator.scala | 2 +- .../typescript/TsConversionTranslator.scala | 4 +- .../baboon/typer/BaboonComparator.scala | 111 +++++++++++++----- .../baboon/typer/BaboonRules.scala | 6 +- .../baboon/typer/model/Conversion.scala | 3 +- .../baboon/typer/model/TypedefDiff.scala | 7 +- .../rename-nested-ok/nested-rename-1.baboon | 26 ++++ .../rename-nested-ok/nested-rename-2.baboon | 17 +++ docs/forward-compat.md | 28 ++++- test/sw-stub/Package.swift | 15 +++ 20 files changed, 261 insertions(+), 57 deletions(-) create mode 100644 baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-1.baboon create mode 100644 baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-2.baboon diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierConversionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierConversionTest.scala index d951c9d4d..4c72361b2 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierConversionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/IdentifierConversionTest.scala @@ -112,7 +112,7 @@ abstract class IdentifierConversionTestBase[F[+_, +_]: Error2: TagKK: BaboonTest s"Expected all ops to be Transfer for data→id; got: ${dtoConv.ops}", ) - val transferredFields = dtoConv.ops.collect { case FieldOp.Transfer(f) => f.name.name } + val transferredFields = dtoConv.ops.collect { case FieldOp.Transfer(f, _) => f.name.name } assert( transferredFields.contains("u"), s"Expected field `u` to be transferred; got: $transferredFields", @@ -162,7 +162,7 @@ abstract class IdentifierConversionTestBase[F[+_, +_]: Error2: TagKK: BaboonTest s"Expected all ops to be Transfer for id→data; got: ${dtoConv.ops}", ) - val transferredFields = dtoConv.ops.collect { case FieldOp.Transfer(f) => f.name.name } + val transferredFields = dtoConv.ops.collect { case FieldOp.Transfer(f, _) => f.name.name } assert( transferredFields.contains("u"), s"Expected field `u` to be transferred; got: $transferredFields", diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala index 2a2c64b45..c7725e1b4 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala @@ -6,7 +6,7 @@ import io.septimalmind.baboon.parser.model.issues.{BaboonIssue, EvolutionIssue} import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule import io.septimalmind.baboon.typer.BaboonFamilyManager import io.septimalmind.baboon.typer.model.Conversion.{CopyEnumByName, CustomConversionRequired, DtoConversion, FieldOp, RemovedTypeNoConversion} -import io.septimalmind.baboon.typer.model.{BaboonFamily, Conversion, DerivationFailure, DomainMember, EvolutionStep, Field, Pkg, Typedef, Version} +import io.septimalmind.baboon.typer.model.{BaboonFamily, Conversion, DerivationFailure, DomainMember, EvolutionStep, Field, Pkg, TypeId, Typedef, Version} import izumi.functional.bio.{Error2, F} import izumi.fundamentals.collections.nonempty.{NEList, NEString} import izumi.reflect.TagKK @@ -58,6 +58,15 @@ abstract class RenameSoundnessTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul } } + private def forwardRun(family: BaboonFamily, pkg: String, at: String, typeName: String): List[(String, String)] = { + val lineage = family.domains.toMap(Pkg(NEList.unsafeFrom(pkg.split('.').toList))) + val entries = lineage.evolution.typesForwardReadable(Version.parse(at)).collect { + case (id: TypeId.User, fr) if id.name.name == typeName => fr + }.toList + assert(entries.size == 1, s"expected exactly one type named $typeName at $at, got $entries") + entries.head.readable.toList.map { case (v, g) => (v.toString, g.wireName) } + } + private def renames(c: DtoConversion): Set[(String, String)] = c.ops.collect { case r: FieldOp.Rename => (r.sourceFieldName.name, r.targetField.name.name) }.toSet @@ -368,6 +377,66 @@ abstract class RenameSoundnessTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul } } + "keep a host's forward bound when a nested type is renamed" in { + (manager: BaboonFamilyManager[F]) => + // Neither format puts a nested value's type name on the wire, so renaming `Leaf` moves no + // byte of a `Host` payload. The host must keep its bound; the renamed type must not get one + // of its own, because a top-level payload is identified by the typeId in its envelope. + val v1 = """model rsnd.nestedrename + |version "1.0.0" + |data Leaf : derived[json], derived[ueba] { a: i32 } + |root data Host : derived[json], derived[ueba] { l: Leaf b: i32 } + |""".stripMargin + val v2 = """model rsnd.nestedrename + |version "1.1.0" + |import "1.0.0" { * } without { Leaf Host } + |data Renamed : was[Leaf], derived[json], derived[ueba] { a: i32 } + |root data Host : derived[json], derived[ueba] { l: Renamed b: i32 } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + assert( + forwardRun(family, "rsnd.nestedrename", "1.0.0", "Host") == + List(("1.0.0", "identical"), ("1.1.0", "identical")) + ) + assert(forwardRun(family, "rsnd.nestedrename", "1.0.0", "Leaf") == List(("1.0.0", "identical"))) + } + } + + "read a renamed ADT branch in UEBA but not in JSON" in { + (manager: BaboonFamilyManager[F]) => + // UEBA writes the branch INDEX, JSON writes the branch NAME. Renaming a branch in place + // moves no UEBA byte and breaks the JSON discriminator, so the two axes must part company. + val v1 = """model rsnd.branchrename + |version "1.0.0" + |root adt A : derived[json], derived[ueba] { + | data X { a: i32 } + | data Y { b: str } + |} + |""".stripMargin + val v2 = """model rsnd.branchrename + |version "1.1.0" + |import "1.0.0" { * } without { A } + |root adt A : derived[json], derived[ueba] { + | data X2 : was[X] { a: i32 } + | data Y { b: str } + |} + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + assert( + forwardRun(family, "rsnd.branchrename", "1.0.0", "A") == + List(("1.0.0", "identical"), ("1.1.0", "ueba-identical")) + ) + // the untouched branch keeps both axes; the renamed one gets no bound of its own + assert( + forwardRun(family, "rsnd.branchrename", "1.0.0", "Y") == + List(("1.0.0", "identical"), ("1.1.0", "identical")) + ) + assert(forwardRun(family, "rsnd.branchrename", "1.0.0", "X") == List(("1.0.0", "identical"))) + } + } + "reject a field rename from a name no earlier version ever had" in { (manager: BaboonFamilyManager[F]) => val v1 = """model rsnd.fieldtypo diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/diff/OpDiffFormatter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/diff/OpDiffFormatter.scala index 0eb80859f..087ecb9b1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/diff/OpDiffFormatter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/diff/OpDiffFormatter.scala @@ -29,7 +29,7 @@ final class OpDiffFormatter(useColor: Boolean) { s"$red- ${f.name.name}$reset: ${formatTypeRef(f.tpe)}" case DtoOp.ChangeField(f, newType) => s"$yellow~ ${f.name.name}$reset: ${formatTypeRef(f.tpe)} -> ${formatTypeRef(newType)}" - case DtoOp.KeepField(f, modification) => + case DtoOp.KeepField(f, _, modification) => s"$dim= ${f.name.name}$reset: ${formatTypeRef(f.tpe)} (${formatModification(modification)})" case DtoOp.RenameField(oldField, newField, modification) => val typeChangeStr = if (oldField.tpe != newField.tpe) { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala index de954a975..b6b70c74c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSConversionTranslator.scala @@ -220,7 +220,7 @@ class CSConversionTranslator[F[+_, +_]: Error2]( val fieldRef = q"_from.$baseRef" val initExpr = op match { case o: FieldOp.Transfer => - val recConv = transfer(o.targetField.tpe, fieldRef, 0) + val recConv = transfer(o.targetField.tpe, fieldRef, 0, maybeOldTpe = Some(o.sourceTpe)) o.targetField.tpe match { case _: TypeRef.Scalar => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala index 3feb2ba36..8edce5748 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtConversionTranslator.scala @@ -286,7 +286,7 @@ class DtConversionTranslator[F[+_, +_]: Error2]( val fld = trans.escapeDartKeyword(f.name.name) val localVar = trans.escapeDartKeyword(f.name.name.toLowerCase) val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld", 1) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld", 1, Some(o.sourceTpe)) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, args) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala index 17c4e5746..c1218e02d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvConversionTranslator.scala @@ -229,7 +229,7 @@ class JvConversionTranslator[F[+_, +_]: Error2]( val op = ops(f) val fld = f.name.name val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld()", 1) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld()", 1, o.sourceTpe) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, args) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala index fffc9a2fc..2568a6a62 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtConversionTranslator.scala @@ -226,7 +226,7 @@ class KtConversionTranslator[F[+_, +_]: Error2]( val fld = KtTypeTranslator.escapeKtKeyword(f.name.name) val localName = KtTypeTranslator.escapeKtKeyword(f.name.name.toLowerCase) val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"_from.$fld", 1) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"_from.$fld", 1, o.sourceTpe) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, _) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala index 36e07abe9..53c8c02f1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyConversionTranslator.scala @@ -186,7 +186,7 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( val srcAttrName = escapePyKeyword(fieldName) val fieldRef = q"_from.$srcAttrName" val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, fieldRef) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, fieldRef, Some(o.sourceTpe)) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { @@ -234,7 +234,11 @@ final class PyConversionTranslator[F[+_, +_]: Error2]( case m: FieldOp.SwapCollectionType => swapCollType(srcFieldRef, m) } } - val fieldType = asVersionedIfUserTpe(field.tpe) + // The local holds the CONVERTED value, so it is annotated with the target field's type + // in the TARGET domain. Resolving it in `srcDom` named the old version's class for a + // new-version value, and crashed outright once the type was a rename target with no + // old-domain counterpart. + val fieldType = typeTranslator.asPyRef(field.tpe, domain, evolution, pyFileTools.definitionsBasePkg) // Local variable name: use lowercase of the keyword-escaped attribute name. val localVarName = escapePyKeyword(fieldName).toLowerCase q"$localVarName: $fieldType = $expr" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala index ba074e540..40ce54c0c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala @@ -204,8 +204,8 @@ class RsConversionTranslator[F[+_, +_]: Error2]( val op = ops(f) val fld = toSnakeCase(f.name.name) val expr = op match { - case _: FieldOp.Transfer => - transferField(f.name, f.tpe, f.tpe) + case o: FieldOp.Transfer => + transferField(f.name, o.sourceTpe, f.tpe) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, _) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala index d7c0d2b16..f18493cb9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScConversionTranslator.scala @@ -250,7 +250,7 @@ class ScConversionTranslator[F[+_, +_]: Error2]( val fld = escapeScKeyword(f.name.name) val localVarName = escapeScKeyword(f.name.name.toLowerCase) val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"_from.$fld", 1) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"_from.$fld", 1, Some(o.sourceTpe)) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, args) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala index 589da84dd..678f775d8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwConversionTranslator.scala @@ -281,7 +281,7 @@ class SwConversionTranslator[F[+_, +_]: Error2]( val fld = trans.escapeSwiftKeyword(f.name.name) val ftype = trans.asSwRef(f.tpe, domain, evo) val expr = op match { - case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld", 1) + case o: FieldOp.Transfer => transfer(o.targetField.tpe, q"from.$fld", 1, Some(o.sourceTpe)) case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case TypeRef.Constructor(id, _) => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala index ac22cdfff..dc2fec624 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsConversionTranslator.scala @@ -227,8 +227,8 @@ class TsConversionTranslator[F[+_, +_]: Error2]( val op = ops(f) val fld = f.name.name op match { - case _: FieldOp.Transfer => - transfer(f.tpe, f.tpe, q"from.$fld") + case o: FieldOp.Transfer => + transfer(o.sourceTpe, f.tpe, q"from.$fld") case o: FieldOp.InitializeWithDefault => o.targetField.tpe match { case tpe: TypeRef.Constructor => emptyCollection(tpe) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala index c874d70f2..ec44fdcdb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonComparator.scala @@ -215,32 +215,48 @@ object BaboonComparator { } } - /** Per-step (prev -> last) forward-readability for every type kept under the same - * TypeId in both versions. Absent key = not forward-readable in either format. - * Combines the type's own structural guarantee with a fixpoint over its - * codec-relevant dependencies in the OLD version (the types the old decoder - * actually touches). The two axes propagate independently: the outer UEBA read - * stays in sync only while every nested value is UEBA byte-identical, and the - * outer JSON read survives only while every nested value is JSON-readable. + /** Per-step (prev -> last) forward-readability, keyed by the type's id in the OLD + * version. Absent key = not forward-readable in either format. Combines the + * type's own structural guarantee with a fixpoint over its codec-relevant + * dependencies in the OLD version (the types the old decoder actually touches). + * The two axes propagate independently: the outer UEBA read stays in sync only + * while every nested value is UEBA byte-identical, and the outer JSON read + * survives only while every nested value is JSON-readable. + * + * A type renamed by this step is paired with its new counterpart and keyed by its + * OLD id, because that is the id every reference in the old version points at. It + * therefore still propagates into its hosts -- a nested value carries no type name + * in either format, so renaming it moves no bytes. It gets no forward bound of its + * own: `computeForwardReadable` looks the successor run up by the same old id, + * finds nothing under it in the newer version, and stops the chain. That is the + * right answer, since a top-level payload is identified by the typeId in its + * envelope and no runtime resolves a renamed one. */ private def stepForwardGuarantees(prev: Domain, last: Domain): Map[TypeId, ForwardGuarantee] = { - val kept = prev.defs.meta.nodes.keySet.intersect(last.defs.meta.nodes.keySet) + // declared renames of this step, keyed by the id the old version uses + val renamedTo: Map[TypeId, TypeId] = last.renames.collect { + case (newId, oldId) if prev.defs.meta.nodes.contains(oldId) && last.defs.meta.nodes.contains(newId) => + (oldId: TypeId, newId: TypeId) + } - val own: Map[TypeId, ForwardGuarantee] = kept.map { - id => - val guarantee = (prev.defs.meta.nodes(id), last.defs.meta.nodes(id)) match { + val counterpart: Map[TypeId, TypeId] = + prev.defs.meta.nodes.keySet.intersect(last.defs.meta.nodes.keySet).map(id => (id, id)).toMap ++ renamedTo + + val own: Map[TypeId, ForwardGuarantee] = counterpart.map { + case (oldId, newId) => + val guarantee = (prev.defs.meta.nodes(oldId), last.defs.meta.nodes(newId)) match { case (_: DomainMember.Builtin, _: DomainMember.Builtin) => ForwardGuarantee.identical case (o: DomainMember.User, n: DomainMember.User) => - ownForwardGuarantee(o.defn, n.defn, prev, last) + ownForwardGuarantee(o.defn, n.defn, prev, last, renamedTo) case _ => ForwardGuarantee.none } - (id, guarantee) - }.toMap + (oldId, guarantee) + } // codec-relevant direct dependencies in the OLD version: what the old decoder touches - val deps: Map[TypeId, Set[TypeId]] = kept.map { + val deps: Map[TypeId, Set[TypeId]] = counterpart.keySet.map { id => val d = prev.defs.meta.nodes(id) match { case _: DomainMember.Builtin => Set.empty[TypeId] @@ -287,12 +303,21 @@ object BaboonComparator { * name and is blind to position, so it forgives exactly the opposite set of * changes. Neither subsumes the other. * - * Renames of a TYPE (rather than of a field or an enum member) are not handled - * here: they change the TypeId, so the renamed type is absent from the - * intersection the caller iterates and from its dependents' dependency sets, - * which collapses both axes. That needs rename-aware dependency resolution. + * `renamedTo` maps an old TypeId to the id that replaced it in this step, so a + * reference to a renamed type compares equal to the reference that replaced it. + * Neither format puts a nested value's type name on the wire, so that equality is + * sound for a DTO field type. It is NOT applied to the JSON side of an ADT, whose + * branch name IS the discriminator. */ - private def ownForwardGuarantee(o: Typedef.User, n: Typedef.User, prev: Domain, last: Domain): ForwardGuarantee = { + private def ownForwardGuarantee( + o: Typedef.User, + n: Typedef.User, + prev: Domain, + last: Domain, + renamedTo: Map[TypeId, TypeId], + ): ForwardGuarantee = { + def sameType(oldRef: TypeRef, newRef: TypeRef): Boolean = substituteRenames(oldRef, renamedTo) == newRef + (o, n) match { case (d1: Typedef.Dto, d2: Typedef.Dto) => // `prevName` is carried forward by every later version, so it only denotes a @@ -305,14 +330,14 @@ object BaboonComparator { // field that took the name over from a different field via `was` would hand // the old reader the wrong value, so it does not count. val json = d1.fields.forall { - of => d2.fields.exists(nf => nf.name == of.name && nf.tpe == of.tpe && renamedAtThisStep(nf).isEmpty) + of => d2.fields.exists(nf => nf.name == of.name && sameType(of.tpe, nf.tpe) && renamedAtThisStep(nf).isEmpty) } // UEBA: the position is the identity. A name change at a position is // invisible on the wire, and sound exactly when it is a declared rename of // the field that occupied that position. val prefixAligned = d2.fields.size >= d1.fields.size && d1.fields.zip(d2.fields).forall { - case (of, nf) => of.tpe == nf.tpe && (of.name == nf.name || renamedAtThisStep(nf).contains(of.name)) + case (of, nf) => sameType(of.tpe, nf.tpe) && (of.name == nf.name || renamedAtThisStep(nf).contains(of.name)) } val ueba = if (!prefixAligned) { @@ -362,12 +387,16 @@ object BaboonComparator { val b1 = a1.dataMembers(prev) val b2 = a2.dataMembers(last) - // JSON tags a branch by its name: the new writer must only emit branches - // the old reader already knows. + // JSON tags a branch by its name: the new writer must only emit branches the old + // reader already knows. A renamed branch is a new name on the wire, so renames + // deliberately do NOT resolve here. val json = b2.toSet.subsetOf(b1.toSet) - // UEBA encodes the positional branch index. A branch rename is a type - // rename, excluded above. - ForwardGuarantee(Option.when(b1 == b2)(UebaRead.Full), json) + // UEBA encodes the positional branch index and never the name, so a branch + // renamed in place is invisible to it. + val uebaFull = b1.size == b2.size && b1.zip(b2).forall { + case (ob, nb) => renamedTo.getOrElse(ob, ob) == nb + } + ForwardGuarantee(Option.when(uebaFull)(UebaRead.Full), json) case (f1: Typedef.Foreign, f2: Typedef.Foreign) => // hand-written codecs: only byte-level sameness is derivable @@ -397,6 +426,19 @@ object BaboonComparator { } } + /** `ref` as the old version spells it, rewritten with this step's declared renames. */ + private def substituteRenames(ref: TypeRef, renamedTo: Map[TypeId, TypeId]): TypeRef = ref match { + case TypeRef.Scalar(id) => + renamedTo.get(id) match { + case Some(s: TypeId.Scalar) => TypeRef.Scalar(s) + case _ => ref + } + case c: TypeRef.Constructor => + c.copy(args = c.args.map(a => substituteRenames(a, renamedTo))) + case a: TypeRef.Any => + a.copy(underlying = a.underlying.map(u => substituteRenames(u, renamedTo))) + } + private def renameableNames(domain: Domain, id: TypeId.User): Set[String] = { domain.defs.meta.nodes.get(id) match { case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) => dto.fields.map(_.name.name).toSet @@ -852,21 +894,28 @@ object BaboonComparator { val addedMembers = survivors2.diff(survivors1) val keptMembers = survivors1.intersect(survivors2) + // A field whose type was renamed by this step is unchanged as data: neither format puts a + // nested value's type name on the wire. It stays a KeepField, carrying the old spelling so the + // generated conversion can address the source value and route it through the renamed type's + // own conversion. + val renamedTo: Map[TypeId, TypeId] = changes.renamed.map { case (newId, oldId) => (oldId: TypeId, newId: TypeId) } + def sameType(oldRef: TypeRef, newRef: TypeRef): Boolean = substituteRenames(oldRef, renamedTo) == newRef + val keptFields = keptMembers.map(name => (members1(name), members2(name))) val changedFields = keptFields.filter { case (f1, f2) => - f1.tpe != f2.tpe + !sameType(f1.tpe, f2.tpe) } val unchangedFields = keptFields.filter { case (f1, f2) => - f1.tpe == f2.tpe + sameType(f1.tpe, f2.tpe) }.map { - case (_, f2) => + case (f1, f2) => val directRefs = enquiries.explode(f2.tpe) val modification = figureOutModification(changes, directRefs) - DtoOp.KeepField(f2, modification) + DtoOp.KeepField(f2, f1.tpe, modification) } val renamedFieldOps = renamedFields.values.map { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala index d52ab2ac7..8d3a0dc3e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRules.scala @@ -85,7 +85,7 @@ object BaboonRules { F.pure( DtoConversion( id, - targetDto.fields.map(f => FieldOp.Transfer(f)), + targetDto.fields.map(f => FieldOp.Transfer(f, f.tpe)), Set.empty, targetTpe, ) @@ -121,7 +121,7 @@ object BaboonRules { case None if isRenamed => // Renamed without structural changes - treat all fields as transfers val newDefn = last.defs.meta.nodes(targetTpe).asInstanceOf[DomainMember.User].defn.asInstanceOf[Typedef.Dto] - F.pure(newDefn.fields.map(f => DtoOp.KeepField(f, RefModification.Unchanged)).toList) + F.pure(newDefn.fields.map(f => DtoOp.KeepField(f, f.tpe, RefModification.Unchanged)).toList) case None => F.fail( BaboonIssue.of(EvolutionIssue.UnexpectedDiffType(TypedefDiff.DtoDiff(List.empty), "DTODiff")) @@ -186,7 +186,7 @@ object BaboonRules { } keepFields = ops.collect { - case op: DtoOp.KeepField => FieldOp.Transfer(op.f) + case op: DtoOp.KeepField => FieldOp.Transfer(op.f, op.sourceTpe) } renames = ops.collect { case op: DtoOp.RenameField => op }.toSet diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/Conversion.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/Conversion.scala index db4145d93..1955335ea 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/Conversion.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/Conversion.scala @@ -43,7 +43,8 @@ object Conversion { } object FieldOp { - case class Transfer(targetField: Field) extends FieldOp + /** `sourceTpe` is the field's type as the OLD version spells it; see [[DtoOp.KeepField]]. */ + case class Transfer(targetField: Field, sourceTpe: TypeRef) extends FieldOp case class InitializeWithDefault(targetField: Field) extends FieldOp diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/TypedefDiff.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/TypedefDiff.scala index 34e13be31..43ab594f9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/TypedefDiff.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/model/TypedefDiff.scala @@ -25,7 +25,12 @@ object DtoOp { case class AddField(f: Field) extends DtoOp case class RemoveField(f: Field) extends DtoOp case class ChangeField(f: Field, newType: TypeRef) extends DtoOp - case class KeepField(f: Field, modification: RefModification) extends DtoOp + /** `sourceTpe` is the field's type as the OLD version spells it. It differs from `f.tpe` only + * when the field's type was renamed by this step; neither wire format puts a nested value's type + * name on the wire, so such a field is unchanged as data while still needing the old spelling to + * address the source value. + */ + case class KeepField(f: Field, sourceTpe: TypeRef, modification: RefModification) extends DtoOp case class RenameField(oldField: Field, newField: Field, modification: RefModification) extends DtoOp } diff --git a/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-1.baboon b/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-1.baboon new file mode 100644 index 000000000..e93c8316c --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-1.baboon @@ -0,0 +1,26 @@ +// Evolution fixture: a TYPE RENAME of a nested type, with the host keeping its own name. +// +// Neither wire format puts a nested value's type name on the wire -- UEBA is positional and JSON +// tags a DTO field by the FIELD name, not the type -- so renaming `Leaf` moves no byte of a `Host` +// payload. Two properties depend on that and are pinned here for every backend: +// +// 1. `Host`'s conversion stays auto-derivable, routing the old `Leaf` through the renamed type's +// own conversion. Before rename-aware type equivalence the field read as a type CHANGE and +// every host of a renamed type needed a hand-written conversion. +// 2. `Host` keeps its forward-readability bound across the step (docs/forward-compat.md). +// +// Both conversions are auto-derivable, so this model is safe in the shared model-dir: no stub +// carries a manual conversion for it. + +model rnnested.model + +version "1.0.0" + +data Leaf { + a: i32 +} + +root data Host { + l: Leaf + b: str +} diff --git a/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-2.baboon b/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-2.baboon new file mode 100644 index 000000000..489200ef0 --- /dev/null +++ b/baboon-compiler/src/test/resources/baboon/rename-nested-ok/nested-rename-2.baboon @@ -0,0 +1,17 @@ +model rnnested.model + +version "2.0.0" + +import "1.0.0" { * } without { + Leaf + Host +} + +data Renamed : was[Leaf] { + a: i32 +} + +root data Host { + l: Renamed + b: str +} diff --git a/docs/forward-compat.md b/docs/forward-compat.md index de488434d..0308e025e 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -548,11 +548,13 @@ Two consequences of scheme 2: ## Known gaps -- **ADT branch renames and type renames are not classified.** They change the - TypeId, so the renamed type is absent both from the version intersection the - comparator walks and from its dependents' dependency sets, which collapses - both axes. Field renames and enum member renames keep their owner's TypeId and - are handled. Lifting this needs rename-aware dependency resolution. +- **A renamed type has no forward bound of its own.** Its hosts do (see the + closed gap below), but a top-level payload is identified by the typeId in its + envelope, and no runtime resolves a renamed one: `Domain.renames` is not + emitted into generated metadata at all. Lifting this means publishing the + rename map and resolving typeIds in the decode path of all ten runtimes -- a + metadata addition on the scale of the envelope-v2 work, worth doing only when + a ROOT type actually has to be renamed with old readers still decoding. - **`prefix-any-mode` has no runtime end-to-end test.** The tier requires an appended *fixed-length* field, and every fixed-length scalar is non-defaultable, so such a step needs a hand-written conversion — which the @@ -563,6 +565,22 @@ Two consequences of scheme 2: Closed gaps, kept for the record: +- *A renamed type used to collapse both axes for everything that referenced it*, + and additionally broke conversion derivation for its hosts. Neither format puts + a nested value's type name on the wire -- UEBA is positional, and JSON tags a + DTO field by the FIELD name -- so renaming a nested type moves no byte of its + host's payload. Type equivalence is now rename-aware for one step, which + restores the host's forward bound AND makes its conversion auto-derivable again + (it routes the old value through the renamed type's own conversion, instead of + reading as an incompatible type change and forcing a hand-written conversion on + every host). The renamed type itself still gets no bound, which falls out of + keying the step by the OLD id: hosts resolve their dependency while the chain + finds no successor under that id. For an ADT the two axes part company -- + renames resolve on the UEBA side, whose discriminator is the branch INDEX, and + deliberately not on the JSON side, whose discriminator is the branch NAME, so a + branch rename yields `ueba-identical`. Fixtures: `rename-nested-ok` (compiled by + all nine codegen lanes) and `RenameSoundnessTest`. + - *Missing-tier handling* used to differ: Python, Swift and Rust fell back to the byte-identical bound when `baboonMinReaderVersions` lacked a tier, the other seven runtimes failed fast. All ten now fail fast (encoder failure), diff --git a/test/sw-stub/Package.swift b/test/sw-stub/Package.swift index 4f8acf343..d2b815ec3 100644 --- a/test/sw-stub/Package.swift +++ b/test/sw-stub/Package.swift @@ -96,6 +96,16 @@ let package = Package( dependencies: ["BaboonRuntime", "Fwde2eRename_v1_0_0"], path: "Sources/Fwde2eRename" ), + .target( + name: "RnnestedModel_v1_0_0", + dependencies: ["BaboonRuntime"], + path: "Sources/RnnestedModel_v1_0_0" + ), + .target( + name: "RnnestedModel", + dependencies: ["BaboonRuntime", "RnnestedModel_v1_0_0"], + path: "Sources/RnnestedModel" + ), // ----- Single-version fixtures ---------------------------------------------------------- .target( @@ -342,6 +352,11 @@ let package = Package( dependencies: ["BaboonRuntime", "Fwde2eRename"], path: "Tests/BaboonTests/Fwde2eRename" ), + .testTarget( + name: "RnnestedModelTests", + dependencies: ["BaboonRuntime", "RnnestedModel"], + path: "Tests/BaboonTests/RnnestedModel" + ), .testTarget( name: "MyOkExtractedContractsTests", dependencies: ["BaboonRuntime", "MyOkExtractedContracts"], From fc5030e109a68cd5ef9a6ef77d7ce983e7334b3f Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 10:51:58 +0100 Subject: [PATCH 28/48] fix(scheme): render declared type renames; pin rename invariants with tests Three follow-ups to 65c4513a. (A) `BaboonSchemeRenderer` dropped type-level renames. It emits field- and enum-member-level `was`, but a TYPE rename lives in `Domain.renames` rather than on the member, and nothing read it -- so rendering a version turned a rename into a remove+add for anyone who fed the scheme back in, changing that model's conversions and forward-compat. `SchemeRoundtripTest` could not catch it: it compares two renders of the same model, so a field both of them omit stays invisible. `renderClauses` now emits `was[...]` ahead of the derivations, and the new test asserts the clause survives AND that the reloaded model carries the rename. Fail-first verified by reverting the renderer alone. The same function turned up a second hole: `foreignEnclosed` parses the same `: ...` clause list as every other type header, but `renderForeign` never emitted one, so a `foreign` lost its `derived[...]` too. Same fix. (B) `FieldOp.Transfer.sourceTpe` can be ignored by a consumer without any compile error. Pinned at the model level: the transfer op must address the source by its OLD spelling and build the target with the NEW one. The translator half -- each backend actually using it -- stays guarded by the nine codegen lanes compiling `rename-nested-ok`, which is a complete guard for this class: a renamed type's new name never exists in the old version, so ignoring `sourceTpe` always yields a dangling reference that fails to compile. A nine-backend emission test would only cut detection latency, at the cost of duplicating the per-target child-injector wiring that lives in `Baboon.processTarget`. (C) A rename mid-chain was reasoned about but never run. With the rename at step 2 of 3, a 1.0.0 reader must still read 1.1.0's `Leaf` -- the typeId is unchanged there -- and must not claim 1.2.0, while the host spans the whole chain. Now pinned rather than inferred from the keying. JVM suite 880/880; full gate `mdl --seq :build :test` green, 200/200 lanes. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon/tests/RenameSoundnessTest.scala | 74 +++++++++++++++++++ .../baboon/tests/SchemeRoundtripTest.scala | 22 ++++++ .../baboon/scheme/BaboonSchemeRenderer.scala | 44 +++++++---- 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala index c7725e1b4..59733a12b 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/RenameSoundnessTest.scala @@ -437,6 +437,80 @@ abstract class RenameSoundnessTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul } } + "carry the old spelling of a renamed field type on the transfer op" in { + (manager: BaboonFamilyManager[F]) => + // Guards the compiler half of the rename-aware transfer. The translator half -- each + // backend actually USING `sourceTpe` -- is guarded by the nine codegen lanes compiling + // `rename-nested-ok`: a renamed type's new name never exists in the old version, so + // ignoring `sourceTpe` always yields a dangling reference that fails to compile. + val v1 = """model rsnd.transfersrc + |version "1.0.0" + |data Leaf { a: i32 } + |root data Host { l: Leaf b: str } + |""".stripMargin + val v2 = """model rsnd.transfersrc + |version "1.1.0" + |import "1.0.0" { * } without { Leaf Host } + |data Renamed : was[Leaf] { a: i32 } + |root data Host { l: Renamed b: str } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2).map { + family => + val c = dtoConversion(family, "rsnd.transfersrc", "1.0.0", "1.1.0", "Host") + + val sources = c.ops.collect { + case t: FieldOp.Transfer => (t.targetField.name.name, t.sourceTpe.toString) + }.toSet + assert( + sources == Set(("l", "rsnd.transfersrc/:#Leaf"), ("b", "#str")), + s"transfer ops must address the source by its OLD spelling; got $sources", + ) + + val targets = c.ops.collect { + case t: FieldOp.Transfer => (t.targetField.name.name, t.targetField.tpe.toString) + }.toSet + assert( + targets == Set(("l", "rsnd.transfersrc/:#Renamed"), ("b", "#str")), + s"transfer ops must build the target with its NEW spelling; got $targets", + ) + } + } + + "end a renamed type's run at the step that renames it, not before" in { + (manager: BaboonFamilyManager[F]) => + // The rename happens mid-chain: a 1.0.0 reader can still decode a 1.1.0 `Leaf`, because the + // typeId is unchanged there, and must not claim 1.2.0, where the envelope would name a type + // it has never heard of. The host spans the whole chain either way. + val v1 = """model rsnd.midchain + |version "1.0.0" + |data Leaf { a: i32 } + |root data Host { l: Leaf b: str } + |""".stripMargin + val v2 = """model rsnd.midchain + |version "1.1.0" + |import "1.0.0" { * } + |""".stripMargin + val v3 = """model rsnd.midchain + |version "1.2.0" + |import "1.1.0" { * } without { Leaf Host } + |data Renamed : was[Leaf] { a: i32 } + |root data Host { l: Renamed b: str } + |""".stripMargin + load(manager, "v1.baboon" -> v1, "v2.baboon" -> v2, "v3.baboon" -> v3).map { + family => + assert( + forwardRun(family, "rsnd.midchain", "1.0.0", "Leaf") == + List(("1.0.0", "identical"), ("1.1.0", "identical")), + "a 1.0.0 reader reads 1.1.0's Leaf but must not claim 1.2.0", + ) + assert( + forwardRun(family, "rsnd.midchain", "1.0.0", "Host") == + List(("1.0.0", "identical"), ("1.1.0", "identical"), ("1.2.0", "identical")) + ) + assert(forwardRun(family, "rsnd.midchain", "1.1.0", "Leaf") == List(("1.1.0", "identical"))) + } + } + "reject a field rename from a name no earlier version ever had" in { (manager: BaboonFamilyManager[F]) => val v1 = """model rsnd.fieldtypo diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SchemeRoundtripTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SchemeRoundtripTest.scala index c76a79edf..2bddc8b09 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SchemeRoundtripTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/SchemeRoundtripTest.scala @@ -37,6 +37,28 @@ abstract class SchemeRoundtripTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModul _ <- checkRoundtrip(renderer, manager, family, Pkg(NEList("rename", "ns")), Version.parse("2.0.0")) } yield () } + + "preserve a declared type rename through a render/reload cycle" in { + (loader: BaboonLoader[F], manager: BaboonFamilyManager[F], renderer: BaboonSchemeRenderer) => + // A type rename lives in `Domain.renames`, not on the member, and used to be dropped in + // silence: a rendered scheme turned the rename into a remove+add for anyone who fed it back + // in. The idempotence check above cannot see that on its own -- both renders omitted it -- + // so the clause is asserted explicitly here, and the reloaded model must carry the rename. + val pkg = Pkg(NEList("rename", "ns")) + val version = Version.parse("2.0.0") + for { + family <- loadRenamePkg(loader) + rendered = renderer + .render(family, pkg, version).fold(e => throw new AssertionError(s"render failed: $e"), identity) + _ = assert(rendered.contains("was["), s"rendered scheme lost the type rename:\n$rendered") + reloaded <- manager.load( + List(BaboonParser.Input(FSPath.parse(NEString.unsafeFrom("rt.baboon")), rendered)) + ) + } yield { + val domain2 = reloaded.domains.toMap(pkg).versions.toMap(version) + assert(domain2.renames.nonEmpty, s"reloaded model has no renames; rendered scheme was:\n$rendered") + } + } } private def checkRoundtrip( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/scheme/BaboonSchemeRenderer.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/scheme/BaboonSchemeRenderer.scala index 579c1684f..566d14e28 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/scheme/BaboonSchemeRenderer.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/scheme/BaboonSchemeRenderer.scala @@ -264,20 +264,29 @@ object BaboonSchemeRenderer { val rootPrefix = if (member.root) "root " else "" member.defn match { - case dto: Typedef.Dto => renderDto(sb, dto, member, rootPrefix, indent) + case dto: Typedef.Dto => renderDto(sb, dto, member, rootPrefix, domain, indent) case adt: Typedef.Adt => renderAdt(sb, adt, member, rootPrefix, userNodes, reverseDeps, domain, indent) - case e: Typedef.Enum => renderEnum(sb, e, member, rootPrefix, indent) - case foreign: Typedef.Foreign => renderForeign(sb, foreign, rootPrefix, indent) - case contract: Typedef.Contract => renderContract(sb, contract, member, rootPrefix, indent) - case service: Typedef.Service => renderService(sb, service, member, rootPrefix, userNodes, indent) + case e: Typedef.Enum => renderEnum(sb, e, member, rootPrefix, domain, indent) + case foreign: Typedef.Foreign => renderForeign(sb, foreign, member, rootPrefix, domain, indent) + case contract: Typedef.Contract => renderContract(sb, contract, member, rootPrefix, domain, indent) + case service: Typedef.Service => renderService(sb, service, member, rootPrefix, userNodes, domain, indent) } } - private def renderDerivations(member: DomainMember.User): String = { + /** The `: ...` clause list of a type header: a declared rename first, then derivations. + * + * A type rename lives in `domain.renames`, not on the member, so rendering only the + * derivations dropped it silently and turned a rename into a remove+add for anyone who fed + * the rendered scheme back in. `SchemeRoundtripTest` cannot catch that on its own: it compares + * two renders of the same model, so a field both of them omit stays invisible. + */ + private def renderClauses(member: DomainMember.User, domain: Domain): String = { + val wasClause = domain.renames.get(member.id).map(oldId => s"was[${renderTypeRef(TypeRef.Scalar(oldId))}]") val derivations = member.derivations.toList.collect { case io.septimalmind.baboon.parser.model.RawMemberMeta.Derived(id) => s"derived[$id]" }.sorted - if (derivations.nonEmpty) s" : ${derivations.mkString(", ")}" else "" + val clauses = wasClause.toList ++ derivations + if (clauses.nonEmpty) s" : ${clauses.mkString(", ")}" else "" } private def renderDto( @@ -285,9 +294,10 @@ object BaboonSchemeRenderer { dto: Typedef.Dto, member: DomainMember.User, rootPrefix: String, + domain: Domain, indent: String, ): Unit = { - val derivations = renderDerivations(member) + val derivations = renderClauses(member, domain) sb.append(s"$indent${rootPrefix}data ${dto.id.name.name}$derivations {\n") dto.contracts.foreach { contractId => @@ -311,7 +321,7 @@ object BaboonSchemeRenderer { domain: Domain, indent: String, ): Unit = { - val derivations = renderDerivations(member) + val derivations = renderClauses(member, domain) sb.append(s"$indent${rootPrefix}adt ${adt.id.name.name}$derivations {\n") adt.contracts.foreach { contractId => @@ -335,9 +345,10 @@ object BaboonSchemeRenderer { e: Typedef.Enum, member: DomainMember.User, rootPrefix: String, + domain: Domain, indent: String, ): Unit = { - val derivations = renderDerivations(member) + val derivations = renderClauses(member, domain) sb.append(s"$indent${rootPrefix}enum ${e.id.name.name}$derivations {\n") e.members.toList.foreach { m => @@ -351,10 +362,15 @@ object BaboonSchemeRenderer { private def renderForeign( sb: StringBuilder, foreign: Typedef.Foreign, + member: DomainMember.User, rootPrefix: String, + domain: Domain, indent: String, ): Unit = { - sb.append(s"$indent${rootPrefix}foreign ${foreign.id.name.name} {\n") + // `foreignEnclosed` parses the same `: ...` clause list as every other type header, so a + // foreign carrying `was[...]` or `derived[...]` lost it on render. + val derivations = renderClauses(member, domain) + sb.append(s"$indent${rootPrefix}foreign ${foreign.id.name.name}$derivations {\n") foreign.runtimeMapping.foreach { rtRef => sb.append(s"$indent rt = ${renderTypeRef(rtRef)}\n") @@ -380,9 +396,10 @@ object BaboonSchemeRenderer { contract: Typedef.Contract, member: DomainMember.User, rootPrefix: String, + domain: Domain, indent: String, ): Unit = { - val derivations = renderDerivations(member) + val derivations = renderClauses(member, domain) sb.append(s"$indent${rootPrefix}contract ${contract.id.name.name}$derivations {\n") contract.contracts.foreach { contractId => @@ -402,6 +419,7 @@ object BaboonSchemeRenderer { member: DomainMember.User, rootPrefix: String, userNodes: Map[TypeId.User, DomainMember.User], + domain: Domain, indent: String, ): Unit = { val serviceOwnerPath = service.id.owner match { @@ -409,7 +427,7 @@ object BaboonSchemeRenderer { case Owner.Ns(path) => path.toSeq case _ => Seq.empty[TypeName] } - val derivations = renderDerivations(member) + val derivations = renderClauses(member, domain) sb.append(s"$indent${rootPrefix}service ${service.id.name.name}$derivations {\n") service.methods.foreach { method => From 7d0609db989c888e7b90728bbecbd2930a862919 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 10:55:56 +0100 Subject: [PATCH 29/48] docs(forward-compat): record the renamed-type top-level bound as a deliberate non-goal Links the decision record (#94) and corrects the runtime count (nine, not ten -- my own inconsistency, every other reference in the file says nine). Co-Authored-By: Claude Opus 5 (1M context) --- docs/forward-compat.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/forward-compat.md b/docs/forward-compat.md index 0308e025e..caa480c81 100644 --- a/docs/forward-compat.md +++ b/docs/forward-compat.md @@ -548,13 +548,19 @@ Two consequences of scheme 2: ## Known gaps -- **A renamed type has no forward bound of its own.** Its hosts do (see the - closed gap below), but a top-level payload is identified by the typeId in its - envelope, and no runtime resolves a renamed one: `Domain.renames` is not - emitted into generated metadata at all. Lifting this means publishing the - rename map and resolving typeIds in the decode path of all ten runtimes -- a - metadata addition on the scale of the envelope-v2 work, worth doing only when - a ROOT type actually has to be renamed with old readers still decoding. +- **A renamed type has no forward bound of its own** -- deliberately, and we do + NOT intend to change this (https://github.com/7mind/baboon/issues/94). Its + hosts do (see the closed gap below), but a top-level payload is identified by + the typeId in its envelope, and no runtime resolves a renamed one: + `Domain.renames` is not emitted into generated metadata at all. Publishing a + bound would be an overclaim -- it would promise a writer that an old reader can + decode something that reader cannot even identify. Lifting it would mean + emitting the rename map and resolving typeIds through the rename chain in the + decode path of all nine runtimes, a permanent metadata surface on the scale of + the envelope-v2 work, for the single case of renaming a ROOT type while older + readers must keep decoding it. The renamed type's run stops at the renaming + step, pinned by `RenameSoundnessTest`; silently extending it would hand writers + an unusable bound. - **`prefix-any-mode` has no runtime end-to-end test.** The tier requires an appended *fixed-length* field, and every fixed-length scalar is non-defaultable, so such a step needs a hand-written conversion — which the From 506c097d0a9375c36d53520e61922bae024a03dd Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 12:03:04 +0100 Subject: [PATCH 30/48] refactor(translators): resolve the current domain from the subcontext, not by threading The per-domain subcontexts were already in place and every submodule-bound component injected `Domain`/`BaboonEvolution` correctly. The threading was one layer down: those components handed the CURRENT domain to a stateless outer helper on every call, because `*TypeTranslator` is bound outside the subcontext and takes `(domain, evo)` per method. 152 call sites in subcontext-scoped code passed a domain the caller had already been injected with. Two facts, both checked rather than assumed, make the current domain safe to resolve from the subcontext: - The outer per-family users (`*BaboonTranslator`, `*McpServerGenerator`) call only `to*Pkg`-style methods, which take no `Domain`. Nothing outside a domain scope needs the domain-taking API. - The genuine exceptions are the source-side renders in conversions -- `srcDom` (16 sites) and `higherDom` (1) -- already distinguished by `@Id("source")` vs `@Id("current")`. Those keep the explicit-domain API unchanged. So each backend gains a `*DomainTypes` facade bound INSIDE its `makeSubcontext(...).withSubmodule`, closing over the injected current `Domain` and `BaboonEvolution` and forwarding to the untouched translator. Domain-free helpers (`escapeSwiftKeyword`, `toSnakeCase`, ...) stay where they are, so the facade carries only what the subcontext actually makes ambient. Across nine backends: ~413 call sites shed their two trailing arguments, and the compiler then identified 12 now-unused `evo` constructor parameters and 5 `makeFixture(domain, evolution)` parameter pairs, all removed. The nine `*ConversionTranslator.Factory` bindings deliberately stay factories: conversions are built per source-version pair from outside any domain scope, so a subcontext would turn five named arguments into five `.provide(...)` calls at the same site -- no threading removed, and the `@Id` disambiguation lost. Pure refactor, verified as such: generated output for all nine backends over the shared model-dir is byte-identical to the pre-refactor baseline (4512 files), checked both before and after the parameter cleanup. Full gate `mdl --seq :build :test` green, 200/200 lanes; JVM suite 877 passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/septimalmind/baboon/BaboonModule.scala | 9 ++++ .../csharp/CSCodecFixtureTranslator.scala | 12 ++--- .../csharp/CSCodecTestsTranslator.scala | 11 ++--- .../translator/csharp/CSDefnTranslator.scala | 41 ++++++++-------- .../translator/csharp/CSDomainTreeTools.scala | 7 +-- .../translator/csharp/CSDomainTypes.scala | 30 ++++++++++++ .../csharp/CSJsonCodecGenerator.scala | 19 +++---- .../csharp/CSServiceWiringTranslator.scala | 8 +-- .../csharp/CSUEBACodecGenerator.scala | 9 ++-- .../dart/DtCodecFixtureTranslator.scala | 10 ++-- .../dart/DtCodecTestsTranslator.scala | 13 +++-- .../translator/dart/DtDefnTranslator.scala | 31 ++++++------ .../translator/dart/DtDomainTreeTools.scala | 7 +-- .../translator/dart/DtDomainTypes.scala | 22 +++++++++ .../dart/DtJsonCodecGenerator.scala | 19 +++---- .../dart/DtServiceWiringTranslator.scala | 8 +-- .../dart/DtUEBACodecGenerator.scala | 11 +++-- .../java/JvCodecFixtureTranslator.scala | 6 +-- .../java/JvCodecTestsTranslator.scala | 11 ++--- .../translator/java/JvDefnTranslator.scala | 35 ++++++------- .../translator/java/JvDomainTreeTools.scala | 9 ++-- .../translator/java/JvDomainTypes.scala | 26 ++++++++++ .../java/JvJsonCodecGenerator.scala | 47 +++++++----------- .../java/JvServiceWiringTranslator.scala | 11 +++-- .../java/JvUEBACodecGenerator.scala | 13 ++--- .../kotlin/KtCodecFixtureTranslator.scala | 3 +- .../kotlin/KtCodecTestsTranslator.scala | 11 ++--- .../translator/kotlin/KtDefnTranslator.scala | 33 +++++++------ .../translator/kotlin/KtDomainTreeTools.scala | 9 ++-- .../translator/kotlin/KtDomainTypes.scala | 22 +++++++++ .../kotlin/KtJsonCodecGenerator.scala | 19 +++---- .../kotlin/KtServiceWiringTranslator.scala | 7 +-- .../kotlin/KtUEBACodecGenerator.scala | 11 +++-- .../python/PyCodecFixtureTranslator.scala | 3 +- .../python/PyCodecTestTranslator.scala | 3 +- .../translator/python/PyDefnTranslator.scala | 33 +++++++------ .../translator/python/PyDomainTreeTools.scala | 5 +- .../translator/python/PyDomainTypes.scala | 24 +++++++++ .../python/PyJsonCodecGenerator.scala | 11 +++-- .../python/PyServiceWiringTranslator.scala | 9 ++-- .../rust/RsCodecFixtureTranslator.scala | 10 ++-- .../translator/rust/RsDefnTranslator.scala | 49 ++++++++++--------- .../translator/rust/RsDomainTypes.scala | 20 ++++++++ .../rust/RsJsonCodecGenerator.scala | 1 - .../rust/RsServiceWiringTranslator.scala | 13 ++--- .../rust/RsUEBACodecGenerator.scala | 7 +-- .../scl/ScCodecFixtureTranslator.scala | 4 +- .../scl/ScCodecTestsTranslator.scala | 12 ++--- .../translator/scl/ScDefnTranslator.scala | 42 ++++++++-------- .../translator/scl/ScDomainTreeTools.scala | 10 ++-- .../baboon/translator/scl/ScDomainTypes.scala | 24 +++++++++ .../translator/scl/ScJsonCodecGenerator.scala | 22 ++++----- .../scl/ScServiceWiringTranslator.scala | 13 +++-- .../translator/scl/ScUEBACodecGenerator.scala | 10 ++-- .../swift/SwCodecFixtureTranslator.scala | 16 +++--- .../swift/SwCodecTestsTranslator.scala | 15 +++--- .../translator/swift/SwDefnTranslator.scala | 39 ++++++++------- .../translator/swift/SwDomainTreeTools.scala | 7 +-- .../translator/swift/SwDomainTypes.scala | 24 +++++++++ .../swift/SwJsonCodecGenerator.scala | 17 ++++--- .../swift/SwServiceWiringTranslator.scala | 24 ++++----- .../swift/SwUEBACodecGenerator.scala | 7 +-- .../typescript/TsCodecFixtureTranslator.scala | 16 +++--- .../typescript/TsCodecTestsTranslator.scala | 5 +- .../typescript/TsDefnTranslator.scala | 43 ++++++++-------- .../typescript/TsDomainTreeTools.scala | 3 +- .../translator/typescript/TsDomainTypes.scala | 26 ++++++++++ .../typescript/TsJsonCodecGenerator.scala | 26 +++++----- .../TsServiceWiringTranslator.scala | 33 +++++++------ .../typescript/TsUEBACodecGenerator.scala | 14 +++--- 70 files changed, 711 insertions(+), 469 deletions(-) create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTypes.scala diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala index a5bc27f1c..033c5f41d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala @@ -88,6 +88,7 @@ class BaboonCommonCSModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { make[CSCodecTestsTranslator].from[CSCodecTestsTranslator.Impl] make[CSCodecFixtureTranslator].from[CSRandomMethodTranslatorImpl] make[CSServiceWiringTranslator].from[CSServiceWiringTranslator.Impl] + make[CSDomainTypes] make[CSDomainTreeTools].from[CSDomainTreeTools.CSDomainTreeToolsImpl] many[CSCodecTranslator] @@ -119,6 +120,7 @@ class BaboonCommonScModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[ScDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[ScDomainTypes] make[ScDomainTreeTools].from[ScDomainTreeTools.ScDomainTreeToolsImpl] make[ScDefnTranslator[F]].from[ScDefnTranslator.ScDefnTranslatorImpl[F]] make[ScCodecFixtureTranslator].from[ScCodecFixtureTranslator.ScRandomMethodTranslatorImpl] @@ -152,6 +154,7 @@ class BaboonCommonPyModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { make[PyDefnTranslator[F]].from[PyDefnTranslatorImpl[F]] + make[PyDomainTypes] make[PyDomainTreeTools].from[PyDomainTreeTools.PyDomainTreeToolsImpl] make[PyCodecFixtureTranslator].from[PyCodecFixtureTranslator.PyCodecFixtureTranslatorImpl] make[PyCodecTestTranslator].from[PyCodecTestTranslator.PyCodecTestTranslatorImpl] @@ -185,6 +188,7 @@ class BaboonCommonRsModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { make[RsDefnTranslator[F]].from[RsDefnTranslator.RsDefnTranslatorImpl[F]] + make[RsDomainTypes] make[RsDomainTreeTools].from[RsDomainTreeTools.RsDomainTreeToolsImpl] make[RsCodecFixtureTranslator].from[RsCodecFixtureTranslator.RsCodecFixtureTranslatorImpl] make[RsCodecTestsTranslator].from[RsCodecTestsTranslator.Impl] @@ -221,6 +225,7 @@ class BaboonCommonTsModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { make[TsServiceWiringTranslator].from[TsServiceWiringTranslator.Impl] make[TsCodecFixtureTranslator].from[TsCodecFixtureTranslator.TsCodecFixtureTranslatorImpl] make[TsCodecTestsTranslator].from[TsCodecTestsTranslator.Impl] + make[TsDomainTypes] make[TsDomainTreeTools].from[TsDomainTreeTools.TsDomainTreeToolsImpl] many[TsCodecTranslator] .add[TsJsonCodecGenerator] @@ -249,6 +254,7 @@ class BaboonCommonKtModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[KtDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[KtDomainTypes] make[KtDomainTreeTools].from[KtDomainTreeTools.KtDomainTreeToolsImpl] make[KtDefnTranslator[F]].from[KtDefnTranslator.KtDefnTranslatorImpl[F]] make[KtCodecFixtureTranslator].from[KtCodecFixtureTranslator.Impl] @@ -282,6 +288,7 @@ class BaboonCommonJvModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[JvDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[JvDomainTypes] make[JvDomainTreeTools].from[JvDomainTreeTools.JvDomainTreeToolsImpl] make[JvDefnTranslator[F]].from[JvDefnTranslator.JvDefnTranslatorImpl[F]] make[JvCodecFixtureTranslator].from[JvCodecFixtureTranslator.Impl] @@ -314,6 +321,7 @@ class BaboonCommonDtModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[DtDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DtDomainTypes] make[DtDomainTreeTools].from[DtDomainTreeTools.DtDomainTreeToolsImpl] make[DtDefnTranslator[F]].from[DtDefnTranslator.DtDefnTranslatorImpl[F]] make[DtCodecFixtureTranslator].from[DtCodecFixtureTranslator.Impl] @@ -346,6 +354,7 @@ class BaboonCommonSwModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[SwDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[SwDomainTypes] make[SwDomainTreeTools].from[SwDomainTreeTools.SwDomainTreeToolsImpl] make[SwDefnTranslator[F]].from[SwDefnTranslator.SwDefnTranslatorImpl[F]] make[SwCodecFixtureTranslator].from[SwCodecFixtureTranslator.Impl] diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala index d3e686df6..42581e359 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala @@ -15,10 +15,10 @@ trait CSCodecFixtureTranslator { object CSCodecFixtureTranslator { final class CSRandomMethodTranslatorImpl( translator: CSTypeTranslator, + domainTypes: CSDomainTypes, enquiries: BaboonEnquiries, domain: Domain, lineage: BaboonLineage, - evo: BaboonEvolution, csTypeInfo: CSTypeInfo, ) extends CSCodecFixtureTranslator { @@ -55,7 +55,7 @@ object CSCodecFixtureTranslator { private case object FixJson extends FixtureFormat private def doTranslateDto(dto: Typedef.Dto): TextTree[CSValue] = { - val fullType = translator.asCsType(dto.id, domain, evo) + val fullType = domainTypes.asCsType(dto.id) def body(format: FixtureFormat): TextTree[CSValue] = { val generatedFields = dto.fields.map(f => genType(f.tpe, format)) @@ -225,9 +225,9 @@ object CSCodecFixtureTranslator { def render(tpe: TypeRef): TextTree[CSValue] = { BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Cs) match { case TypeRef.Constructor(Builtins.opt, args) => q"${render(args.head)}?" - case TypeRef.Constructor(Builtins.map, args) => q"${translator.asCsType(Builtins.map, domain, evo)}<${render(args.head)}, ${render(args.last)}>" - case TypeRef.Constructor(id, args) => q"${translator.asCsType(id, domain, evo)}<${render(args.head)}>" - case TypeRef.Scalar(id) => q"${translator.asCsType(id, domain, evo)}" + case TypeRef.Constructor(Builtins.map, args) => q"${domainTypes.asCsType(Builtins.map)}<${render(args.head)}, ${render(args.last)}>" + case TypeRef.Constructor(id, args) => q"${domainTypes.asCsType(id)}<${render(args.head)}>" + case TypeRef.Scalar(id) => q"${domainTypes.asCsType(id)}" // Mirrors `CSTypeTranslator.asCsRef` — a collection element of type `any` is `AnyOpaque`. case _: TypeRef.Any => q"$baboonAnyOpaque" } @@ -260,7 +260,7 @@ object CSCodecFixtureTranslator { case TypeId.Builtins.bit => q"$baboonFixture.NextBoolean()" - case id: TypeId.User if enquiries.isEnum(tpe, domain) => q"$baboonFixture.NextRandomEnum<${translator.asCsType(id, domain, evo).fullyQualified}>()" + case id: TypeId.User if enquiries.isEnum(tpe, domain) => q"$baboonFixture.NextRandomEnum<${domainTypes.asCsType(id).fullyQualified}>()" case TypeId.User(_, _, name) => // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01 (C# analog). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala index 3a2f6672c..a2f291280 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala @@ -21,6 +21,7 @@ object CSCodecTestsTranslator { final class Impl( codecs: Set[CSCodecTranslator], typeTranslator: CSTypeTranslator, + domainTypes: CSDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: CSTarget, @@ -56,8 +57,8 @@ object CSCodecTestsTranslator { // `AnyOpaqueUeba`-bearing fixture (`Random`); JSON codec test uses the `AnyOpaqueJson`-bearing // fixture (`RandomJson`). Each fixture matches its codec's native any-field branch so round- // trip avoids cross-format conversion and never needs `BaboonCodecContext.WithFacade`. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { case jsonCodec: CSJsonCodecGenerator => @@ -111,16 +112,14 @@ object CSCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[CSValue] = { val randomMethod = if (useJsonAny) "RandomJson" else "Random" val randomAllMethod = if (useJsonAny) "RandomAllJson" else "RandomAll" definition.defn match { case e: Typedef.Enum => q"var fixture = $baboonFixture.NextRandomEnum<${escapeCsKeyword(e.id.name.name)}>();" - case _: Typedef.Adt => q"var fixtures = ${typeTranslator.csFixtureRef(definition.id, domain, evolution)}.$randomAllMethod();" - case _ => q"var fixture = ${typeTranslator.csFixtureRef(definition.id, domain, evolution)}.$randomMethod();" + case _: Typedef.Adt => q"var fixtures = ${domainTypes.csFixtureRef(definition.id)}.$randomAllMethod();" + case _ => q"var fixture = ${domainTypes.csFixtureRef(definition.id)}.$randomMethod();" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala index 4da8254c4..5481e046e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala @@ -64,6 +64,7 @@ object CSDefnTranslator { class CSDefnTranslatorImpl[F[+_, +_]: Applicative2 /* This impl has no errors right now */ ]( target: CSTarget, trans: CSTypeTranslator, + domainTypes: CSDomainTypes, csTrees: CSTreeTools, csDomTrees: CSDomainTreeTools, csFiles: CSFileTools, @@ -117,7 +118,7 @@ object CSDefnTranslator { val wiringOutput = wiringTranslator .translate(defn).map { wiringTree => - val srcRef = trans.asCsTypeKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(defn.id) val ns = srcRef.pkg.parts Output( getOutputPath(defn, suffix = Some(".Wiring")), @@ -131,7 +132,7 @@ object CSDefnTranslator { val clientOutput = wiringTranslator .translateClient(defn).map { clientTree => - val srcRef = trans.asCsTypeKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(defn.id) val ns = srcRef.pkg.parts Output( getOutputPath(defn, suffix = Some("_Client")), @@ -232,8 +233,8 @@ object CSDefnTranslator { } } - val csTypeRef = trans.asCsType(defn.id, domain, evo) - val srcRef = trans.asCsTypeKeepForeigns(defn.id, domain, evo) + val csTypeRef = domainTypes.asCsType(defn.id) + val srcRef = domainTypes.asCsTypeKeepForeigns(defn.id) val repr = makeRepr(defn, csTypeRef, isLatestVersion) @@ -308,7 +309,7 @@ object CSDefnTranslator { defn.defn match { case contract: Typedef.Contract => val methods = renderContractFields(contract.fields).join("\n") - val refs = contract.contracts.map(t => q"${trans.asCsType(t, domain, evo)}") ++ List(q"$genMarker") + val refs = contract.contracts.map(t => q"${domainTypes.asCsType(t)}") ++ List(q"$genMarker") val parents = makeParents(refs) DefnRepr( @@ -321,7 +322,7 @@ object CSDefnTranslator { case dto: Typedef.Dto => val outs = dto.fields.map { f => - val tpe = trans.asCsRef(f.tpe, domain, evo) + val tpe = domainTypes.asCsRef(f.tpe) val mname = escapeCsKeyword(s"${f.name.name.capitalize}") (mname, tpe, f) } @@ -346,10 +347,10 @@ object CSDefnTranslator { csTrees.renderParamDocs(mname, f.docs, "") }.mkString - val contractParents = dto.contracts.toSeq.map(c => q"${trans.asCsType(c, domain, evo)}") + val contractParents = dto.contracts.toSeq.map(c => q"${domainTypes.asCsType(c)}") val adtParents = dto.id.owner match { - case Owner.Adt(id) => Seq(q"${trans.asCsType(id, domain, evo)}", q"$iBaboonAdtMemberMeta") + case Owner.Adt(id) => Seq(q"${domainTypes.asCsType(id)}", q"$iBaboonAdtMemberMeta") case _ => Seq.empty } @@ -450,14 +451,14 @@ object CSDefnTranslator { ) case adt: Typedef.Adt => - val allParents = Seq(q"$genMarker") ++ adt.contracts.map(t => q"${trans.asCsType(t, domain, evo)}") + val allParents = Seq(q"$genMarker") ++ adt.contracts.map(t => q"${domainTypes.asCsType(t)}") val parents = makeParents(allParents.toList) val allFields = enquiries.unfold(domain, adt.contracts) val abstractFields = allFields.map { f => - val tpe = trans.asCsRef(f.tpe, domain, evo) + val tpe = domainTypes.asCsRef(f.tpe) val mname = escapeCsKeyword(s"${f.name.name.capitalize}") // todo: dedup q"public abstract $tpe $mname { get; init; }" }.join("\n") @@ -507,7 +508,7 @@ object CSDefnTranslator { } val methods = service.methods.map { m => - val plan = new ServiceMethodPlan(m, tpe => trans.asCsRef(tpe, domain, evo), resolved, CSTypes.escapeCsKeyword(m.name.name.capitalize)) + val plan = new ServiceMethodPlan(m, tpe => domainTypes.asCsRef(tpe), resolved, CSTypes.escapeCsKeyword(m.name.name.capitalize)) val out = plan.output val err = plan.error val csFqName: CSValue => String = { @@ -559,7 +560,7 @@ object CSDefnTranslator { case None => q"" case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => q"" case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.asCsTypeKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(f.id) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val codecFqn = s"${srcRef.pkg.parts.mkString(".")}.$hostName" @@ -598,7 +599,7 @@ object CSDefnTranslator { * fixture, and test emitters so all three agree on the type's location. */ private def wrapInContainer(defn: DomainMember.User, srcRef: CSValue.CSType, tree: TextTree[CSValue]): TextTree[CSValue] = { - trans.serviceMethodContainers(defn.id, domain, evo) match { + domainTypes.serviceMethodContainers(defn.id) match { case Some((nsPrefix, classes)) => val nested = classes.foldRight(tree) { (cls, acc) => @@ -619,14 +620,14 @@ object CSDefnTranslator { * types keep the fixture beside the type's own namespace. */ private def wrapFixtureNs(defn: DomainMember.User, srcRef: CSValue.CSType, tree: TextTree[CSValue]): TextTree[CSValue] = { - trans.serviceMethodFixtureNs(defn.id, domain, evo) match { + domainTypes.serviceMethodFixtureNs(defn.id) match { case Some(ns) => csTrees.inNs(ns, tree) case None => csTrees.inNs(srcRef.pkg.parts.toSeq, tree) } } private def makeFixtureRepr(defn: DomainMember.User): Option[TextTree[CSValue]] = { - val srcRef = trans.asCsTypeKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(defn.id) val fixtureTree = codecsFixture.translate(defn) val fixtureTreeWithNs = fixtureTree.map(t => wrapFixtureNs(defn, srcRef, t)) @@ -635,8 +636,8 @@ object CSDefnTranslator { } private def makeTestRepr(defn: DomainMember.User): Option[TextTree[CSValue]] = { - val csTypeRef = trans.asCsType(defn.id, domain, evo) - val srcRef = trans.asCsTypeKeepForeigns(defn.id, domain, evo) + val csTypeRef = domainTypes.asCsType(defn.id) + val srcRef = domainTypes.asCsTypeKeepForeigns(defn.id) val testTree = codecsTests.translate(defn, csTypeRef, srcRef) val testTreeWithNs = testTree.map(t => wrapFixtureNs(defn, srcRef, t)) @@ -649,7 +650,7 @@ object CSDefnTranslator { ): List[TextTree[CSValue]] = { fields.map { f => - val tpe = trans.asCsRef(f.tpe, domain, evo) + val tpe = domainTypes.asCsRef(f.tpe) val mname = escapeCsKeyword(s"${f.name.name.capitalize}") val fieldEx = q"public $tpe $mname { get; }" prependDocs(f.docs, fieldEx) @@ -840,7 +841,7 @@ object CSDefnTranslator { val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asCsRef(f.tpe, domain, evo) + val tpe = domainTypes.asCsRef(f.tpe) val parseHead = q"""{ @@ -974,7 +975,7 @@ object CSDefnTranslator { | $valVar = (($either.Right)__r).Value; |}""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.asCsTypeKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.asCsTypeKeepForeigns(uid) val nestedCodec = CSType(nestedTpe.pkg, s"${nestedTpe.name}Codec", fq = false, CSTypeOrigin.Other) q"""{ | var __r = cursor.Expect('{'); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala index 4c17224a6..0610c7b50 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala @@ -15,6 +15,7 @@ trait CSDomainTreeTools { object CSDomainTreeTools { class CSDomainTreeToolsImpl( trans: CSTypeTranslator, + domainTypes: CSDomainTypes, domain: Domain, evo: BaboonEvolution, ) extends CSDomainTreeTools { @@ -54,7 +55,7 @@ object CSDomainTreeTools { List( q"""public const $csString BaboonAdtTypeIdentifierValue = "${id.toString}"; |public $csString BaboonAdtTypeIdentifier() => BaboonAdtTypeIdentifierValue; - |public ${csTpe.fullyQualified} BaboonAdtType() => typeof(${trans.asCsType(id, domain, evo)}); + |public ${csTpe.fullyQualified} BaboonAdtType() => typeof(${domainTypes.asCsType(id)}); |""".stripMargin ) case _ => List.empty @@ -101,13 +102,13 @@ object CSDomainTreeTools { } private def makeRefMeta(defn: DomainMember.User): Seq[TextTree[CSValue.CSType]] = { - val csType = trans.asCsType(defn.id, domain, evo).fullyQualified + val csType = domainTypes.asCsType(defn.id).fullyQualified val adtMethods = defn.id.owner match { case Owner.Adt(id) => List( q"""public override $csString BaboonAdtTypeIdentifier() => $csType.BaboonAdtTypeIdentifierValue; - |public override ${csTpe.fullyQualified} BaboonAdtType() => typeof(${trans.asCsType(id, domain, evo)}); + |public override ${csTpe.fullyQualified} BaboonAdtType() => typeof(${domainTypes.asCsType(id)}); |""".stripMargin ) case _ => List.empty diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala new file mode 100644 index 000000000..72e42ea88 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala @@ -0,0 +1,30 @@ +package io.septimalmind.baboon.translator.csharp + +import io.septimalmind.baboon.translator.csharp.CSValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[CSTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[CSTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class CSDomainTypes(trans: CSTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asCsRef(tpe: TypeRef, mutableCollections: Boolean = false): TextTree[CSValue] = trans.asCsRef(tpe, domain, evo, mutableCollections) + + def asCsType(tpe: TypeId, mutableCollections: Boolean = false): CSType = trans.asCsType(tpe, domain, evo, mutableCollections) + + def asCsTypeKeepForeigns(tid: TypeId.User): CSType = trans.asCsTypeKeepForeigns(tid, domain, evo) + + def isService(tid: TypeId.User): Boolean = trans.isService(tid, domain) + + def serviceMethodContainers(tid: TypeId.User): Option[(Seq[String], Seq[String])] = trans.serviceMethodContainers(tid, domain, evo) + + def serviceMethodFixtureNs(tid: TypeId.User): Option[Seq[String]] = trans.serviceMethodFixtureNs(tid, domain, evo) + + def csFixtureRef(tid: TypeId.User): CSType = trans.csFixtureRef(tid, domain, evo) + + def deNull(tpe: TypeRef, ref: TextTree[CSValue]): TextTree[CSValue] = trans.deNull(tpe, domain, ref) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala index cf90d13df..f9909a412 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class CSJsonCodecGenerator( trans: CSTypeTranslator, + domainTypes: CSDomainTypes, csDomTrees: CSDomainTreeTools, target: CSTarget, domain: Domain, @@ -160,7 +161,7 @@ class CSJsonCodecGenerator( // only (never on the wire), so the rename is wire-safe. val branchNameRef = s"${escapeCsKeyword(branchName.toLowerCase)}_$idx" - val branchTpe = trans.asCsType(m, domain, evo) + val branchTpe = domainTypes.asCsType(m) val branchCodec = codecName(branchTpe, CSTypeOrigin(m, domain)) val routedBranchEncoder = q"$branchCodec.Instance.Encode(ctx, $branchNameRef)" @@ -304,7 +305,7 @@ class CSJsonCodecGenerator( // `.asDerived` is mandatory: it tells `isUpgradeable` to resolve // higher twins via `asCsTypeKeepForeigns` (preserving the user's // package), not via `asCsType` (which would deref to e.g. `System`). - val srcRef = trans.asCsTypeKeepForeigns(uid, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(uid) val hostTpe = CSValue.CSType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost", srcRef.fq, CSTypeOrigin(uid, domain).asDerived) q"$hostTpe.Instance.EncodeKey($ref)" case None => @@ -412,8 +413,8 @@ class CSJsonCodecGenerator( // catch (Exception e) — NOT broader (PR-I-D01 pattern guidance): // narrower than Throwable-equivalents would still propagate Errors. // `.asDerived` is mandatory (see encoder note above). - val srcRef = trans.asCsTypeKeepForeigns(uid, domain, evo) - val derefTpe = trans.asCsType(uid, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(uid) + val derefTpe = domainTypes.asCsType(uid) val hostTpe = CSValue.CSType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost", srcRef.fq, CSTypeOrigin(uid, domain).asDerived) q"""((System.Func<$derefTpe>)(() => { try { return $hostTpe.Instance.DecodeKey($ref); } catch (Exception e) { throw new $baboonCodecException.DecoderFailure("malformed key: " + $ref, e); } }))()""" case None => @@ -423,13 +424,13 @@ class CSJsonCodecGenerator( // PR-F (M24): throw BaboonCodecException.DecoderFailure on Left for // cross-language malformed-key consistency (replaces unchecked cast). case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.asCsTypeKeepForeigns(uid, domain, evo) + val targetTpe = domainTypes.asCsTypeKeepForeigns(uid) val codecClassName = CSValue.CSType(targetTpe.pkg, s"${targetTpe.name}Codec", targetTpe.fq, targetTpe.origin) q"""($codecClassName.ParseRepr($ref) switch { $either.Right __r => __r.Value, _ => throw new $baboonCodecException.DecoderFailure("malformed key: " + $ref) })""" // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.asCsTypeKeepForeigns(uid, domain, evo) + val targetTpe = domainTypes.asCsTypeKeepForeigns(uid) val innerDec = decodeKey(inner.tpe, ref) q"new $targetTpe(${escapeCsKeyword(inner.name.name.capitalize)}: $innerDec)" case o => @@ -473,9 +474,9 @@ class CSJsonCodecGenerator( case TypeId.Builtins.map => val arg = codecArgs.arg("kv") val keyDec = decodeKey(args.head, arg) - val keyType = trans.asCsRef(args.head, domain, evo) + val keyType = domainTypes.asCsRef(args.head) val valueDec = mkDecoder(args.last, arg, codecArgs.next) - val valueType = trans.asCsRef(args.last, domain, evo) + val valueType = domainTypes.asCsRef(args.last) q"""$BaboonTools.ReadJsonDict<$keyType, $valueType>($ref, $arg => $keyDec, $arg => $valueDec)""" case TypeId.Builtins.lst => @@ -507,7 +508,7 @@ class CSJsonCodecGenerator( } def codecName(id: TypeId.User): CSValue.CSType = { - codecName(trans.asCsTypeKeepForeigns(id, domain, evo), CSTypeOrigin(id, domain)) + codecName(domainTypes.asCsTypeKeepForeigns(id), CSTypeOrigin(id, domain)) } def codecName(name: CSValue.CSType, origin: CSTypeOrigin.TypeInDomain): CSValue.CSType = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala index add59dcaa..57daf65c8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala @@ -20,16 +20,16 @@ object CSServiceWiringTranslator { class Impl( target: CSTarget, trans: CSTypeTranslator, + domainTypes: CSDomainTypes, codecs: Set[CSCodecTranslator], domain: Domain, - evo: BaboonEvolution, ) extends CSServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "cs", target.language.serviceResult, target.language.pragmas) private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[CSValue]] = - new ServiceMethodPlan(method, trans.asCsRef(_, domain, evo), resolved, CSTypes.escapeCsKeyword(method.name.name.capitalize)) + new ServiceMethodPlan(method, domainTypes.asCsRef(_), resolved, CSTypes.escapeCsKeyword(method.name.name.capitalize)) private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "cs", target.language.serviceContext, target.language.pragmas) @@ -70,12 +70,12 @@ object CSServiceWiringTranslator { } private def jsonCodecName(typeId: TypeId.User): CSValue.CSType = { - val srcRef = trans.asCsTypeKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(typeId) CSValue.CSType(srcRef.pkg, s"${srcRef.name}_JsonCodec", srcRef.fq, CSTypeOrigin(typeId, domain).asDerived) } private def uebaCodecName(typeId: TypeId.User): CSValue.CSType = { - val srcRef = trans.asCsTypeKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.asCsTypeKeepForeigns(typeId) CSValue.CSType(srcRef.pkg, s"${srcRef.name}_UEBACodec", srcRef.fq, CSTypeOrigin(typeId, domain).asDerived) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala index 8dfc08900..956d0038d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala @@ -14,6 +14,7 @@ import izumi.fundamentals.platform.strings.TextTree.style.c.* class CSUEBACodecGenerator( trans: CSTypeTranslator, + domainTypes: CSDomainTypes, csDomTrees: CSDomainTreeTools, target: CSTarget, domain: Domain, @@ -443,10 +444,10 @@ class CSUEBACodecGenerator( case TypeId.Builtins.map => val keyArg = codecArgs.arg("wk") val keyDecoder = mkDecoder(c.args.head, keyArg, codecArgs.next) - val keyType = trans.asCsRef(c.args.head, domain, evo) + val keyType = domainTypes.asCsRef(c.args.head) val valueArg = codecArgs.arg("wv") val valueDecoder = mkDecoder(c.args.last, valueArg, codecArgs.next) - val valueType = trans.asCsRef(c.args.last, domain, evo) + val valueType = domainTypes.asCsRef(c.args.last) q"""$BaboonTools.ReadDict<$keyType, $valueType>($wref, $keyArg => $keyDecoder, $valueArg => $valueDecoder)""" case TypeId.Builtins.lst => val arg = codecArgs.arg("wi") @@ -491,7 +492,7 @@ class CSUEBACodecGenerator( |else |{ | $wref.Write((byte)1); - | ${mkEncoder(c.args.head, trans.deNull(c.args.head, domain, ref), wref, codecArgs.next).endC().shift(4).trim} + | ${mkEncoder(c.args.head, domainTypes.deNull(c.args.head, ref), wref, codecArgs.next).endC().shift(4).trim} |}""".stripMargin case TypeId.Builtins.map => @@ -527,7 +528,7 @@ class CSUEBACodecGenerator( } def codecName(id: TypeId.User): CSValue.CSType = { - codecName(trans.asCsTypeKeepForeigns(id, domain, evo), CSTypeOrigin(id, domain)) + codecName(domainTypes.asCsTypeKeepForeigns(id), CSTypeOrigin(id, domain)) } def codecName(name: CSValue.CSType, origin: CSTypeOrigin.TypeInDomain): CSValue.CSType = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala index e146a838b..fb80a545a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala @@ -26,9 +26,9 @@ object DtCodecFixtureTranslator { final class Impl( translator: DtTypeTranslator, + domainTypes: DtDomainTypes, enquiries: BaboonEnquiries, domain: Domain, - evo: BaboonEvolution, ) extends DtCodecFixtureTranslator { override def translate( @@ -72,7 +72,7 @@ object DtCodecFixtureTranslator { } private def doTranslateDto(dto: Typedef.Dto): TextTree[DtValue] = { - val fullType = translator.toDtTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toDtTypeRefKeepForeigns(dto.id) def body(format: FixtureFormat): TextTree[DtValue] = { val generatedFields = dto.fields.map { f => @@ -97,7 +97,7 @@ object DtCodecFixtureTranslator { } private def doTranslateAdt(adt: Typedef.Adt): TextTree[DtValue] = { - val fullType = translator.toDtTypeRefKeepForeigns(adt.id, domain, evo) + val fullType = domainTypes.toDtTypeRefKeepForeigns(adt.id) val members = adt.members.toList .flatMap(domain.defs.meta.nodes.get) .collect { case DomainMember.User(_, d: Typedef.Dto, _, _) => d } @@ -199,10 +199,10 @@ object DtCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBool()" case u: TypeId.User if enquiries.isEnum(tpe, domain) => - val enumType = translator.toDtTypeRefKeepForeigns(u, domain, evo) + val enumType = domainTypes.toDtTypeRefKeepForeigns(u) q"rnd.mkEnum($enumType.values)" case u: TypeId.User => - val fixturePkg = translator.effectiveDtPkg(u.owner, domain, evo) + val fixturePkg = domainTypes.effectiveDtPkg(u.owner) val importAsFileName = u.owner match { case Owner.Adt(adtId) => translator.toSnakeCase(adtId.name.name) case _ => translator.toSnakeCase(u.name.name) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala index e16dd2405..0774d63d1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala @@ -23,6 +23,7 @@ object DtCodecTestsTranslator { final class Impl( codecs: Set[DtCodecTranslator], typeTranslator: DtTypeTranslator, + domainTypes: DtDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: DtTarget, @@ -97,8 +98,8 @@ object DtCodecTestsTranslator { // `AnyOpaqueJson`-bearing fixture (`randomJson`). Each fixture matches its codec's native // any-field branch so round-trip avoids cross-format conversion and never needs a // `BaboonCodecContext.withFacade` ctx. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { codec => @@ -160,11 +161,9 @@ object DtCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[DtValue] = { - val dtType = typeTranslator.asDtType(definition.id, domain, evolution) + val dtType = domainTypes.asDtType(definition.id) val fixtureName = s"${definition.id.name.name.capitalize}_Fixture" val randomMethod = if (useJsonAny) "randomJson" else "random" val randomAllMethod = if (useJsonAny) "randomAllJson" else "randomAll" @@ -258,7 +257,7 @@ object DtCodecTestsTranslator { val typeId = definition.id.render q"""test('Cross-language JSON writing', () { | final rnd = $baboonRandomFactory.create(); - | ${makeFixture(definition, domain, evo, useJsonAny = true).shift(2).trim} + | ${makeFixture(definition, useJsonAny = true).shift(2).trim} | ${makeJsonWriteBody(definition, srcRef).shift(2).trim} |}); |""".stripMargin @@ -310,7 +309,7 @@ object DtCodecTestsTranslator { val typeId = definition.id.render q"""test('Cross-language UEBA writing', () { | final rnd = $baboonRandomFactory.create(); - | ${makeFixture(definition, domain, evo, useJsonAny = false).shift(2).trim} + | ${makeFixture(definition, useJsonAny = false).shift(2).trim} | ${makeUebaWriteBody(definition, srcRef).shift(2).trim} |}); |""".stripMargin diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala index 0203e6d99..a7256edf0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala @@ -49,6 +49,7 @@ object DtDefnTranslator { dtFiles: DtFileTools, dtTrees: DtTreeTools, trans: DtTypeTranslator, + domainTypes: DtDomainTypes, codecs: Set[DtCodecTranslator], codecTests: DtCodecTestsTranslator, codecsFixture: DtCodecFixtureTranslator, @@ -117,8 +118,8 @@ object DtDefnTranslator { } private def doTranslateTest(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val dtTypeRef = trans.asDtType(defn.id, domain, evo) - val srcRef = trans.toDtTypeRefKeepForeigns(defn.id, domain, evo) + val dtTypeRef = domainTypes.asDtType(defn.id) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(defn.id) val testPath = getOutputPath(defn, suffix = Some("_test")) val typePath = getOutputPath(defn) val fixturePath = getOutputPath(defn, suffix = Some("_fixture")) @@ -167,8 +168,8 @@ object DtDefnTranslator { } } - val dtTypeRef = trans.asDtType(defn.id, domain, evo) - val srcRef = trans.toDtTypeRefKeepForeigns(defn.id, domain, evo) + val dtTypeRef = domainTypes.asDtType(defn.id) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(defn.id) val repr = makeRepr(defn, dtTypeRef, isLatestVersion) @@ -254,7 +255,7 @@ object DtDefnTranslator { case None => DefnRepr(q"", Nil) case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => DefnRepr(q"", Nil) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toDtTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(f.id) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val hostFqn = s"${srcRef.pkg.parts.mkString(".")}.$hostName" @@ -303,7 +304,7 @@ object DtDefnTranslator { val fieldDeclarations = dto.fields.map { f => - val t = trans.asDtRef(f.tpe, domain, evo) + val t = domainTypes.asDtRef(f.tpe) val dartName = trans.escapeDartKeyword(f.name.name) val overridePrefix = if (contractFieldNames.contains(f.name.name)) "@override " else "" val fieldTree = f.tpe match { @@ -326,10 +327,10 @@ object DtDefnTranslator { } } - val contractParents = dto.contracts.map(c => trans.toDtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = dto.contracts.map(c => domainTypes.toDtTypeRefKeepForeigns(c)) val (adtMarker, adtParent) = dto.id.owner match { case Owner.Adt(adtId) => - val adtType = trans.toDtTypeRefKeepForeigns(adtId, domain, evo) + val adtType = domainTypes.toDtTypeRefKeepForeigns(adtId) // PR-F: switched from BaboonAdtMemberMeta (static-only marker) to BaboonAdtMember // (instance interface with `String get baboonAdtTypeIdentifier`). The runtime // already uses `value is BaboonAdtMember` for the useAdtIdentifier path; the @@ -516,7 +517,7 @@ object DtDefnTranslator { mainMeta: List[DtDomainTreeTools.MetaField], codecMeta: Iterable[TextTree[DtValue]], ): DefnRepr = { - val contractParents = adt.contracts.map(c => trans.toDtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = adt.contracts.map(c => domainTypes.toDtTypeRefKeepForeigns(c)) // MFACADE-PR-F: include BaboonMetaProvider on the sealed parent so polymorphic // dispatch sees the interface; concrete branch DTOs implement it via renderDto's // own conformance + instance getters. @@ -555,12 +556,12 @@ object DtDefnTranslator { ): DefnRepr = { val methods = contract.fields.map { f => - val t = trans.asDtRef(f.tpe, domain, evo) + val t = domainTypes.asDtRef(f.tpe) val dartName = trans.escapeDartKeyword(f.name.name) val methodEx = q"$t get $dartName;" prependDocs(f.docs, methodEx) } - val contractParents = contract.contracts.map(c => trans.toDtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = contract.contracts.map(c => domainTypes.toDtTypeRefKeepForeigns(c)) val parents = (contractParents :+ genMarker).distinct val implementsClause = if (parents.nonEmpty) q" implements ${parents.map(t => q"$t").join(", ")}" else q"" val body = if (methods.nonEmpty) methods.joinN() else q"" @@ -587,8 +588,8 @@ object DtDefnTranslator { val ctxTypeParam = wiringTranslator.serviceInterfaceTypeParam val methods = service.methods.map { m => - val in = trans.asDtRef(m.sig, domain, evo) - val out = m.out.map(trans.asDtRef(_, domain, evo)) + val in = domainTypes.asDtRef(m.sig) + val out = m.out.map(domainTypes.asDtRef(_)) val baseRet = out.map(o => q"$o").getOrElse(q"void") // Under `--dt-async-services` the interface method returns `Future` // (`Future` for void), aligning with the always-async Dart @@ -703,7 +704,7 @@ object DtDefnTranslator { val resVar = s"${srcFieldName}_r" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asDtRef(f.tpe, domain, evo) + val tpe = domainTypes.asDtRef(f.tpe) val parseHead = q"""final ${srcFieldName}_fnr = $baboonIdRepr.parseFieldName(cursor, "$srcFieldName"); @@ -804,7 +805,7 @@ object DtDefnTranslator { |if ($resVar is $baboonLeft) return $baboonLeft($resVar.value); |final $tpe $valVar = ($resVar as $baboonRight).value;""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toDtTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toDtTypeRefKeepForeigns(uid) // The codec lives in the SAME file as the nested type (not a sibling // file). importAs forces the resolver to use the type's file name. val nestedCodec = DtType(nestedTpe.pkg, s"${nestedTpe.name}Codec", importAs = Some(trans.toSnakeCase(nestedTpe.name))) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala index d98f18dfb..5ae53ba3a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala @@ -29,6 +29,7 @@ object DtDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: DtTypeTranslator, + domainTypes: DtDomainTypes, ) extends DtDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { mainMeta(defn) ++ sameInVersion(defn) ++ adtMeta(defn) @@ -45,7 +46,7 @@ object DtDomainTreeTools { // BaboonMetaProvider-required instance name; signature/value/refValue use the // `Const`-suffixed static identifier. private def mainMeta(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asDtType(defn.id, domain, evolution).asName + val ref = domainTypes.asDtType(defn.id).asName val baboonDomainVersion = MetaField( q"static const String baboonDomainVersionConst", q"'${domain.version.v.toString}'", @@ -76,7 +77,7 @@ object DtDomainTreeTools { private def adtMeta(defn: DomainMember.User): List[MetaField] = { defn.id.owner match { case Owner.Adt(id) => - val adtRef = typeTranslator.asDtType(defn.id, domain, evolution).asName + val adtRef = domainTypes.asDtType(defn.id).asName val adtTypeIdentifier = MetaField( q"static const String baboonAdtTypeIdentifierConst", q"'${id.toString}'", @@ -91,7 +92,7 @@ object DtDomainTreeTools { } private def sameInVersion(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asDtType(defn.id, domain, evolution).asName + val ref = domainTypes.asDtType(defn.id).asName val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"'${v.v.toString}'") val sameInVersion = MetaField( q"static const List baboonSameInVersionsConst", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala new file mode 100644 index 000000000..5086dc73e --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala @@ -0,0 +1,22 @@ +package io.septimalmind.baboon.translator.dart + +import io.septimalmind.baboon.translator.dart.DtValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[DtTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[DtTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class DtDomainTypes(trans: DtTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asDtRef(tpe: TypeRef): TextTree[DtValue] = trans.asDtRef(tpe, domain, evo) + + def asDtType(tpe: TypeId): DtType = trans.asDtType(tpe, domain, evo) + + def toDtTypeRefKeepForeigns(tid: TypeId.User): DtType = trans.toDtTypeRefKeepForeigns(tid, domain, evo) + + def effectiveDtPkg(owner: Owner): DtPackageId = trans.effectiveDtPkg(owner, domain, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala index 50fae82ff..55f64c9ab 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtJsonCodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class DtJsonCodecGenerator( trans: DtTypeTranslator, + domainTypes: DtDomainTypes, target: DtTarget, domain: Domain, evo: BaboonEvolution, @@ -110,7 +111,7 @@ class DtJsonCodecGenerator( val branches = adt.dataMembers(domain).map { m => val branchName = m.name.name - val fqBranch = trans.toDtTypeRefKeepForeigns(m, domain, evo) + val fqBranch = domainTypes.toDtTypeRefKeepForeigns(m) val branchRef = q"branchVal" val routedBranchEncoder = q"${codecName(fqBranch)}.instance.encode(ctx, $branchRef)" @@ -221,7 +222,7 @@ class DtJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => // PR-I.1d (M24 Phase 3.1): Custom-foreign map keys route through // the emitted `_KeyCodecHost.instance` extension hook. - val srcRef = trans.toDtTypeRefKeepForeigns(uid, domain, evo) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(uid) val host = DtValue.DtType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost", importAs = Some(trans.toSnakeCase(srcRef.name))) q"$host.instance.encodeKey($ref)" case None => @@ -264,7 +265,7 @@ class DtJsonCodecGenerator( case _ => ref } case _ => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"$targetTpe.instance.encode(ctx, $ref)" } case o => @@ -309,11 +310,11 @@ class DtJsonCodecGenerator( // Custom-mapped Dart type (mirrors TS `$ref as ` shape). No per- // foreign `_JsonCodec` class is emitted (PR-I.1d-N03). case _ => - val dartType = trans.asDtRef(TypeRef.Scalar(u), domain, evo) + val dartType = domainTypes.asDtRef(TypeRef.Scalar(u)) q"$ref as $dartType" } case _ => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"$targetTpe.instance.decode(ctx, $ref)" } case o => @@ -360,7 +361,7 @@ class DtJsonCodecGenerator( case ud: DomainMember.User => ud.defn match { case _: Typedef.Enum => - val targetTpe = trans.toDtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toDtTypeRefKeepForeigns(u) q"$targetTpe.parse($ref)!" case f: Typedef.Foreign => f.bindings.get(BaboonLang.Dart) match { @@ -371,7 +372,7 @@ class DtJsonCodecGenerator( // the emitted `_KeyCodecHost.instance` extension hook. // `on Exception catch (e)` (NOT broader): keeps fail-fast on Error / // ControlThrowable parity (PR-I-D01 pattern guidance). - val srcRef = trans.toDtTypeRefKeepForeigns(u, domain, evo) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(u) val host = DtValue.DtType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost", importAs = Some(trans.toSnakeCase(srcRef.name))) q"""(() { try { return $host.instance.decodeKey($ref); } on Exception catch (e) { throw $baboonDecoderFailure('malformed key: ' + $ref, e); } })()""" case None => @@ -381,13 +382,13 @@ class DtJsonCodecGenerator( // PR-F (M24): throw BaboonDecoderFailure on Left for cross-language // malformed-key consistency (replaces unchecked cast). case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.toDtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toDtTypeRefKeepForeigns(u) val nestedCodec = DtValue.DtType(targetTpe.pkg, s"${targetTpe.name}Codec", importAs = Some(trans.toSnakeCase(targetTpe.name))) q"""(switch ($nestedCodec.parseRepr($ref)) { $baboonRight(:final value) => value, _ => throw $baboonDecoderFailure('malformed key: ' + $ref) })""" // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct (named arg). case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.toDtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toDtTypeRefKeepForeigns(u) val innerDec = decodeKey(inner.tpe, ref) val dartName = trans.escapeDartKeyword(inner.name.name) q"$targetTpe($dartName: $innerDec)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala index 44bac4a02..1606fff90 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala @@ -28,9 +28,9 @@ object DtServiceWiringTranslator { class Impl( target: DtTarget, trans: DtTypeTranslator, + domainTypes: DtDomainTypes, codecs: Set[DtCodecTranslator], domain: Domain, - evo: BaboonEvolution, ) extends DtServiceWiringTranslator { // When the Dart `asyncServices` flag is on, the service interface methods @@ -72,7 +72,7 @@ object DtServiceWiringTranslator { case DomainMember.User(_, service: Typedef.Service, _, _) => val endpoints = service.methods.map { m => - EndpointPlan(m, trans.escapeDartKeyword(m.name.name), trans.asDtRef(m.sig, domain, evo), m.out.map(o => trans.asDtRef(o, domain, evo)).getOrElse(q"void")) + EndpointPlan(m, trans.escapeDartKeyword(m.name.name), domainTypes.asDtRef(m.sig), m.out.map(o => domainTypes.asDtRef(o)).getOrElse(q"void")) } service.id -> ServicePlan(endpoints, codecActive(service, "Json"), codecActive(service, "Ueba")) }.toMap @@ -81,13 +81,13 @@ object DtServiceWiringTranslator { private def hasActiveUebaCodecs(service: Typedef.Service): Boolean = servicePlans(service.id).uebaActive private def jsonCodecName(typeId: TypeId.User): DtValue.DtType = { - val srcRef = trans.toDtTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(typeId) val baseFileName = srcRef.importAs.getOrElse(trans.toSnakeCase(srcRef.name)) DtValue.DtType(srcRef.pkg, s"${srcRef.name}_JsonCodec", srcRef.fq, importAs = Some(baseFileName)) } private def uebaCodecName(typeId: TypeId.User): DtValue.DtType = { - val srcRef = trans.toDtTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toDtTypeRefKeepForeigns(typeId) val baseFileName = srcRef.importAs.getOrElse(trans.toSnakeCase(srcRef.name)) DtValue.DtType(srcRef.pkg, s"${srcRef.name}_UebaCodec", srcRef.fq, importAs = Some(baseFileName)) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala index b8737b106..6aa61deab 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtUEBACodecGenerator.scala @@ -15,6 +15,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class DtUEBACodecGenerator( trans: DtTypeTranslator, + domainTypes: DtDomainTypes, target: DtTarget, domain: Domain, evo: BaboonEvolution, @@ -170,7 +171,7 @@ class DtUEBACodecGenerator( case (m, idx) => val branchName = m.name.name - val adtRef = trans.toDtTypeRefKeepForeigns(m, domain, evo) + val adtRef = domainTypes.toDtTypeRefKeepForeigns(m) val cName = codecName(adtRef) val castedName = trans.escapeDartKeyword(branchName.substring(0, 1).toLowerCase + branchName.substring(1)) @@ -390,11 +391,11 @@ class DtUEBACodecGenerator( DtForeignWirePlan.ueba(f) match { case DtForeignWirePlan.Inline(ref) => mkDecoder(ref) case DtForeignWirePlan.Codec => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, reader)""" } case _ => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, reader)""" } } @@ -429,11 +430,11 @@ class DtUEBACodecGenerator( DtForeignWirePlan.ueba(f) match { case DtForeignWirePlan.Inline(wireRef) => mkEncoder(wireRef, ref, wref, depth) case DtForeignWirePlan.Codec => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref);""" } case _ => - val targetTpe = codecName(trans.toDtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toDtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref);""" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala index 64b341b9b..8cea0f628 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala @@ -28,9 +28,9 @@ object JvCodecFixtureTranslator { final class Impl( target: JvTarget, translator: JvTypeTranslator, + domainTypes: JvDomainTypes, enquiries: BaboonEnquiries, domain: Domain, - evo: BaboonEvolution, ) extends JvCodecFixtureTranslator { override def translate( @@ -86,7 +86,7 @@ object JvCodecFixtureTranslator { } private def doTranslateDto(dto: Typedef.Dto): TextTree[JvValue] = { - val fullType = translator.toJvTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toJvTypeRefKeepForeigns(dto.id) def body(format: FixtureFormat): TextTree[JvValue] = { val generatedFields = dto.fields.map(f => genType(f.tpe, format)) @@ -108,7 +108,7 @@ object JvCodecFixtureTranslator { } private def doTranslateAdt(adt: Typedef.Adt): TextTree[JvValue] = { - val fullType = translator.toJvTypeRefKeepForeigns(adt.id, domain, evo) + val fullType = domainTypes.toJvTypeRefKeepForeigns(adt.id) val members = adt.members.toList .flatMap(domain.defs.meta.nodes.get) .collect { case DomainMember.User(_, d: Typedef.Dto, _, _) => d } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala index 52de1399a..2de550b2b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala @@ -20,6 +20,7 @@ object JvCodecTestsTranslator { final class Impl( codecs: Set[JvCodecTranslator], typeTranslator: JvTypeTranslator, + domainTypes: JvDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: JvTarget, @@ -54,8 +55,8 @@ object JvCodecTestsTranslator { // `AnyOpaqueJson`-bearing fixture (`randomJson`). Each fixture matches its codec's native // any-field branch so round-trip avoids cross-format conversion and never needs a // `BaboonCodecContext.withFacade` ctx. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { case jsonCodec: JvJsonCodecGenerator => @@ -125,8 +126,6 @@ object JvCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[JvValue] = { val randomMethod = if (useJsonAny) "randomJson" else "random" @@ -135,9 +134,9 @@ object JvCodecTestsTranslator { case e: Typedef.Enum => q"var fixture = ${e.id.name.name}.all().get(rnd.nextInt(${e.id.name.name}.all().size()));" case _: Typedef.Adt => - q"var fixtures = ${typeTranslator.asJvType(definition.id, domain, evolution)}_Fixture.$randomAllMethod(rnd);" + q"var fixtures = ${domainTypes.asJvType(definition.id)}_Fixture.$randomAllMethod(rnd);" case _ => - q"var fixture = ${typeTranslator.asJvType(definition.id, domain, evolution)}_Fixture.$randomMethod(rnd);" + q"var fixture = ${domainTypes.asJvType(definition.id)}_Fixture.$randomMethod(rnd);" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala index 7cdd570dd..215aa8915 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala @@ -57,6 +57,7 @@ object JvDefnTranslator { jvFiles: JvFileTools, jvTrees: JvTreeTools, trans: JvTypeTranslator, + domainTypes: JvDomainTypes, codecs: Set[JvCodecTranslator], codecTests: JvCodecTestsTranslator, codecsFixture: JvCodecFixtureTranslator, @@ -151,7 +152,7 @@ object JvDefnTranslator { } private def makeFixtureRepr(defn: DomainMember.User): Option[TextTree[JvValue]] = { - val srcRef = trans.toJvTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val fixtureTree = codecsFixture.translate(defn) @@ -205,8 +206,8 @@ object JvDefnTranslator { } private def makeTestRepr(defn: DomainMember.User): Option[TextTree[JvValue]] = { - val jvTypeRef = trans.asJvType(defn.id, domain, evo) - val srcRef = trans.toJvTypeRefKeepForeigns(defn.id, domain, evo) + val jvTypeRef = domainTypes.asJvType(defn.id) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val testTree = codecTests.translate(defn, jvTypeRef, srcRef) @@ -230,8 +231,8 @@ object JvDefnTranslator { } } - val jvTypeRef = trans.asJvType(defn.id, domain, evo) - val srcRef = trans.toJvTypeRefKeepForeigns(defn.id, domain, evo) + val jvTypeRef = domainTypes.asJvType(defn.id) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(defn.id) val repr = makeRepr(defn, jvTypeRef, isLatestVersion) @@ -326,7 +327,7 @@ object JvDefnTranslator { case None => DefnRepr(q"", Nil, Nil) case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => DefnRepr(q"", Nil, Nil) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toJvTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(f.id) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val codecFqn = s"${srcRef.pkg.parts.mkString(".")}.$hostName" @@ -381,17 +382,17 @@ object JvDefnTranslator { ): DefnRepr = { val params = dto.fields.map { f => - val t = trans.asJvRef(f.tpe, domain, evo) + val t = domainTypes.asJvRef(f.tpe) val javaName = JvTypeTranslator.escapeJvKeyword(f.name.name) val fieldTree = q"$t $javaName" prependDocs(f.docs, fieldTree) } val paramsList = if (params.nonEmpty) params.join(",\n") else q"" - val contractParents = dto.contracts.map(c => trans.toJvTypeRefKeepForeigns(c, domain, evo)) + val contractParents = dto.contracts.map(c => domainTypes.toJvTypeRefKeepForeigns(c)) val (adtMarker, adtParent) = dto.id.owner match { case Owner.Adt(adtId) => - val adtType = trans.toJvTypeRefKeepForeigns(adtId, domain, evo) + val adtType = domainTypes.toJvTypeRefKeepForeigns(adtId) (Seq(iBaboonAdtMemberMeta), Seq(adtType)) case _ => (Seq.empty, Seq.empty) } @@ -516,7 +517,7 @@ object JvDefnTranslator { mainMeta: List[JvDomainTreeTools.MetaField], codecMeta: Iterable[TextTree[JvValue]], ): DefnRepr = { - val contractParents = adt.contracts.map(c => trans.toJvTypeRefKeepForeigns(c, domain, evo)) + val contractParents = adt.contracts.map(c => domainTypes.toJvTypeRefKeepForeigns(c)) val parents = (contractParents :+ genMarker).distinct val parentsList = parents.map(t => q"$t").join(", ") @@ -556,14 +557,14 @@ object JvDefnTranslator { ): DefnRepr = { val methods = contract.fields.map { f => - val t = trans.asJvRef(f.tpe, domain, evo) + val t = domainTypes.asJvRef(f.tpe) val javaName = JvTypeTranslator.escapeJvKeyword(f.name.name) val methodTree = q"$t $javaName();" prependDocs(f.docs, methodTree) } - val contractParents = contract.contracts.map(c => trans.toJvTypeRefKeepForeigns(c, domain, evo)) + val contractParents = contract.contracts.map(c => domainTypes.toJvTypeRefKeepForeigns(c)) val adtParent = contract.id.owner match { - case Owner.Adt(adtId) => Seq(trans.toJvTypeRefKeepForeigns(adtId, domain, evo)) + case Owner.Adt(adtId) => Seq(domainTypes.toJvTypeRefKeepForeigns(adtId)) case _ => Seq.empty } val parents = (adtParent ++ contractParents :+ genMarker).distinct @@ -595,7 +596,7 @@ object JvDefnTranslator { } val methods = service.methods.map { m => - val plan = new ServiceMethodPlan(m, tpe => trans.asJvRef(tpe, domain, evo), resolved, JvTypeTranslator.escapeJvKeyword(m.name.name)) + val plan = new ServiceMethodPlan(m, tpe => domainTypes.asJvRef(tpe), resolved, JvTypeTranslator.escapeJvKeyword(m.name.name)) val in = plan.input val out = plan.output val err = plan.error @@ -642,7 +643,7 @@ object JvDefnTranslator { } private def effectivePkg(owner: Owner): JvValue.JvPackageId = { - trans.effectiveJvPkg(owner, domain, evo) + domainTypes.effectiveJvPkg(owner) } private def getOutputPath(defn: DomainMember.User, suffix: Option[String] = None): String = { @@ -778,7 +779,7 @@ object JvDefnTranslator { val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asJvRef(f.tpe, domain, evo) + val tpe = domainTypes.asJvRef(f.tpe) val parseHead = q"""{ @@ -904,7 +905,7 @@ object JvDefnTranslator { | $valVar = (($baboonEither.Right) __r).value(); |}""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toJvTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toJvTypeRefKeepForeigns(uid) val nestedCodec = JvType(nestedTpe.pkg, s"${nestedTpe.name}Codec") q"""{ | $baboonEither __r = cursor.expect('{'); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala index 94b7778ae..518c771b4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala @@ -25,6 +25,7 @@ object JvDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: JvTypeTranslator, + domainTypes: JvDomainTypes, ) extends JvDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { mainMeta(defn) ++ sameInVersion(defn) ++ adtMeta(defn) @@ -35,7 +36,7 @@ object JvDomainTreeTools { } private def mainMeta(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asJvType(defn.id, domain, evolution).fullyQualified + val ref = domainTypes.asJvType(defn.id).fullyQualified val baboonDomainVersion = MetaField( q"public static final $jvString baboonDomainVersion", q"\"${domain.version.v.toString}\"", @@ -57,7 +58,7 @@ object JvDomainTreeTools { private def adtMeta(defn: DomainMember.User): List[MetaField] = { defn.id.owner match { case Owner.Adt(id) => - val adtRef = typeTranslator.asJvType(defn.id, domain, evolution).fullyQualified + val adtRef = domainTypes.asJvType(defn.id).fullyQualified val adtTypeIdentifier = MetaField( q"public static final $jvString baboonAdtTypeIdentifier", q"\"${id.toString}\"", @@ -65,7 +66,7 @@ object JvDomainTreeTools { ) val baboonAdtType = MetaField( q"public static final ${javaClass.fullyQualified} baboonAdtType", - q"${typeTranslator.asJvType(id, domain, evolution)}.class", + q"${domainTypes.asJvType(id)}.class", q"$adtRef.baboonAdtType", ) List(adtTypeIdentifier, baboonAdtType) @@ -74,7 +75,7 @@ object JvDomainTreeTools { } private def sameInVersion(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asJvType(defn.id, domain, evolution).fullyQualified + val ref = domainTypes.asJvType(defn.id).fullyQualified val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField( q"public static final $jvList<$jvString> baboonSameInVersions", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala new file mode 100644 index 000000000..db62a6b96 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala @@ -0,0 +1,26 @@ +package io.septimalmind.baboon.translator.java + +import io.septimalmind.baboon.translator.java.JvValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[JvTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[JvTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class JvDomainTypes(trans: JvTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asJvRef(tpe: TypeRef): TextTree[JvValue] = trans.asJvRef(tpe, domain, evo) + + def asJvBoxedRef(tpe: TypeRef): TextTree[JvValue] = trans.asJvBoxedRef(tpe, domain, evo) + + def asJvType(tpe: TypeId): JvType = trans.asJvType(tpe, domain, evo) + + def asJvBoxedType(tpe: TypeId): JvType = trans.asJvBoxedType(tpe, domain, evo) + + def toJvTypeRefKeepForeigns(tid: TypeId.User): JvType = trans.toJvTypeRefKeepForeigns(tid, domain, evo) + + def effectiveJvPkg(owner: Owner): JvPackageId = trans.effectiveJvPkg(owner, domain, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala index 42977278a..ee1e0a949 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class JvJsonCodecGenerator( trans: JvTypeTranslator, + domainTypes: JvDomainTypes, target: JvTarget, domain: Domain, evo: BaboonEvolution, @@ -115,7 +116,7 @@ class JvJsonCodecGenerator( val branches = adt.dataMembers(domain).map { m => val branchName = m.name.name - val fqBranch = trans.toJvTypeRefKeepForeigns(m, domain, evo) + val fqBranch = domainTypes.toJvTypeRefKeepForeigns(m) val branchRef = q"branchVal" val routedBranchEncoder = q"${codecName(fqBranch, m.owner)}.INSTANCE.encode(ctx, $branchRef)" @@ -236,8 +237,8 @@ class JvJsonCodecGenerator( // PR-I.1b (M24 Phase 3.1): Custom-foreign map keys route through // the emitted `_KeyCodecHost.instance()` extension hook. val hostTpe = JvValue.JvType( - trans.toJvTypeRefKeepForeigns(uid, domain, evo).pkg, - s"${trans.toJvTypeRefKeepForeigns(uid, domain, evo).name}_KeyCodecHost", + domainTypes.toJvTypeRefKeepForeigns(uid).pkg, + s"${domainTypes.toJvTypeRefKeepForeigns(uid).name}_KeyCodecHost", ) q"$hostTpe.instance().encodeKey($ref)" case None => @@ -272,11 +273,11 @@ class JvJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref, depth) case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"$targetTpe.INSTANCE.encode(ctx, $ref)" } case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"$targetTpe.INSTANCE.encode(ctx, $ref)" } case o => @@ -327,11 +328,11 @@ class JvJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => decodeElement(aliasedRef, ref, depth) case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"$targetTpe.INSTANCE.decode(ctx, $ref)" } case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"$targetTpe.INSTANCE.decode(ctx, $ref)" } case o => @@ -343,29 +344,21 @@ class JvJsonCodecGenerator( q"""$ref == null || $ref.isNull() ? java.util.Optional.empty() : java.util.Optional.of(${decodeElement(c.args.head, ref, depth + 1)})""" case TypeId.Builtins.lst => val elemDec = decodeElement(c.args.head, q"$varName", depth + 1) - q"""((java.util.function.Supplier<$jvList<${trans.asJvBoxedRef(c.args.head, domain, evo)}>>) () -> { var lst = new $jvArrayList<${trans.asJvBoxedRef( + q"""((java.util.function.Supplier<$jvList<${domainTypes.asJvBoxedRef(c.args.head)}>>) () -> { var lst = new $jvArrayList<${domainTypes.asJvBoxedRef( c.args.head, - domain, - evo, )}>(); for (var $varName : (Iterable<$jsonNode>) () -> $ref.elements()) { lst.add($elemDec); } return lst; }).get()""" case TypeId.Builtins.set => val elemDec = decodeElement(c.args.head, q"$varName", depth + 1) - q"""((java.util.function.Supplier<$jvSet<${trans.asJvBoxedRef(c.args.head, domain, evo)}>>) () -> { var set = new $jvLinkedHashSet<${trans.asJvBoxedRef( + q"""((java.util.function.Supplier<$jvSet<${domainTypes.asJvBoxedRef(c.args.head)}>>) () -> { var set = new $jvLinkedHashSet<${domainTypes.asJvBoxedRef( c.args.head, - domain, - evo, )}>(); for (var $varName : (Iterable<$jsonNode>) () -> $ref.elements()) { set.add($elemDec); } return set; }).get()""" case TypeId.Builtins.map => val keyDec = decodeKey(c.args.head, q"$varName.getKey()") val valueDec = decodeElement(c.args.last, q"$varName.getValue()", depth + 1) - q"""((java.util.function.Supplier<$jvMap<${trans.asJvBoxedRef(c.args.head, domain, evo)}, ${trans.asJvBoxedRef( + q"""((java.util.function.Supplier<$jvMap<${domainTypes.asJvBoxedRef(c.args.head)}, ${domainTypes.asJvBoxedRef( c.args.last, - domain, - evo, - )}>>) () -> { var map = new $jvLinkedHashMap<${trans.asJvBoxedRef(c.args.head, domain, evo)}, ${trans.asJvBoxedRef( + )}>>) () -> { var map = new $jvLinkedHashMap<${domainTypes.asJvBoxedRef(c.args.head)}, ${domainTypes.asJvBoxedRef( c.args.last, - domain, - evo, )}>(); for (var $varName : (Iterable>) () -> $ref.fields()) { map.put($keyDec, $valueDec); } return map; }).get()""" case o => throw new RuntimeException(s"BUG: Unexpected type: $o") } @@ -399,7 +392,7 @@ class JvJsonCodecGenerator( case ud: DomainMember.User => ud.defn match { case _: Typedef.Enum => - val targetTpe = trans.toJvTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toJvTypeRefKeepForeigns(u) q"""$targetTpe.parse($ref)""" case f: Typedef.Foreign => f.bindings.get(BaboonLang.Java) match { @@ -411,8 +404,8 @@ class JvJsonCodecGenerator( // catch (Exception e) — NOT Throwable (PR-I-D01 pattern guidance): // Throwable would swallow Error (OOM/StackOverflow), which we want to // propagate to fail-fast. - val srcRef = trans.toJvTypeRefKeepForeigns(u, domain, evo) - val derefTpe = trans.asJvType(u, domain, evo) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(u) + val derefTpe = domainTypes.asJvType(u) val hostTpe = JvValue.JvType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost") q"""((java.util.function.Supplier<$derefTpe>) () -> { try { return $hostTpe.instance().decodeKey($ref); } catch (Exception e) { throw new $baboonCodecException.DecoderFailure("malformed key: " + $ref, e); } }).get()""" case None => @@ -422,13 +415,13 @@ class JvJsonCodecGenerator( // PR-F (M24): throw BaboonCodecException.DecoderFailure on Left for // cross-language malformed-key consistency (replaces unchecked cast). case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.toJvTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toJvTypeRefKeepForeigns(u) val nestedCodec = JvValue.JvType(targetTpe.pkg, s"${targetTpe.name}Codec") q"""((java.util.function.Supplier<$targetTpe>) () -> { var __e = $nestedCodec.parseRepr($ref); if (__e instanceof $baboonEither.Right __r) { return ($targetTpe) __r.value(); } throw new $baboonCodecException.DecoderFailure("malformed key: " + $ref); }).get()""" // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.toJvTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toJvTypeRefKeepForeigns(u) val innerDec = decodeKey(inner.tpe, ref) q"new $targetTpe($innerDec)" case o => throw new RuntimeException(s"BUG: Unexpected key usertype: $o") @@ -444,10 +437,8 @@ class JvJsonCodecGenerator( tpe match { case TypeRef.Constructor(id, args) if id.name.name == "opt" => val fieldNode = q"""$jsonObjRef.get("$fieldName")""" - q"""((java.util.function.Supplier>) () -> { var v = $fieldNode; return v == null || v.isNull() ? java.util.Optional.empty() : java.util.Optional.of(${decodeElement( args.head, q"v", @@ -486,7 +477,7 @@ class JvJsonCodecGenerator( val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) val ownerPrefix = name.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" - val realPkg = trans.effectiveJvPkg(owner, domain, evo) + val realPkg = domainTypes.effectiveJvPkg(owner) JvValue.JvType(realPkg, s"$prefixStr${name.name}_JsonCodec", name.fq) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala index c5a0139e3..985c84219 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala @@ -17,6 +17,7 @@ object JvServiceWiringTranslator { class Impl( target: JvTarget, trans: JvTypeTranslator, + domainTypes: JvDomainTypes, codecs: Set[JvCodecTranslator], domain: Domain, evo: BaboonEvolution, @@ -26,7 +27,7 @@ object JvServiceWiringTranslator { ServiceResultResolver.resolve(domain, "java", target.language.serviceResult, target.language.pragmas) private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[JvValue]] = - new ServiceMethodPlan(method, trans.asJvRef(_, domain, evo), resolved, JvTypeTranslator.escapeJvKeyword(method.name.name)) + new ServiceMethodPlan(method, domainTypes.asJvRef(_), resolved, JvTypeTranslator.escapeJvKeyword(method.name.name)) private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "java", target.language.serviceContext, target.language.pragmas) @@ -57,20 +58,20 @@ object JvServiceWiringTranslator { } private def jsonCodecName(typeId: TypeId.User): JvValue.JvType = { - val srcRef = trans.toJvTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(typeId) val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) val ownerPrefix = srcRef.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" - val realPkg = trans.effectiveJvPkg(typeId.owner, domain, evo) + val realPkg = domainTypes.effectiveJvPkg(typeId.owner) JvValue.JvType(realPkg, s"$prefixStr${srcRef.name}_JsonCodec", srcRef.fq) } private def uebaCodecName(typeId: TypeId.User): JvValue.JvType = { - val srcRef = trans.toJvTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toJvTypeRefKeepForeigns(typeId) val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) val ownerPrefix = srcRef.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" - val realPkg = trans.effectiveJvPkg(typeId.owner, domain, evo) + val realPkg = domainTypes.effectiveJvPkg(typeId.owner) JvValue.JvType(realPkg, s"$prefixStr${srcRef.name}_UEBACodec", srcRef.fq) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala index a4cf662b0..263198dc4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class JvUEBACodecGenerator( trans: JvTypeTranslator, + domainTypes: JvDomainTypes, target: JvTarget, domain: Domain, evo: BaboonEvolution, @@ -175,7 +176,7 @@ class JvUEBACodecGenerator( case (m, idx) => val branchName = m.name.name - val adtRef = trans.toJvTypeRefKeepForeigns(m, domain, evo) + val adtRef = domainTypes.toJvTypeRefKeepForeigns(m) val cName = codecName(adtRef, m.owner) val castedName = JvTypeTranslator.escapeJvKeyword(branchName.toLowerCase) @@ -396,11 +397,11 @@ class JvUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkDecoder(aliasedRef) case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"""$targetTpe.INSTANCE.decode(ctx, input)""" } case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"""$targetTpe.INSTANCE.decode(ctx, input)""" } } @@ -437,11 +438,11 @@ class JvUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref, wref) case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"""$targetTpe.INSTANCE.encode(ctx, $wref, $ref);""" } case _ => - val targetTpe = codecName(trans.toJvTypeRefKeepForeigns(u, domain, evo), u.owner) + val targetTpe = codecName(domainTypes.toJvTypeRefKeepForeigns(u), u.owner) q"""$targetTpe.INSTANCE.encode(ctx, $wref, $ref);""" } } @@ -509,7 +510,7 @@ class JvUEBACodecGenerator( val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) val ownerPrefix = name.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" - val realPkg = trans.effectiveJvPkg(owner, domain, evo) + val realPkg = domainTypes.effectiveJvPkg(owner) JvValue.JvType(realPkg, s"$prefixStr${name.name}_UEBACodec", name.fq) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala index a70c4fb7f..15bbe7a64 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala @@ -18,6 +18,7 @@ object KtCodecFixtureTranslator { final class Impl( target: KtTarget, translator: KtTypeTranslator, + domainTypes: KtDomainTypes, enquiries: BaboonEnquiries, domain: Domain, evo: BaboonEvolution, @@ -89,7 +90,7 @@ object KtCodecFixtureTranslator { } private def doTranslateDto(dto: Typedef.Dto): TextTree[KtValue] = { - val fullType = translator.toKtTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toKtTypeRefKeepForeigns(dto.id) def body(format: FixtureFormat): TextTree[KtValue] = { val generatedFields = dto.fields.map(f => genType(f.tpe, format)) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala index a8226133b..31aa00dae 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala @@ -20,6 +20,7 @@ object KtCodecTestsTranslator { final class Impl( codecs: Set[KtCodecTranslator], typeTranslator: KtTypeTranslator, + domainTypes: KtDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: KtTarget, @@ -57,8 +58,8 @@ object KtCodecTestsTranslator { // `AnyOpaqueJson`-bearing fixture (`randomJson`). Each fixture matches its codec's native // any-field branch so round-trip avoids cross-format conversion and never needs a // `BaboonCodecContext.withFacade` ctx. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { case jsonCodec: KtJsonCodecGenerator => @@ -135,16 +136,14 @@ object KtCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[KtValue] = { val randomMethod = if (useJsonAny) "randomJson" else "random" val randomAllMethod = if (useJsonAny) "randomAllJson" else "randomAll" definition.defn match { case e: Typedef.Enum => q"val fixture = rnd.randomElement(${e.id.name.name}.all())" - case _: Typedef.Adt => q"val fixtures = ${typeTranslator.asKtType(definition.id, domain, evolution)}_Fixture.$randomAllMethod(rnd)" - case _ => q"val fixture = ${typeTranslator.asKtType(definition.id, domain, evolution)}_Fixture.$randomMethod(rnd)" + case _: Typedef.Adt => q"val fixtures = ${domainTypes.asKtType(definition.id)}_Fixture.$randomAllMethod(rnd)" + case _ => q"val fixture = ${domainTypes.asKtType(definition.id)}_Fixture.$randomMethod(rnd)" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala index 9a6997731..d971e32a9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala @@ -50,6 +50,7 @@ object KtDefnTranslator { ktFiles: KtFileTools, ktTrees: KtTreeTools, trans: KtTypeTranslator, + domainTypes: KtDomainTypes, ktTypes: KtTypes, codecs: Set[KtCodecTranslator], codecTests: KtCodecTestsTranslator, @@ -71,7 +72,7 @@ object KtDefnTranslator { val registrations = codecs.toList.map(codec => codec.id -> repr.codecs.flatMap(reg => reg.trees.get(codec.id).map(expr => q"${reg.tpeId}, $expr"))) - val srcRef = trans.toKtTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(defn.id) val mainOutput = Output( getOutputPath(defn), @@ -132,7 +133,7 @@ object KtDefnTranslator { } private def makeFixtureRepr(defn: DomainMember.User): Option[TextTree[KtValue]] = { - val srcRef = trans.toKtTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val fixtureTree = codecsFixture.translate(defn) @@ -180,8 +181,8 @@ object KtDefnTranslator { } private def makeTestRepr(defn: DomainMember.User): Option[TextTree[KtValue]] = { - val ktTypeRef = trans.asKtType(defn.id, domain, evo) - val srcRef = trans.toKtTypeRefKeepForeigns(defn.id, domain, evo) + val ktTypeRef = domainTypes.asKtType(defn.id) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val testTree = codecTests.translate(defn, ktTypeRef, srcRef) @@ -205,8 +206,8 @@ object KtDefnTranslator { } } - val ktTypeRef = trans.asKtType(defn.id, domain, evo) - val srcRef = trans.toKtTypeRefKeepForeigns(defn.id, domain, evo) + val ktTypeRef = domainTypes.asKtType(defn.id) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(defn.id) val repr = makeRepr(defn, ktTypeRef, isLatestVersion) @@ -266,16 +267,16 @@ object KtDefnTranslator { val contractFieldNames = collectContractFieldNames(dto.contracts) val params = dto.fields.map { f => - val t = trans.asKtNullableRef(f.tpe, domain, evo) + val t = domainTypes.asKtNullableRef(f.tpe) val prefix = if (contractFieldNames.contains(f.name.name)) "override val" else "val" val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) val fieldTree = q"$prefix $ktName: $t" prependDocs(f.docs, fieldTree) } val paramsList = if (params.nonEmpty) params.join(",\n") else q"" - val contractParents = dto.contracts.map(c => trans.toKtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = dto.contracts.map(c => domainTypes.toKtTypeRefKeepForeigns(c)) val (adtParent, adtMarker) = dto.id.owner match { - case Owner.Adt(id) => (Some(trans.toKtTypeRefKeepForeigns(id, domain, evo)), Seq(iBaboonAdtMemberMeta)) + case Owner.Adt(id) => (Some(domainTypes.toKtTypeRefKeepForeigns(id)), Seq(iBaboonAdtMemberMeta)) case _ => (None, Seq.empty) } val interfaceParents = (adtMarker ++ contractParents :+ genMarker).distinct @@ -376,7 +377,7 @@ object KtDefnTranslator { ) case adt: Typedef.Adt => - val contractParents = adt.contracts.map(c => trans.toKtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = adt.contracts.map(c => domainTypes.toKtTypeRefKeepForeigns(c)) val parents = (contractParents :+ genMarker).distinct val parentsList = if (parents.nonEmpty) q" : ${parents.map(t => q"$t").join(", ")}" else q"" @@ -407,12 +408,12 @@ object KtDefnTranslator { case contract: Typedef.Contract => val methods = contract.fields.map { f => - val t = trans.asKtNullableRef(f.tpe, domain, evo) + val t = domainTypes.asKtNullableRef(f.tpe) val ktName = KtTypeTranslator.escapeKtKeyword(f.name.name) val methodTree = q"val $ktName: $t" prependDocs(f.docs, methodTree) } - val contractParents = contract.contracts.map(c => trans.toKtTypeRefKeepForeigns(c, domain, evo)) + val contractParents = contract.contracts.map(c => domainTypes.toKtTypeRefKeepForeigns(c)) val parents = (contractParents :+ genMarker).distinct val parentsList = if (parents.nonEmpty) q" : ${parents.map(t => q"$t").join(", ")}" else q"" val body = if (methods.nonEmpty) methods.joinN() else q"" @@ -433,7 +434,7 @@ object KtDefnTranslator { } val methods = service.methods.map { m => - val plan = new ServiceMethodPlan(m, trans.asKtRef(_, domain, evo), resolved, KtTypeTranslator.escapeKtKeyword(m.name.name)) + val plan = new ServiceMethodPlan(m, domainTypes.asKtRef(_), resolved, KtTypeTranslator.escapeKtKeyword(m.name.name)) val in = plan.input val out = plan.output val err = plan.error @@ -495,7 +496,7 @@ object KtDefnTranslator { case None => q"" case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => q"" case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toKtTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(f.id) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val codecFqn = s"${srcRef.pkg.parts.mkString(".")}.$hostName" @@ -677,7 +678,7 @@ object KtDefnTranslator { val valVar = s"${srcFieldName}_v" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asKtRef(f.tpe, domain, evo) + val tpe = domainTypes.asKtRef(f.tpe) // All locals beyond `valVar` (which the constructor consumes) are scoped // per-field with unique suffix names so multiple fields can declare them @@ -790,7 +791,7 @@ object KtDefnTranslator { |if ($rNh is $baboonEither.Left) return $baboonEither.Left($rNh.value) |val $valVar: $tpe = ($rNh as $baboonEither.Right).value""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toKtTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toKtTypeRefKeepForeigns(uid) val nestedCodec = KtType(nestedTpe.pkg, s"${nestedTpe.name}Codec") val rOpen = s"${srcFieldName}_ro" val rClose = s"${srcFieldName}_rc" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala index bce5d0482..06727b035 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala @@ -25,6 +25,7 @@ object KtDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: KtTypeTranslator, + domainTypes: KtDomainTypes, ktTypes: KtTypes, ) extends KtDomainTreeTools { import ktTypes.* @@ -38,7 +39,7 @@ object KtDomainTreeTools { } private def mainMeta(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asKtType(defn.id, domain, evolution).fullyQualified + val ref = domainTypes.asKtType(defn.id).fullyQualified val baboonDomainVersion = MetaField( q"val baboonDomainVersion: $ktString", q"\"${domain.version.v.toString}\"", @@ -60,7 +61,7 @@ object KtDomainTreeTools { private def adtMeta(defn: DomainMember.User): List[MetaField] = { defn.id.owner match { case Owner.Adt(id) => - val adtRef = typeTranslator.asKtType(defn.id, domain, evolution).fullyQualified + val adtRef = domainTypes.asKtType(defn.id).fullyQualified val adtTypeIdentifier = MetaField( q"val baboonAdtTypeIdentifier: $ktString", q"\"${id.toString}\"", @@ -68,7 +69,7 @@ object KtDomainTreeTools { ) val baboonAdtType = MetaField( q"val baboonAdtType: ${javaClass.fullyQualified}<*>", - q"${typeTranslator.asKtType(id, domain, evolution)}$classRefSuffix", + q"${domainTypes.asKtType(id)}$classRefSuffix", q"$adtRef.baboonAdtType", ) List(adtTypeIdentifier, baboonAdtType) @@ -77,7 +78,7 @@ object KtDomainTreeTools { } private def sameInVersion(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asKtType(defn.id, domain, evolution).fullyQualified + val ref = domainTypes.asKtType(defn.id).fullyQualified val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField( q"val baboonSameInVersions: $ktList<$ktString>", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala new file mode 100644 index 000000000..a6d3898c8 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala @@ -0,0 +1,22 @@ +package io.septimalmind.baboon.translator.kotlin + +import io.septimalmind.baboon.translator.kotlin.KtValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[KtTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[KtTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class KtDomainTypes(trans: KtTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asKtRef(tpe: TypeRef): TextTree[KtValue] = trans.asKtRef(tpe, domain, evo) + + def asKtNullableRef(tpe: TypeRef): TextTree[KtValue] = trans.asKtNullableRef(tpe, domain, evo) + + def asKtType(tpe: TypeId): KtType = trans.asKtType(tpe, domain, evo) + + def toKtTypeRefKeepForeigns(tid: TypeId.User): KtType = trans.toKtTypeRefKeepForeigns(tid, domain, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala index ab3952d41..5650561b1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class KtJsonCodecGenerator( trans: KtTypeTranslator, + domainTypes: KtDomainTypes, target: KtTarget, domain: Domain, evo: BaboonEvolution, @@ -217,7 +218,7 @@ class KtJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => // PR-I.1b (M24 Phase 3.1): Custom-foreign map keys route through // the emitted `_KeyCodecHost.instance` extension hook. - val srcRef = trans.toKtTypeRefKeepForeigns(uid, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(uid) val hostTpe = KtValue.KtType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost") q"$hostTpe.instance.encodeKey($ref)" case None => @@ -253,11 +254,11 @@ class KtJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref) case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"$targetTpe.encode(ctx, $ref)" } case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"$targetTpe.encode(ctx, $ref)" } case o => @@ -294,11 +295,11 @@ class KtJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => decodeElement(aliasedRef, ref) case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"$targetTpe.decode(ctx, $ref)" } case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"$targetTpe.decode(ctx, $ref)" } case o => @@ -348,7 +349,7 @@ class KtJsonCodecGenerator( case ud: DomainMember.User => ud.defn match { case _: Typedef.Enum => - val targetTpe = trans.toKtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toKtTypeRefKeepForeigns(u) q"$targetTpe.parse($ref) ?: throw IllegalArgumentException(\"Cannot parse enum key: \" + $ref)" case f: Typedef.Foreign => f.bindings.get(BaboonLang.Kotlin) match { @@ -359,7 +360,7 @@ class KtJsonCodecGenerator( // the emitted `_KeyCodecHost.instance` extension hook. // catch (e: Exception) — NOT Throwable (PR-I-D01 pattern guidance): // Throwable would swallow Error (OOM/StackOverflow), defeating fail-fast. - val srcRef = trans.toKtTypeRefKeepForeigns(u, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(u) val hostTpe = KtValue.KtType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost") q"""try { $hostTpe.instance.decodeKey($ref) } catch (__kc: Exception) { throw $baboonCodecException.DecoderFailure("malformed key: " + $ref, __kc) }""" case None => @@ -369,13 +370,13 @@ class KtJsonCodecGenerator( // PR-F (M24): throw BaboonCodecException.DecoderFailure on Left for // cross-language malformed-key consistency (replaces unchecked cast). case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.toKtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toKtTypeRefKeepForeigns(u) val nestedCodec = KtValue.KtType(targetTpe.pkg, s"${targetTpe.name}Codec") q"""when (val __r = $nestedCodec.parseRepr($ref)) { is $baboonEither.Right -> __r.value; is $baboonEither.Left -> throw $baboonCodecException.DecoderFailure("malformed key: " + $ref) }""" // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.toKtTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toKtTypeRefKeepForeigns(u) val innerDec = decodeKey(inner.tpe, ref) q"$targetTpe($innerDec)" case o => throw new RuntimeException(s"BUG: Unexpected key usertype: $o") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala index e6f060829..7f389f608 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala @@ -19,6 +19,7 @@ object KtServiceWiringTranslator { class Impl( target: KtTarget, trans: KtTypeTranslator, + domainTypes: KtDomainTypes, codecs: Set[KtCodecTranslator], domain: Domain, evo: BaboonEvolution, @@ -53,7 +54,7 @@ object KtServiceWiringTranslator { ServiceResultResolver.resolve(domain, "kotlin", target.language.serviceResult, target.language.pragmas) private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[KtValue]] = - new ServiceMethodPlan(method, trans.asKtRef(_, domain, evo), resolved, KtTypeTranslator.escapeKtKeyword(method.name.name)) + new ServiceMethodPlan(method, domainTypes.asKtRef(_), resolved, KtTypeTranslator.escapeKtKeyword(method.name.name)) private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "kotlin", target.language.serviceContext, target.language.pragmas) @@ -115,12 +116,12 @@ object KtServiceWiringTranslator { } private def jsonCodecName(typeId: TypeId.User): KtValue.KtType = { - val srcRef = trans.toKtTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(typeId) KtValue.KtType(srcRef.pkg, s"${srcRef.name}_JsonCodec", srcRef.fq) } private def uebaCodecName(typeId: TypeId.User): KtValue.KtType = { - val srcRef = trans.toKtTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toKtTypeRefKeepForeigns(typeId) KtValue.KtType(srcRef.pkg, s"${srcRef.name}_UEBACodec", srcRef.fq) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala index 8c69bcd1f..b17620146 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class KtUEBACodecGenerator( trans: KtTypeTranslator, + domainTypes: KtDomainTypes, target: KtTarget, domain: Domain, evo: BaboonEvolution, @@ -180,7 +181,7 @@ class KtUEBACodecGenerator( val branchName = m.name.name val fqBranch = q"$branchNs.$branchName" - val adtRef = trans.toKtTypeRefKeepForeigns(m, domain, evo) + val adtRef = domainTypes.toKtTypeRefKeepForeigns(m) val cName = codecName(adtRef) val castedName = KtTypeTranslator.escapeKtKeyword(branchName.toLowerCase) @@ -396,11 +397,11 @@ class KtUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkDecoder(aliasedRef) case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, wire)""" } case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, wire)""" } } @@ -436,11 +437,11 @@ class KtUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref, wref) case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" } case _ => - val targetTpe = codecName(trans.toKtTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toKtTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala index fd673a242..e4db9f24e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala @@ -28,6 +28,7 @@ object PyCodecFixtureTranslator { final class PyCodecFixtureTranslatorImpl( typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, enquiries: BaboonEnquiries, evolution: BaboonEvolution, pyFileTools: PyFileTools, @@ -140,7 +141,7 @@ object PyCodecFixtureTranslator { case TypeId.Builtins.bytes => q"$baboonFixture.next_bytes()" case id: TypeId.User if enquiries.isEnum(tpe, domain) => - val tpe = typeTranslator.asPyType(id, domain, evolution, pyFileTools.definitionsBasePkg) + val tpe = domainTypes.asPyType(id, pyFileTools.definitionsBasePkg) q"$baboonFixture.next_random_enum($tpe)" case u: TypeId.User => // Propagate the codec branch into nested user-type fixtures so any-fields nested in diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala index 992d064c2..596e00d10 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala @@ -17,6 +17,7 @@ object PyCodecTestTranslator { final class PyCodecTestTranslatorImpl( fixtureTranslator: PyCodecFixtureTranslator, typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, codecs: Set[PyCodecTranslator], enquiries: BaboonEnquiries, evolution: BaboonEvolution, @@ -153,7 +154,7 @@ object PyCodecTestTranslator { val randomAllMethod = if (useJsonAny) "random_all_json" else "random_all" defn.defn match { case _: Typedef.Enum => - val enumTpe = typeTranslator.asPyType(defn.id, domain, evolution, pyFileTools.definitionsBasePkg) + val enumTpe = domainTypes.asPyType(defn.id, pyFileTools.definitionsBasePkg) q"fixture = $baboonFixture.next_random_enum($enumTpe)" case _: Typedef.Adt => val fixtureType = fixtureTranslator.fixtureType(defn.id) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala index c5cb9b846..b2e8aa8a5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala @@ -49,6 +49,7 @@ object PyDefnTranslator { codecsFixture: PyCodecFixtureTranslator, codecsTests: PyCodecTestTranslator, typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, baboonEnquiries: BaboonEnquiries, codecs: Set[PyCodecTranslator], pyDomTrees: PyDomainTreeTools, @@ -83,8 +84,8 @@ object PyDefnTranslator { val codecsTestsOut = codecsTests .translate( defn, - typeTranslator.asPyType(defn.id, domain, evolution, fileTools.definitionsBasePkg), - typeTranslator.asPyTypeKeepForeigns(defn.id, domain, evolution, fileTools.definitionsBasePkg), + domainTypes.asPyType(defn.id, fileTools.definitionsBasePkg), + domainTypes.asPyTypeKeepForeigns(defn.id, fileTools.definitionsBasePkg), ).map( codecsTest => Output( @@ -188,8 +189,8 @@ object PyDefnTranslator { } } - val pyRef = typeTranslator.asPyType(defn.id, domain, evolution, fileTools.definitionsBasePkg) - val srcRef = typeTranslator.asPyTypeKeepForeigns(defn.id, domain, evolution, fileTools.definitionsBasePkg) + val pyRef = domainTypes.asPyType(defn.id, fileTools.definitionsBasePkg) + val srcRef = domainTypes.asPyTypeKeepForeigns(defn.id, fileTools.definitionsBasePkg) val repr = mkRepr(defn, isLatestVersion) val defnRepr = List(obsoletePrevious(repr.defn)) @@ -244,7 +245,7 @@ object PyDefnTranslator { val genMarkerParent = if (adtParent.nonEmpty || contractParents.nonEmpty) Nil else List(genMarker) val adtMemberMetaParent = if (adtParent.isEmpty) Nil else List(baboonAdtMemberMeta) - val superclassesTypes = (adtParent ++ uniqueContracts).map(c => typeTranslator.asPyType(c, domain, evolution, fileTools.definitionsBasePkg)) + val superclassesTypes = (adtParent ++ uniqueContracts).map(c => domainTypes.asPyType(c, fileTools.definitionsBasePkg)) val parentTypes = superclassesTypes ++ genMarkerParent ++ adtMemberMetaParent :+ pydanticBaseModel @@ -318,7 +319,7 @@ object PyDefnTranslator { val adtContractDefs = adt.contracts.flatMap(domain.defs.meta.nodes.get).collect { case u: DomainMember.User => u } val adtSuperclasses = baboonEnquiries.collectParents(domain, adtContractDefs).toSet val uniqueAdtContracts = adt.contracts.filterNot(c => adtSuperclasses.contains(c)) - val contracts = uniqueAdtContracts.map(c => typeTranslator.asPyType(c, domain, evolution, fileTools.definitionsBasePkg)) + val contracts = uniqueAdtContracts.map(c => domainTypes.asPyType(c, fileTools.definitionsBasePkg)) val defaultParents = contracts ++ List(pydanticBaseModel) val genMarkerParent = if (adt.contracts.isEmpty) List(genMarker) else Nil val allParents = defaultParents ++ genMarkerParent @@ -390,12 +391,12 @@ object PyDefnTranslator { ) case contract: Typedef.Contract => - val contracts = contract.contracts.map(c => typeTranslator.asPyType(c, domain, evolution, fileTools.definitionsBasePkg)) + val contracts = contract.contracts.map(c => domainTypes.asPyType(c, fileTools.definitionsBasePkg)) val allParents = if (contract.contracts.isEmpty) List(genMarker, pyABC) ++ contracts else contracts val parents = mkParents(allParents) val methods = contract.fields.map { f => - val tpe = typeTranslator.asPyRef(f.tpe, domain, evolution, fileTools.definitionsBasePkg) + val tpe = domainTypes.asPyRef(f.tpe, fileTools.definitionsBasePkg) val name = escapePyKeyword(f.name.name) q"""@$pyAbstractMethod |def $name(self) -> $tpe: @@ -423,9 +424,9 @@ object PyDefnTranslator { } val methods = service.methods.map { m => - val inType = typeTranslator.asPyRef(m.sig, domain, evolution, fileTools.definitionsBasePkg) - val outType = m.out.map(typeTranslator.asPyRef(_, domain, evolution, fileTools.definitionsBasePkg)) - val errType = m.err.map(typeTranslator.asPyRef(_, domain, evolution, fileTools.definitionsBasePkg)) + val inType = domainTypes.asPyRef(m.sig, fileTools.definitionsBasePkg) + val outType = m.out.map(domainTypes.asPyRef(_, fileTools.definitionsBasePkg)) + val errType = m.err.map(domainTypes.asPyRef(_, fileTools.definitionsBasePkg)) val retAnnotation: TextTree[PyValue] = if (resolved.noErrors || errType.isEmpty) { outType.getOrElse(q"None") } else { @@ -499,8 +500,8 @@ object PyDefnTranslator { // Foreign types are not emitted as Python typealiases (see line 90+ of mkRepr — pre-PR // emitted q"" for Foreign), so the user-facing name "FStr" doesn't exist as a Python // identifier. Methods accept/return the runtime Python type directly. - val srcRef = typeTranslator.asPyTypeKeepForeigns(f.id, domain, evolution, fileTools.definitionsBasePkg) - val pyRef = typeTranslator.asPyType(f.id, domain, evolution, fileTools.definitionsBasePkg) + val srcRef = domainTypes.asPyTypeKeepForeigns(f.id, fileTools.definitionsBasePkg) + val pyRef = domainTypes.asPyType(f.id, fileTools.definitionsBasePkg) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val hostFqn = s"${srcRef.moduleId.path.toList.mkString(".")}.$hostName" @@ -547,7 +548,7 @@ object PyDefnTranslator { field => val plan = PyFieldPlan(field, contractsFields.contains(field)) val fieldName = plan.wireName - val fieldType = typeTranslator.asPyRef(field.tpe, domain, evolution, fileTools.definitionsBasePkg) + val fieldType = domainTypes.asPyRef(field.tpe, fileTools.definitionsBasePkg) // A trailing `_` suffix is needed when either the field is a Python keyword (PEP 8 // convention) or the field implements a contract abstract method (to avoid a Python // name-clash with the @property accessor). In both cases a pydantic alias preserves the @@ -569,7 +570,7 @@ object PyDefnTranslator { // valid Python (e.g. `def class_(self)` instead of `def class(self)`). val propertyName = plan.accessorName q"""@property - |def $propertyName(self) -> ${typeTranslator.asPyRef(f.tpe, domain, evolution, fileTools.definitionsBasePkg)}: + |def $propertyName(self) -> ${domainTypes.asPyRef(f.tpe, fileTools.definitionsBasePkg)}: | return self.${plan.attributeName} |""".stripMargin } @@ -809,7 +810,7 @@ object PyDefnTranslator { | return $resVar |$valVar = $resVar.value""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = typeTranslator.asPyTypeKeepForeigns(uid, domain, evolution, fileTools.definitionsBasePkg) + val nestedTpe = domainTypes.asPyTypeKeepForeigns(uid, fileTools.definitionsBasePkg) val nestedCodec = PyType(nestedTpe.moduleId, s"${nestedTpe.name}Codec") q"""${srcFieldName}_ro = cursor.expect("{") |if isinstance(${srcFieldName}_ro, $baboonLeftType): diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala index 9eb3ed0a8..5b50e135a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala @@ -15,6 +15,7 @@ object PyDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, pyFileTools: PyFileTools, ) extends PyDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): Seq[TextTree[PyValue]] = { @@ -59,7 +60,7 @@ object PyDomainTreeTools { } private def makeRefMeta(defn: DomainMember.User): Seq[TextTree[PyValue]] = { - val pyType = typeTranslator.asPyType(defn.id, domain, evolution, pyFileTools.definitionsBasePkg) + val pyType = domainTypes.asPyType(defn.id, pyFileTools.definitionsBasePkg) val adtMethods = defn.id.owner match { case Owner.Adt(id) => @@ -67,7 +68,7 @@ object PyDomainTreeTools { q"""baboon_adt_type_identifier: $pyClassVar[$pyStr] = $pyType.baboon_adt_type_identifier |@$pyClassMethod |def baboon_adt_type(cls) -> type: - | return type(${typeTranslator.asPyType(id, domain, evolution, pyFileTools.definitionsBasePkg)}) + | return type(${domainTypes.asPyType(id, pyFileTools.definitionsBasePkg)}) |""".stripMargin ) case _ => Nil diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala new file mode 100644 index 000000000..4314ac71c --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala @@ -0,0 +1,24 @@ +package io.septimalmind.baboon.translator.python + +import io.septimalmind.baboon.translator.python.PyValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[PyTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[PyTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class PyDomainTypes(trans: PyTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asPyRef(tpe: TypeRef, pkgBase: List[String] = Nil): TextTree[PyValue] = trans.asPyRef(tpe, domain, evo, pkgBase) + + def asPyType(tpe: TypeId, pkgBase: List[String] = Nil): PyType = trans.asPyType(tpe, domain, evo, pkgBase) + + def asPyTypeDerefForeign(tid: TypeId.User, pkgBase: List[String] = Nil): PyType = trans.asPyTypeDerefForeign(tid, domain, evo, pkgBase) + + def asPyTypeKeepForeigns(tid: TypeId.User, pkgBase: List[String] = Nil): PyType = trans.asPyTypeKeepForeigns(tid, domain, evo, pkgBase) + + def asPyTypeVersioned(tid: TypeId.User, pkgBase: List[String]): PyType = trans.asPyTypeVersioned(tid, domain, evo, pkgBase) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala index 299ffdbc8..1f6531373 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala @@ -13,6 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree.Quote final class PyJsonCodecGenerator( typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, treeTools: PyDomainTreeTools, pyFileTools: PyFileTools, evolution: BaboonEvolution, @@ -268,7 +269,7 @@ final class PyJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkJsonKeyEncoder(aliasedRef, ref) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val srcRef = typeTranslator.asPyTypeKeepForeigns(u, domain, evolution, pyFileTools.definitionsBasePkg) + val srcRef = domainTypes.asPyTypeKeepForeigns(u, pyFileTools.definitionsBasePkg) val host = PyType(srcRef.moduleId, s"${srcRef.name}_KeyCodecHost") q"$host.instance().encode_key($ref)" case None => @@ -340,10 +341,10 @@ final class PyJsonCodecGenerator( // (ScJsonCodecGenerator.scala:476-481). A malformed key raises ValueError from the // enum constructor — consistent with the fail-fast contract for the other key arms. case Some(DomainMember.User(_, _: Typedef.Enum, _, _)) => - val tpeRef = typeTranslator.asPyTypeKeepForeigns(u, domain, evolution, pyFileTools.definitionsBasePkg) + val tpeRef = domainTypes.asPyTypeKeepForeigns(u, pyFileTools.definitionsBasePkg) q"$tpeRef($ref)" case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.isIdentifier => - val tpeRef = typeTranslator.asPyTypeKeepForeigns(u, domain, evolution, pyFileTools.definitionsBasePkg) + val tpeRef = domainTypes.asPyTypeKeepForeigns(u, pyFileTools.definitionsBasePkg) val codecClass = PyType(tpeRef.moduleId, s"${tpeRef.name}Codec") // PR-F (M24): throw BaboonCodecException.DecoderFailure on Left for cross-language // malformed-key consistency. Use a single-call lambda to bind the parse result @@ -354,7 +355,7 @@ final class PyJsonCodecGenerator( q"""(lambda __r: __r.value if isinstance(__r, $baboonRightType) else (_ for _ in ()).throw($baboonCodecException.DecoderFailure(f"malformed key: {$ref}")))($codecClass.parse_repr($ref))""" case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val tpeRef = typeTranslator.asPyTypeKeepForeigns(u, domain, evolution, pyFileTools.definitionsBasePkg) + val tpeRef = domainTypes.asPyTypeKeepForeigns(u, pyFileTools.definitionsBasePkg) val innerDec = mkJsonKeyDecoder(inner.tpe, ref) // Use the keyword-escaped attribute name as the constructor kwarg. val attrName = if (PyKeywords.isKeyword(inner.name.name)) s"${inner.name.name}_" else inner.name.name @@ -369,7 +370,7 @@ final class PyJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkJsonKeyDecoder(aliasedRef, ref) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val srcRef = typeTranslator.asPyTypeKeepForeigns(u, domain, evolution, pyFileTools.definitionsBasePkg) + val srcRef = domainTypes.asPyTypeKeepForeigns(u, pyFileTools.definitionsBasePkg) val host = PyType(srcRef.moduleId, s"${srcRef.name}_KeyCodecHost") // PR-I.2-D01: wrap the host call via `_try_decode_key` to re-raise any exception // (including user-raised DecoderFailure with a custom message) as diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala index 9da67ef08..e08d2ba10 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala @@ -21,6 +21,7 @@ object PyServiceWiringTranslator { class Impl( target: PyTarget, typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, codecs: Set[PyCodecTranslator], domain: Domain, evolution: BaboonEvolution, @@ -202,8 +203,8 @@ object PyServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => - val inType = typeTranslator.asPyRef(m.sig, domain, evolution, fileTools.definitionsBasePkg) - val outType = m.out.map(o => typeTranslator.asPyRef(o, domain, evolution, fileTools.definitionsBasePkg)) + val inType = domainTypes.asPyRef(m.sig, fileTools.definitionsBasePkg) + val outType = m.out.map(o => domainTypes.asPyRef(o, fileTools.definitionsBasePkg)) val retAnnot: TextTree[PyValue] = outType.getOrElse(q"None") val uebaMethod = if (hasUeba) { @@ -509,7 +510,7 @@ object PyServiceWiringTranslator { private def generateNoErrorsWiring(service: Typedef.Service): TextTree[PyValue] = { val svcName = service.id.name.name - val svcType = typeTranslator.asPyType(service.id, domain, evolution, fileTools.definitionsBasePkg) + val svcType = domainTypes.asPyType(service.id, fileTools.definitionsBasePkg) val jsonFn = if (hasActiveJsonCodecs(service)) @@ -655,7 +656,7 @@ object PyServiceWiringTranslator { private def generateErrorsWiring(service: Typedef.Service): TextTree[PyValue] = { val svcName = service.id.name.name - val svcType = typeTranslator.asPyType(service.id, domain, evolution, fileTools.definitionsBasePkg) + val svcType = domainTypes.asPyType(service.id, fileTools.definitionsBasePkg) val jsonFn = if (hasActiveJsonCodecs(service)) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala index 20ac8f6bd..6a8883df0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala @@ -17,9 +17,9 @@ object RsCodecFixtureTranslator { final class RsCodecFixtureTranslatorImpl( target: RsTarget, translator: RsTypeTranslator, + domainTypes: RsDomainTypes, enquiries: BaboonEnquiries, domain: Domain, - evo: BaboonEvolution, ) extends RsCodecFixtureTranslator { override def translate(definition: DomainMember.User): Option[TextTree[RsValue]] = { @@ -46,7 +46,7 @@ object RsCodecFixtureTranslator { private case object FixJson extends FixtureFormat private def doTranslateDto(dto: Typedef.Dto): TextTree[RsValue] = { - val fullType = translator.toRsTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toRsTypeRefKeepForeigns(dto.id) def body(format: FixtureFormat): TextTree[RsValue] = { val generatedFields = dto.fields.map { @@ -70,7 +70,7 @@ object RsCodecFixtureTranslator { } private def doTranslateAdt(adt: Typedef.Adt): TextTree[RsValue] = { - val adtName = translator.asRsType(adt.id, domain, evo) + val adtName = domainTypes.asRsType(adt.id) val members = adt.members.toList .flatMap(domain.defs.meta.nodes.get) .collect { case DomainMember.User(_, d: Typedef.Dto, _, _) => d } @@ -123,7 +123,7 @@ object RsCodecFixtureTranslator { f => q"${toSnakeCase(f.name.name)}: ${genType(f.tpe, format)}," } - val fullType = translator.toRsTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toRsTypeRefKeepForeigns(dto.id) val rndParam = if (dto.fields.isEmpty) "_rnd" else "rnd" val name = format match { case FixUeba => fixtureFnName(dto.id) @@ -236,7 +236,7 @@ object RsCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.next_bit()" case u: TypeId.User if enquiries.isEnum(tpe, domain) => - val enumType = translator.asRsType(u, domain, evo) + val enumType = domainTypes.asRsType(u) q"rnd.mk_enum(&$enumType::all())" case u: TypeId.User => // Propagate the codec branch into nested user-type fixtures so any-fields nested in diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index faec32b01..d69d29182 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -38,6 +38,7 @@ object RsDefnTranslator { rsFiles: RsFileTools, rsTrees: RsTreeTools, trans: RsTypeTranslator, + domainTypes: RsDomainTypes, codecs: Set[RsCodecTranslator], codecTests: RsCodecTestsTranslator, codecsFixture: RsCodecFixtureTranslator, @@ -63,7 +64,7 @@ object RsDefnTranslator { private def doTranslate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { val repr = makeRepr(defn) - val codecTrees = codecs.toList.flatMap(t => t.translate(defn, trans.asRsType(defn.id, domain, evo), trans.toRsTypeRefKeepForeigns(defn.id, domain, evo)).toList) + val codecTrees = codecs.toList.flatMap(t => t.translate(defn, domainTypes.asRsType(defn.id), domainTypes.toRsTypeRefKeepForeigns(defn.id)).toList) val metaTrees = makeMetaImpls(defn) val allDefs = (repr +: codecTrees ++: metaTrees).joinNN() @@ -128,8 +129,8 @@ object RsDefnTranslator { } private def doTranslateTest(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val csTypeRef = trans.asRsType(defn.id, domain, evo) - val srcRef = trans.toRsTypeRefKeepForeigns(defn.id, domain, evo) + val csTypeRef = domainTypes.asRsType(defn.id) + val srcRef = domainTypes.toRsTypeRefKeepForeigns(defn.id) val testTreeOpt = codecTests.translate(defn, csTypeRef, srcRef) F.pure(testTreeOpt.map { testTree => @@ -159,7 +160,7 @@ object RsDefnTranslator { private def makeMetaImpls(defn: DomainMember.User): List[TextTree[RsValue]] = { val isLatestVersion = domain.version == evo.latest - val name = trans.asRsType(defn.id, domain, evo) + val name = domainTypes.asRsType(defn.id) defn.defn match { case _: Typedef.Dto => @@ -174,7 +175,7 @@ object RsDefnTranslator { mid => domain.defs.meta.nodes.get(mid) match { case Some(mdefn: DomainMember.User) => - val branchName = trans.asRsType(mdefn.id, domain, evo) + val branchName = domainTypes.asRsType(mdefn.id) List(rsDomainTreeTools.makeBaboonGeneratedImpl(mdefn, branchName, isLatestVersion)) case _ => Nil } @@ -188,7 +189,7 @@ object RsDefnTranslator { } private def makeRepr(defn: DomainMember.User): TextTree[RsValue] = { - val name = trans.asRsType(defn.id, domain, evo) + val name = domainTypes.asRsType(defn.id) defn.defn match { case dto: Typedef.Dto => @@ -232,7 +233,7 @@ object RsDefnTranslator { case None => q"" case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => q"" case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toRsTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toRsTypeRefKeepForeigns(f.id) val foreignName = srcRef.name val snake = toSnakeCase(foreignName) val codecTrait = s"${foreignName}_KeyCodec" @@ -242,7 +243,7 @@ object RsDefnTranslator { val registerName = s"register_${snake}_keycodec" val adapterMod = s"${snake}_as_map_key" val hostFqn = (srcRef.crate.parts.toSeq :+ getterName).mkString("::") - val derefedTpe = trans.asRsType(f.id, domain, evo) + val derefedTpe = domainTypes.asRsType(f.id) // Stringy allowlist: only the actual Rust string type spellings the // codegen emits (`std::string::String` is what `asRsType` produces for // stringy Customs; `String` and `&str` are accepted as user-friendly @@ -470,7 +471,7 @@ object RsDefnTranslator { val rawVar = s"${toSnakeCase(srcFieldName)}_raw" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asRsRef(f.tpe, domain, evo) + val tpe = domainTypes.asRsRef(f.tpe) val parseHead = q"""crate::baboon_identifier_repr::parse_field_name(cursor, "$srcFieldName")?;""" @@ -544,7 +545,7 @@ object RsDefnTranslator { q"""let $rawVar = cursor.read_until_structural(); |let $valVar: $tpe = crate::baboon_identifier_repr::parse_bytes_hex(&$rawVar)?;""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toRsTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toRsTypeRefKeepForeigns(uid) // The nested type's parse_repr_cursor lives in // `_repr_codec` inside its own module — see // renderIdentifierParseRepr for naming rationale. @@ -755,14 +756,14 @@ object RsDefnTranslator { // ADT-owned contracts are not emitted as standalone traits — skip. case Owner.Adt(_) => None case _ => - val contractTpe = trans.asRsType(cid, domain, evo) + val contractTpe = domainTypes.asRsType(cid) // Every own contract field must be a duplicated host field; if // not, omit this impl rather than emit non-compiling code. if (c.fields.forall(f => hostFieldNames.contains(toSnakeCase(f.name.name)))) { val methods = c.fields.map { f => val rsName = toSnakeCase(f.name.name) - val rawT = trans.asRsRef(f.tpe, domain, evo) + val rawT = domainTypes.asRsRef(f.tpe) // D22/T55: the trait accessor always declares `-> &rawT` (never &Box). // When the host stores the field as Box (needsBox=true), the accessor // body uses `self.field.as_ref()` which returns `&rawT` via Deref — exactly @@ -812,7 +813,7 @@ object RsDefnTranslator { case Some(DomainMember.User(_, fdef: Typedef.Foreign, _, _)) => fdef.bindings.get(BaboonLang.Rust) match { case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val srcRef = trans.toRsTypeRefKeepForeigns(uid, domain, evo) + val srcRef = domainTypes.toRsTypeRefKeepForeigns(uid) val getter = s"${toSnakeCase(srcRef.name)}_keycodec" Some((srcRef.crate.parts.toSeq :+ getter).mkString("::")) case _ => None @@ -864,7 +865,7 @@ object RsDefnTranslator { val (leafTpe, _, wrappers) = peelWrapperChain(dto, name).getOrElse( throw new IllegalStateException(s"renderUserMapKeyAdapter: peelWrapperChain returned None for ${name.name}") ) - val leafT = trans.asRsRef(leafTpe, domain, evo) + val leafT = domainTypes.asRsRef(leafTpe) // Parse via str::parse; for primitive scalars Rust's FromStr matches // Display's wire form. Bytes/timestamps require dedicated parsers. // PR-I.3: foreign leaves route through the foreign's `_keycodec()` @@ -1095,7 +1096,7 @@ object RsDefnTranslator { case TypeRef.Scalar(uid: TypeId.User) => domain.defs.meta.nodes.get(uid) match { case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) if isUserMapKeyEligibleDto(dto) => - val rsT = trans.toRsTypeRefKeepForeigns(uid, domain, evo) + val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) val modName = s"${toSnakeCase(rsT.name)}_as_map_key" Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) // PR-I.3 (M24 Phase 3.3): direct foreign map key — route through @@ -1104,7 +1105,7 @@ object RsDefnTranslator { case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => f.bindings.get(BaboonLang.Rust) match { case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val rsT = trans.toRsTypeRefKeepForeigns(uid, domain, evo) + val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) val modName = s"${toSnakeCase(rsT.name)}_as_map_key" Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) case _ => None @@ -1167,7 +1168,7 @@ object RsDefnTranslator { case s @ TypeRef.Scalar(u: TypeId.User) => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, nested: Typedef.Dto, _, _)) => - val nestedRs = trans.toRsTypeRefKeepForeigns(u, domain, evo) + val nestedRs = domainTypes.toRsTypeRefKeepForeigns(u) peelWrapperChain(nested, nestedRs).map { case (leafTpe, deepPath, innerWrappers) => (leafTpe, s"$fieldSnake.$deepPath", innerWrappers :+ (fieldSnake, rsName)) @@ -1252,7 +1253,7 @@ object RsDefnTranslator { case mdefn: DomainMember.User => mdefn.defn match { case dto: Typedef.Dto => - val branchName = trans.asRsType(dto.id, domain, evo) + val branchName = domainTypes.asRsType(dto.id) prependDocs(mdefn.docs, makeDtoRepr(dto, branchName)) case other => throw new RuntimeException(s"BUG: ADT member should be Dto, got: $other") @@ -1266,7 +1267,7 @@ object RsDefnTranslator { mid => domain.defs.meta.nodes(mid) match { case mdefn: DomainMember.User => - codecs.toList.flatMap(_.translate(mdefn, trans.asRsType(mdefn.id, domain, evo), trans.toRsTypeRefKeepForeigns(mdefn.id, domain, evo)).toList) + codecs.toList.flatMap(_.translate(mdefn, domainTypes.asRsType(mdefn.id), domainTypes.toRsTypeRefKeepForeigns(mdefn.id)).toList) case _ => Nil } } @@ -1278,7 +1279,7 @@ object RsDefnTranslator { // rsVariantName: keyword-escaped Rust identifier used in source code. val wireVariantName = mid.name.name.capitalize val rsVariantName = escapeRustTypeName(wireVariantName) - val branchType = trans.asRsType(mid, domain, evo) + val branchType = domainTypes.asRsType(mid) q"$rsVariantName(${branchType.asName})," } @@ -1399,7 +1400,7 @@ object RsDefnTranslator { val contract = defn.defn.asInstanceOf[Typedef.Contract] val methods = contract.fields.map { f => - val t = trans.asRsRef(f.tpe, domain, evo) + val t = domainTypes.asRsRef(f.tpe) val methodEx = q"fn ${toSnakeCase(f.name.name)}(&self) -> &$t;" prependDocs(f.docs, methodEx) } @@ -1420,9 +1421,9 @@ object RsDefnTranslator { val service = defn.defn.asInstanceOf[Typedef.Service] val methods = service.methods.map { m => - val inType = trans.asRsRef(m.sig, domain, evo) - val outType = m.out.map(trans.asRsRef(_, domain, evo)) - val errType = m.err.map(trans.asRsRef(_, domain, evo)) + val inType = domainTypes.asRsRef(m.sig) + val outType = m.out.map(domainTypes.asRsRef(_)) + val errType = m.err.map(domainTypes.asRsRef(_)) val rsFqName: RsValue => String = { case t: RsValue.RsType => if (t.predef) t.name else (t.crate.parts :+ t.name).mkString("::") case t: RsValue.RsTypeName => t.name diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala new file mode 100644 index 000000000..7852ef2f3 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala @@ -0,0 +1,20 @@ +package io.septimalmind.baboon.translator.rust + +import io.septimalmind.baboon.translator.rust.RsValue.RsType +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[RsTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[RsTypeTranslator]] stays for the callers that genuinely mean another + * domain: a conversion renders the SOURCE version's types alongside the current ones. + */ +final class RsDomainTypes(trans: RsTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asRsRef(tpe: TypeRef): TextTree[RsValue] = trans.asRsRef(tpe, domain, evo) + + def asRsType(tpe: TypeId): RsType = trans.asRsType(tpe, domain, evo) + + def toRsTypeRefKeepForeigns(tid: TypeId.User): RsType = trans.toRsTypeRefKeepForeigns(tid, domain, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index afd151300..c695bcafe 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -12,7 +12,6 @@ class RsJsonCodecGenerator( trans: RsTypeTranslator, target: RsTarget, domain: Domain, - evo: BaboonEvolution, ) extends RsCodecTranslator { override def translate(defn: DomainMember.User, rsRef: RsValue.RsType, srcRef: RsValue.RsType): Option[TextTree[RsValue]] = { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala index b2d97afb7..52c77b131 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala @@ -20,6 +20,7 @@ object RsServiceWiringTranslator { class Impl( target: RsTarget, trans: RsTypeTranslator, + domainTypes: RsDomainTypes, codecs: Set[RsCodecTranslator], domain: Domain, evo: BaboonEvolution, @@ -197,7 +198,7 @@ object RsServiceWiringTranslator { val clientMethods = service.methods.flatMap { m => val inFq = inTypeFq(m) - val outFq = m.out.map(o => renderFq(q"${trans.asRsRef(o, domain, evo)}")).getOrElse("()") + val outFq = m.out.map(o => renderFq(q"${domainTypes.asRsRef(o)}")).getOrElse("()") val uebaMethod = if (hasUeba) { val decodeResult = m.out match { @@ -472,7 +473,7 @@ object RsServiceWiringTranslator { private def generateServiceWrappers(service: Typedef.Service): TextTree[RsValue] = { val svcName = service.id.name.name - val svcType = trans.asRsType(service.id, domain, evo) + val svcType = domainTypes.asRsType(service.id) val jsonWrapper = if (hasActiveJsonCodecs(service)) Some(generateOneWrapper(WrapperSpec(service, svcName, svcType, isJson = true))) else None @@ -653,7 +654,7 @@ object RsServiceWiringTranslator { } private def inTypeFq(m: Typedef.MethodDef): String = { - val ref = trans.asRsType(m.sig.id, domain, evo) + val ref = domainTypes.asRsType(m.sig.id) renderFq(q"$ref") } @@ -661,7 +662,7 @@ object RsServiceWiringTranslator { private def generateNoErrorsWiring(service: Typedef.Service): TextTree[RsValue] = { val svcName = service.id.name.name - val svcType = trans.asRsType(service.id, domain, evo) + val svcType = domainTypes.asRsType(service.id) val jsonFn = if (hasActiveJsonCodecs(service)) @@ -761,7 +762,7 @@ object RsServiceWiringTranslator { private def leftMapHint(m: Typedef.MethodDef): String = if (errorIsPhantom) { val errFq = m.err - .map(errRef => renderFq(q"${trans.asRsRef(errRef, domain, evo)}")) + .map(errRef => renderFq(q"${domainTypes.asRsRef(errRef)}")) .getOrElse("()") s"::<$errFq, _, _, _>" } else "" @@ -770,7 +771,7 @@ object RsServiceWiringTranslator { private def generateErrorsWiring(service: Typedef.Service): TextTree[RsValue] = { val svcName = service.id.name.name - val svcType = trans.asRsType(service.id, domain, evo) + val svcType = domainTypes.asRsType(service.id) val jsonFn = if (hasActiveJsonCodecs(service)) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala index 6559822c6..b6ca99f68 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsUEBACodecGenerator.scala @@ -12,6 +12,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class RsUEBACodecGenerator( trans: RsTypeTranslator, + domainTypes: RsDomainTypes, target: RsTarget, domain: Domain, evo: BaboonEvolution, @@ -294,7 +295,7 @@ class RsUEBACodecGenerator( val decBranches = branches.map { case (mid, idx) => val branchName = escapeRustTypeName(mid.name.name.capitalize) - val branchType = trans.asRsType(mid, domain, evo) + val branchType = domainTypes.asRsType(mid) if (target.language.wrappedAdtBranchCodecs) { q"""${idx.toString} => { | let v = ${branchType.asName}::decode_ueba_branch(ctx, reader)?; @@ -400,11 +401,11 @@ class RsUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkDecoder(aliasedRef) case _ => - val tpe = trans.asRsType(u, domain, evo) + val tpe = domainTypes.asRsType(u) q"${tpe.asName}::decode_ueba(ctx, reader)?" } case _ => - val tpe = trans.asRsType(u, domain, evo) + val tpe = domainTypes.asRsType(u) q"${tpe.asName}::decode_ueba(ctx, reader)?" } case o => throw new RuntimeException(s"BUG: Unexpected type: $o") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala index 14cdd644d..4051ef874 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala @@ -17,7 +17,7 @@ trait ScCodecFixtureTranslator { object ScCodecFixtureTranslator { final class ScRandomMethodTranslatorImpl( target: ScTarget, - translator: ScTypeTranslator, + domainTypes: ScDomainTypes, enquiries: BaboonEnquiries, domain: Domain, evo: BaboonEvolution, @@ -91,7 +91,7 @@ object ScCodecFixtureTranslator { private case object FixJson extends FixtureFormat private def doTranslateDto(dto: Typedef.Dto): TextTree[ScValue] = { - val fullType = translator.toScTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toScTypeRefKeepForeigns(dto.id) def body(format: FixtureFormat): TextTree[ScValue] = { if (dto.fields.isEmpty) { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala index 12c227a65..533020f18 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala @@ -19,7 +19,7 @@ trait ScCodecTestsTranslator { object ScCodecTestsTranslator { final class Impl( codecs: Set[ScCodecTranslator], - typeTranslator: ScTypeTranslator, + domainTypes: ScDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: ScTarget, @@ -58,8 +58,8 @@ object ScCodecTestsTranslator { // fixture (`random`); JSON codec test uses the `AnyOpaqueJson`-bearing fixture (`randomJson`). // Each fixture matches its codec's native any-field branch so round-trip avoids cross-format // conversion and never needs `BaboonCodecContext.WithFacade`. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { case jsonCodec: ScJsonCodecGenerator => @@ -162,16 +162,14 @@ object ScCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[ScValue] = { val randomMethod = if (useJsonAny) "randomJson" else "random" val randomAllMethod = if (useJsonAny) "randomAllJson" else "randomAll" definition.defn match { case e: Typedef.Enum => q"val fixture = rnd.randomElement(${e.id.name.name}.all)" - case _: Typedef.Adt => q"val fixtures = ${typeTranslator.asScType(definition.id, domain, evolution)}_Fixture.$randomAllMethod(rnd)" - case _ => q"val fixture = ${typeTranslator.asScType(definition.id, domain, evolution)}_Fixture.$randomMethod(rnd)" + case _: Typedef.Adt => q"val fixtures = ${domainTypes.asScType(definition.id)}_Fixture.$randomAllMethod(rnd)" + case _ => q"val fixture = ${domainTypes.asScType(definition.id)}_Fixture.$randomMethod(rnd)" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala index 41c0859c0..b5393a4b9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala @@ -49,7 +49,7 @@ object ScDefnTranslator { evo: BaboonEvolution, scFiles: ScFileTools, scTrees: ScTreeTools, - trans: ScTypeTranslator, + domainTypes: ScDomainTypes, codecs: Set[ScCodecTranslator], codecTests: ScCodecTestsTranslator, codecsFixture: ScCodecFixtureTranslator, @@ -67,7 +67,7 @@ object ScDefnTranslator { private def doTranslate(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { val repr = makeFullRepr(defn, inNs = true) - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val registrations = codecs.toList.map(codec => codec.id -> repr.codecs.flatMap(reg => reg.trees.get(codec.id).map(expr => q"${reg.tpeId}, $expr"))) @@ -116,7 +116,7 @@ object ScDefnTranslator { } private def doTranslateFixtures(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val fixtureTreeOut = makeFixtureRepr(defn).map { fixtureTreeWithNs => Output( @@ -130,7 +130,7 @@ object ScDefnTranslator { F.pure(fixtureTreeOut.toList) } private def makeFixtureRepr(defn: DomainMember.User): Option[TextTree[ScValue]] = { - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val fixtureTree = codecsFixture.translate(defn) @@ -146,7 +146,7 @@ object ScDefnTranslator { } } private def doTranslateTest(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val codecTestOut = makeTestRepr(defn).map { codecTestWithNS => Output( @@ -164,7 +164,7 @@ object ScDefnTranslator { val rtTree = wiringTranslator.translateServiceRt(domain) val result = rtTree.map { tree => - val pkg = trans.toScPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val wrapped = scTrees.inNs(pkg.parts.toSeq, tree) val fbase = scFiles.basename(domain, evo) Output( @@ -178,8 +178,8 @@ object ScDefnTranslator { } private def makeTestRepr(defn: DomainMember.User): Option[TextTree[ScValue]] = { - val csTypeRef = trans.asScType(defn.id, domain, evo) - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val csTypeRef = domainTypes.asScType(defn.id) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val ns = srcRef.pkg.parts val testTree = codecTests.translate(defn, csTypeRef, srcRef) @@ -212,8 +212,8 @@ object ScDefnTranslator { ): DefnRepr = { val isLatestVersion = domain.version == evo.latest - val scTypeRef = trans.asScType(defn.id, domain, evo) - val srcRef = trans.toScTypeRefKeepForeigns(defn.id, domain, evo) + val scTypeRef = domainTypes.asScType(defn.id) + val srcRef = domainTypes.toScTypeRefKeepForeigns(defn.id) val repr = makeRepr(defn, scTypeRef, isLatestVersion) @@ -285,14 +285,14 @@ object ScDefnTranslator { case dto: Typedef.Dto => val params = dto.fields.map { f => - val t = trans.asScRef(f.tpe, domain, evo) + val t = domainTypes.asScRef(f.tpe) val fieldEx = q"${escapeScKeyword(f.name.name)}: $t" prependDocs(f.docs, fieldEx) } val paramsList = if (params.nonEmpty) params.join(",\n") else q"" - val contractParents = dto.contracts.map(c => trans.toScTypeRefKeepForeigns(c, domain, evo)) + val contractParents = dto.contracts.map(c => domainTypes.toScTypeRefKeepForeigns(c)) val adtParents = dto.id.owner match { - case Owner.Adt(id) => Seq(trans.toScTypeRefKeepForeigns(id, domain, evo), iBaboonAdtMemberMeta) + case Owner.Adt(id) => Seq(domainTypes.toScTypeRefKeepForeigns(id), iBaboonAdtMemberMeta) case _ => Seq.empty } val parents = adtParents ++ contractParents :+ genMarker @@ -370,7 +370,7 @@ object ScDefnTranslator { DefnRepr(Seq(traitTree, companion).joinNN(), Nil) case adt: Typedef.Adt => - val parents = adt.contracts.map(c => trans.toScTypeRefKeepForeigns(c, domain, evo)) :+ genMarker + val parents = adt.contracts.map(c => domainTypes.toScTypeRefKeepForeigns(c)) :+ genMarker val extendsClauseAdt = if (parents.nonEmpty) q" extends ${parents.map(t => q"$t").join(" with ")}" else q"" val sealedTrait = q"""sealed trait ${name.name}$extendsClauseAdt""".stripMargin val memberTrees = adt.members.map { @@ -399,11 +399,11 @@ object ScDefnTranslator { case contract: Typedef.Contract => val methods = contract.fields.map { f => - val t = trans.asScRef(f.tpe, domain, evo) + val t = domainTypes.asScRef(f.tpe) val methodEx = q"def ${escapeScKeyword(f.name.name)}: $t" prependDocs(f.docs, methodEx) } - val parents = contract.contracts.map(c => trans.toScTypeRefKeepForeigns(c, domain, evo)) :+ genMarker + val parents = contract.contracts.map(c => domainTypes.toScTypeRefKeepForeigns(c)) :+ genMarker val extendsClause = if (parents.nonEmpty) q" extends ${parents.map(t => q"$t").join(" with ")}" else q"" val body = if (methods.nonEmpty) methods.joinN() else q"" DefnRepr( @@ -423,7 +423,7 @@ object ScDefnTranslator { } val methods = service.methods.map { m => - val plan = new ServiceMethodPlan(m, trans.asScRef(_, domain, evo), resolved, m.name.name) + val plan = new ServiceMethodPlan(m, domainTypes.asScRef(_), resolved, m.name.name) val in = plan.input val out = plan.output val err = plan.error @@ -491,7 +491,7 @@ object ScDefnTranslator { case None => Nil case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => Nil case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toScTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(f.id) val traitName = s"${srcRef.name}_KeyCodec" val traitFqn = s"${srcRef.pkg.parts.mkString(".")}.$traitName" val isStringy = decl == "java.lang.String" @@ -613,7 +613,7 @@ object ScDefnTranslator { | case Left(e) => return Left(e) |}""".stripMargin case IdentifierFieldKind.SignedInt | IdentifierFieldKind.SignedLong => - val tpeRef = trans.asScRef(f.tpe, domain, evo) + val tpeRef = domainTypes.asScRef(f.tpe) val rangeCheck = signedRangeCheck(f.tpe) val typeName = signedTypeName(f.tpe) q"""val ${fieldName}_raw = cursor.readUntilStructural() @@ -633,7 +633,7 @@ object ScDefnTranslator { | } |}""".stripMargin case IdentifierFieldKind.UnsignedSmallInt => - val tpeRef = trans.asScRef(f.tpe, domain, evo) + val tpeRef = domainTypes.asScRef(f.tpe) val rangeCheck = unsignedSmallRangeCheck(f.tpe) val typeName = unsignedSmallTypeName(f.tpe) q"""val ${fieldName}_raw = cursor.readUntilStructural() @@ -698,7 +698,7 @@ object ScDefnTranslator { | case Left(e) => return Left(e) |}""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toScTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toScTypeRefKeepForeigns(uid) val nestedCodec = ScValue.ScType(nestedTpe.pkg, s"${nestedTpe.name}Codec", nestedTpe.inObject) q"""cursor.expect('{') match { | case Left(e) => return Left(e) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala index 3fd07bad8..5b72fdc29 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala @@ -34,7 +34,7 @@ object ScDomainTreeTools { final class ScDomainTreeToolsImpl( domain: Domain, evolution: BaboonEvolution, - typeTranslator: ScTypeTranslator, + domainTypes: ScDomainTypes, ) extends ScDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { mainMeta(defn) ++ sameInVersion(defn) ++ adtMeta(defn) @@ -45,7 +45,7 @@ object ScDomainTreeTools { } private def mainMeta(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asScType(defn.id, domain, evolution) + val ref = domainTypes.asScType(defn.id) val baboonDomainVersion = MetaField( q"def baboonDomainVersion: $scString", q"\"${domain.version.v.toString}\"", @@ -67,7 +67,7 @@ object ScDomainTreeTools { private def adtMeta(defn: DomainMember.User): List[MetaField] = { defn.id.owner match { case Owner.Adt(id) => - val adtRef = typeTranslator.asScType(defn.id, domain, evolution) + val adtRef = domainTypes.asScType(defn.id) val adtTypeIdentifier = MetaField( q"def baboonAdtTypeIdentifier: $scString", q"\"${id.toString}\"", @@ -75,7 +75,7 @@ object ScDomainTreeTools { ) val baboonAdtType = MetaField( q"def baboonAdtType: _root_.${javaClass.fullyQualified}[?]", - q"${typeTranslator.asScType(id, domain, evolution)}.getClass", + q"${domainTypes.asScType(id)}.getClass", q"$adtRef.baboonAdtType", ) List(adtTypeIdentifier, baboonAdtType) @@ -84,7 +84,7 @@ object ScDomainTreeTools { } private def sameInVersion(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asScType(defn.id, domain, evolution) + val ref = domainTypes.asScType(defn.id) val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField.cached( "baboonSameInVersions", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTypes.scala new file mode 100644 index 000000000..d3112249f --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTypes.scala @@ -0,0 +1,24 @@ +package io.septimalmind.baboon.translator.scl + +import io.septimalmind.baboon.translator.scl.ScValue.{ScPackageId, ScType} +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[ScTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[ScTypeTranslator]] stays for the callers that genuinely mean another + * domain: a conversion renders the SOURCE version's types alongside the current ones, and the + * per-family translator walks several lineages. + */ +final class ScDomainTypes(trans: ScTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asScRef(tpe: TypeRef): TextTree[ScValue] = trans.asScRef(tpe, domain, evo) + + def asScType(tpe: TypeId): ScType = trans.asScType(tpe, domain, evo) + + def toScTypeRefKeepForeigns(tid: TypeId.User): ScType = trans.toScTypeRefKeepForeigns(tid, domain, evo) + + /** The package of the current domain's own version. */ + def currentPkg: ScPackageId = trans.toScPkg(domain.id, domain.version, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala index a95816bd7..20198b457 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScJsonCodecGenerator.scala @@ -12,7 +12,7 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class ScJsonCodecGenerator( - trans: ScTypeTranslator, + domainTypes: ScDomainTypes, target: ScTarget, domain: Domain, evo: BaboonEvolution, @@ -270,8 +270,8 @@ class ScJsonCodecGenerator( // impl is identity for stringy foreigns; non-stringy foreigns // require host registration before first use. val keyCodecHost = ScValue.ScType( - trans.toScTypeRefKeepForeigns(uid, domain, evo).pkg, - s"${trans.toScTypeRefKeepForeigns(uid, domain, evo).name}_KeyCodec", + domainTypes.toScTypeRefKeepForeigns(uid).pkg, + s"${domainTypes.toScTypeRefKeepForeigns(uid).name}_KeyCodec", ) q"$keyCodecHost.instance.encodeKey($ref)" case None => @@ -306,11 +306,11 @@ class ScJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref) case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"$targetTpe.instance.encode(ctx, $ref)" } case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"$targetTpe.instance.encode(ctx, $ref)" } case o => @@ -389,7 +389,7 @@ class ScJsonCodecGenerator( // `_JsonCodec` ScType (harvested by the ScType-only import collector, // ScBaboonTranslator.renderTree) instead of a bare string-concatenated // suffix that never resolves from a nested package (D5). - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(uid, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(uid)) // PR-F (M24): malformed map-key consistency — replace silent `.toOption` // with explicit Right/Left match that throws BaboonCodecException.DecoderFailure // on Left so cross-language behaviour is uniform. @@ -404,20 +404,20 @@ class ScJsonCodecGenerator( // uniform across stringy and non-stringy foreigns: the host's // decodeKey is invoked, and any thrown exception is normalized // to BaboonCodecException.DecoderFailure (PR-F invariant). - val targetTpe = trans.toScTypeRefKeepForeigns(uid, domain, evo) + val targetTpe = domainTypes.toScTypeRefKeepForeigns(uid) val keyCodecHost = ScValue.ScType(targetTpe.pkg, s"${targetTpe.name}_KeyCodec") q"""$circeKeyDecoder.instance(s => try { Some($keyCodecHost.instance.decodeKey(s)) } catch { case e: Exception => throw $baboonCodecException.DecoderFailure(s"malformed key: $$s", e) })""" } // M19/PR-60: id types — call the parser-based round trip. // PR-F (M24): throw BaboonCodecException.DecoderFailure on parse failure. case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.toScTypeRefKeepForeigns(uid, domain, evo) + val targetTpe = domainTypes.toScTypeRefKeepForeigns(uid) val nestedCodec = ScValue.ScType(targetTpe.pkg, s"${targetTpe.name}Codec", targetTpe.inObject) q"""$circeKeyDecoder.instance(s => $nestedCodec.parseRepr(s) match { case Right(v) => Some(v); case Left(_) => throw $baboonCodecException.DecoderFailure(s"malformed key: $$s") })""" // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.toScTypeRefKeepForeigns(uid, domain, evo) + val targetTpe = domainTypes.toScTypeRefKeepForeigns(uid) val innerKeyDec = getKeyDecoder(inner.tpe) q"$circeKeyDecoder.instance(s => $innerKeyDec.apply(s).map(v => $targetTpe(${inner.name.name} = v)))" case o => throw new RuntimeException(s"BUG: Unexpected key usertype: $o") @@ -447,11 +447,11 @@ class ScJsonCodecGenerator( // bare string suffix on the foreign type leaves it unimported and // additionally drags the companion-less foreign type into the import // set (D4). - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"$targetTpe.circeDecoder" } case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"$targetTpe.circeDecoder" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala index 6fcce7276..4b9a19f65 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala @@ -18,17 +18,16 @@ trait ScServiceWiringTranslator { object ScServiceWiringTranslator { class Impl( target: ScTarget, - trans: ScTypeTranslator, + domainTypes: ScDomainTypes, codecs: Set[ScCodecTranslator], domain: Domain, - evo: BaboonEvolution, ) extends ScServiceWiringTranslator { private val resolved: ResolvedServiceResult = ServiceResultResolver.resolve(domain, "scala", target.language.serviceResult, target.language.pragmas) private def methodPlan(method: Typedef.MethodDef): ServiceMethodPlan[TextTree[ScValue]] = - new ServiceMethodPlan(method, trans.asScRef(_, domain, evo), resolved, method.name.name) + new ServiceMethodPlan(method, domainTypes.asScRef(_), resolved, method.name.name) private val resolvedCtx: ResolvedServiceContext = ServiceContextResolver.resolve(domain, "scala", target.language.serviceContext, target.language.pragmas) @@ -74,12 +73,12 @@ object ScServiceWiringTranslator { if (ctxUsed) "" else "@scala.annotation.nowarn(\"cat=unused-params\")\n" private def jsonCodecName(typeId: TypeId.User): ScValue.ScType = { - val srcRef = trans.toScTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(typeId) ScValue.ScType(srcRef.pkg, s"${srcRef.name}_JsonCodec", fq = srcRef.fq) } private def uebaCodecName(typeId: TypeId.User): ScValue.ScType = { - val srcRef = trans.toScTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toScTypeRefKeepForeigns(typeId) ScValue.ScType(srcRef.pkg, s"${srcRef.name}_UEBACodec", fq = srcRef.fq) } @@ -430,7 +429,7 @@ object ScServiceWiringTranslator { // (e.g. `petstore` in `petstore.api.petstore.addpet.Out`) binds to a NESTED sub-package of // the enclosing `package petstore.api`, producing "object api is not a member of package // petstore.api.petstore" in depth-2+ packages. ScTypes constants already carry `_root_`; - // user refs from `trans.asScRef` / `toScPkg` do not, so we prepend it here. Predef types + // user refs from `domainTypes.asScRef` / `toScPkg` do not, so we prepend it here. Predef types // (Int, String, …) render bare and need no qualification. private def renderFq(tree: TextTree[ScValue]): String = tree.mapRender { case t: ScValue.ScType => @@ -664,7 +663,7 @@ object ScServiceWiringTranslator { // IBaboonServiceRt is always emitted in the domain root package; use the FQ name // so that wiring objects inside a namespace sub-package can still reference it. private val iBaboonServiceRtFq: String = { - val rootPkg = trans.toScPkg(domain.id, domain.version, evo) + val rootPkg = domainTypes.currentPkg ("_root_" :: (rootPkg.parts.toList :+ "IBaboonServiceRt")).mkString(".") } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala index 556221384..b5395849a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScUEBACodecGenerator.scala @@ -12,7 +12,7 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class ScUEBACodecGenerator( - trans: ScTypeTranslator, + domainTypes: ScDomainTypes, target: ScTarget, domain: Domain, evo: BaboonEvolution, @@ -381,11 +381,11 @@ class ScUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkDecoder(aliasedRef) case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, wire).toTry.get""" } case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"""$targetTpe.instance.decode(ctx, wire).toTry.get""" } } @@ -428,11 +428,11 @@ class ScUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkEncoder(aliasedRef, ref, wref) case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" } case _ => - val targetTpe = codecName(trans.toScTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toScTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala index 2c031e2ea..f7ae184ee 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala @@ -26,9 +26,9 @@ object SwCodecFixtureTranslator { final class Impl( translator: SwTypeTranslator, + domainTypes: SwDomainTypes, enquiries: BaboonEnquiries, domain: Domain, - evo: BaboonEvolution, ) extends SwCodecFixtureTranslator { override def translate( @@ -70,11 +70,11 @@ object SwCodecFixtureTranslator { } private def fixtureTpeName(id: TypeId.User): TextTree[SwValue] = { - q"${translator.fixtureClassName(id, domain, evo)}" + q"${domainTypes.fixtureClassName(id)}" } private def doTranslateDto(dto: Typedef.Dto): TextTree[SwValue] = { - val fullType = translator.toSwTypeRefKeepForeigns(dto.id, domain, evo) + val fullType = domainTypes.toSwTypeRefKeepForeigns(dto.id) def fields(format: FixtureFormat): TextTree[SwValue] = { dto.fields.map { @@ -100,7 +100,7 @@ object SwCodecFixtureTranslator { } private def doTranslateAdt(adt: Typedef.Adt): TextTree[SwValue] = { - val fullType = translator.toSwTypeRefKeepForeigns(adt.id, domain, evo) + val fullType = domainTypes.toSwTypeRefKeepForeigns(adt.id) val members = adt.members.toList .flatMap(domain.defs.meta.nodes.get) .collect { case DomainMember.User(_, d: Typedef.Dto, _, _) => d } @@ -219,7 +219,7 @@ object SwCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBool()" case u: TypeId.User if enquiries.isEnum(tpe, domain) => - val enumType = translator.toSwTypeRefKeepForeigns(u, domain, evo) + val enumType = domainTypes.toSwTypeRefKeepForeigns(u) q"rnd.mkEnum($enumType.all)" // PR-26.4 (M26): explicit Foreign-arm. BaboonRef-mapped Foreigns recurse on the aliased @@ -268,9 +268,9 @@ object SwCodecFixtureTranslator { } case u: TypeId.User => - val fixturePkg = translator.effectiveSwPkg(u.owner, domain, evo) - val fixtureClassName = translator.fixtureClassName(u, domain, evo) - val fixtureFileName = s"${translator.toSnakeCase(translator.toSwTypeRefKeepForeigns(u, domain, evo).name)}_fixture" + val fixturePkg = domainTypes.effectiveSwPkg(u.owner) + val fixtureClassName = domainTypes.fixtureClassName(u) + val fixtureFileName = s"${translator.toSnakeCase(domainTypes.toSwTypeRefKeepForeigns(u).name)}_fixture" val fixtureType = SwValue.SwType(fixturePkg, fixtureClassName, importAs = Some(fixtureFileName)) // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01 (Swift analog). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala index 18016046e..fed6b6a5a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala @@ -24,6 +24,7 @@ object SwCodecTestsTranslator { final class Impl( codecs: Set[SwCodecTranslator], typeTranslator: SwTypeTranslator, + domainTypes: SwDomainTypes, logger: BLogger, enquiries: BaboonEnquiries, target: SwTarget, @@ -82,8 +83,8 @@ object SwCodecTestsTranslator { // `AnyOpaque.json`-bearing fixture (`randomJson`). Each fixture matches its codec's native // any-field branch so round-trip avoids cross-format conversion and never needs a // `BaboonCodecContext.withFacade` ctx. - val uebaFixture = makeFixture(definition, domain, evo, useJsonAny = false) - val jsonFixture = makeFixture(definition, domain, evo, useJsonAny = true) + val uebaFixture = makeFixture(definition, useJsonAny = false) + val jsonFixture = makeFixture(definition, useJsonAny = true) codecs .filter(_.isActive(definition.id)).map { codec => @@ -145,12 +146,10 @@ object SwCodecTestsTranslator { private def makeFixture( definition: DomainMember.User, - domain: Domain, - evolution: BaboonEvolution, useJsonAny: Boolean, ): TextTree[SwValue] = { - val swType = typeTranslator.asSwType(definition.id, domain, evolution) - val fixtureName = typeTranslator.fixtureClassName(definition.id, domain, evolution) + val swType = domainTypes.asSwType(definition.id) + val fixtureName = domainTypes.fixtureClassName(definition.id) val randomMethod = if (useJsonAny) "randomJson" else "random" val randomAllMethod = if (useJsonAny) "randomAllJson" else "randomAll" definition.defn match { @@ -229,7 +228,7 @@ object SwCodecTestsTranslator { val typeId = definition.id.render q"""func testCrossLanguageJsonWrite() throws { | let rnd = $baboonRandomFactory.create() - | ${makeFixture(definition, domain, evo, useJsonAny = true).shift(4).trim} + | ${makeFixture(definition, useJsonAny = true).shift(4).trim} | ${makeJsonWriteBody(definition, srcRef).shift(4).trim} |} |""".stripMargin @@ -275,7 +274,7 @@ object SwCodecTestsTranslator { val typeId = definition.id.render q"""func testCrossLanguageUebaWrite() throws { | let rnd = $baboonRandomFactory.create() - | ${makeFixture(definition, domain, evo, useJsonAny = false).shift(4).trim} + | ${makeFixture(definition, useJsonAny = false).shift(4).trim} | ${makeUebaWriteBody(definition, srcRef).shift(4).trim} |} |""".stripMargin diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala index 7523d3a87..2f6e45f06 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala @@ -50,6 +50,7 @@ object SwDefnTranslator { swFiles: SwFileTools, swTrees: SwTreeTools, trans: SwTypeTranslator, + domainTypes: SwDomainTypes, codecs: Set[SwCodecTranslator], codecTests: SwCodecTestsTranslator, codecsFixture: SwCodecFixtureTranslator, @@ -144,8 +145,8 @@ object SwDefnTranslator { } private def doTranslateTest(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val swTypeRef = trans.asSwType(defn.id, domain, evo) - val srcRef = trans.toSwTypeRefKeepForeigns(defn.id, domain, evo) + val swTypeRef = domainTypes.asSwType(defn.id) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(defn.id) val testPath = getOutputPath(defn, suffix = Some("_test")) val typePath = getOutputPath(defn) val fixturePath = getOutputPath(defn, suffix = Some("_fixture")) @@ -195,8 +196,8 @@ object SwDefnTranslator { } } - val swTypeRef = trans.asSwType(defn.id, domain, evo) - val srcRef = trans.toSwTypeRefKeepForeigns(defn.id, domain, evo) + val swTypeRef = domainTypes.asSwType(defn.id) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(defn.id) val reprName = defn.defn match { case _: Typedef.Foreign => srcRef case _ => swTypeRef @@ -272,7 +273,7 @@ object SwDefnTranslator { defn: DomainMember.User, name: SwType, ): DefnRepr = { - val target = trans.asSwRef(TypeRef.Scalar(defn.id), domain, evo) + val target = domainTypes.asSwRef(TypeRef.Scalar(defn.id)) val typealias = q"public typealias ${name.asDeclName} = $target" val keyCodecBlock = defn.defn match { case f: Typedef.Foreign => makeForeignKeyCodecRepr(f, name) @@ -303,7 +304,7 @@ object SwDefnTranslator { case None => q"" case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.BaboonRef)) => q"" case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(decl, _))) => - val srcRef = trans.toSwTypeRefKeepForeigns(f.id, domain, evo) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(f.id) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val hostFqn = s"${srcRef.pkg.parts.mkString(".")}.$hostName" @@ -434,7 +435,7 @@ object SwDefnTranslator { val fieldDeclarations = dto.fields.map { f => - val t = trans.asSwRef(f.tpe, domain, evo) + val t = domainTypes.asSwRef(f.tpe) val escaped = trans.escapeSwiftKeyword(f.name.name) if (recursiveFields.contains(f.name.name)) { prependDocs(f.docs, q"@$baboonIndirect public var $escaped: $t") @@ -443,7 +444,7 @@ object SwDefnTranslator { } } - val contractParents = dto.contracts.map(c => trans.toSwTypeRefKeepForeigns(c, domain, evo)) + val contractParents = dto.contracts.map(c => domainTypes.toSwTypeRefKeepForeigns(c)) val adtMemberProto = dto.id.owner match { case Owner.Adt(_) => Seq(iBaboonAdtMemberMeta) @@ -485,7 +486,7 @@ object SwDefnTranslator { // `self` _self: String ← caller still writes `.init(self: ...)`, body uses `_self` val initParams = dto.fields.map { f => - val t = trans.asSwRef(f.tpe, domain, evo) + val t = domainTypes.asSwRef(f.tpe) val escaped = trans.escapeSwiftKeyword(f.name.name) val internalId = if (f.name.name == "self") q"_self" else q"$escaped" if (f.name.name == "self") q"$escaped $internalId: $t" else q"$escaped: $t" @@ -642,7 +643,7 @@ object SwDefnTranslator { val resVar = s"${swFieldName}_r" val isLast = idx == dto.fields.length - 1 val kind = IdentifierFieldKind.classify(f.tpe) - val tpe = trans.asSwRef(f.tpe, domain, evo) + val tpe = domainTypes.asSwRef(f.tpe) val parseHead = q"""let ${swFieldName}_fnr = $baboonIdRepr.parseFieldName(cursor, "$srcFieldName") @@ -729,7 +730,7 @@ object SwDefnTranslator { |if case .left(let l) = $resVar { return .left(l) } |guard case .right(let $valVar) = $resVar else { return .left("internal: bytes decode") }""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = trans.toSwTypeRefKeepForeigns(uid, domain, evo) + val nestedTpe = domainTypes.toSwTypeRefKeepForeigns(uid) val nestedCodec = SwType(nestedTpe.pkg, s"${nestedTpe.name}Codec") q"""let ${swFieldName}_ro = cursor.expect("{") |if case .left(let l) = ${swFieldName}_ro { return .left(l) } @@ -869,7 +870,7 @@ object SwDefnTranslator { mid => val memberName = mid.name.name val caseName = trans.escapeSwiftKeyword(memberName.head.toLower.toString + memberName.tail) - val memberRef = trans.toSwTypeRefKeepForeigns(mid, domain, evo) + val memberRef = domainTypes.toSwTypeRefKeepForeigns(mid) q"case $caseName(${memberRef.asDeclName})" }.toList @@ -909,11 +910,11 @@ object SwDefnTranslator { ): DefnRepr = { val methods = contract.fields.map { f => - val t = trans.asSwRef(f.tpe, domain, evo) + val t = domainTypes.asSwRef(f.tpe) val escaped = trans.escapeSwiftKeyword(f.name.name) prependDocs(f.docs, q"var $escaped: $t { get }") } - val contractParents = contract.contracts.map(c => trans.toSwTypeRefKeepForeigns(c, domain, evo)) + val contractParents = contract.contracts.map(c => domainTypes.toSwTypeRefKeepForeigns(c)) val parents = (contractParents :+ genMarker).distinct val conformanceClause = parents.map(t => q"$t").join(", ") val body = if (methods.nonEmpty) methods.joinN() else q"" @@ -978,9 +979,9 @@ object SwDefnTranslator { val methods = service.methods.map { m => - val in = trans.asSwRef(m.sig, domain, evo) - val out = m.out.map(trans.asSwRef(_, domain, evo)) - val err = m.err.map(trans.asSwRef(_, domain, evo)) + val in = domainTypes.asSwRef(m.sig) + val out = m.out.map(domainTypes.asSwRef(_)) + val err = m.err.map(domainTypes.asSwRef(_)) // Bare-`out` cases (noErrors mode, or an err-free method) keep the // original `out` SwType subtree verbatim so the render codec still @@ -1015,14 +1016,14 @@ object SwDefnTranslator { private def getOutputPath(defn: DomainMember.User, suffix: Option[String] = None): String = { val fbase = swFiles.basename(domain, evo) - val typeRef = trans.toSwTypeRefKeepForeigns(defn.id, domain, evo) + val typeRef = domainTypes.toSwTypeRefKeepForeigns(defn.id) val flatName = typeRef.name.replace('.', '_') val fname = s"${trans.toSnakeCase(flatName)}${suffix.getOrElse("")}.swift" s"$fbase/$fname" } private def getOutputModule(defn: DomainMember.User): SwValue.SwPackageId = { - trans.effectiveSwPkg(defn.defn.id.owner, domain, evo) + domainTypes.effectiveSwPkg(defn.defn.id.owner) } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala index c0de4106d..612ea1a0b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala @@ -32,6 +32,7 @@ object SwDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: SwTypeTranslator, + domainTypes: SwDomainTypes, ) extends SwDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { mainMeta(defn) ++ sameInVersion(defn) ++ adtMeta(defn) @@ -42,7 +43,7 @@ object SwDomainTreeTools { } private def mainMeta(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asSwType(defn.id, domain, evolution).asDeclName + val ref = domainTypes.asSwType(defn.id).asDeclName val baboonDomainVersion = MetaField( q"""public static let baboonDomainVersion: String""", q""""${domain.version.v.toString}"""", @@ -70,7 +71,7 @@ object SwDomainTreeTools { private def adtMeta(defn: DomainMember.User): List[MetaField] = { defn.id.owner match { case Owner.Adt(id) => - val adtRef = typeTranslator.asSwType(defn.id, domain, evolution).asDeclName + val adtRef = domainTypes.asSwType(defn.id).asDeclName val adtTypeIdentifier = MetaField( q"""public static let baboonAdtTypeIdentifier: String""", q""""${id.toString}"""", @@ -84,7 +85,7 @@ object SwDomainTreeTools { } private def sameInVersion(defn: DomainMember.User): List[MetaField] = { - val ref = typeTranslator.asSwType(defn.id, domain, evolution).asDeclName + val ref = domainTypes.asSwType(defn.id).asDeclName val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s""""${v.v.toString}"""") val sameInVersion = MetaField( q"public static let baboonSameInVersions: [String]", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala new file mode 100644 index 000000000..b43bd92a0 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala @@ -0,0 +1,24 @@ +package io.septimalmind.baboon.translator.swift + +import io.septimalmind.baboon.translator.swift.SwValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[SwTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[SwTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class SwDomainTypes(trans: SwTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asSwRef(tpe: TypeRef): TextTree[SwValue] = trans.asSwRef(tpe, domain, evo) + + def asSwType(tpe: TypeId): SwType = trans.asSwType(tpe, domain, evo) + + def toSwTypeRefKeepForeigns(tid: TypeId.User): SwType = trans.toSwTypeRefKeepForeigns(tid, domain, evo) + + def fixtureClassName(tid: TypeId.User): String = trans.fixtureClassName(tid, domain, evo) + + def effectiveSwPkg(owner: Owner): SwPackageId = trans.effectiveSwPkg(owner, domain, evo) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala index 6f4b208da..f1b4c6cf8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwJsonCodecGenerator.scala @@ -12,6 +12,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class SwJsonCodecGenerator( trans: SwTypeTranslator, + domainTypes: SwDomainTypes, target: SwTarget, domain: Domain, evo: BaboonEvolution, @@ -110,7 +111,7 @@ class SwJsonCodecGenerator( m => val branchName = m.name.name val caseName = trans.escapeSwiftKeyword(branchName.head.toLower.toString + branchName.tail) - val fqBranch = trans.toSwTypeRefKeepForeigns(m, domain, evo) + val fqBranch = domainTypes.toSwTypeRefKeepForeigns(m) val routedBranchEncoder = q"${codecName(fqBranch)}.instance.encode(ctx, branchVal)" val branchEncoder = if (target.language.wrappedAdtBranchCodecs) { @@ -231,7 +232,7 @@ class SwJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => encodeKey(aliasedRef, ref) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val srcRef = trans.toSwTypeRefKeepForeigns(uid, domain, evo) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(uid) val host = SwValue.SwType(srcRef.pkg, s"${srcRef.name}_KeyCodecHost") q"$host.instance.encodeKey($ref)" case None => @@ -257,7 +258,7 @@ class SwJsonCodecGenerator( id match { case b: TypeId.BuiltinScalar => SwScalarCodecs.jsonEncode(b, ref) case u: TypeId.User => - val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toSwTypeRefKeepForeigns(u)) q"$targetTpe.instance.encode(ctx, $ref)" case o => throw new RuntimeException(s"BUG: Unexpected type: $o") @@ -297,7 +298,7 @@ class SwJsonCodecGenerator( val decoded = SwScalarCodecs.jsonDecode(b, ref) (decoded.expression, decoded.mayThrow) case u: TypeId.User => - val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toSwTypeRefKeepForeigns(u)) (q"$targetTpe.instance.decode(ctx, $ref)", true) case o => throw new RuntimeException(s"BUG: Unexpected type: $o") @@ -374,7 +375,7 @@ class SwJsonCodecGenerator( case _: Typedef.Enum => // PR-F (M24): throw BaboonCodecException.decoderFailure on parse failure // for cross-language malformed-key consistency (replaces forced unwrap). - val targetTpe = trans.toSwTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toSwTypeRefKeepForeigns(u) ( q"""{ () throws -> $targetTpe in guard let __r = $targetTpe.parse($ref) else { throw $baboonCodecException.decoderFailure(\"malformed key: \\($ref)\", nil) }; return __r }()""", true, @@ -389,7 +390,7 @@ class SwJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => decodeKey(aliasedRef, ref) case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val foreignTpe = trans.toSwTypeRefKeepForeigns(u, domain, evo) + val foreignTpe = domainTypes.toSwTypeRefKeepForeigns(u) val host = SwValue.SwType(foreignTpe.pkg, s"${foreignTpe.name}_KeyCodecHost") ( q"""{ () throws -> $foreignTpe in do { return try $host.instance.decodeKey($ref) } catch let e { throw $baboonCodecException.decoderFailure(\"malformed key: \\($ref)\", e) } }()""", @@ -402,7 +403,7 @@ class SwJsonCodecGenerator( // PR-F (M24): throw BaboonCodecException.decoderFailure on .left for // cross-language malformed-key consistency (replaces fatalError). case d: Typedef.Dto if d.isIdentifier => - val targetTpe = trans.toSwTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toSwTypeRefKeepForeigns(u) val nestedCodec = SwValue.SwType(targetTpe.pkg, s"${targetTpe.name}Codec") ( q"""{ () throws -> $targetTpe in guard case .right(let __r) = $nestedCodec.parseRepr($ref) else { throw $baboonCodecException.decoderFailure(\"malformed key: \\($ref)\", nil) }; return __r }()""", @@ -411,7 +412,7 @@ class SwJsonCodecGenerator( // M19/PR-60: single-primitive-field wrappers — peel and recurse, then construct. case d: Typedef.Dto if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val targetTpe = trans.toSwTypeRefKeepForeigns(u, domain, evo) + val targetTpe = domainTypes.toSwTypeRefKeepForeigns(u) val (innerDec, innerThr) = decodeKey(inner.tpe, ref) (q"$targetTpe(${inner.name.name}: $innerDec)", innerThr) case o => throw new RuntimeException(s"BUG: Unexpected key usertype: $o") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala index 887e8e1ce..b48070116 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala @@ -18,9 +18,9 @@ object SwServiceWiringTranslator { class Impl( target: SwTarget, trans: SwTypeTranslator, + domainTypes: SwDomainTypes, codecs: Set[SwCodecTranslator], domain: Domain, - evo: BaboonEvolution, ) extends SwServiceWiringTranslator { // Async axis. When enabled the service protocol methods (emitted in @@ -126,13 +126,13 @@ object SwServiceWiringTranslator { } private def jsonCodecName(typeId: TypeId.User): SwType = { - val srcRef = trans.toSwTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(typeId) val baseFileName = srcRef.importAs.getOrElse(trans.toSnakeCase(srcRef.name)) SwType(srcRef.pkg, s"${srcRef.name}_JsonCodec", srcRef.fq, importAs = Some(baseFileName)) } private def uebaCodecName(typeId: TypeId.User): SwType = { - val srcRef = trans.toSwTypeRefKeepForeigns(typeId, domain, evo) + val srcRef = domainTypes.toSwTypeRefKeepForeigns(typeId) val baseFileName = srcRef.importAs.getOrElse(trans.toSnakeCase(srcRef.name)) SwType(srcRef.pkg, s"${srcRef.name}_UebaCodec", srcRef.fq, importAs = Some(baseFileName)) } @@ -256,8 +256,8 @@ object SwServiceWiringTranslator { val clientMethods: Seq[TextTree[SwValue]] = service.methods.flatMap { m => - val inRef = trans.asSwRef(m.sig, domain, evo) - val retType = m.out.map(o => trans.asSwRef(o, domain, evo)) + val inRef = domainTypes.asSwRef(m.sig) + val retType = m.out.map(o => domainTypes.asSwRef(o)) val retDecl = retType.map(t => q" -> $t").getOrElse(q"") val uebaMethod = if (hasUeba) { @@ -348,7 +348,7 @@ object SwServiceWiringTranslator { * resolve. */ private def serviceImplType(service: Typedef.Service): TextTree[SwValue] = - q"${trans.toSwTypeRefKeepForeigns(service.id, domain, evo)}" + q"${domainTypes.toSwTypeRefKeepForeigns(service.id)}" private def svcCtxParamName: Option[String] = resolvedCtx match { case ResolvedServiceContext.NoContext => None @@ -553,7 +553,7 @@ object SwServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asSwRef(m.sig, domain, evo) + val inRef = domainTypes.asSwRef(m.sig) val decodeIn = jsonDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"wire") if (isAsync) generateErrorsJsonCaseAsync(m, inRef, decodeIn) else generateErrorsJsonCaseSync(m, inRef, decodeIn) @@ -691,7 +691,7 @@ object SwServiceWiringTranslator { val encodeOut = jsonEncodeExpr(outRef.id.asInstanceOf[TypeId.Scalar], q"v") val callStep = if (hasErrType) { - q"""let output: ${ctTree(q"$baboonWiringError", trans.asSwRef(outRef, domain, evo))} + q"""let output: ${ctTree(q"$baboonWiringError", domainTypes.asSwRef(outRef))} |do { | let callResult = try await impl.${m.name.name}(${ctxImplCallArg}arg: decoded) | output = rt.leftMap( @@ -700,7 +700,7 @@ object SwServiceWiringTranslator { | return rt.fail($baboonWiringError.callFailed(method, error)) |}""".stripMargin } else { - q"""let output: ${ctTree(q"$baboonWiringError", trans.asSwRef(outRef, domain, evo))} + q"""let output: ${ctTree(q"$baboonWiringError", domainTypes.asSwRef(outRef))} |do { | let callResultValue = try await impl.${m.name.name}(${ctxImplCallArg}arg: decoded) | output = rt.pure(callResultValue) @@ -753,7 +753,7 @@ object SwServiceWiringTranslator { val cases = service.methods.map { m => - val inRef = trans.asSwRef(m.sig, domain, evo) + val inRef = domainTypes.asSwRef(m.sig) val decodeIn = uebaDecodeExpr(m.sig.id.asInstanceOf[TypeId.Scalar], q"reader") if (isAsync) generateErrorsUebaCaseAsync(m, inRef, decodeIn) else generateErrorsUebaCaseSync(m, inRef, decodeIn) @@ -879,7 +879,7 @@ object SwServiceWiringTranslator { val encStmt = uebaEncodeStmt(outRef.id.asInstanceOf[TypeId.Scalar], q"writer", q"v") val callStep = if (hasErrType) { - q"""let output: ${ctTree(q"$baboonWiringError", trans.asSwRef(outRef, domain, evo))} + q"""let output: ${ctTree(q"$baboonWiringError", domainTypes.asSwRef(outRef))} |do { | let callResult = try await impl.${m.name.name}(${ctxImplCallArg}arg: decoded) | output = rt.leftMap( @@ -888,7 +888,7 @@ object SwServiceWiringTranslator { | return rt.fail($baboonWiringError.callFailed(method, error)) |}""".stripMargin } else { - q"""let output: ${ctTree(q"$baboonWiringError", trans.asSwRef(outRef, domain, evo))} + q"""let output: ${ctTree(q"$baboonWiringError", domainTypes.asSwRef(outRef))} |do { | let callResultValue = try await impl.${m.name.name}(${ctxImplCallArg}arg: decoded) | output = rt.pure(callResultValue) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala index ac3c827b8..cb08e799e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwUEBACodecGenerator.scala @@ -14,6 +14,7 @@ import izumi.fundamentals.platform.strings.TextTree.* class SwUEBACodecGenerator( trans: SwTypeTranslator, + domainTypes: SwDomainTypes, target: SwTarget, domain: Domain, evo: BaboonEvolution, @@ -152,7 +153,7 @@ class SwUEBACodecGenerator( val branchName = m.name.name val caseName = trans.escapeSwiftKeyword(branchName.head.toLower.toString + branchName.tail) - val adtRef = trans.toSwTypeRefKeepForeigns(m, domain, evo) + val adtRef = domainTypes.toSwTypeRefKeepForeigns(m) val cName = codecName(adtRef) val encBody = if (target.language.wrappedAdtBranchCodecs) { @@ -348,7 +349,7 @@ class SwUEBACodecGenerator( val decoded = SwScalarCodecs.uebaDecode(s, q"reader") (decoded.expression, decoded.mayThrow) case u: TypeId.User => - val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toSwTypeRefKeepForeigns(u)) (q"$targetTpe.instance.decode(ctx, reader)", true) } case c: TypeRef.Constructor => @@ -387,7 +388,7 @@ class SwUEBACodecGenerator( case s: TypeId.BuiltinScalar => SwScalarCodecs.uebaEncode(s, wref, ref) case u: TypeId.User => - val targetTpe = codecName(trans.toSwTypeRefKeepForeigns(u, domain, evo)) + val targetTpe = codecName(domainTypes.toSwTypeRefKeepForeigns(u)) q"""$targetTpe.instance.encode(ctx, $wref, $ref)""" } case c: TypeRef.Constructor => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala index 3560c0dc4..a329421cf 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala @@ -25,9 +25,9 @@ object TsCodecFixtureTranslator { final class TsCodecFixtureTranslatorImpl( translator: TsTypeTranslator, + domainTypes: TsDomainTypes, enquiries: BaboonEnquiries, domain: Domain, - evo: BaboonEvolution, tsFileTools: TsFileTools, ) extends TsCodecFixtureTranslator { @@ -47,7 +47,7 @@ object TsCodecFixtureTranslator { } private def doTranslateDto(dto: Typedef.Dto): TextTree[TsValue] = { - val fullType = translator.asTsTypeKeepForeigns(dto.id, domain, evo, tsFileTools.definitionsBasePkg) + val fullType = domainTypes.asTsTypeKeepForeigns(dto.id, tsFileTools.definitionsBasePkg) def body(format: FixtureFormat): TextTree[TsValue] = { val generatedFields = dto.fields.map(f => q"${genType(f.tpe, format)},") @@ -66,7 +66,7 @@ object TsCodecFixtureTranslator { } private def doTranslateAdt(adt: Typedef.Adt): TextTree[TsValue] = { - val adtName = translator.asTsType(adt.id, domain, evo, tsFileTools.definitionsBasePkg) + val adtName = domainTypes.asTsType(adt.id, tsFileTools.definitionsBasePkg) val members = adt.members.toList .flatMap(domain.defs.meta.nodes.get) .collect { case DomainMember.User(_, d: Typedef.Dto, _, _) => d } @@ -106,7 +106,7 @@ object TsCodecFixtureTranslator { } private def doTranslateDtoPrivate(dto: Typedef.Dto): TextTree[TsValue] = { - val fullType = translator.asTsTypeKeepForeigns(dto.id, domain, evo, tsFileTools.definitionsBasePkg) + val fullType = domainTypes.asTsTypeKeepForeigns(dto.id, tsFileTools.definitionsBasePkg) def body(format: FixtureFormat): TextTree[TsValue] = { val generatedFields = dto.fields.map(f => q"${genType(f.tpe, format)},") @@ -133,7 +133,7 @@ object TsCodecFixtureTranslator { } private def fixtureFnRef(id: TypeId.User, format: FixtureFormat): TsValue.TsType = { - val userType = translator.asTsTypeKeepForeigns(id, domain, evo, tsFileTools.fixturesBasePkg) + val userType = domainTypes.asTsTypeKeepForeigns(id, tsFileTools.fixturesBasePkg) val partsList = userType.moduleId.path val fixtureModule = TsValue.TsModuleId(partsList.init :+ (partsList.last + ".fixture")) TsValue.TsType(fixtureModule, fixtureFnName(id, format)) @@ -212,13 +212,13 @@ object TsCodecFixtureTranslator { case TypeId.Builtins.bytes => q"rnd.nextBytes()" case TypeId.Builtins.uid => q"rnd.nextUid()" case TypeId.Builtins.tsu => - translator.asTsType(TypeId.Builtins.tsu, domain, evo) match { + domainTypes.asTsType(TypeId.Builtins.tsu) match { case TsTypes.tsString => q"rnd.nextTsu().toISOString()" case TsTypes.tsDate => q"rnd.nextTsu().date" case _ => q"rnd.nextTsu()" } case TypeId.Builtins.tso => - translator.asTsType(TypeId.Builtins.tso, domain, evo) match { + domainTypes.asTsType(TypeId.Builtins.tso) match { case TsTypes.tsString => q"rnd.nextTso().toISOString()" case TsTypes.tsDate => q"rnd.nextTso().date" case _ => q"rnd.nextTso()" @@ -226,7 +226,7 @@ object TsCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBit()" case u: TypeId.User if enquiries.isEnum(tpe, domain) => - val enumType = translator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) + val enumType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val enumValues = TsValue.TsType(enumType.moduleId, s"${enumType.name}_values") q"rnd.mkEnum($enumValues)" case u: TypeId.User => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala index 76be53a14..8a83c82f3 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala @@ -19,6 +19,7 @@ object TsCodecTestsTranslator { final class Impl( codecs: Set[TsCodecTranslator], typeTranslator: TsTypeTranslator, + domainTypes: TsDomainTypes, enquiries: BaboonEnquiries, target: TsTarget, domain: Domain, @@ -54,10 +55,8 @@ object TsCodecTestsTranslator { private def makeTests(definition: DomainMember.User, srcRef: TsValue.TsType): TextTree[TsValue] = { val testFnName = typeTranslator.camelToKebab(srcRef.name).replace('-', '_') val fixtureBaseName = s"random_${typeTranslator.camelToKebab(definition.id.name.name).replace('-', '_')}" - val fixtureModule = typeTranslator.toTsModule( + val fixtureModule = domainTypes.toTsModule( definition.id, - domain, - evo, tsFileTools.fixturesBasePkg, ".fixture", ) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala index 50ee02acb..1b38aafac 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDefnTranslator.scala @@ -47,6 +47,7 @@ object TsDefnTranslator { domain: Domain, evo: BaboonEvolution, typeTranslator: TsTypeTranslator, + domainTypes: TsDomainTypes, codecs: Set[TsCodecTranslator], codecTests: TsCodecTestsTranslator, codecsFixture: TsCodecFixtureTranslator, @@ -101,7 +102,7 @@ object TsDefnTranslator { Output( getOutputPath(defn), repr.defn, - typeTranslator.toTsModule(defn.id, domain, evo, tsFileTools.definitionsBasePkg), + domainTypes.toTsModule(defn.id, tsFileTools.definitionsBasePkg), CompilerProduct.Definition, exports = Some(repr.exports), ) @@ -121,8 +122,8 @@ object TsDefnTranslator { } } - val tsTypeRef = typeTranslator.asTsType(defn.id, domain, evo, tsFileTools.definitionsBasePkg) - val srcRef = typeTranslator.asTsTypeKeepForeigns(defn.id, domain, evo, tsFileTools.definitionsBasePkg) + val tsTypeRef = domainTypes.asTsType(defn.id, tsFileTools.definitionsBasePkg) + val srcRef = domainTypes.asTsTypeKeepForeigns(defn.id, tsFileTools.definitionsBasePkg) val renderedCodecs = codecs.toList @@ -162,7 +163,7 @@ object TsDefnTranslator { case f: Typedef.Foreign => f.bindings.get(BaboonLang.Typescript) match { case Some(Typedef.ForeignEntry(_, _: Typedef.ForeignMapping.Custom)) => - val foreignName = typeTranslator.asTsTypeKeepForeigns(f.id, domain, evo, tsFileTools.definitionsBasePkg).name + val foreignName = domainTypes.asTsTypeKeepForeigns(f.id, tsFileTools.definitionsBasePkg).name List(ExportedSymbol(s"${foreignName}_KeyCodec", typeOnly = true), ExportedSymbol(s"${foreignName}_KeyCodecHost", typeOnly = false)) case _ => Nil } @@ -189,7 +190,7 @@ object TsDefnTranslator { // The codec/host names use the foreign's declared name (`FStr`) — not the deref'd // mapped name (`string`) that `name` resolves to. The `value: $name` field type // continues to use `name` so the signature reflects the host language type. - val srcRef = typeTranslator.asTsTypeKeepForeigns(f.id, domain, evo, tsFileTools.definitionsBasePkg) + val srcRef = domainTypes.asTsTypeKeepForeigns(f.id, tsFileTools.definitionsBasePkg) val codecName = s"${srcRef.name}_KeyCodec" val hostName = s"${srcRef.name}_KeyCodecHost" val defaultName = s"_default${srcRef.name}_KeyCodec" @@ -252,8 +253,8 @@ object TsDefnTranslator { } private def doTranslateTest(defn: DomainMember.User): F[NEList[BaboonIssue], List[Output]] = { - val tsTypeRef = typeTranslator.asTsType(defn.id, domain, evo) - val srcRef = typeTranslator.asTsTypeKeepForeigns(defn.id, domain, evo, tsFileTools.definitionsBasePkg) + val tsTypeRef = domainTypes.asTsType(defn.id) + val srcRef = domainTypes.asTsTypeKeepForeigns(defn.id, tsFileTools.definitionsBasePkg) val testTreeOpt = codecTests.translate(defn, tsTypeRef, srcRef) F.pure(testTreeOpt.map { testTree => @@ -278,12 +279,12 @@ object TsDefnTranslator { val mainMeta = tsDomainTreeTools.makeDataMeta(defn) val codecMeta = codecs.flatMap(_.codecMeta(defn, name)) val meta = mainMeta ++ codecMeta - val fieldsNameAndType = dto.fields.map(f => f.name -> typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)) - val contractParents = dto.contracts.map(c => typeTranslator.asTsTypeKeepForeigns(c, domain, evo, tsFileTools.definitionsBasePkg)) + val fieldsNameAndType = dto.fields.map(f => f.name -> domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg)) + val contractParents = dto.contracts.map(c => domainTypes.asTsTypeKeepForeigns(c, tsFileTools.definitionsBasePkg)) val adtContracts = dto.id.owner match { case Owner.Adt(id) => domain.defs.meta.nodes(id) match { - case u: DomainMember.User => u.defn.asInstanceOf[Typedef.Adt].contracts.map(tid => typeTranslator.asTsType(tid, domain, evo, tsFileTools.definitionsBasePkg)) + case u: DomainMember.User => u.defn.asInstanceOf[Typedef.Adt].contracts.map(tid => domainTypes.asTsType(tid, tsFileTools.definitionsBasePkg)) case other => throw new RuntimeException(s"BUG: missing/wrong adt: $id => $other") } case _ => Seq.empty @@ -311,7 +312,7 @@ object TsDefnTranslator { val getters = dto.fields.map { f => - val tpe = typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg) + val tpe = domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg) // The public getter is an accessor identifier; escape it for a keyword-named field // (`class` -> `class_`) so the codecs' `value.` reads (which assume the escaped // accessor name) resolve. The backing private field `_${name}` is `_`-prefixed and always @@ -329,7 +330,7 @@ object TsDefnTranslator { // RHS reference must agree. The private field (`_${name}`) and all object-literal/member-access wire // keys keep the raw name, so the wire format is unchanged. val constrcutorParams = - dto.fields.map(f => q"${typeTranslator.escapeTsKeyword(f.name.name)}: ${typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)}").join(", ") + dto.fields.map(f => q"${typeTranslator.escapeTsKeyword(f.name.name)}: ${domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg)}").join(", ") val constructorInside = fieldsNameAndType.map { case (n, _) => @@ -353,7 +354,7 @@ object TsDefnTranslator { val withParamFields = dto.fields.map { f => - q"${f.name.name}?: ${typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)}" + q"${f.name.name}?: ${domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg)}" } val withArgs = dto.fields.map { @@ -370,7 +371,7 @@ object TsDefnTranslator { val fromPlainParamFields = dto.fields.map { f => - q"${f.name.name}: ${typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg)}" + q"${f.name.name}: ${domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg)}" } val fromPlainArgs = dto.fields.map { @@ -519,7 +520,7 @@ object TsDefnTranslator { // Branch references use the prefixed branch symbol (`_`) via asTsType. The `isX` // instanceof type guards are intentionally dropped — narrowing is done on the union members // directly (instanceof, or a discriminator) and the guards were unused internally and by tests. - val branchRefs = adt.members.toList.map(m => q"${typeTranslator.asTsType(m, domain, evo, tsFileTools.definitionsBasePkg)}") + val branchRefs = adt.members.toList.map(m => q"${domainTypes.asTsType(m, tsFileTools.definitionsBasePkg)}") DefnRepr( q"""export type $name = ${branchRefs.join(" | ")} @@ -539,7 +540,7 @@ object TsDefnTranslator { val contract = defn.defn.asInstanceOf[Typedef.Contract] val methods = contract.fields.map { f => - val t = typeTranslator.asTsRef(f.tpe, domain, evo, tsFileTools.definitionsBasePkg) + val t = domainTypes.asTsRef(f.tpe, tsFileTools.definitionsBasePkg) val member = q"readonly ${f.name.name}: $t;" prependDocs(f.docs, member) } @@ -564,9 +565,9 @@ object TsDefnTranslator { val service = defn.defn.asInstanceOf[Typedef.Service] val methods = service.methods.map { m => - val inType = typeTranslator.asTsRef(m.sig, domain, evo, tsFileTools.definitionsBasePkg) - val outType = m.out.map(typeTranslator.asTsRef(_, domain, evo, tsFileTools.definitionsBasePkg)) - val errType = m.err.map(typeTranslator.asTsRef(_, domain, evo, tsFileTools.definitionsBasePkg)) + val inType = domainTypes.asTsRef(m.sig, tsFileTools.definitionsBasePkg) + val outType = m.out.map(domainTypes.asTsRef(_, tsFileTools.definitionsBasePkg)) + val errType = m.err.map(domainTypes.asTsRef(_, tsFileTools.definitionsBasePkg)) val baseRetTree: TextTree[TsValue] = if (resolved.noErrors || errType.isEmpty) { outType.getOrElse(q"void") @@ -818,7 +819,7 @@ object TsDefnTranslator { |if ($resVar.tag === "Left") return { tag: "Left", value: $resVar.value }; |const $valVar: Uint8Array = $resVar.value;""".stripMargin case IdentifierFieldKind.NestedId(uid) => - val nestedTpe = typeTranslator.asTsTypeKeepForeigns(uid, domain, evo, tsFileTools.definitionsBasePkg) + val nestedTpe = domainTypes.asTsTypeKeepForeigns(uid, tsFileTools.definitionsBasePkg) val nestedCodecName = nestedTpe.name.head.toLower.toString + nestedTpe.name.tail + "Codec" val nestedCodecRef = TsType(nestedTpe.moduleId, nestedCodecName) q"""const ${srcFieldName}_ro = cursor.expect("{"); @@ -891,7 +892,7 @@ object TsDefnTranslator { case _ => defn.defn.id.owner match { case Owner.Toplevel => s"$fbase/$fname" - case Owner.Ns(path) => s"$fbase/${typeTranslator.renderNsOwnerPath(path, domain).mkString("/")}/$fname" + case Owner.Ns(path) => s"$fbase/${domainTypes.renderNsOwnerPath(path).mkString("/")}/$fname" case Owner.Adt(id) => s"$fbase/${id.name.name.toLowerCase}.$fname" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala index 66b9d8248..a953e3e16 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala @@ -15,10 +15,11 @@ object TsDomainTreeTools { domain: Domain, evolution: BaboonEvolution, typeTranslator: TsTypeTranslator, + domainTypes: TsDomainTypes, tsFileTools: TsFileTools, ) extends TsDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[TextTree[TsValue]] = { - val source = typeTranslator.asTsType(defn.id, domain, evolution, tsFileTools.definitionsBasePkg) + val source = domainTypes.asTsType(defn.id, tsFileTools.definitionsBasePkg) mainMeta(defn, source, isCodec = false) ++ sameInVersion(defn, source) ++ adtMeta(defn, source, isCodec = false) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTypes.scala new file mode 100644 index 000000000..d97984211 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTypes.scala @@ -0,0 +1,26 @@ +package io.septimalmind.baboon.translator.typescript + +import io.septimalmind.baboon.translator.typescript.TsValue.* +import io.septimalmind.baboon.typer.model.* +import izumi.fundamentals.platform.strings.TextTree + +/** The current domain's view of [[TsTypeTranslator]]. + * + * Bound inside the per-domain subcontext, so a component that already holds the current `Domain` + * and `BaboonEvolution` renders references without threading them through every call. The + * explicit-domain API on [[TsTypeTranslator]] stays for the callers that genuinely mean another domain: a + * conversion renders the SOURCE version's types alongside the current ones. + */ +final class TsDomainTypes(trans: TsTypeTranslator, domain: Domain, evo: BaboonEvolution) { + def asTsRef(tpe: TypeRef, pkgBase: List[String] = Nil): TextTree[TsValue] = trans.asTsRef(tpe, domain, evo, pkgBase) + + def asTsType(tpe: TypeId, pkgBase: List[String] = Nil): TsType = trans.asTsType(tpe, domain, evo, pkgBase) + + def asTsTypeDerefForeign(tid: TypeId.User, pkgBase: List[String]): TsType = trans.asTsTypeDerefForeign(tid, domain, evo, pkgBase) + + def asTsTypeKeepForeigns(tid: TypeId.User, pkgBase: List[String]): TsType = trans.asTsTypeKeepForeigns(tid, domain, evo, pkgBase) + + def toTsModule(tid: TypeId.User, pkgBase: List[String], suffix: String = ""): TsModuleId = trans.toTsModule(tid, domain, evo, pkgBase, suffix) + + def renderNsOwnerPath(path: Seq[TypeName]): List[String] = trans.renderNsOwnerPath(path, domain) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala index ce4f11ba0..a92c2da50 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala @@ -10,9 +10,9 @@ import izumi.fundamentals.platform.strings.TextTree.* class TsJsonCodecGenerator( trans: TsTypeTranslator, + domainTypes: TsDomainTypes, target: TsTarget, domain: Domain, - evo: BaboonEvolution, enquiries: BaboonEnquiries, tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, @@ -161,7 +161,7 @@ class TsJsonCodecGenerator( val encCases = dataMembers.map { mid => val branchName = mid.name.name - val branchType = trans.asTsTypeDerefForeign(mid, domain, evo, tsFileTools.definitionsBasePkg) + val branchType = domainTypes.asTsTypeDerefForeign(mid, tsFileTools.definitionsBasePkg) val branchCodec = codecName(branchType) if (target.language.wrappedAdtBranchCodecs) { q"""if (value instanceof $branchType) { @@ -177,7 +177,7 @@ class TsJsonCodecGenerator( val decCases = dataMembers.map { mid => val branchName = mid.name.name - val branchType = trans.asTsTypeKeepForeigns(mid, domain, evo, tsFileTools.definitionsBasePkg) + val branchType = domainTypes.asTsTypeKeepForeigns(mid, tsFileTools.definitionsBasePkg) val branchCodec = codecName(branchType) q"""case "$branchName": return $branchCodec.instance.decode($tsBaboonCodecContext.Default, obj[key])""" } @@ -209,11 +209,11 @@ class TsJsonCodecGenerator( // Custom foreign value: route through the emitted `_JsonCodec` (throws unless the // host registered an impl via lazyInstance). Naming uses keep-foreigns to match the // codec class emitted in the foreign's own module. - val codec = codecName(trans.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg)) + val codec = codecName(domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg)) q"$codec.instance.encode($tsBaboonCodecContext.Default, $ref)" } case Some(DomainMember.User(_, _: Typedef.Enum | _: Typedef.Dto | _: Typedef.Adt, _, _)) => - val tsType = trans.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.encode($tsBaboonCodecContext.Default, $ref)" case _ => ref @@ -271,7 +271,7 @@ class TsJsonCodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => // PR-I.1d (M24 Phase 3.1): Custom-foreign map keys route through // the emitted `_KeyCodecHost.instance` extension hook. - val srcRef = trans.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg) + val srcRef = domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg) val hostTpe = TsValue.TsType(srcRef.moduleId, s"${srcRef.name}_KeyCodecHost") q"$hostTpe.instance.encodeKey($ref)" case None => @@ -294,7 +294,7 @@ class TsJsonCodecGenerator( case TypeRef.Scalar(u: TypeId.User) => domain.defs.meta.nodes.get(u) match { case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.isIdentifier => - val tsType = trans.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val codecObj = tsType.name.head.toLower.toString + tsType.name.tail + "Codec" val codecRef = TsValue.TsType(tsType.moduleId, codecObj) // PR-F (M24): throw BaboonDecoderFailure on Left for cross-language malformed-key @@ -302,13 +302,13 @@ class TsJsonCodecGenerator( q"""((): $tsType => { const __e = $codecRef.parseRepr($ref); if (__e.tag === "Right") return __e.value; throw new $tsBaboonDecoderFailure("malformed key: " + $ref); })()""" case Some(DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.size == 1 && d.contracts.isEmpty => val inner = d.fields.head - val tsType = trans.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val innerDec = mkJsonKeyDecoder(inner.tpe, ref) q"new $tsType($innerDec)" case Some(DomainMember.User(_, _: Typedef.Enum, _, _)) => // PR-G (M24.2.2): enum keys — parse via the generated `_parse` helper, // matching the value-position enum decoder shape. - val tsType = trans.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val parser = TsValue.TsType(tsType.moduleId, s"${tsType.name}_parse") q"$parser($ref)" case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => @@ -321,8 +321,8 @@ class TsJsonCodecGenerator( // `catch (e: unknown)` (PR-I-D01 pattern guidance for TS): TS catches // are `unknown`-typed by tsconfig `useUnknownInCatchVariables` default; // we still discriminate Error-vs-everything-else only at the throw site. - val srcRef = trans.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg) - val mapped = trans.asTsType(u, domain, evo) + val srcRef = domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg) + val mapped = domainTypes.asTsType(u) val hostTpe = TsValue.TsType(srcRef.moduleId, s"${srcRef.name}_KeyCodecHost") q"""((): $mapped => { try { return $hostTpe.instance.decodeKey($ref); } catch (e) { throw new $tsBaboonDecoderFailure("malformed key: " + $ref, { cause: e }); } })()""" case None => @@ -394,11 +394,11 @@ class TsJsonCodecGenerator( case _ => // Custom foreign value: route through the emitted `_JsonCodec` (throws unless the // host registered an impl via lazyInstance), replacing the prior no-op `as` cast. - val codec = codecName(trans.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg)) + val codec = codecName(domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg)) q"$codec.instance.decode($tsBaboonCodecContext.Default, $ref)" } case Some(DomainMember.User(_, _: Typedef.Enum | _: Typedef.Dto | _: Typedef.Adt, _, _)) => - val tsType = trans.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.decode($tsBaboonCodecContext.Default, $ref)" case _ => ref diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala index 487610521..b1a24ca35 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsServiceWiringTranslator.scala @@ -23,6 +23,7 @@ object TsServiceWiringTranslator { class Impl( target: TsTarget, typeTranslator: TsTypeTranslator, + domainTypes: TsDomainTypes, codecs: Set[TsCodecTranslator], domain: Domain, evo: BaboonEvolution, @@ -120,7 +121,7 @@ object TsServiceWiringTranslator { // JSON encode/decode for both User types (via generated codec) and BuiltinScalar (inline). private def jsonDecodeExpr(id: TypeId, wire: TextTree[TsValue]): TextTree[TsValue] = id match { case u: TypeId.User => - val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsJsonCodecGenerator => c }.get.codecName(tsType) q"$codec.instance.decode($tsBaboonCodecContext.Default, $wire)" case b: TypeId.BuiltinScalar => scalarOps.decodeJson(b, wire) @@ -129,7 +130,7 @@ object TsServiceWiringTranslator { private def jsonEncodeExpr(id: TypeId, value: TextTree[TsValue]): TextTree[TsValue] = id match { case u: TypeId.User => - val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsJsonCodecGenerator => c }.get.codecName(tsType) q"$codec.instance.encode($tsBaboonCodecContext.Default, $value)" case b: TypeId.BuiltinScalar => scalarOps.encodeJson(b, value) @@ -138,7 +139,7 @@ object TsServiceWiringTranslator { private def uebaDecodeExpr(id: TypeId, reader: TextTree[TsValue]): TextTree[TsValue] = id match { case u: TypeId.User => - val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsUEBACodecGenerator => c }.get.codecName(tsType) q"$codec.instance.decode($codecCtxRef, $reader)" case b: TypeId.BuiltinScalar => scalarOps.decodeUeba(b, reader) @@ -147,7 +148,7 @@ object TsServiceWiringTranslator { private def uebaEncodeStmt(id: TypeId, writer: TextTree[TsValue], value: TextTree[TsValue]): TextTree[TsValue] = id match { case u: TypeId.User => - val tsType = typeTranslator.asTsType(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val codec = codecs.collectFirst { case c: TsUEBACodecGenerator => c }.get.codecName(tsType) q"$codec.instance.encode($codecCtxRef, $value, $writer);" case b: TypeId.BuiltinScalar => scalarOps.encodeUeba(b, writer, value) @@ -216,7 +217,7 @@ object TsServiceWiringTranslator { val wiringPath = getWiringPath(defn) val wiringModule = TsValue.TsModuleId(tsFileTools.definitionsBasePkg ++ wiringPath.stripSuffix(".ts").split('/').toList) - val serviceName = typeTranslator.asTsType(service.id, domain, evo, tsFileTools.definitionsBasePkg).name + val serviceName = domainTypes.asTsType(service.id, tsFileTools.definitionsBasePkg).name val exported = List(true -> activeJsonCodec(service), false -> activeBinCodec(service)).flatMap { case (isJson, codec) => codec.toList.flatMap { @@ -245,7 +246,7 @@ object TsServiceWiringTranslator { override def translateClient(defn: DomainMember.User): Option[TsDefnTranslator.Output] = { defn.defn match { case service: Typedef.Service => - val svcType = typeTranslator.asTsType(service.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(service.id, tsFileTools.definitionsBasePkg) val jsonCodec = activeJsonCodec(service) val binCodec = activeBinCodec(service) @@ -261,8 +262,8 @@ object TsServiceWiringTranslator { // service client is always async" note in docs/language-features.md. val clientMethods = service.methods.flatMap { m => - val inType = typeTranslator.asTsRef(m.sig, domain, evo, tsFileTools.definitionsBasePkg) - val outType = m.out.map(typeTranslator.asTsRef(_, domain, evo, tsFileTools.definitionsBasePkg)) + val inType = domainTypes.asTsRef(m.sig, tsFileTools.definitionsBasePkg) + val outType = m.out.map(domainTypes.asTsRef(_, tsFileTools.definitionsBasePkg)) val retType = outType.getOrElse(q"void") val jsonMethod = jsonCodec.map { @@ -369,7 +370,7 @@ object TsServiceWiringTranslator { val implFields = services.map { s => - val svcType = typeTranslator.asTsType(s.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(s.id, tsFileTools.definitionsBasePkg) q"${s.id.name.name}: ${typeTranslator.serviceInterfaceRef(svcType)}$ctxTypeArg" } @@ -378,7 +379,7 @@ object TsServiceWiringTranslator { s => activeBinCodec(s).map { _ => - val svcType = typeTranslator.asTsType(s.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(s.id, tsFileTools.definitionsBasePkg) val wiringFnRef = typeTranslator.serviceInvokeRef( TsValue.TsModuleId(tsFileTools.definitionsBasePkg ++ getWiringPathForService(s).stripSuffix(".ts").split('/').toList), svcType.name, @@ -423,7 +424,7 @@ object TsServiceWiringTranslator { s => activeJsonCodec(s).map { _ => - val svcType = typeTranslator.asTsType(s.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(s.id, tsFileTools.definitionsBasePkg) val wiringFnRef = typeTranslator.serviceInvokeRef( TsValue.TsModuleId(tsFileTools.definitionsBasePkg ++ getWiringPathForService(s).stripSuffix(".ts").split('/').toList), svcType.name, @@ -624,7 +625,7 @@ object TsServiceWiringTranslator { // ========== noErrors mode ========== private def generateNoErrorsWiring(service: Typedef.Service): TextTree[TsValue] = { - val svcType = typeTranslator.asTsType(service.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(service.id, tsFileTools.definitionsBasePkg) val jsonFn = if (activeJsonCodec(service).isDefined) Some(generateNoErrorsJsonFn(service, svcType)) @@ -732,7 +733,7 @@ object TsServiceWiringTranslator { } private def generateErrorsWiring(service: Typedef.Service): TextTree[TsValue] = { - val svcType = typeTranslator.asTsType(service.id, domain, evo, tsFileTools.definitionsBasePkg) + val svcType = domainTypes.asTsType(service.id, tsFileTools.definitionsBasePkg) val jsonFn = if (activeJsonCodec(service).isDefined) Some(generateErrorsJsonFn(service, svcType)) @@ -772,7 +773,7 @@ object TsServiceWiringTranslator { wk: WireKind, wireType: String, ): TextTree[TsValue] = { - val inTypeRef = typeTranslator.asTsType(m.sig.id, domain, evo, tsFileTools.definitionsBasePkg) + val inTypeRef = domainTypes.asTsType(m.sig.id, tsFileTools.definitionsBasePkg) val hasErrType = m.err.isDefined && !resolved.noErrors def mkDecode(id: TypeId): TextTree[TsValue] = wk match { @@ -796,7 +797,7 @@ object TsServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outTypeRef = typeTranslator.asTsType(outRef.id, domain, evo, tsFileTools.definitionsBasePkg) + val outTypeRef = domainTypes.asTsType(outRef.id, tsFileTools.definitionsBasePkg) if (hasErrType) { q"""let output: $outTypeRef; @@ -863,7 +864,7 @@ object TsServiceWiringTranslator { val callAndEncodeStep = m.out match { case Some(outRef) => - val outTypeRef = typeTranslator.asTsType(outRef.id, domain, evo, tsFileTools.definitionsBasePkg) + val outTypeRef = domainTypes.asTsType(outRef.id, tsFileTools.definitionsBasePkg) val callBody = if (hasErrType) { q"""try { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala index 1258ef6d5..a1710b363 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala @@ -11,9 +11,9 @@ import izumi.fundamentals.platform.strings.TextTree.* class TsUEBACodecGenerator( typeTranslator: TsTypeTranslator, + domainTypes: TsDomainTypes, target: TsTarget, domain: Domain, - evo: BaboonEvolution, enquiries: BaboonEnquiries, tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, @@ -246,7 +246,7 @@ class TsUEBACodecGenerator( val encBranches = branches.map { case (mid, idx) => - val branchType = typeTranslator.asTsType(mid, domain, evo, tsFileTools.definitionsBasePkg) + val branchType = domainTypes.asTsType(mid, tsFileTools.definitionsBasePkg) val codecType = codecName(branchType) if (target.language.wrappedAdtBranchCodecs) { q"""if (value instanceof $branchType) { @@ -264,7 +264,7 @@ class TsUEBACodecGenerator( val decBranches = branches.map { case (mid, idx) => - val branchType = typeTranslator.asTsType(mid, domain, evo, tsFileTools.definitionsBasePkg) + val branchType = domainTypes.asTsType(mid, tsFileTools.definitionsBasePkg) val codecType = codecName(branchType) if (target.language.wrappedAdtBranchCodecs) { q"""case ${idx.toString}: return $codecType.instance.decodeBranch(ctx, reader)""" @@ -298,12 +298,12 @@ class TsUEBACodecGenerator( mkEncoder(aliasedRef, ref, writer) case _ => // keep-foreigns naming matches the emitted `_UEBACodec` and the decoder branch. - val tsType = typeTranslator.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.encode(ctx, $ref, $w);" } case _ => - val tsType = typeTranslator.asTsTypeDerefForeign(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeDerefForeign(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.encode(ctx, $ref, $w);" } @@ -366,12 +366,12 @@ class TsUEBACodecGenerator( case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.BaboonRef(aliasedRef))) => mkDecoder(aliasedRef) case _ => - val tsType = typeTranslator.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.decode(ctx, reader)" } case _ => - val tsType = typeTranslator.asTsTypeKeepForeigns(u, domain, evo, tsFileTools.definitionsBasePkg) + val tsType = domainTypes.asTsTypeKeepForeigns(u, tsFileTools.definitionsBasePkg) val codec = codecName(tsType) q"$codec.instance.decode(ctx, reader)" } From e7725bd56a126a3a79dcfec13f1b4c9f63ea85da Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 14:54:56 +0100 Subject: [PATCH 31/48] refactor(translators): resolve enquiries and evolution from the subcontext too Second pass over the same question, measuring CROSS-COMPONENT ambient passing -- a value handed from one DI component to another -- rather than every mention of `domain`. Threading between private methods of a single class is ordinary parameter passing, not a missing scope; a subcontext only helps when separate components need the same ambient value. That measurement found 121 remaining passes in four groups, and widening the ambient set to `domain.version` / `domain.id` is what surfaced the most interesting one. - `DomainEnquiries` (new, language-agnostic, bound in all nine submodules): `BaboonEnquiries` is shared by the typer, the validator and every backend, so it keeps its explicit-domain API; the facade fixes the current domain for the 57 generator-side calls to `isRecursiveTypedef`, `hasForeignType`, `isEnum`, `unfold` and `collectParents`. - `DomainEvolution` (new, likewise shared): `BaboonEvolution` is keyed by version because it describes a whole lineage, and inside a per-domain subcontext exactly one of those keys is ever meaningful. 20 sites. - `currentPkg` on each `*DomainTypes`, absorbing `toXxPkg(domain.id, domain.version, evo)`. 26 sites. - `toPyModule(tid, domain.version, evolution, pkgBase)`. 10 sites. - `translateServiceRt(domain)` loses its parameter in the eight backends that had one: both caller and callee are subcontext-scoped and both already inject the current domain, so the argument was passing a value to its own owner. TypeScript was already parameterless -- the proof it was removable. The payoff is larger than the call sites: a clean compile then reported 67 injected dependencies that had become entirely unused, pruned to fixpoint over two waves (59, then 8 more that only became dead once the first were gone). `trans`, `typeTranslator`, `enquiries`, `evo` and `evolution` disappear from constructors across all nine backends -- dependencies that existed only to be forwarded. Left alone deliberately: `*FileTools.basename(domain, evo)` (15 sites) would need nine facades for two calls each, or file-path naming folded into the type-rendering facade; `csTypeInfo` (5 sites, C# only) would couple `CSDomainTypes` to it for five calls; and a per-lineage subcontext would mean extracting nine new DI classes to remove ~45 private-method parameters. Pure refactor: generated output for all nine backends is byte-identical to the pre-refactor baseline (4512 files), re-verified after each batch and after the prune. Full gate green, 200/200 lanes; `RTCodecTest` 9/9 with the gate's artifacts present. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/septimalmind/baboon/BaboonModule.scala | 19 +++++++++++++ .../baboon/translator/DomainEnquiries.scala | 27 +++++++++++++++++++ .../baboon/translator/DomainEvolution.scala | 18 +++++++++++++ .../csharp/CSCodecFixtureTranslator.scala | 10 +++---- .../csharp/CSCodecTestsTranslator.scala | 8 +++--- .../translator/csharp/CSDefnTranslator.scala | 20 +++++++------- .../translator/csharp/CSDomainTreeTools.scala | 1 - .../translator/csharp/CSDomainTypes.scala | 3 +++ .../csharp/CSJsonCodecGenerator.scala | 1 - .../csharp/CSServiceWiringTranslator.scala | 5 ++-- .../csharp/CSUEBACodecGenerator.scala | 1 - .../dart/DtCodecFixtureTranslator.scala | 13 ++++----- .../dart/DtCodecTestsTranslator.scala | 8 +++--- .../translator/dart/DtDefnTranslator.scala | 6 ++--- .../translator/dart/DtDomainTreeTools.scala | 10 +++---- .../translator/dart/DtDomainTypes.scala | 3 +++ .../dart/DtServiceWiringTranslator.scala | 4 +-- .../java/JvCodecFixtureTranslator.scala | 14 +++++----- .../java/JvCodecTestsTranslator.scala | 8 +++--- .../translator/java/JvDefnTranslator.scala | 5 ++-- .../translator/java/JvDomainTreeTools.scala | 10 +++---- .../translator/java/JvDomainTypes.scala | 3 +++ .../java/JvJsonCodecGenerator.scala | 3 +-- .../java/JvServiceWiringTranslator.scala | 10 +++---- .../java/JvUEBACodecGenerator.scala | 3 +-- .../kotlin/KtCodecFixtureTranslator.scala | 14 +++++----- .../kotlin/KtCodecTestsTranslator.scala | 8 +++--- .../translator/kotlin/KtDefnTranslator.scala | 9 +++---- .../translator/kotlin/KtDomainTreeTools.scala | 10 +++---- .../translator/kotlin/KtDomainTypes.scala | 3 +++ .../kotlin/KtJsonCodecGenerator.scala | 1 - .../kotlin/KtServiceWiringTranslator.scala | 10 +++---- .../kotlin/KtUEBACodecGenerator.scala | 1 - .../python/PyCodecFixtureTranslator.scala | 12 ++++----- .../python/PyCodecTestTranslator.scala | 8 +++--- .../translator/python/PyDefnTranslator.scala | 22 +++++++-------- .../translator/python/PyDomainTreeTools.scala | 10 +++---- .../translator/python/PyDomainTypes.scala | 4 +++ .../python/PyJsonCodecGenerator.scala | 3 +-- .../python/PyServiceWiringTranslator.scala | 10 +++---- .../python/PyUEBACodecGenerator.scala | 5 ++-- .../rust/RsCodecFixtureTranslator.scala | 10 +++---- .../rust/RsCodecTestsTranslator.scala | 8 +++--- .../translator/rust/RsDefnTranslator.scala | 14 +++++----- .../translator/rust/RsDomainTypes.scala | 5 +++- .../rust/RsJsonCodecGenerator.scala | 1 - .../rust/RsServiceWiringTranslator.scala | 8 +++--- .../scl/ScCodecFixtureTranslator.scala | 13 ++++----- .../scl/ScCodecTestsTranslator.scala | 7 ++--- .../translator/scl/ScDefnTranslator.scala | 2 +- .../translator/scl/ScDomainTreeTools.scala | 9 ++++--- .../scl/ScServiceWiringTranslator.scala | 4 +-- .../swift/SwCodecFixtureTranslator.scala | 8 +++--- .../swift/SwCodecTestsTranslator.scala | 7 ++--- .../translator/swift/SwDefnTranslator.scala | 4 +-- .../translator/swift/SwDomainTreeTools.scala | 10 +++---- .../translator/swift/SwDomainTypes.scala | 3 +++ .../swift/SwServiceWiringTranslator.scala | 4 +-- .../typescript/TsBaboonTranslator.scala | 1 - .../typescript/TsCodecFixtureTranslator.scala | 9 ++++--- .../typescript/TsCodecTestsTranslator.scala | 7 ++--- .../typescript/TsDomainTreeTools.scala | 7 ++--- .../typescript/TsJsonCodecGenerator.scala | 5 ++-- .../typescript/TsUEBACodecGenerator.scala | 1 - 64 files changed, 281 insertions(+), 209 deletions(-) create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEnquiries.scala create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEvolution.scala diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala index 033c5f41d..bd4cff4a9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/BaboonModule.scala @@ -32,6 +32,7 @@ import izumi.functional.bio.Guarantee2 import izumi.functional.bio.Monad2 import izumi.functional.bio.ParallelErrorAccumulatingOps2 import izumi.reflect.TagKK +import io.septimalmind.baboon.translator.{DomainEnquiries, DomainEvolution} class BaboonSharedModule[F[+_, +_]: Error2: MaybeSuspend2: TagKK] extends ModuleDef { // not all the definitions are required for parent locator, so it's easier to double-include @@ -88,6 +89,8 @@ class BaboonCommonCSModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { make[CSCodecTestsTranslator].from[CSCodecTestsTranslator.Impl] make[CSCodecFixtureTranslator].from[CSRandomMethodTranslatorImpl] make[CSServiceWiringTranslator].from[CSServiceWiringTranslator.Impl] + make[DomainEvolution] + make[DomainEnquiries] make[CSDomainTypes] make[CSDomainTreeTools].from[CSDomainTreeTools.CSDomainTreeToolsImpl] @@ -120,6 +123,8 @@ class BaboonCommonScModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[ScDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DomainEvolution] + make[DomainEnquiries] make[ScDomainTypes] make[ScDomainTreeTools].from[ScDomainTreeTools.ScDomainTreeToolsImpl] make[ScDefnTranslator[F]].from[ScDefnTranslator.ScDefnTranslatorImpl[F]] @@ -154,6 +159,8 @@ class BaboonCommonPyModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { make[PyDefnTranslator[F]].from[PyDefnTranslatorImpl[F]] + make[DomainEvolution] + make[DomainEnquiries] make[PyDomainTypes] make[PyDomainTreeTools].from[PyDomainTreeTools.PyDomainTreeToolsImpl] make[PyCodecFixtureTranslator].from[PyCodecFixtureTranslator.PyCodecFixtureTranslatorImpl] @@ -188,6 +195,8 @@ class BaboonCommonRsModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { make[RsDefnTranslator[F]].from[RsDefnTranslator.RsDefnTranslatorImpl[F]] + make[DomainEvolution] + make[DomainEnquiries] make[RsDomainTypes] make[RsDomainTreeTools].from[RsDomainTreeTools.RsDomainTreeToolsImpl] make[RsCodecFixtureTranslator].from[RsCodecFixtureTranslator.RsCodecFixtureTranslatorImpl] @@ -225,6 +234,8 @@ class BaboonCommonTsModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { make[TsServiceWiringTranslator].from[TsServiceWiringTranslator.Impl] make[TsCodecFixtureTranslator].from[TsCodecFixtureTranslator.TsCodecFixtureTranslatorImpl] make[TsCodecTestsTranslator].from[TsCodecTestsTranslator.Impl] + make[DomainEvolution] + make[DomainEnquiries] make[TsDomainTypes] make[TsDomainTreeTools].from[TsDomainTreeTools.TsDomainTreeToolsImpl] many[TsCodecTranslator] @@ -254,6 +265,8 @@ class BaboonCommonKtModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[KtDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DomainEvolution] + make[DomainEnquiries] make[KtDomainTypes] make[KtDomainTreeTools].from[KtDomainTreeTools.KtDomainTreeToolsImpl] make[KtDefnTranslator[F]].from[KtDefnTranslator.KtDefnTranslatorImpl[F]] @@ -288,6 +301,8 @@ class BaboonCommonJvModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[JvDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DomainEvolution] + make[DomainEnquiries] make[JvDomainTypes] make[JvDomainTreeTools].from[JvDomainTreeTools.JvDomainTreeToolsImpl] make[JvDefnTranslator[F]].from[JvDefnTranslator.JvDefnTranslatorImpl[F]] @@ -321,6 +336,8 @@ class BaboonCommonDtModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[DtDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DomainEvolution] + make[DomainEnquiries] make[DtDomainTypes] make[DtDomainTreeTools].from[DtDomainTreeTools.DtDomainTreeToolsImpl] make[DtDefnTranslator[F]].from[DtDefnTranslator.DtDefnTranslatorImpl[F]] @@ -354,6 +371,8 @@ class BaboonCommonSwModule[F[+_, +_]: Error2: TagKK] extends ModuleDef { makeSubcontext[SwDefnTranslator[F]] .localDependencies(List(DIKey[Domain], DIKey[BaboonEvolution])) .withSubmodule(new ModuleDef { + make[DomainEvolution] + make[DomainEnquiries] make[SwDomainTypes] make[SwDomainTreeTools].from[SwDomainTreeTools.SwDomainTreeToolsImpl] make[SwDefnTranslator[F]].from[SwDefnTranslator.SwDefnTranslatorImpl[F]] diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEnquiries.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEnquiries.scala new file mode 100644 index 000000000..dc152f553 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEnquiries.scala @@ -0,0 +1,27 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.BaboonEnquiries +import io.septimalmind.baboon.typer.model.* + +/** The current domain's view of the domain-taking half of [[BaboonEnquiries]]. + * + * `BaboonEnquiries` is shared by the typer, the validator and every backend, so it stays bound + * globally with an explicit-domain API. This facade is bound inside each per-domain subcontext, so + * a code generator that already holds the current `Domain` asks its questions without re-supplying + * it. Language-agnostic, hence one class bound in all nine submodules rather than one per backend. + */ +final class DomainEnquiries(enquiries: BaboonEnquiries, domain: Domain) { + def hasForeignType(definition: DomainMember.User): Boolean = enquiries.hasForeignType(definition, domain) + + def hasForeignType(definition: DomainMember.User, lang: BaboonLang): Boolean = + enquiries.hasForeignType(definition, domain, lang) + + def isRecursiveTypedef(definition: DomainMember.User): Boolean = enquiries.isRecursiveTypedef(definition, domain) + + def isEnum(tpe: TypeRef): Boolean = enquiries.isEnum(tpe, domain) + + def unfold(contracts: List[TypeId.User]): List[Field] = enquiries.unfold(domain, contracts) + + def collectParents(definitions: List[DomainMember.User]): List[TypeId.User] = + enquiries.collectParents(domain, definitions) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEvolution.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEvolution.scala new file mode 100644 index 000000000..294d7ed59 --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/DomainEvolution.scala @@ -0,0 +1,18 @@ +package io.septimalmind.baboon.translator + +import io.septimalmind.baboon.typer.model.* + +/** The current domain's position in its lineage. + * + * `BaboonEvolution` is keyed by version because it describes a whole lineage; inside a per-domain + * subcontext exactly one of those keys is meaningful. This facade fixes it, so a code generator + * asks what is unchanged, forward-readable, or readable by which readers without re-deriving + * `domain.version` at every call. Language-agnostic, hence one class bound in all nine submodules. + */ +final class DomainEvolution(evo: BaboonEvolution, domain: Domain) { + def typesUnchangedSince: Map[TypeId, UnmodifiedSince] = evo.typesUnchangedSince(domain.version) + + def typesForwardReadable: Map[TypeId, ForwardReadable] = evo.typesForwardReadable(domain.version) + + def minReaders(id: TypeId): Map[ForwardCompatTier, Version] = evo.minReaders(domain.version, id) +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala index 42581e359..319667e74 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecFixtureTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEnquiries trait CSCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[CSValue]] @@ -14,9 +15,8 @@ trait CSCodecFixtureTranslator { object CSCodecFixtureTranslator { final class CSRandomMethodTranslatorImpl( - translator: CSTypeTranslator, domainTypes: CSDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, lineage: BaboonLineage, csTypeInfo: CSTypeInfo, @@ -29,8 +29,8 @@ object CSCodecFixtureTranslator { None } else { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Cs) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Cs) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -260,7 +260,7 @@ object CSCodecFixtureTranslator { case TypeId.Builtins.bit => q"$baboonFixture.NextBoolean()" - case id: TypeId.User if enquiries.isEnum(tpe, domain) => q"$baboonFixture.NextRandomEnum<${domainTypes.asCsType(id).fullyQualified}>()" + case id: TypeId.User if domainEnquiries.isEnum(tpe) => q"$baboonFixture.NextRandomEnum<${domainTypes.asCsType(id).fullyQualified}>()" case TypeId.User(_, _, name) => // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01 (C# analog). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala index a2f291280..228cefc83 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSCodecTestsTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.* import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait CSCodecTestsTranslator { def translate( @@ -20,10 +21,9 @@ trait CSCodecTestsTranslator { object CSCodecTestsTranslator { final class Impl( codecs: Set[CSCodecTranslator], - typeTranslator: CSTypeTranslator, domainTypes: CSDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: CSTarget, domain: Domain, evo: BaboonEvolution, @@ -36,8 +36,8 @@ object CSCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Cs) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Cs) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !target.language.enableDeprecatedEncoders && !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala index 5481e046e..d27ae3820 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala @@ -14,6 +14,7 @@ import izumi.functional.bio.{Applicative2, F} import izumi.fundamentals.collections.nonempty.NEList import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait CSDefnTranslator[F[+_, +_]] { def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[CSDefnTranslator.Output]] @@ -63,7 +64,6 @@ object CSDefnTranslator { class CSDefnTranslatorImpl[F[+_, +_]: Applicative2 /* This impl has no errors right now */ ]( target: CSTarget, - trans: CSTypeTranslator, domainTypes: CSDomainTypes, csTrees: CSTreeTools, csDomTrees: CSDomainTreeTools, @@ -77,7 +77,7 @@ object CSDefnTranslator { lineage: BaboonLineage, types: TypeInfo, csTypeInfo: CSTypeInfo, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, ) extends CSDefnTranslator[F] { type Out[T] = F[NEList[BaboonIssue], T] @@ -109,7 +109,7 @@ object CSDefnTranslator { val mainOutput = Output( getOutputPath(defn), reprOut, - trans.toCsPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, codecReg = regsPerCodec, origin = OutputOrigin.TypeInDomain(defn.id, domain.id, domain.version), @@ -123,7 +123,7 @@ object CSDefnTranslator { Output( getOutputPath(defn, suffix = Some(".Wiring")), Some(csTrees.inNs(ns.toSeq, wiringTree)), - trans.toCsPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, origin = OutputOrigin.TypeInDomain(defn.id, domain.id, domain.version), ) @@ -137,7 +137,7 @@ object CSDefnTranslator { Output( getOutputPath(defn, suffix = Some("_Client")), Some(csTrees.inNs(ns.toSeq, clientTree)), - trans.toCsPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, origin = OutputOrigin.TypeInDomain(defn.id, domain.id, domain.version), ) @@ -159,7 +159,7 @@ object CSDefnTranslator { Output( getOutputPath(defn, suffix = Some(".Fixture")), Some(fixtureTreeWithNs), - trans.toCsPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Fixture, origin = OutputOrigin.TypeInDomain(defn.id, domain.id, domain.version), ) @@ -181,7 +181,7 @@ object CSDefnTranslator { Output( getOutputPath(defn, suffix = Some(".Tests")), Some(codecTestWithNS), - trans.toCsPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Test, origin = OutputOrigin.TypeInDomain(defn.id, domain.id, domain.version), ) @@ -191,10 +191,10 @@ object CSDefnTranslator { } override def translateServiceRt(): Out[List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => - val pkg = trans.toCsPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val wrapped = csTrees.inNs(pkg.parts.toSeq, tree) val fbase = csFiles.basename(domain, evo) Output( @@ -454,7 +454,7 @@ object CSDefnTranslator { val allParents = Seq(q"$genMarker") ++ adt.contracts.map(t => q"${domainTypes.asCsType(t)}") val parents = makeParents(allParents.toList) - val allFields = enquiries.unfold(domain, adt.contracts) + val allFields = domainEnquiries.unfold( adt.contracts) val abstractFields = allFields.map { f => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala index 0610c7b50..1a6fea640 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTreeTools.scala @@ -14,7 +14,6 @@ trait CSDomainTreeTools { object CSDomainTreeTools { class CSDomainTreeToolsImpl( - trans: CSTypeTranslator, domainTypes: CSDomainTypes, domain: Domain, evo: BaboonEvolution, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala index 72e42ea88..8cd0c25ef 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDomainTypes.scala @@ -27,4 +27,7 @@ final class CSDomainTypes(trans: CSTypeTranslator, domain: Domain, evo: BaboonEv def csFixtureRef(tid: TypeId.User): CSType = trans.csFixtureRef(tid, domain, evo) def deNull(tpe: TypeRef, ref: TextTree[CSValue]): TextTree[CSValue] = trans.deNull(tpe, domain, ref) + + /** The package/crate of the current domain's own version. */ + def currentPkg: CSPackageId = trans.toCsPkg(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala index f9909a412..feddfaa31 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSJsonCodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class CSJsonCodecGenerator( - trans: CSTypeTranslator, domainTypes: CSDomainTypes, csDomTrees: CSDomainTreeTools, target: CSTarget, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala index 57daf65c8..55e249bc7 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala @@ -11,7 +11,7 @@ import izumi.fundamentals.platform.strings.TextTree.* trait CSServiceWiringTranslator { def translate(defn: DomainMember.User): Option[TextTree[CSValue]] - def translateServiceRt(domain: Domain): Option[TextTree[CSValue]] + def translateServiceRt(): Option[TextTree[CSValue]] def translateClient(defn: DomainMember.User): Option[TextTree[CSValue]] } @@ -19,7 +19,6 @@ trait CSServiceWiringTranslator { object CSServiceWiringTranslator { class Impl( target: CSTarget, - trans: CSTypeTranslator, domainTypes: CSDomainTypes, codecs: Set[CSCodecTranslator], domain: Domain, @@ -109,7 +108,7 @@ object CSServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[CSValue]] = { + override def translateServiceRt(): Option[TextTree[CSValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala index 956d0038d..13b99a0a6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSUEBACodecGenerator.scala @@ -13,7 +13,6 @@ import izumi.fundamentals.platform.strings.TextTree.* import izumi.fundamentals.platform.strings.TextTree.style.c.* class CSUEBACodecGenerator( - trans: CSTypeTranslator, domainTypes: CSDomainTypes, csDomTrees: CSDomainTreeTools, target: CSTarget, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala index fb80a545a..02b83b764 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecFixtureTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait DtCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[DtValue]] @@ -27,7 +28,7 @@ object DtCodecFixtureTranslator { final class Impl( translator: DtTypeTranslator, domainTypes: DtDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, ) extends DtCodecFixtureTranslator { @@ -35,8 +36,8 @@ object DtCodecFixtureTranslator { definition: DomainMember.User ): Option[TextTree[DtValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Dart) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Dart) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -55,8 +56,8 @@ object DtCodecFixtureTranslator { case _: DomainMember.Builtin => None case u: DomainMember.User => u.defn match { - case _ if enquiries.hasForeignType(u, domain, BaboonLang.Dart) => None - case _ if enquiries.isRecursiveTypedef(u, domain) => None + case _ if domainEnquiries.hasForeignType(u, BaboonLang.Dart) => None + case _ if domainEnquiries.isRecursiveTypedef(u) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -198,7 +199,7 @@ object DtCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBool()" - case u: TypeId.User if enquiries.isEnum(tpe, domain) => + case u: TypeId.User if domainEnquiries.isEnum(tpe) => val enumType = domainTypes.toDtTypeRefKeepForeigns(u) q"rnd.mkEnum($enumType.values)" case u: TypeId.User => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala index 0774d63d1..56cad8443 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtCodecTestsTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait DtCodecTestsTranslator { def translate( @@ -22,10 +23,9 @@ trait DtCodecTestsTranslator { object DtCodecTestsTranslator { final class Impl( codecs: Set[DtCodecTranslator], - typeTranslator: DtTypeTranslator, domainTypes: DtDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: DtTarget, domain: Domain, evo: BaboonEvolution, @@ -41,8 +41,8 @@ object DtCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Dart) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Dart) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala index a7256edf0..3e53a592d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDefnTranslator.scala @@ -138,10 +138,10 @@ object DtDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => - val pkg = trans.toDtPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val fbase = dtFiles.basename(domain, evo) Output( s"$fbase/baboon_service_rt.dart", @@ -889,7 +889,7 @@ object DtDefnTranslator { } private def getOutputModule(defn: DomainMember.User): DtValue.DtPackageId = { - val basePkg = trans.toDtPkg(domain.id, domain.version, evo) + val basePkg = domainTypes.currentPkg defn.defn.id.owner match { case Owner.Toplevel => basePkg case Owner.Ns(path) => DtValue.DtPackageId(basePkg.parts ++ path.map(_.name.toLowerCase)) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala index 5ae53ba3a..346db06c9 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTreeTools.scala @@ -4,6 +4,7 @@ import io.septimalmind.baboon.translator.dart.DtDomainTreeTools.MetaField import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait DtDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[MetaField] @@ -27,8 +28,7 @@ object DtDomainTreeTools { final class DtDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, - typeTranslator: DtTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: DtDomainTypes, ) extends DtDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { @@ -93,7 +93,7 @@ object DtDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = domainTypes.asDtType(defn.id).asName - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"'${v.v.toString}'") + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id).sameIn.map(v => s"'${v.v.toString}'") val sameInVersion = MetaField( q"static const List baboonSameInVersionsConst", q"[${unmodifiedSince.mkString(", ")}]", @@ -102,7 +102,7 @@ object DtDomainTreeTools { q"List", isCodecData = false, ) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s"'${v.v.toString}': '${tier.wireName}'" } @@ -114,7 +114,7 @@ object DtDomainTreeTools { q"Map", isCodecData = false, ) - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s"'${tier.wireName}': '${v.v.toString}'" } val minReaders = MetaField( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala index 5086dc73e..821399a2f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtDomainTypes.scala @@ -19,4 +19,7 @@ final class DtDomainTypes(trans: DtTypeTranslator, domain: Domain, evo: BaboonEv def toDtTypeRefKeepForeigns(tid: TypeId.User): DtType = trans.toDtTypeRefKeepForeigns(tid, domain, evo) def effectiveDtPkg(owner: Owner): DtPackageId = trans.effectiveDtPkg(owner, domain, evo) + + /** The package/crate of the current domain's own version. */ + def currentPkg: DtPackageId = trans.toDtPkg(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala index 1606fff90..592c8f67b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/dart/DtServiceWiringTranslator.scala @@ -10,7 +10,7 @@ import izumi.fundamentals.platform.strings.TextTree.* trait DtServiceWiringTranslator { def translate(defn: DomainMember.User): Option[TextTree[DtValue]] def translateClient(defn: DomainMember.User): Option[TextTree[DtValue]] - def translateServiceRt(domain: Domain): Option[TextTree[DtValue]] + def translateServiceRt(): Option[TextTree[DtValue]] /** Generic clause (``) appended to the service-interface class name when * an `abstract` service-context mode is active; empty otherwise (incl. `none` @@ -124,7 +124,7 @@ object DtServiceWiringTranslator { s"$typeName$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[DtValue]] = { + override def translateServiceRt(): Option[TextTree[DtValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala index 8cea0f628..906eb23ca 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecFixtureTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait JvCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[JvValue]] @@ -27,9 +28,8 @@ object JvCodecFixtureTranslator { final class Impl( target: JvTarget, - translator: JvTypeTranslator, domainTypes: JvDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, ) extends JvCodecFixtureTranslator { @@ -37,8 +37,8 @@ object JvCodecFixtureTranslator { definition: DomainMember.User ): Option[TextTree[JvValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Java) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Java) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -69,8 +69,8 @@ object JvCodecFixtureTranslator { case _: DomainMember.Builtin => None case u: DomainMember.User => u.defn match { - case _ if enquiries.hasForeignType(u, domain, BaboonLang.Java) => None - case _ if enquiries.isRecursiveTypedef(u, domain) => None + case _ if domainEnquiries.hasForeignType(u, BaboonLang.Java) => None + case _ if domainEnquiries.isRecursiveTypedef(u) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -234,7 +234,7 @@ object JvCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBit()" - case TypeId.User(_, _, name) if enquiries.isEnum(tpe, domain) => q"rnd.mkEnum(${name.name}.class, ${name.name}.values())" + case TypeId.User(_, _, name) if domainEnquiries.isEnum(tpe) => q"rnd.mkEnum(${name.name}.class, ${name.name}.values())" case u: TypeId.User => // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01 (Java analog). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala index 2de550b2b..50f0e260a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvCodecTestsTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait JvCodecTestsTranslator { def translate( @@ -19,10 +20,9 @@ trait JvCodecTestsTranslator { object JvCodecTestsTranslator { final class Impl( codecs: Set[JvCodecTranslator], - typeTranslator: JvTypeTranslator, domainTypes: JvDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: JvTarget, domain: Domain, evo: BaboonEvolution, @@ -35,8 +35,8 @@ object JvCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Java) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Java) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala index 215aa8915..c6e12dd84 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDefnTranslator.scala @@ -56,7 +56,6 @@ object JvDefnTranslator { evo: BaboonEvolution, jvFiles: JvFileTools, jvTrees: JvTreeTools, - trans: JvTypeTranslator, domainTypes: JvDomainTypes, codecs: Set[JvCodecTranslator], codecTests: JvCodecTestsTranslator, @@ -183,10 +182,10 @@ object JvDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => - val pkg = trans.toJvPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val wrapped = jvTrees.inPkg(pkg.parts.toSeq, tree) val fbase = jvFiles.basename(domain, evo) Output( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala index 518c771b4..3bd039a79 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTreeTools.scala @@ -5,6 +5,7 @@ import io.septimalmind.baboon.translator.java.JvTypes.{javaClass, jvList, jvMap, import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait JvDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[MetaField] @@ -23,8 +24,7 @@ object JvDomainTreeTools { final class JvDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, - typeTranslator: JvTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: JvDomainTypes, ) extends JvDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { @@ -76,13 +76,13 @@ object JvDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = domainTypes.asJvType(defn.id).fullyQualified - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField( q"public static final $jvList<$jvString> baboonSameInVersions", q"$jvList.of(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s"""java.util.Map.entry("${v.v.toString}", "${tier.wireName}")""" } @@ -91,7 +91,7 @@ object JvDomainTreeTools { q"$jvMap.ofEntries(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s"""java.util.Map.entry("${tier.wireName}", "${v.v.toString}")""" } val minReaders = MetaField( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala index db62a6b96..01699c999 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvDomainTypes.scala @@ -23,4 +23,7 @@ final class JvDomainTypes(trans: JvTypeTranslator, domain: Domain, evo: BaboonEv def toJvTypeRefKeepForeigns(tid: TypeId.User): JvType = trans.toJvTypeRefKeepForeigns(tid, domain, evo) def effectiveJvPkg(owner: Owner): JvPackageId = trans.effectiveJvPkg(owner, domain, evo) + + /** The package/crate of the current domain's own version. */ + def currentPkg: JvPackageId = trans.toJvPkg(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala index ee1e0a949..97faf723e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvJsonCodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class JvJsonCodecGenerator( - trans: JvTypeTranslator, domainTypes: JvDomainTypes, target: JvTarget, domain: Domain, @@ -474,7 +473,7 @@ class JvJsonCodecGenerator( } def codecName(name: JvValue.JvType, owner: Owner): JvValue.JvType = { - val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) + val domainPkg = domainTypes.currentPkg val ownerPrefix = name.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" val realPkg = domainTypes.effectiveJvPkg(owner) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala index 985c84219..337384930 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvServiceWiringTranslator.scala @@ -10,17 +10,15 @@ import izumi.fundamentals.platform.strings.TextTree.* trait JvServiceWiringTranslator { def translate(defn: DomainMember.User): Option[TextTree[JvValue]] def translateClient(defn: DomainMember.User): Option[TextTree[JvValue]] - def translateServiceRt(domain: Domain): Option[TextTree[JvValue]] + def translateServiceRt(): Option[TextTree[JvValue]] } object JvServiceWiringTranslator { class Impl( target: JvTarget, - trans: JvTypeTranslator, domainTypes: JvDomainTypes, codecs: Set[JvCodecTranslator], domain: Domain, - evo: BaboonEvolution, ) extends JvServiceWiringTranslator { private val resolved: ResolvedServiceResult = @@ -59,7 +57,7 @@ object JvServiceWiringTranslator { private def jsonCodecName(typeId: TypeId.User): JvValue.JvType = { val srcRef = domainTypes.toJvTypeRefKeepForeigns(typeId) - val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) + val domainPkg = domainTypes.currentPkg val ownerPrefix = srcRef.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" val realPkg = domainTypes.effectiveJvPkg(typeId.owner) @@ -68,7 +66,7 @@ object JvServiceWiringTranslator { private def uebaCodecName(typeId: TypeId.User): JvValue.JvType = { val srcRef = domainTypes.toJvTypeRefKeepForeigns(typeId) - val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) + val domainPkg = domainTypes.currentPkg val ownerPrefix = srcRef.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" val realPkg = domainTypes.effectiveJvPkg(typeId.owner) @@ -111,7 +109,7 @@ object JvServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[JvValue]] = { + override def translateServiceRt(): Option[TextTree[JvValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala index 263198dc4..79117df6b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvUEBACodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class JvUEBACodecGenerator( - trans: JvTypeTranslator, domainTypes: JvDomainTypes, target: JvTarget, domain: Domain, @@ -507,7 +506,7 @@ class JvUEBACodecGenerator( } def codecName(name: JvValue.JvType, owner: Owner): JvValue.JvType = { - val domainPkg = trans.toJvPkg(domain.id, domain.version, evo) + val domainPkg = domainTypes.currentPkg val ownerPrefix = name.pkg.parts.toSeq.drop(domainPkg.parts.toSeq.length) val prefixStr = if (ownerPrefix.nonEmpty) ownerPrefix.mkString("_") + "_" else "" val realPkg = domainTypes.effectiveJvPkg(owner) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala index 15bbe7a64..7b5019092 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecFixtureTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait KtCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[KtValue]] @@ -17,9 +18,8 @@ trait KtCodecFixtureTranslator { object KtCodecFixtureTranslator { final class Impl( target: KtTarget, - translator: KtTypeTranslator, domainTypes: KtDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, evo: BaboonEvolution, ) extends KtCodecFixtureTranslator { @@ -31,8 +31,8 @@ object KtCodecFixtureTranslator { definition: DomainMember.User ): Option[TextTree[KtValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Kotlin) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Kotlin) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -73,8 +73,8 @@ object KtCodecFixtureTranslator { case _: DomainMember.Builtin => None case u: DomainMember.User => u.defn match { - case _ if enquiries.hasForeignType(u, domain, BaboonLang.Kotlin) => None - case _ if enquiries.isRecursiveTypedef(u, domain) => None + case _ if domainEnquiries.hasForeignType(u, BaboonLang.Kotlin) => None + case _ if domainEnquiries.isRecursiveTypedef(u) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -235,7 +235,7 @@ object KtCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBit()" - case TypeId.User(_, _, name) if enquiries.isEnum(tpe, domain) => q"rnd.mkEnum(${name.name})" + case TypeId.User(_, _, name) if domainEnquiries.isEnum(tpe) => q"rnd.mkEnum(${name.name})" case u: TypeId.User => // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01 (Kotlin analog). diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala index 31aa00dae..b8f0ee485 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtCodecTestsTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait KtCodecTestsTranslator { def translate( @@ -19,10 +20,9 @@ trait KtCodecTestsTranslator { object KtCodecTestsTranslator { final class Impl( codecs: Set[KtCodecTranslator], - typeTranslator: KtTypeTranslator, domainTypes: KtDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: KtTarget, domain: Domain, evo: BaboonEvolution, @@ -38,8 +38,8 @@ object KtCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Kotlin) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Kotlin) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala index d971e32a9..8c78fca84 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDefnTranslator.scala @@ -49,7 +49,6 @@ object KtDefnTranslator { evo: BaboonEvolution, ktFiles: KtFileTools, ktTrees: KtTreeTools, - trans: KtTypeTranslator, domainTypes: KtDomainTypes, ktTypes: KtTypes, codecs: Set[KtCodecTranslator], @@ -124,7 +123,7 @@ object KtDefnTranslator { Output( getOutputPath(defn, suffix = Some("Fixture")), fixtureTreeWithPkg, - trans.toKtPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Fixture, ) } @@ -155,7 +154,7 @@ object KtDefnTranslator { Output( getOutputPath(defn, suffix = Some("Tests")), codecTestWithPkg, - trans.toKtPkg(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Test, ) } @@ -164,10 +163,10 @@ object KtDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => - val pkg = trans.toKtPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val wrapped = ktTrees.inPkg(pkg.parts.toSeq, tree) val fbase = ktFiles.basename(domain, evo) Output( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala index 06727b035..c6d498ce4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTreeTools.scala @@ -5,6 +5,7 @@ import io.septimalmind.baboon.translator.kotlin.KtTypes.{ktList, ktMap, ktString import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait KtDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[MetaField] @@ -23,8 +24,7 @@ object KtDomainTreeTools { final class KtDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, - typeTranslator: KtTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: KtDomainTypes, ktTypes: KtTypes, ) extends KtDomainTreeTools { @@ -79,13 +79,13 @@ object KtDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = domainTypes.asKtType(defn.id).fullyQualified - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField( q"val baboonSameInVersions: $ktList<$ktString>", q"listOf(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}" to "${tier.wireName}"""" } @@ -94,7 +94,7 @@ object KtDomainTreeTools { q"mapOf(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s""""${tier.wireName}" to "${v.v.toString}"""" } val minReaders = MetaField( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala index a6d3898c8..439047d56 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtDomainTypes.scala @@ -19,4 +19,7 @@ final class KtDomainTypes(trans: KtTypeTranslator, domain: Domain, evo: BaboonEv def asKtType(tpe: TypeId): KtType = trans.asKtType(tpe, domain, evo) def toKtTypeRefKeepForeigns(tid: TypeId.User): KtType = trans.toKtTypeRefKeepForeigns(tid, domain, evo) + + /** The package/crate of the current domain's own version. */ + def currentPkg: KtPackageId = trans.toKtPkg(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala index 5650561b1..41ef7598f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtJsonCodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class KtJsonCodecGenerator( - trans: KtTypeTranslator, domainTypes: KtDomainTypes, target: KtTarget, domain: Domain, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala index 7f389f608..213459bff 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtServiceWiringTranslator.scala @@ -12,17 +12,15 @@ trait KtServiceWiringTranslator { def translateClient(defn: DomainMember.User): Option[TextTree[KtValue]] - def translateServiceRt(domain: Domain): Option[TextTree[KtValue]] + def translateServiceRt(): Option[TextTree[KtValue]] } object KtServiceWiringTranslator { class Impl( target: KtTarget, - trans: KtTypeTranslator, domainTypes: KtDomainTypes, codecs: Set[KtCodecTranslator], domain: Domain, - evo: BaboonEvolution, ktTypes: KtTypes, ) extends KtServiceWiringTranslator { import ktTypes.* @@ -165,7 +163,7 @@ object KtServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[KtValue]] = { + override def translateServiceRt(): Option[TextTree[KtValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { @@ -528,7 +526,7 @@ object KtServiceWiringTranslator { if (resolved.noErrors) None else { // IBaboonServiceRt is emitted in the domain's root package, not in baboon.runtime.shared. - val rootPkg = trans.toKtPkg(domain.id, domain.version, evo) + val rootPkg = domainTypes.currentPkg Some(("rt", q"${KtValue.KtType(rootPkg, "IBaboonServiceRt")}$rtTypeArg")) } } @@ -579,7 +577,7 @@ object KtServiceWiringTranslator { // IBaboonServiceRt is always emitted in the domain root package; use the FQ name // so that wiring objects inside a namespace sub-package can still reference it. private val iBaboonServiceRtFq: String = { - val rootPkg = trans.toKtPkg(domain.id, domain.version, evo) + val rootPkg = domainTypes.currentPkg (rootPkg.parts.toList :+ "IBaboonServiceRt").mkString(".") } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala index b17620146..5f0d92865 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtUEBACodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class KtUEBACodecGenerator( - trans: KtTypeTranslator, domainTypes: KtDomainTypes, target: KtTarget, domain: Domain, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala index e4db9f24e..0b47987ae 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecFixtureTranslator.scala @@ -9,6 +9,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEnquiries trait PyCodecFixtureTranslator { def translate(defn: DomainMember.User): Option[TextTree[PyValue]] @@ -29,7 +30,7 @@ object PyCodecFixtureTranslator { final class PyCodecFixtureTranslatorImpl( typeTranslator: PyTypeTranslator, domainTypes: PyDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, evolution: BaboonEvolution, pyFileTools: PyFileTools, domain: Domain, @@ -41,8 +42,8 @@ object PyCodecFixtureTranslator { override def translate(defn: DomainMember.User): Option[TextTree[PyValue]] = { defn.defn match { - case _ if enquiries.hasForeignType(defn, domain, BaboonLang.Py) => None - case _ if enquiries.isRecursiveTypedef(defn, domain) => None + case _ if domainEnquiries.hasForeignType(defn, BaboonLang.Py) => None + case _ if domainEnquiries.isRecursiveTypedef(defn) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -140,7 +141,7 @@ object PyCodecFixtureTranslator { case TypeId.Builtins.bytes => q"$baboonFixture.next_bytes()" - case id: TypeId.User if enquiries.isEnum(tpe, domain) => + case id: TypeId.User if domainEnquiries.isEnum(tpe) => val tpe = domainTypes.asPyType(id, pyFileTools.definitionsBasePkg) q"$baboonFixture.next_random_enum($tpe)" case u: TypeId.User => @@ -198,8 +199,7 @@ object PyCodecFixtureTranslator { override def fixtureType(tid: TypeId.User): PyType = { val typeName = s"${tid.name.name.capitalize}_Fixture" - val pyModuleId = typeTranslator - .toPyModule(tid, domain.version, evolution, pyFileTools.fixturesBasePkg) + val pyModuleId = domainTypes.toPyModule(tid, pyFileTools.fixturesBasePkg) .withModuleName(typeName) PyType(pyModuleId, typeName) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala index 596e00d10..212912a8e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyCodecTestTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEnquiries trait PyCodecTestTranslator { def translate(defn: DomainMember.User, pyRef: PyType, srcRef: PyType): Option[TextTree[PyValue]] @@ -16,10 +17,9 @@ trait PyCodecTestTranslator { object PyCodecTestTranslator { final class PyCodecTestTranslatorImpl( fixtureTranslator: PyCodecFixtureTranslator, - typeTranslator: PyTypeTranslator, domainTypes: PyDomainTypes, codecs: Set[PyCodecTranslator], - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, evolution: BaboonEvolution, pyFileTools: PyFileTools, pyTarget: PyTarget, @@ -34,8 +34,8 @@ object PyCodecTestTranslator { val isLatestVersion = domain.version == evolution.latest defn match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Py) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Py) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala index b2e8aa8a5..e6b2a075d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDefnTranslator.scala @@ -14,6 +14,7 @@ import izumi.functional.bio.{Applicative2, F} import izumi.fundamentals.collections.nonempty.NEList import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEnquiries trait PyDefnTranslator[F[+_, +_]] { def translate(defn: DomainMember.User): F[NEList[BaboonIssue], List[PyDefnTranslator.Output]] @@ -48,9 +49,8 @@ object PyDefnTranslator { target: PyTarget, codecsFixture: PyCodecFixtureTranslator, codecsTests: PyCodecTestTranslator, - typeTranslator: PyTypeTranslator, domainTypes: PyDomainTypes, - baboonEnquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, codecs: Set[PyCodecTranslator], pyDomTrees: PyDomainTreeTools, evolution: BaboonEvolution, @@ -91,7 +91,7 @@ object PyDefnTranslator { Output( getOutputPath(defn, prefix = Some("test_")), codecsTest, - typeTranslator.toPyModule(defn.id, domain.version, evolution, fileTools.testsBasePkg), + domainTypes.toPyModule(defn.id, fileTools.testsBasePkg), CompilerProduct.Test, ) ) @@ -100,7 +100,7 @@ object PyDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => val fbase = fileTools.basename(domain, evolution) @@ -126,7 +126,7 @@ object PyDefnTranslator { Output( getOutputPath(defn, suffix = Some("_Fixture")), fixture, - typeTranslator.toPyModule(defn.id, domain.version, evolution, fileTools.fixturesBasePkg), + domainTypes.toPyModule(defn.id, fileTools.fixturesBasePkg), CompilerProduct.Fixture, ) ) @@ -142,7 +142,7 @@ object PyDefnTranslator { val mainOutput = Output( getOutputPath(defn), repr.defn, - typeTranslator.toPyModule(defn.id, domain.version, evolution, fileTools.definitionsBasePkg), + domainTypes.toPyModule(defn.id, fileTools.definitionsBasePkg), CompilerProduct.Definition, codecReg = regsPerCodec, ) @@ -150,8 +150,7 @@ object PyDefnTranslator { val wiringOutput = wiringTranslator .translate(defn).map { wiringTree => - val wiringModule = typeTranslator - .toPyModule(defn.id, domain.version, evolution, fileTools.definitionsBasePkg) + val wiringModule = domainTypes.toPyModule(defn.id, fileTools.definitionsBasePkg) .withModuleName(s"${defn.id.name.name}_Wiring") Output( getOutputPath(defn, suffix = Some("_Wiring")), @@ -164,8 +163,7 @@ object PyDefnTranslator { val clientOutput = wiringTranslator .translateClient(defn).map { clientTree => - val clientModule = typeTranslator - .toPyModule(defn.id, domain.version, evolution, fileTools.definitionsBasePkg) + val clientModule = domainTypes.toPyModule(defn.id, fileTools.definitionsBasePkg) .withModuleName(s"${defn.id.name.name}_Client") Output( getOutputPath(defn, suffix = Some("_Client")), @@ -240,7 +238,7 @@ object PyDefnTranslator { } val directParentsDefs = (adtParent ++ contractParents).flatMap(domain.defs.meta.nodes.get).collect { case u: DomainMember.User => u } - val superclasses = baboonEnquiries.collectParents(domain, directParentsDefs).toSet + val superclasses = domainEnquiries.collectParents( directParentsDefs).toSet val uniqueContracts = dtoContracts.filterNot(c1 => superclasses.contains(c1)) val genMarkerParent = if (adtParent.nonEmpty || contractParents.nonEmpty) Nil else List(genMarker) val adtMemberMetaParent = if (adtParent.isEmpty) Nil else List(baboonAdtMemberMeta) @@ -317,7 +315,7 @@ object PyDefnTranslator { ) case adt: Typedef.Adt => val adtContractDefs = adt.contracts.flatMap(domain.defs.meta.nodes.get).collect { case u: DomainMember.User => u } - val adtSuperclasses = baboonEnquiries.collectParents(domain, adtContractDefs).toSet + val adtSuperclasses = domainEnquiries.collectParents( adtContractDefs).toSet val uniqueAdtContracts = adt.contracts.filterNot(c => adtSuperclasses.contains(c)) val contracts = uniqueAdtContracts.map(c => domainTypes.asPyType(c, fileTools.definitionsBasePkg)) val defaultParents = contracts ++ List(pydanticBaseModel) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala index 5b50e135a..b6b70ca5c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTreeTools.scala @@ -4,6 +4,7 @@ import io.septimalmind.baboon.translator.python.PyTypes.* import io.septimalmind.baboon.typer.model.{BaboonEvolution, Domain, DomainMember, Owner, Typedef} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait PyDomainTreeTools { def makeDataMeta(defn: DomainMember.User): Seq[TextTree[PyValue]] @@ -13,8 +14,7 @@ trait PyDomainTreeTools { object PyDomainTreeTools { final class PyDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, - typeTranslator: PyTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: PyDomainTypes, pyFileTools: PyFileTools, ) extends PyDomainTreeTools { @@ -44,10 +44,10 @@ object PyDomainTreeTools { } val unmodifiedMethods = if (!isCodec) { - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString(", ") - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight) + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight) .map { case (tier, v) => s""""${tier.wireName}": "${v.v.toString}"""" }.mkString(", ") List( q"""baboon_same_in_versions: $pyClassVar[$pyList[$pyStr]] = [${unmodifiedSince.sameIn.map(v => q"\"${v.v.toString}\"").toList.join(", ")}]""".stripMargin, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala index 4314ac71c..26c918bab 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyDomainTypes.scala @@ -21,4 +21,8 @@ final class PyDomainTypes(trans: PyTypeTranslator, domain: Domain, evo: BaboonEv def asPyTypeKeepForeigns(tid: TypeId.User, pkgBase: List[String] = Nil): PyType = trans.asPyTypeKeepForeigns(tid, domain, evo, pkgBase) def asPyTypeVersioned(tid: TypeId.User, pkgBase: List[String]): PyType = trans.asPyTypeVersioned(tid, domain, evo, pkgBase) + + /** The module of `tid` at the current domain's version. */ + def toPyModule(tid: TypeId.User, pkgBase: List[String]): PyModuleId = + trans.toPyModule(tid, domain.version, evo, pkgBase) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala index 1f6531373..edcfadba1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala @@ -12,7 +12,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote final class PyJsonCodecGenerator( - typeTranslator: PyTypeTranslator, domainTypes: PyDomainTypes, treeTools: PyDomainTreeTools, pyFileTools: PyFileTools, @@ -507,7 +506,7 @@ final class PyJsonCodecGenerator( override def codecType(tid: TypeId.User): PyType = { val typeName = s"${tid.name.name.capitalize}_JsonCodec" - val moduleId = typeTranslator.toPyModule(tid, domain.version, evolution, pyFileTools.definitionsBasePkg) + val moduleId = domainTypes.toPyModule(tid, pyFileTools.definitionsBasePkg) PyType(moduleId, typeName) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala index e08d2ba10..52e35946c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyServiceWiringTranslator.scala @@ -12,7 +12,7 @@ import izumi.fundamentals.platform.strings.TextTree.* trait PyServiceWiringTranslator { def translate(defn: DomainMember.User): Option[TextTree[PyValue]] - def translateServiceRt(domain: Domain): Option[TextTree[PyValue]] + def translateServiceRt(): Option[TextTree[PyValue]] def translateClient(defn: DomainMember.User): Option[TextTree[PyValue]] } @@ -20,11 +20,9 @@ trait PyServiceWiringTranslator { object PyServiceWiringTranslator { class Impl( target: PyTarget, - typeTranslator: PyTypeTranslator, domainTypes: PyDomainTypes, codecs: Set[PyCodecTranslator], domain: Domain, - evolution: BaboonEvolution, fileTools: PyFileTools, ) extends PyServiceWiringTranslator { @@ -61,13 +59,13 @@ object PyServiceWiringTranslator { private def jsonCodecType(typeId: TypeId.User): PyType = { val typeName = s"${typeId.name.name.capitalize}_JsonCodec" - val moduleId = typeTranslator.toPyModule(typeId, domain.version, evolution, fileTools.definitionsBasePkg) + val moduleId = domainTypes.toPyModule(typeId, fileTools.definitionsBasePkg) PyType(moduleId, typeName) } private def uebaCodecType(typeId: TypeId.User): PyType = { val typeName = s"${typeId.name.name.capitalize}_UEBACodec" - val moduleId = typeTranslator.toPyModule(typeId, domain.version, evolution, fileTools.definitionsBasePkg) + val moduleId = domainTypes.toPyModule(typeId, fileTools.definitionsBasePkg) PyType(moduleId, typeName) } @@ -107,7 +105,7 @@ object PyServiceWiringTranslator { private lazy val ibaboonServiceRtType: PyType = PyType(serviceRtModule, "IBaboonServiceRt") - override def translateServiceRt(domain: Domain): Option[TextTree[PyValue]] = { + override def translateServiceRt(): Option[TextTree[PyValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala index 6b7833634..b5fee6bfb 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala @@ -13,7 +13,7 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote class PyUEBACodecGenerator( - typeTranslator: PyTypeTranslator, + domainTypes: PyDomainTypes, treeTools: PyDomainTreeTools, evolution: BaboonEvolution, pyFileTools: PyFileTools, @@ -557,8 +557,7 @@ class PyUEBACodecGenerator( override def codecType(tid: TypeId.User): PyType = { val typeName = s"${tid.name.name.capitalize}_UEBACodec" - val moduleId = typeTranslator - .toPyModule(tid, domain.version, evolution, pyFileTools.definitionsBasePkg) + val moduleId = domainTypes.toPyModule(tid, pyFileTools.definitionsBasePkg) PyType(moduleId, typeName) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala index 6a8883df0..03e76dc90 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecFixtureTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait RsCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[RsValue]] @@ -16,16 +17,15 @@ trait RsCodecFixtureTranslator { object RsCodecFixtureTranslator { final class RsCodecFixtureTranslatorImpl( target: RsTarget, - translator: RsTypeTranslator, domainTypes: RsDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, ) extends RsCodecFixtureTranslator { override def translate(definition: DomainMember.User): Option[TextTree[RsValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Rust) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Rust) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -235,7 +235,7 @@ object RsCodecFixtureTranslator { case TypeId.Builtins.tso => q"rnd.next_tso()" case TypeId.Builtins.bit => q"rnd.next_bit()" - case u: TypeId.User if enquiries.isEnum(tpe, domain) => + case u: TypeId.User if domainEnquiries.isEnum(tpe) => val enumType = domainTypes.asRsType(u) q"rnd.mk_enum(&$enumType::all())" case u: TypeId.User => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala index 308c435f3..818e43fb6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala @@ -6,6 +6,7 @@ import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, DomainMember, Typedef} import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait RsCodecTestsTranslator { def translate( @@ -18,8 +19,7 @@ trait RsCodecTestsTranslator { object RsCodecTestsTranslator { final class Impl( codecs: Set[RsCodecTranslator], - typeTranslator: RsTypeTranslator, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: RsTarget, domain: Domain, evo: BaboonEvolution, @@ -32,8 +32,8 @@ object RsCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Rust) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Rust) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index d69d29182..41a0b563f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -71,7 +71,7 @@ object RsDefnTranslator { val mainOutput = Output( getOutputPath(defn), allDefs, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, isAdt = defn.defn.isInstanceOf[Typedef.Adt], ) @@ -82,7 +82,7 @@ object RsDefnTranslator { Output( getOutputPath(defn, suffix = Some("_wiring")), wiringTree, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, ) }.toList @@ -93,7 +93,7 @@ object RsDefnTranslator { Output( getOutputPath(defn, suffix = Some("_client")), clientTree, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, ) }.toList @@ -115,7 +115,7 @@ object RsDefnTranslator { Output( getOutputPath(defn, suffix = Some("_fixture")), fixtureTree, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Fixture, ) }.toList) @@ -137,21 +137,21 @@ object RsDefnTranslator { Output( getOutputPath(defn, suffix = Some("_tests")), testTree, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Test, ) }.toList) } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => val fbase = rsFiles.basename(domain, evo) Output( s"$fbase/baboon_service_rt.rs", tree, - trans.toRsCrate(domain.id, domain.version, evo), + domainTypes.currentPkg, CompilerProduct.Definition, ) }.toList diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala index 7852ef2f3..049fb74ff 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDomainTypes.scala @@ -1,6 +1,6 @@ package io.septimalmind.baboon.translator.rust -import io.septimalmind.baboon.translator.rust.RsValue.RsType +import io.septimalmind.baboon.translator.rust.RsValue.{RsCrateId, RsType} import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree @@ -17,4 +17,7 @@ final class RsDomainTypes(trans: RsTypeTranslator, domain: Domain, evo: BaboonEv def asRsType(tpe: TypeId): RsType = trans.asRsType(tpe, domain, evo) def toRsTypeRefKeepForeigns(tid: TypeId.User): RsType = trans.toRsTypeRefKeepForeigns(tid, domain, evo) + + /** The package/crate of the current domain's own version. */ + def currentPkg: RsCrateId = trans.toRsCrate(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index c695bcafe..6c0600e90 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -9,7 +9,6 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class RsJsonCodecGenerator( - trans: RsTypeTranslator, target: RsTarget, domain: Domain, ) extends RsCodecTranslator { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala index 52c77b131..c1afd4fb5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala @@ -13,17 +13,15 @@ trait RsServiceWiringTranslator { def translateClient(defn: DomainMember.User): Option[TextTree[RsValue]] - def translateServiceRt(domain: Domain): Option[TextTree[RsValue]] + def translateServiceRt(): Option[TextTree[RsValue]] } object RsServiceWiringTranslator { class Impl( target: RsTarget, - trans: RsTypeTranslator, domainTypes: RsDomainTypes, codecs: Set[RsCodecTranslator], domain: Domain, - evo: BaboonEvolution, rsTypes: RsTypes, ) extends RsServiceWiringTranslator { import rsTypes.* @@ -55,7 +53,7 @@ object RsServiceWiringTranslator { } private lazy val rtCrate: RsValue.RsCrateId = { - val baseCrate = trans.toRsCrate(domain.id, domain.version, evo) + val baseCrate = domainTypes.currentPkg RsValue.RsCrateId(izumi.fundamentals.collections.nonempty.NEList.unsafeFrom((baseCrate.parts :+ "baboon_service_rt").toList)) } private lazy val ibaboonServiceRt: RsValue.RsType = RsValue.RsType(rtCrate, "IBaboonServiceRt") @@ -65,7 +63,7 @@ object RsServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[RsValue]] = { + override def translateServiceRt(): Option[TextTree[RsValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala index 4051ef874..beb0fa389 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecFixtureTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait ScCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[ScValue]] @@ -18,7 +19,7 @@ object ScCodecFixtureTranslator { final class ScRandomMethodTranslatorImpl( target: ScTarget, domainTypes: ScDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, evo: BaboonEvolution, ) extends ScCodecFixtureTranslator { @@ -31,8 +32,8 @@ object ScCodecFixtureTranslator { definition: DomainMember.User ): Option[TextTree[ScValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Scala) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Scala) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -64,8 +65,8 @@ object ScCodecFixtureTranslator { case _: DomainMember.Builtin => None case u: DomainMember.User => u.defn match { - case _ if enquiries.hasForeignType(u, domain, BaboonLang.Scala) => None - case _ if enquiries.isRecursiveTypedef(u, domain) => None + case _ if domainEnquiries.hasForeignType(u, BaboonLang.Scala) => None + case _ if domainEnquiries.isRecursiveTypedef(u) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -242,7 +243,7 @@ object ScCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBit()" - case TypeId.User(_, _, name) if enquiries.isEnum(tpe, domain) => q"rnd.mkEnum(${name.name})" + case TypeId.User(_, _, name) if domainEnquiries.isEnum(tpe) => q"rnd.mkEnum(${name.name})" case u: TypeId.User => // Propagate the codec branch into nested user-type fixtures so any-fields nested in // sub-DTOs/ADTs match the same codec direction. See PR-07-D01. diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala index 533020f18..772d4e848 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScCodecTestsTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait ScCodecTestsTranslator { def translate( @@ -21,7 +22,7 @@ object ScCodecTestsTranslator { codecs: Set[ScCodecTranslator], domainTypes: ScDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: ScTarget, domain: Domain, evo: BaboonEvolution, @@ -34,8 +35,8 @@ object ScCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Scala) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Scala) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala index b5393a4b9..2e532fe51 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDefnTranslator.scala @@ -161,7 +161,7 @@ object ScDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => val pkg = domainTypes.currentPkg diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala index 5b72fdc29..6b3f77e26 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScDomainTreeTools.scala @@ -5,6 +5,7 @@ import io.septimalmind.baboon.translator.scl.ScTypes.{javaClass, scList, scStrin import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait ScDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[MetaField] @@ -33,7 +34,7 @@ object ScDomainTreeTools { final class ScDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, + domainEvolution: DomainEvolution, domainTypes: ScDomainTypes, ) extends ScDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { @@ -85,14 +86,14 @@ object ScDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = domainTypes.asScType(defn.id) - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id).sameIn.map(v => s"\"${v.v.toString}\"") val sameInVersion = MetaField.cached( "baboonSameInVersions", q"$scList[$scString]", q"$scList(${unmodifiedSince.mkString(", ")})", q"$ref.baboonSameInVersions", ) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}" -> "${tier.wireName}"""" } @@ -102,7 +103,7 @@ object ScDomainTreeTools { q"${ScTypes.scMap}(${forwardEntries.mkString(", ")})", q"$ref.baboonForwardReadable", ) - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s""""${tier.wireName}" -> "${v.v.toString}"""" } val minReaders = MetaField.cached( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala index 4b9a19f65..bf5869621 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScServiceWiringTranslator.scala @@ -12,7 +12,7 @@ trait ScServiceWiringTranslator { def translateClient(defn: DomainMember.User): Option[TextTree[ScValue]] - def translateServiceRt(domain: Domain): Option[TextTree[ScValue]] + def translateServiceRt(): Option[TextTree[ScValue]] } object ScServiceWiringTranslator { @@ -124,7 +124,7 @@ object ScServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[ScValue]] = { + override def translateServiceRt(): Option[TextTree[ScValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala index f7ae184ee..03461b9d5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecFixtureTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait SwCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[SwValue]] @@ -28,6 +29,7 @@ object SwCodecFixtureTranslator { translator: SwTypeTranslator, domainTypes: SwDomainTypes, enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, ) extends SwCodecFixtureTranslator { @@ -38,7 +40,7 @@ object SwCodecFixtureTranslator { // PR-26.4 (M26): hasForeignType filter lifted — `genScalar` now synthesizes deref'd // Foreign fixtures (BaboonRef recurses on aliased ref; Custom dispatches via Swift-decl // allowlist). Closes PR-I.2-D02 / PR-68-D02. - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -58,7 +60,7 @@ object SwCodecFixtureTranslator { case u: DomainMember.User => u.defn match { // PR-26.4 (M26): hasForeignType filter lifted (see translate() above). - case _ if enquiries.isRecursiveTypedef(u, domain) => None + case _ if domainEnquiries.isRecursiveTypedef(u) => None case _: Typedef.Contract => None case _: Typedef.Enum => None case _: Typedef.Foreign => None @@ -218,7 +220,7 @@ object SwCodecFixtureTranslator { case TypeId.Builtins.bit => q"rnd.nextBool()" - case u: TypeId.User if enquiries.isEnum(tpe, domain) => + case u: TypeId.User if domainEnquiries.isEnum(tpe) => val enumType = domainTypes.toSwTypeRefKeepForeigns(u) q"rnd.mkEnum($enumType.all)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala index fed6b6a5a..7d783ee83 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwCodecTestsTranslator.scala @@ -8,6 +8,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, Domain, DomainMember import io.septimalmind.baboon.util.BLogger import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait SwCodecTestsTranslator { def translate( @@ -26,7 +27,7 @@ object SwCodecTestsTranslator { typeTranslator: SwTypeTranslator, domainTypes: SwDomainTypes, logger: BLogger, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: SwTarget, domain: Domain, evo: BaboonEvolution, @@ -50,8 +51,8 @@ object SwCodecTestsTranslator { // `Tests/RuntimeTests/` exercise the JSON path (which works via PR-I.2's // `KeyCodecHost`) — see `M24ForeignFixtureRoundTripTests.swift`. Closes // PR-I.2-D02 + PR-68-D02. - case d if enquiries.hasForeignType(d, domain) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala index 2f6e45f06..c84a35fec 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDefnTranslator.scala @@ -165,10 +165,10 @@ object SwDefnTranslator { } override def translateServiceRt(): F[NEList[BaboonIssue], List[Output]] = { - val rtTree = wiringTranslator.translateServiceRt(domain) + val rtTree = wiringTranslator.translateServiceRt() val result = rtTree.map { tree => - val pkg = trans.toSwPkg(domain.id, domain.version, evo) + val pkg = domainTypes.currentPkg val fbase = swFiles.basename(domain, evo) Output( s"$fbase/baboon_service_rt.swift", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala index 612ea1a0b..08e30dfa5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTreeTools.scala @@ -4,6 +4,7 @@ import io.septimalmind.baboon.translator.swift.SwDomainTreeTools.MetaField import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait SwDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[MetaField] @@ -30,8 +31,7 @@ object SwDomainTreeTools { final class SwDomainTreeToolsImpl( domain: Domain, - evolution: BaboonEvolution, - typeTranslator: SwTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: SwDomainTypes, ) extends SwDomainTreeTools { override def makeDataMeta(defn: DomainMember.User): List[MetaField] = { @@ -86,7 +86,7 @@ object SwDomainTreeTools { private def sameInVersion(defn: DomainMember.User): List[MetaField] = { val ref = domainTypes.asSwType(defn.id).asDeclName - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id).sameIn.map(v => s""""${v.v.toString}"""") + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id).sameIn.map(v => s""""${v.v.toString}"""") val sameInVersion = MetaField( q"public static let baboonSameInVersions: [String]", q"[${unmodifiedSince.mkString(", ")}]", @@ -94,7 +94,7 @@ object SwDomainTreeTools { "baboonSameInVersions", "[String]", ) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardEntries = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" } @@ -105,7 +105,7 @@ object SwDomainTreeTools { "baboonForwardReadable", "[String: String]", ) - val minReaderEntries = evolution.minReaders(domain.version, defn.id).toList.sortBy(_._1.weight).map { + val minReaderEntries = domainEvolution.minReaders(defn.id).toList.sortBy(_._1.weight).map { case (tier, v) => s""""${tier.wireName}": "${v.v.toString}"""" } val minReaders = MetaField( diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala index b43bd92a0..1afc5bf5b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwDomainTypes.scala @@ -21,4 +21,7 @@ final class SwDomainTypes(trans: SwTypeTranslator, domain: Domain, evo: BaboonEv def fixtureClassName(tid: TypeId.User): String = trans.fixtureClassName(tid, domain, evo) def effectiveSwPkg(owner: Owner): SwPackageId = trans.effectiveSwPkg(owner, domain, evo) + + /** The package/crate of the current domain's own version. */ + def currentPkg: SwPackageId = trans.toSwPkg(domain.id, domain.version, evo) } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala index b48070116..5887a58d4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/swift/SwServiceWiringTranslator.scala @@ -10,7 +10,7 @@ import izumi.fundamentals.platform.strings.TextTree.* trait SwServiceWiringTranslator { def translate(defn: DomainMember.User): Option[TextTree[SwValue]] - def translateServiceRt(domain: Domain): Option[TextTree[SwValue]] + def translateServiceRt(): Option[TextTree[SwValue]] def translateClient(defn: DomainMember.User): Option[TextTree[SwValue]] } @@ -174,7 +174,7 @@ object SwServiceWiringTranslator { s"${resolved.resultType.get}$p" } - override def translateServiceRt(domain: Domain): Option[TextTree[SwValue]] = { + override def translateServiceRt(): Option[TextTree[SwValue]] = { if (resolved.noErrors) return None val hasServices = domain.defs.meta.nodes.values.exists { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala index e9fcf8ecd..74ca82818 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsBaboonTranslator.scala @@ -21,7 +21,6 @@ class TsBaboonTranslator[F[+_, +_]: Error2]( defnTranslator: Subcontext[TsDefnTranslator[F]], target: TsTarget, tsFileTools: TsFileTools, - enquiries: BaboonEnquiries, typeTranslator: TsTypeTranslator, mcpHook: McpServerGeneratorHook[F], ) extends BaboonAbstractTranslator[F] { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala index a329421cf..1fc695df1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecFixtureTranslator.scala @@ -7,6 +7,7 @@ import io.septimalmind.baboon.typer.model.TypeId.Builtins import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries trait TsCodecFixtureTranslator { def translate(definition: DomainMember.User): Option[TextTree[TsValue]] @@ -26,7 +27,7 @@ object TsCodecFixtureTranslator { final class TsCodecFixtureTranslatorImpl( translator: TsTypeTranslator, domainTypes: TsDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, domain: Domain, tsFileTools: TsFileTools, ) extends TsCodecFixtureTranslator { @@ -35,8 +36,8 @@ object TsCodecFixtureTranslator { override def translate(definition: DomainMember.User): Option[TextTree[TsValue]] = { definition.defn match { - case _ if enquiries.hasForeignType(definition, domain, BaboonLang.Typescript) => None - case _ if enquiries.isRecursiveTypedef(definition, domain) => None + case _ if domainEnquiries.hasForeignType(definition, BaboonLang.Typescript) => None + case _ if domainEnquiries.isRecursiveTypedef(definition) => None case dto: Typedef.Dto => Some(doTranslateDto(dto)) case adt: Typedef.Adt => Some(doTranslateAdt(adt)) case _: Typedef.Contract => None @@ -225,7 +226,7 @@ object TsCodecFixtureTranslator { } case TypeId.Builtins.bit => q"rnd.nextBit()" - case u: TypeId.User if enquiries.isEnum(tpe, domain) => + case u: TypeId.User if domainEnquiries.isEnum(tpe) => val enumType = domainTypes.asTsType(u, tsFileTools.definitionsBasePkg) val enumValues = TsValue.TsType(enumType.moduleId, s"${enumType.name}_values") q"rnd.mkEnum($enumValues)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala index 8a83c82f3..ce366f52d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsCodecTestsTranslator.scala @@ -6,6 +6,7 @@ import io.septimalmind.baboon.typer.model.{BaboonEvolution, BaboonLang, Domain, import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* import TsTypes.{baboonRandom, tsAssertCrossLanguageFixtureRootExists, tsBaboonBinReader, tsBaboonBinWriter, tsBaboonCodecContext, tsCrossLanguageFixturePath, tsReadFile, tsString} +import io.septimalmind.baboon.translator.DomainEnquiries trait TsCodecTestsTranslator { def translate( @@ -20,7 +21,7 @@ object TsCodecTestsTranslator { codecs: Set[TsCodecTranslator], typeTranslator: TsTypeTranslator, domainTypes: TsDomainTypes, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, target: TsTarget, domain: Domain, evo: BaboonEvolution, @@ -35,8 +36,8 @@ object TsCodecTestsTranslator { val isLatestVersion = domain.version == evo.latest definition match { - case d if enquiries.hasForeignType(d, domain, BaboonLang.Typescript) => None - case d if enquiries.isRecursiveTypedef(d, domain) => None + case d if domainEnquiries.hasForeignType(d, BaboonLang.Typescript) => None + case d if domainEnquiries.isRecursiveTypedef(d) => None case d if d.defn.isInstanceOf[Typedef.NonDataTypedef] => None case _ if !isLatestVersion => None case _ => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala index a953e3e16..1deac5b1d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsDomainTreeTools.scala @@ -4,6 +4,7 @@ import io.septimalmind.baboon.translator.typescript.TsValue.TsType import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.Quote +import io.septimalmind.baboon.translator.DomainEvolution trait TsDomainTreeTools { def makeDataMeta(defn: DomainMember.User): List[TextTree[TsValue]] @@ -14,7 +15,7 @@ object TsDomainTreeTools { final class TsDomainTreeToolsImpl( domain: Domain, evolution: BaboonEvolution, - typeTranslator: TsTypeTranslator, + domainEvolution: DomainEvolution, domainTypes: TsDomainTypes, tsFileTools: TsFileTools, ) extends TsDomainTreeTools { @@ -28,8 +29,8 @@ object TsDomainTreeTools { } private def sameInVersion(defn: DomainMember.User, source: TsType): List[TextTree[TsValue]] = { - val unmodifiedSince = evolution.typesUnchangedSince(domain.version)(defn.id) - val forward = evolution.typesForwardReadable(domain.version)(defn.id) + val unmodifiedSince = domainEvolution.typesUnchangedSince(defn.id) + val forward = domainEvolution.typesForwardReadable(defn.id) val forwardLiteral = forward.readable.toList.map { case (v, tier) => s""""${v.v.toString}": "${tier.wireName}"""" }.mkString("{ ", ", ", " }") val minReaderLiteral = evolution diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala index a92c2da50..517ff76c6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsJsonCodecGenerator.scala @@ -7,19 +7,20 @@ import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* +import io.septimalmind.baboon.translator.DomainEnquiries class TsJsonCodecGenerator( trans: TsTypeTranslator, domainTypes: TsDomainTypes, target: TsTarget, domain: Domain, - enquiries: BaboonEnquiries, + domainEnquiries: DomainEnquiries, tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, ) extends TsCodecTranslator { private val scalarOps = new TsScalarCodecOps(target) override def translate(defn: DomainMember.User, tsRef: TsValue.TsType, srcRef: TsValue.TsType): Option[TextTree[TsValue]] = { - // PR-I.1d (M24 Phase 3.1): the prior `&& !enquiries.hasForeignType(defn, domain)` short-circuit + // PR-I.1d (M24 Phase 3.1): the prior `&& !domainEnquiries.hasForeignType(defn)` short-circuit // suppressed JsonCodec emission for any type containing a Custom-bound foreign — including // single-primitive wrappers like `ItemKey { v: FStr }` and `Holder { m: map[ItemKey, str] }`. // With the new `_KeyCodecHost` hook (emitted from `TsDefnTranslator`'s foreign emit site), diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala index a1710b363..97c5dcba4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsUEBACodecGenerator.scala @@ -14,7 +14,6 @@ class TsUEBACodecGenerator( domainTypes: TsDomainTypes, target: TsTarget, domain: Domain, - enquiries: BaboonEnquiries, tsFileTools: TsFileTools, tsDomainTreeTools: TsDomainTreeTools, ) extends TsCodecTranslator { From 8e5204e38ab30a6accc9c4264fe083a5f4171808 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 16:06:38 +0100 Subject: [PATCH 32/48] ci(macos): stop caching the sbt 2.x disk cache on the Intel runner `build-macos-amd64-13` failed while every other job in the run passed -- and it failed in POST-JOB CLEANUP, after build, tests, `:smoke` and the 35MB artifact upload had all succeeded: ##[error]The template is not valid. sbt/setup-sbt/v1/action.yml (Line: 81, Col: 14): hashFiles('**/*.sbt, **/*.properties') couldn't finish within 120 seconds duration_ms=120219 -> conclusion=failure The step is `sbt/setup-sbt`'s own "sbt 2.x disk cache", which caches $HOME/Library/Caches/sbt -- a directory sbt 1.x never writes. We pin sbt.version=1.11.7 (now 1.13.0), so that cache is empty by construction, and we were paying a whole-workspace `hashFiles('**/*.sbt', '**/*.properties')` to compute its key. By teardown the workspace holds every backend's generated code plus node_modules, cargo target, .dart_tool and Swift .build, where `*.properties` is abundant; the walk exceeds the 120s expression limit. Why only this job: it is genuine Intel hardware (`os: macos-15-intel`, RUNNER_ARCH=X64 -- not x64-under-Rosetta) and the slowest in the matrix at 2h42m against 1h30m for macos-aarch64 and 1h19m for linux-amd64. The others walk the same oversized tree inside the limit. So the fix is not Intel-specific in principle, but this is the only job that trips it -- and the only job that uses `sbt/setup-sbt` at all, since Linux gets sbt through nix. Dependency caching is untouched: graalvm/setup-graalvm already does it with `cache: 'sbt'` two steps earlier. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/baboon-build.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/baboon-build.yml b/.github/workflows/baboon-build.yml index 46e72b31b..69921af47 100644 --- a/.github/workflows/baboon-build.yml +++ b/.github/workflows/baboon-build.yml @@ -128,6 +128,17 @@ jobs: native-image-job-reports: true - name: Set up SBT uses: sbt/setup-sbt@v1 + with: + # The action's post step caches the sbt 2.x disk cache + # ($HOME/Library/Caches/sbt), which sbt 1.x never writes -- see + # project/build.properties. Building its key costs a whole-workspace + # hashFiles('**/*.sbt', '**/*.properties'), which by teardown has to walk every + # backend's generated code plus node_modules / cargo target / .dart_tool / + # Swift .build. That exceeds the 120s expression limit on the Intel runner (the + # slowest of the matrix at ~2h40m) and fails the job AFTER build, tests and + # artifact upload have all succeeded. Dependency caching is unaffected: + # graalvm/setup-graalvm above already does it via `cache: 'sbt'`. + disk-cache: false - name: Set up Dart SDK uses: dart-lang/setup-dart@v1 - name: Install mudyla From 48d882f968d26a46fc68e317b45d837033d87a17 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 16:06:38 +0100 Subject: [PATCH 33/48] build: sbt 1.11.7 -> 1.13.0 Latest stable 1.x (1.12.15 is the previous line). sbt 2.x is blocked on io.7mind.izumi.sbt:sbt-izumi, which publishes only for sbt 1 (_2.12_1.0) while the other three plugins already ship _sbt2_3 builds -- and it is load-bearing, supplying the whole scalacOptions set (including -Wconf:cat=other-match-analysis:error), the git-derived version macros and sbt-release, so there is nothing to work around locally. Verified: nix-pinned launcher fetches 1.13.0, `sbt +compile` passes, scalacOptions unchanged, full gate green 200/200. Co-Authored-By: Claude Opus 5 (1M context) --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index 7be021262..f693be1be 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.11.7 \ No newline at end of file +sbt.version=1.13.0 \ No newline at end of file From b0d7b0b1c66585a03096d8e41ce0542df16029f2 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 16:06:52 +0100 Subject: [PATCH 34/48] build: bump dependencies and sbt plugins to latest same-major releases All 13 are same-major moves, so no API migration; verified by `sbt +compile` (cross-build JVM + Scala.js, which is where the stricter -Wconf lives), the JVM suite (877 passing) and the full gate (200/200 lanes, native-image included). scala 2.13.16 -> 2.13.18 izumi 1.2.24 -> 1.2.25 classgraph 4.8.181 -> 4.8.195 scala-parser-combinators 2.4.0 -> 2.5.0 scalatest 3.2.19 -> 3.2.20 circe 0.14.1 -> 0.14.16 magnolia1_2 1.1.10 -> 1.1.14 kind-projector 0.13.3 -> 0.13.4 case-app 2.1.0-M30 -> 2.1.0 (off a prerelease) sbt-native-packager 1.10.4 -> 1.12.0 sbt-izumi 0.0.101 -> 0.0.121 sbt-scalajs 1.19.0 -> 1.22.0 sbt-scalajs-crossproject 1.3.2 -> 1.4.0 Notes on the two riskiest: - kind-projector is `cross CrossVersion.full`, so it resolves per exact Scala version; kind-projector_2.13.18 0.13.4 was confirmed present before moving Scala. - sbt-native-packager backs `GraalVMNativeImagePlugin`, so the native-image build is the real check for it; the gate's `:build` lane passes. Generated output for all nine backends is byte-identical to the pre-bump baseline (4512 files) -- the check that matters for circe and magnolia, since those drive metadata JSON emission. Held back, as genuine majors to decide separately: - jline 3.26.3 -> 4.4.5: the explore shell's interactive path, which the suite exercises only thinly (ExplorerTest drives commands directly, not the JLine terminal), so a break could pass CI and surface only in `baboon explore`. - json-schema-validator 1.5.9 -> 3.0.7: test-only, so contained, but 1 -> 3. Co-Authored-By: Claude Opus 5 (1M context) --- build.sbt | 18 +++++++++--------- project/plugins.sbt | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/build.sbt b/build.sbt index 0cc7f5e90..6c1cfc3b3 100644 --- a/build.sbt +++ b/build.sbt @@ -8,9 +8,9 @@ lazy val refreshFlakeTask = taskKey[Unit]("Refresh flake.nix") lazy val isLinux: Boolean = System.getProperty("os.name").toLowerCase.contains("linux") lazy val isAmd64: Boolean = System.getProperty("os.arch") == "amd64" -ThisBuild / scalaVersion := "2.13.16" +ThisBuild / scalaVersion := "2.13.18" -lazy val izumiVersion = "1.2.24" +lazy val izumiVersion = "1.2.25" // Shared settings for both JVM and JS lazy val sharedSettings = Seq( @@ -24,24 +24,24 @@ lazy val sharedSettings = Seq( "distage-core", ).map("io.7mind.izumi" %%% _ % izumiVersion), libraryDependencies ++= Seq( - "io.github.classgraph" % "classgraph" % "4.8.181" + "io.github.classgraph" % "classgraph" % "4.8.195" ), libraryDependencies ++= Seq( - "org.scala-lang.modules" %%% "scala-parser-combinators" % "2.4.0" + "org.scala-lang.modules" %%% "scala-parser-combinators" % "2.5.0" ), libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % "3.2.19" % Test, + "org.scalatest" %%% "scalatest" % "3.2.20" % Test, ), libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", "io.circe" %%% "circe-parser" - ).map(_ % "0.14.1"), + ).map(_ % "0.14.16"), libraryDependencies ++= Seq( - "com.softwaremill.magnolia1_2" %%% "magnolia" % "1.1.10", + "com.softwaremill.magnolia1_2" %%% "magnolia" % "1.1.14", "org.scala-lang" % "scala-reflect" % scalaVersion.value % "provided" ), - addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full), + addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.4" cross CrossVersion.full), scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -87,7 +87,7 @@ lazy val baboon = crossProject(JSPlatform, JVMPlatform) .jvmSettings( Compile / unmanagedResourceDirectories += baseDirectory.value / "src" / "main" / "resources", libraryDependencies ++= Seq( - "com.github.alexarchambault" %% "case-app" % "2.1.0-M30", + "com.github.alexarchambault" %% "case-app" % "2.1.0", "org.jline" % "jline" % "3.26.3", "io.7mind.izumi" %% "distage-testkit-scalatest" % izumiVersion % Test, // Real Draft 2020-12 JSON-Schema validator for the MCP inputSchema diff --git a/project/plugins.sbt b/project/plugins.sbt index 4ce19c1ec..1bb448bd7 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ //addSbtPlugin("org.jetbrains.scala" % "sbt-ide-settings" % "1.1.1") -addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4") -addSbtPlugin("io.7mind.izumi.sbt" % "sbt-izumi" % "0.0.101") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.12.0") +addSbtPlugin("io.7mind.izumi.sbt" % "sbt-izumi" % "0.0.121") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.4.0") From 45972d8e57db15c46e62cfa0ad6e9ef96e152bfe Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 17:10:44 +0100 Subject: [PATCH 35/48] build: bump jline 3 -> 4 and json-schema-validator 1 -> 3 Both were held back in the previous commit as "genuine majors"; that was excess caution rather than a finding. Tried properly, both land. jline 3.26.3 -> 4.4.5: compiles with no source changes. Everything this build touches is unchanged across the major -- TerminalBuilder, LineReaderBuilder, LineReader.Option, Completer/Candidate/ParsedLine, EndOfFileException and UserInterruptException. Since compiling says nothing about runtime linkage for a major bump, the shell was exercised for real: `:explore` builds its terminal and line reader, loads a domain, accepts a piped command and exits 0. That matters because the suite drives explore COMMANDS directly (ExplorerTest) and never the JLine terminal, so a linkage break here would otherwise have passed CI and surfaced only for someone running `baboon explore`. json-schema-validator 1.5.9 -> 3.0.7: this one did break, in one test file. The API was renamed at 2.0, so there is no gentler intermediate (2.0.7 has the new shape too): JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) -> SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) SchemaValidatorsConfig.builder().build() -> dropped; getSchema(String, InputFormat) uses the registry default com.networknt.schema.JsonSchema -> com.networknt.schema.Schema validate(str, InputFormat.JSON) -> unchanged, now returns List[Error] `validate(String, InputFormat)` surviving is what keeps this cheap: 3.x moved to Jackson 3 (`tools.jackson`), so had the string overload gone the test would have needed Jackson 3 types of its own. As it is, Jackson 3 only arrives transitively and no test code touches it. The migration is not vacuous: the file's `assertRejects` case (an instance missing a required field must produce validation messages) still passes, so the validator remains live and discriminating rather than silently accepting. JVM suite 880/880 with zero cancellations; full gate green 200/200. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon/tests/McpInputSchemaEmissionTest.scala | 9 ++++----- build.sbt | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala index 2304350d3..81aaaf585 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala @@ -1,6 +1,6 @@ package io.septimalmind.baboon.tests -import com.networknt.schema.{InputFormat, JsonSchemaFactory, SchemaValidatorsConfig, SpecVersion} +import com.networknt.schema.{InputFormat, SchemaRegistry, SpecificationVersion} import io.circe.Json import io.septimalmind.baboon.BaboonLoader import io.septimalmind.baboon.parser.model.issues.BaboonIssue @@ -82,16 +82,15 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe // ── real JSON-Schema validator (networknt, Draft 2020-12) ────────────────── - private val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + private val registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12) /** Compile a schema with the real validator. This parses the schema document * AND eagerly resolves every `$ref` (including the local `#/$defs/...` * closure); a dangling ref or malformed schema throws here. Returns the * compiled schema for optional instance validation. */ - private def compileSchema(schema: Json): com.networknt.schema.JsonSchema = { - val cfg = SchemaValidatorsConfig.builder().build() - factory.getSchema(schema.noSpaces, InputFormat.JSON, cfg) + private def compileSchema(schema: Json): com.networknt.schema.Schema = { + registry.getSchema(schema.noSpaces, InputFormat.JSON) } private def assertWellFormed(name: String, schema: Json): org.scalatest.Assertion = { diff --git a/build.sbt b/build.sbt index 6c1cfc3b3..7a3d81767 100644 --- a/build.sbt +++ b/build.sbt @@ -88,11 +88,11 @@ lazy val baboon = crossProject(JSPlatform, JVMPlatform) Compile / unmanagedResourceDirectories += baseDirectory.value / "src" / "main" / "resources", libraryDependencies ++= Seq( "com.github.alexarchambault" %% "case-app" % "2.1.0", - "org.jline" % "jline" % "3.26.3", + "org.jline" % "jline" % "4.4.5", "io.7mind.izumi" %% "distage-testkit-scalatest" % izumiVersion % Test, // Real Draft 2020-12 JSON-Schema validator for the MCP inputSchema // well-formedness gate (JVM test scope only — never shipped). - "com.networknt" % "json-schema-validator" % "1.5.9" % Test + "com.networknt" % "json-schema-validator" % "3.0.7" % Test ) ) .jvmConfigure(_.enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin)) From 7c36771264f3ded5f7e42f0b82b957d3f86da562 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Mon, 21 Sep 2026 17:19:01 +0100 Subject: [PATCH 36/48] build: regenerate deps.lock.json for the bumped dependencies My dependency-bump commits were incomplete. `deps.lock.json` is a tracked lockfile of resolved sbt dependencies, generated by `squish-lockfile` and consumed by flake.nix (`mkCoursierCache { lockfilePath = ./deps.lock.json; }`). It pinned every version those commits moved -- izumi 1.2.24 x64, circe 0.14.1 x24, scala 2.13.16 x14, plus classgraph, jline, json-schema-validator and case-app -- so the nix build path could no longer resolve the dependency set. That is what failed `check-flake-runs-linux-aarch64` and fail-fast-cancelled the rest of the run for b0d7b0b1. `mdl :build :test` does not exercise the nix path, so no number of green local gates would have caught it; the build even has `refreshFlakeTask` wired up for exactly this, which I should have reached for when changing versions. Regenerated with `squish-lockfile ./lockfile-config.json` (657 artifacts; +1026/-1178 lines) and verified with the CI job's own command: nix run . -- --model-dir ./test/conv-test :cs --output ... :scala --output ... which exits 0 and emits 198 files, i.e. baboon builds from the lockfile and runs. One residual `2.13.16` is correct and stays: scala-reflect-2.13.16.pom, a transitive POM read during resolution, not a stale pin. Co-Authored-By: Claude Opus 5 (1M context) --- deps.lock.json | 2204 ++++++++++++++++++++++-------------------------- 1 file changed, 1026 insertions(+), 1178 deletions(-) diff --git a/deps.lock.json b/deps.lock.json index 4722dd764..548ed59a3 100644 --- a/deps.lock.json +++ b/deps.lock.json @@ -10,44 +10,28 @@ "sha256": "1rfn4vd8kk3pl0qzbgzv7db7yjf8wqrdsh29l8inp13y8s4r53sx" }, { - "url": "https://repo1.maven.org/maven2/ch/qos/logback/logback-core/1.3.9/logback-core-1.3.9.jar", - "sha256": "0qf8h3cbvpi015bbzmxipfpgz74rinp0hwnfgzi1b7i5k1ssri9d" + "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.13/shapeless_2.13-2.3.13.jar", + "sha256": "0x2xh1zy20wzmvn7ahf6s3pmc80gj86mdan2daklf90yr19sh8mq" }, { - "url": "https://repo1.maven.org/maven2/ch/qos/logback/logback-core/1.3.9/logback-core-1.3.9.pom", - "sha256": "0a7lp8zamb8jqjwix00349nzq3a06laah41xqmyc1z938f1g298z" + "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.13/shapeless_2.13-2.3.13.pom", + "sha256": "15gfxhcffq47365fgya4vnp4xzr5vkkzm9djr8ymn2mf20f4xk5v" }, { - "url": "https://repo1.maven.org/maven2/ch/qos/logback/logback-parent/1.3.9/logback-parent-1.3.9.pom", - "sha256": "104db5mhd0zbcgpiksb7cfpwb0qkfb1lvyhj5pwdbz64072rp5lc" + "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_sjs1_2.13/2.3.13/shapeless_sjs1_2.13-2.3.13.jar", + "sha256": "060fv8cxbf22misgzrjqrkcgnd8cd2wlhl52s0fvl1an8vc8pys0" }, { - "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.12/shapeless_2.13-2.3.12.jar", - "sha256": "0bm96812lwwwgj8pa8jrh06vv03v89ya36hxk0c1f3cfpgw4w1ka" + "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_sjs1_2.13/2.3.13/shapeless_sjs1_2.13-2.3.13.pom", + "sha256": "1r092vs4mg6h5dn53l4v43al3x4970vlxxdif1dl75k1sl7kbc38" }, { - "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.12/shapeless_2.13-2.3.12.pom", - "sha256": "0c8h9pk486jc5pk99r5zyrg4rw6l8bxjfg4qv6a4ymn510z3aky9" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-apache-http_2.12/0.9.4/gigahorse-apache-http_2.12-0.9.4.jar", + "sha256": "1m6wp8i4gjj4572n7wjg8c9v404wy14015av6lk0flg2v5rk2whq" }, { - "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_2.13/2.3.7/shapeless_2.13-2.3.7.pom", - "sha256": "11sq61g5xx14r4x0j7mzbyn0p6qj2asgmwdp5xbwy900sks71m9l" - }, - { - "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_sjs1_2.13/2.3.7/shapeless_sjs1_2.13-2.3.7.jar", - "sha256": "1fd2dxqiharb3g3n3psch181b5mwy7r3012p7p5ql9arg9crc0m9" - }, - { - "url": "https://repo1.maven.org/maven2/com/chuusai/shapeless_sjs1_2.13/2.3.7/shapeless_sjs1_2.13-2.3.7.pom", - "sha256": "0mwmz2d8qdbsx516gna43i3zzlbg8r7df930x0526rk0pmk9x4f4" - }, - { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-apache-http_2.12/0.9.3/gigahorse-apache-http_2.12-0.9.3.jar", - "sha256": "0dz19ijzwyghcxjqhilyy8z36c23gk74md94c67kildfkns3jxc2" - }, - { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-apache-http_2.12/0.9.3/gigahorse-apache-http_2.12-0.9.3.pom", - "sha256": "15j4qh41ccawjxb4ribsiy95g6m5skglas5lvys9h68napj1zqfw" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-apache-http_2.12/0.9.4/gigahorse-apache-http_2.12-0.9.4.pom", + "sha256": "0r6d3041my1b1d2bmbqy227r5ypqbq9ch2bxhwwbmmjff2ihfwa9" }, { "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-core_2.12/0.4.0/gigahorse-core_2.12-0.4.0.jar", @@ -58,12 +42,12 @@ "sha256": "016qvlpk20in5a2wdp438frbi83fz5sf2nbada4j9vlfdjjgrjxa" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-core_2.12/0.9.3/gigahorse-core_2.12-0.9.3.jar", - "sha256": "0xgdp3r7m3r1hj5gdmwj95xhcmxiq660d2pir5y2i7pbnhl61abl" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-core_2.12/0.9.4/gigahorse-core_2.12-0.9.4.jar", + "sha256": "01mdd4ks0jsdn47hvmwybzdsgxh78sms9rr72wvbflf2s4xa653v" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-core_2.12/0.9.3/gigahorse-core_2.12-0.9.3.pom", - "sha256": "1l067rjbsm79032si2xzh9q5cmzhw4a0hw66rdf8yr7lvdmnd9bn" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-core_2.12/0.9.4/gigahorse-core_2.12-0.9.4.pom", + "sha256": "1d1cicf6cacjlz07y442fl2nlfk4i2fzicijzsmawgc9h136y5rc" }, { "url": "https://repo1.maven.org/maven2/com/eed3si9n/gigahorse-okhttp_2.12/0.4.0/gigahorse-okhttp_2.12-0.4.0.jar", @@ -74,20 +58,20 @@ "sha256": "0cpj5379a9d626s2bn211xg5vf15bf0pj1v94rzxdlzb4z9avwkv" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-apache-httpclient5/0.9.3/shaded-apache-httpclient5-0.9.3.jar", - "sha256": "0q4ghs6wphya93qi73n24yaigq1gf24zqycif4ml4b9496zzqdr3" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-apache-httpclient5/0.9.4/shaded-apache-httpclient5-0.9.4.jar", + "sha256": "033i1qdp3knzxdjg1vwibv88fn94cprvi64xfpl8hbgi8i1mgmnd" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-apache-httpclient5/0.9.3/shaded-apache-httpclient5-0.9.3.pom", - "sha256": "1yldpbva1m2ngn6whf335dsq4dqf6vp264q87vkbam6zkh3kwzdq" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-apache-httpclient5/0.9.4/shaded-apache-httpclient5-0.9.4.pom", + "sha256": "08gik1ga3s3bi70d1i9qy9iazlkndx855z0087nlf6fg8ywjiwhm" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-jawn-parser_2.12/1.3.2/shaded-jawn-parser_2.12-1.3.2.jar", - "sha256": "1glk3xlb39hnd4q0bn218d1jzd73w98ql011p19mmcj9s7zjrx3n" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-jawn-parser_2.12/1.7.0/shaded-jawn-parser_2.12-1.7.0.jar", + "sha256": "1nz61894gv8ni0w6jz8ahzya2a9842rj021h25khlygd0pvqc7df" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-jawn-parser_2.12/1.3.2/shaded-jawn-parser_2.12-1.3.2.pom", - "sha256": "1yvk08m25846019giv14nlsjzjgkrivsyy0i8ajhbv67pm8nvchv" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-jawn-parser_2.12/1.7.0/shaded-jawn-parser_2.12-1.7.0.pom", + "sha256": "1x8j2fczcryylysd0gp0ypxmgmyavyzackshd7h2qs3rz6fd5742" }, { "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-scalajson_2.12/1.0.0-M4/shaded-scalajson_2.12-1.0.0-M4.jar", @@ -97,113 +81,93 @@ "url": "https://repo1.maven.org/maven2/com/eed3si9n/shaded-scalajson_2.12/1.0.0-M4/shaded-scalajson_2.12-1.0.0-M4.pom", "sha256": "1l0mfsrynxykl0f0b0i9d2nkz92d4075n2pql5bk9awc51a07yfa" }, - { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-core_2.12/0.10.1/sjson-new-core_2.12-0.10.1.jar", - "sha256": "1awkmprry5pkzx6cqbfggckidffpf2jpvrqjg5zd10ykjna0rpry" - }, { "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-core_2.12/0.10.1/sjson-new-core_2.12-0.10.1.pom", "sha256": "1c75ads44iwiagv4x5xdizjvdva2zxx74xi6mqy3mmrmawl931rj" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-murmurhash_2.12/0.10.1/sjson-new-murmurhash_2.12-0.10.1.jar", - "sha256": "13y36nc69inwa9r42bgj82rvb1i6q5c6fdk5n4400kn6ilm02f5s" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-core_2.12/0.10.3/sjson-new-core_2.12-0.10.3.jar", + "sha256": "0q3k0mpnp9vdn69ksyj5p14yccidcpwpxk8lqd817hmb3c7nqvwa" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-murmurhash_2.12/0.10.1/sjson-new-murmurhash_2.12-0.10.1.pom", - "sha256": "1sfhxmskmhwa6dwsl3ldbx6yblmlpq9qixza0xjg77z28ciapmp5" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-core_2.12/0.10.3/sjson-new-core_2.12-0.10.3.pom", + "sha256": "02lr88qar4h27yvk3shh3r2x14mjjgq3dnkaa236sxikqjp3gl72" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-scalajson_2.12/0.10.1/sjson-new-scalajson_2.12-0.10.1.jar", - "sha256": "1vni9j79nkcs5sqp6ik3cxgkgp3ipjggdjadr6ddfrpjl1ld6vqq" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-murmurhash_2.12/0.10.3/sjson-new-murmurhash_2.12-0.10.3.jar", + "sha256": "1dm1i7scx3g91aqhwa4wbv3syifd1k65y1a288cfi331ygq3n2n4" }, { - "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-scalajson_2.12/0.10.1/sjson-new-scalajson_2.12-0.10.1.pom", - "sha256": "1p1ldfhdi292y3r4q2bm7jz2ip840z7xg3gxj1n41c3lbx6nwi19" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-murmurhash_2.12/0.10.3/sjson-new-murmurhash_2.12-0.10.3.pom", + "sha256": "1x0brvq5171xwq9xd47bdj09n57kgz7px3jqfa5lrldr8n4lljn5" }, { - "url": "https://repo1.maven.org/maven2/com/ethlo/time/itu/1.14.0/itu-1.14.0.jar", - "sha256": "0jhnxbfgs21fpim4fcqrfbbrb0hwszng7cbmp2r8m0kprjq0mx2w" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-scalajson_2.12/0.10.3/sjson-new-scalajson_2.12-0.10.3.jar", + "sha256": "0jm31ah531abayvc48fxivvkjxizzmrj4kx38vfhibmvhxvww74z" }, { - "url": "https://repo1.maven.org/maven2/com/ethlo/time/itu/1.14.0/itu-1.14.0.pom", - "sha256": "1iabmqsm05iqq43b5qx469l6hw1q4l9nr2nccff555clq2zf0nhd" - }, - { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.3/jackson-annotations-2.18.3.jar", - "sha256": "0cc9idgz99410viv4a66cwi7gcqzmbdvl6xl11ah59dmh06p99ca" - }, - { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.18.3/jackson-annotations-2.18.3.pom", - "sha256": "0jrqk14m5v5wlxj2605jn8hf5apyd0cq132c7mlip9lywalqpjdr" - }, - { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.3/jackson-core-2.18.3.jar", - "sha256": "1ng1haaz1c9v6n26inpnbh9dx38gkqzppa8g8lhyhg75wp9w8sq5" - }, - { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.3/jackson-core-2.18.3.pom", - "sha256": "1jg3a0pjmn2gdbcpf8iwc5jxvij14gx9ijhmqad7194icf7npp1p" + "url": "https://repo1.maven.org/maven2/com/eed3si9n/sjson-new-scalajson_2.12/0.10.3/sjson-new-scalajson_2.12-0.10.3.pom", + "sha256": "1kia87z7gxrg5a7rahsxmcjc5x7jgj0n33bxaavpxcbf0k0q3i90" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.3/jackson-databind-2.18.3.jar", - "sha256": "13qhgs5c871f3w95fmxf0q4lb8c8jhim3f1kpz2qcqbsbakxs2si" + "url": "https://repo1.maven.org/maven2/com/ethlo/time/itu/1.14.0/itu-1.14.0.jar", + "sha256": "0jhnxbfgs21fpim4fcqrfbbrb0hwszng7cbmp2r8m0kprjq0mx2w" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.3/jackson-databind-2.18.3.pom", - "sha256": "0zv1v8m0f62y2ppzfkjih49fq6ahsfx217nnjn2x9nz42jn4i3z5" + "url": "https://repo1.maven.org/maven2/com/ethlo/time/itu/1.14.0/itu-1.14.0.pom", + "sha256": "1iabmqsm05iqq43b5qx469l6hw1q4l9nr2nccff555clq2zf0nhd" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.3/jackson-dataformat-yaml-2.18.3.jar", - "sha256": "10z4asyyd6v0728pddsghb1vm7jyfgycikyx72affhybrx3hx81w" + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.22/jackson-annotations-2.22.jar", + "sha256": "1l524yhxdil8zwk1ias7dxm1qvi9v5wvjkkhfsl52fkxh2cbbp91" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.3/jackson-dataformat-yaml-2.18.3.pom", - "sha256": "0jl6nj961czqvqg7cvnyv2ig2cj6w2fhqp6172hswhjp5ffplkkb" + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-annotations/2.22/jackson-annotations-2.22.pom", + "sha256": "1l265d1j3p16jjvlpv6qpb63h43fsnck9cf5mhz3xppal19bmhrq" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformats-text/2.18.3/jackson-dataformats-text-2.18.3.pom", - "sha256": "09bfw1pz8743y1rfd0x4r4d6nnircgf4dszdxinrfmg8qsndlwsn" + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-bom/2.19.1/jackson-bom-2.19.1.pom", + "sha256": "1kmrw61dvfj9gn275b7yryhjm8zrgwcy5bd8fwxc271smgp6hvds" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-base/2.18.3/jackson-base-2.18.3.pom", - "sha256": "1zd4yglxdpxknzd34f819m8cyrgxka0dx4k0id4hw8s5hhygvfym" + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-parent/2.19.2/jackson-parent-2.19.2.pom", + "sha256": "0rc340gv3xiq0qrd5cc5kynvgyxdra2kjc4120w4xnh5vmijp6k3" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-bom/2.18.3/jackson-bom-2.18.3.pom", - "sha256": "145j4yd1wmb08gx083vxrprah3wlxv2vpsazh066a611nfpcdm7i" + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-parent/2.22/jackson-parent-2.22.pom", + "sha256": "0afil1flhj9v4w55g5f0a84z24mh1dxbhm6csyz0gwlxnvpsfibx" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/jackson-parent/2.18.1/jackson-parent-2.18.1.pom", - "sha256": "134r22cx3vnzlw8116yhd68n2kwsqhhcjifli95q89h23an2z0nh" + "url": "https://repo1.maven.org/maven2/com/fasterxml/oss-parent/68/oss-parent-68.pom", + "sha256": "0myfl5sfgmlz50sf5ax5v8lbh5a0q6r5b3qhlysvchp2vabgvsi5" }, { - "url": "https://repo1.maven.org/maven2/com/fasterxml/oss-parent/61/oss-parent-61.pom", - "sha256": "0i350gfmdnr4q7wyxv85v3djpqa3qnl8cnc5l1niiscpyly52j9n" + "url": "https://repo1.maven.org/maven2/com/fasterxml/oss-parent/79/oss-parent-79.pom", + "sha256": "1qfsljrik9fys7rc3nfjqkig7a528ylzqgbcawv804zvczfvf1lj" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-annotations_2.13/2.1.0-M30/case-app-annotations_2.13-2.1.0-M30.jar", - "sha256": "16mvbqrlchnpmzxs8igh8gh3r5csxg2ax1s5vivc81p8hp97w5a7" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-annotations_2.13/2.1.0/case-app-annotations_2.13-2.1.0.jar", + "sha256": "1p6m84hjywpl5k9d6xcjk05bzymwmz4v6vnkj2plyrpxk6lch16p" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-annotations_2.13/2.1.0-M30/case-app-annotations_2.13-2.1.0-M30.pom", - "sha256": "05cb5y24wa7rc202ln3s9gla67n823ylcz1vgxbfpdqmzr5939zl" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-annotations_2.13/2.1.0/case-app-annotations_2.13-2.1.0.pom", + "sha256": "1z01nrn0hwxwc5wz9pdck1ki749747v9hz5w34rf9s0ivpm4icwa" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-util_2.13/2.1.0-M30/case-app-util_2.13-2.1.0-M30.jar", - "sha256": "0i904j8qh1m78hfmh8xb7h4m7qkj0xapvcn4iaa0fizh604nhinx" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-util_2.13/2.1.0/case-app-util_2.13-2.1.0.jar", + "sha256": "10ypif4vfl0ls26j3ib3s76bckznd469wd8ghv4xijh9zs3hxww6" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-util_2.13/2.1.0-M30/case-app-util_2.13-2.1.0-M30.pom", - "sha256": "12h85d1psp4b71p4yfjivpf8axwgifp5bid0c3q23k86sr82zpkx" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app-util_2.13/2.1.0/case-app-util_2.13-2.1.0.pom", + "sha256": "12hrzjrlyhb7ks4yjksx1h6fn7l1mbxpz73avn992hpafq7735bm" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app_2.13/2.1.0-M30/case-app_2.13-2.1.0-M30.jar", - "sha256": "191rfcjqwk3rg9ncsfw94rn779fw8s765d1frsqnq4bpffziw3y9" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app_2.13/2.1.0/case-app_2.13-2.1.0.jar", + "sha256": "1ci4dsra47m46wl1d184f6pp2qj4rl8jdrm6ac5mb0sw0r2b5vvs" }, { - "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app_2.13/2.1.0-M30/case-app_2.13-2.1.0-M30.pom", - "sha256": "1ixa9ginwvniivj4l298ikdbnixbrbwrifsckzwc3r1hz84h7iiq" + "url": "https://repo1.maven.org/maven2/com/github/alexarchambault/case-app_2.13/2.1.0/case-app_2.13-2.1.0.pom", + "sha256": "0g4n164fw345x82yzag50p7zc5spb2rfbnrzriagp0j984xglfl2" }, { "url": "https://repo1.maven.org/maven2/com/github/ben-manes/caffeine/caffeine/2.8.5/caffeine-2.8.5.jar", @@ -222,20 +186,20 @@ "sha256": "0imzkqdq7r9p1jcdgx0wcb8nacf2y7c0vf89xp9bbl9aqa1ilyf0" }, { - "url": "https://repo1.maven.org/maven2/com/github/luben/zstd-jni/1.5.5-10/zstd-jni-1.5.5-10.jar", - "sha256": "1hai840sgjdp5rwn6v6x6zsqpk9hk2zqs46h4pr30mp9yhiznrcl" + "url": "https://repo1.maven.org/maven2/com/github/luben/zstd-jni/1.5.6-3/zstd-jni-1.5.6-3.jar", + "sha256": "1mb3ay5kvd8hrv95h9bkad1axjvi1kiqvibx4y0sz3r574dxwbpp" }, { - "url": "https://repo1.maven.org/maven2/com/github/luben/zstd-jni/1.5.5-10/zstd-jni-1.5.5-10.pom", - "sha256": "0v0rhn4d6ipashc970dmrv9mkg92ri2b3z875npxng4yr0g2lhm2" + "url": "https://repo1.maven.org/maven2/com/github/luben/zstd-jni/1.5.6-3/zstd-jni-1.5.6-3.pom", + "sha256": "144lsic5lar40i60wxh9jbxwsybax2jlz11gj0b954r33mxzmlnz" }, { - "url": "https://repo1.maven.org/maven2/com/github/mwiede/jsch/0.2.23/jsch-0.2.23.jar", - "sha256": "0j7aaf9rs0cybaf0xavxm1vr9bfbharhqksjwa84h7acvmwgjpy4" + "url": "https://repo1.maven.org/maven2/com/github/mwiede/jsch/2.27.5/jsch-2.27.5.jar", + "sha256": "0znbw2wf36q79nr149jyb305b4yaxlkmrn7lc5cdyaz0pl0jzj9a" }, { - "url": "https://repo1.maven.org/maven2/com/github/mwiede/jsch/0.2.23/jsch-0.2.23.pom", - "sha256": "1wr7nn03nznqgjxlg6mywh7w1q9rywdvrkfr2xb5cq5jqnpz0583" + "url": "https://repo1.maven.org/maven2/com/github/mwiede/jsch/2.27.5/jsch-2.27.5.pom", + "sha256": "1x0zmqxc7l57y0r0ghv0lppyn2xic241xnryzds14qx23l41nyly" }, { "url": "https://repo1.maven.org/maven2/com/github/plokhotnyuk/jsoniter-scala/jsoniter-scala-core_2.12/2.13.5.2/jsoniter-scala-core_2.12-2.13.5.2.jar", @@ -250,60 +214,60 @@ "sha256": "0bcfmciza6xv1wbsay4g9xiw5k50zzarnrv1yqfmj9lb1wk6lzr3" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-core_2.13/0.17.8/pureconfig-core_2.13-0.17.8.jar", - "sha256": "1k72758y3ac235l43k1z516jpnk6w0i5acrgl7gxgv3j7vnr0a7r" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-core_2.13/0.17.10/pureconfig-core_2.13-0.17.10.jar", + "sha256": "1rd2fcsjpsvj091z12xcf7scsqh1kky01j152kk6m6nd8k4y35hf" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-core_2.13/0.17.8/pureconfig-core_2.13-0.17.8.pom", - "sha256": "19irj4kc5ajdkajs6az51zhd86fhgrazqqqrlxjx8lzhff3qj58l" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-core_2.13/0.17.10/pureconfig-core_2.13-0.17.10.pom", + "sha256": "0kv4zmiqn9pxga0w1c8h6b9f04f0n6znmvshiksfpk088dgcfgb5" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-generic-base_2.13/0.17.8/pureconfig-generic-base_2.13-0.17.8.jar", - "sha256": "1mxa4a2w2xm7p937y7pjzd69ldlr8ygp81lpqjv0si7mc2fng1xy" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-generic-base_2.13/0.17.10/pureconfig-generic-base_2.13-0.17.10.jar", + "sha256": "1h46ipf19g97b0jkix06isqf28g5d2jlqdsd31j1g42p5w397jiq" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-generic-base_2.13/0.17.8/pureconfig-generic-base_2.13-0.17.8.pom", - "sha256": "05vrdcg80xbhdhslwnb43zlrw27a31l5bb2wna77snqz3cxjf920" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-generic-base_2.13/0.17.10/pureconfig-generic-base_2.13-0.17.10.pom", + "sha256": "0pfb9qfk9av0fsnnsbj0b09h1qi86c3zhb7ffcyqr755pnm321qh" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-magnolia_2.13/0.17.8/pureconfig-magnolia_2.13-0.17.8.jar", - "sha256": "1d9lk3ldrhl8g9p0f1w6xxw6dilndkx4dpfbir5m7lfm27k0yz72" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-magnolia_2.13/0.17.10/pureconfig-magnolia_2.13-0.17.10.jar", + "sha256": "128w4nyivb0mb3klps0q1z55jsk93cm0f1mppsqiymcvnqnzq41a" }, { - "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-magnolia_2.13/0.17.8/pureconfig-magnolia_2.13-0.17.8.pom", - "sha256": "16xhymj9g11q50iafnai8xykmcirf59xh6kwlsh1m5yfdggg5br1" + "url": "https://repo1.maven.org/maven2/com/github/pureconfig/pureconfig-magnolia_2.13/0.17.10/pureconfig-magnolia_2.13-0.17.10.pom", + "sha256": "06dliadrvp1ja6kzn44crff9k19aajn2pg5pdrgw5gmn7ax1ag0p" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/pgp-library_2.12/2.2.1/pgp-library_2.12-2.2.1.jar", - "sha256": "1fczkb7flajhqmiy8cmsifb1qmjlmivfb0fyy2vf7l5w01hv729x" + "url": "https://repo1.maven.org/maven2/com/github/sbt/pgp-library_2.12/2.3.1/pgp-library_2.12-2.3.1.jar", + "sha256": "1s5z136vwy4hm9ixv7q9adgh3l2rrjxaa22jcf1zh182nbjnyk4p" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/pgp-library_2.12/2.2.1/pgp-library_2.12-2.2.1.pom", - "sha256": "1qq7290bcfc2x81q0yavmzl951rz3gkl086f66i1grzw9gdmqxk4" + "url": "https://repo1.maven.org/maven2/com/github/sbt/pgp-library_2.12/2.3.1/pgp-library_2.12-2.3.1.pom", + "sha256": "0ivrvq4z30ksn3qjaib3iyazzx8dkl0kdmfwi9pgzbmmq97m6x7j" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-git_2.12_1.0/2.0.1/sbt-git-2.0.1.jar", - "sha256": "04lmyy90pmznnbi2l396zv2d5c8ksclr3nn4if9486y61pdyrki4" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-git_2.12_1.0/2.1.0/sbt-git_2.12_1.0-2.1.0.jar", + "sha256": "0hir6rbrlw3xx4ls9wvhwbnshf6cg30m00bg5fibzqvf2wnan1nq" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-git_2.12_1.0/2.0.1/sbt-git-2.0.1.pom", - "sha256": "09204plixpyprf5rwiifaq6gdvb603vxd8zzwh4sz6h4m5gihwlr" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-git_2.12_1.0/2.1.0/sbt-git_2.12_1.0-2.1.0.pom", + "sha256": "1q3cwi049mywbxlxz7g7w2f1mg38awb9azi3x1xislf4wnz4xq44" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-native-packager_2.12_1.0/1.10.4/sbt-native-packager_2.12_1.0-1.10.4.jar", - "sha256": "1vyvf8c7a9h59acnkrwq9fpk59gh5zv64bgpapsvc85j47z9mi6g" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-native-packager_2.12_1.0/1.12.0/sbt-native-packager_2.12_1.0-1.12.0.jar", + "sha256": "14rj00r2mj0l5kcgyfpfvwr86k04i2mm5w8ak4iwgp9i47l69hil" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-native-packager_2.12_1.0/1.10.4/sbt-native-packager_2.12_1.0-1.10.4.pom", - "sha256": "04ncgk7zh68rffbiwzqqbfhprlgd6431vik9h5jx15j20nb1yjd4" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-native-packager_2.12_1.0/1.12.0/sbt-native-packager_2.12_1.0-1.12.0.pom", + "sha256": "1km3cb4nwm292f7q65mcww5l45m2cla5gqkg7xkz8mzgxglnq4vp" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-pgp_2.12_1.0/2.2.1/sbt-pgp-2.2.1.jar", - "sha256": "0valfznbaaih52yjl78wx6bci5i7fm72xapj167rdn5f7k88haly" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-pgp_2.12_1.0/2.3.1/sbt-pgp_2.12_1.0-2.3.1.jar", + "sha256": "0b0gqw7jz0k9vdv9yps5m92z7yc2hyh8wxyvp8122nncn2bc06gj" }, { - "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-pgp_2.12_1.0/2.2.1/sbt-pgp-2.2.1.pom", - "sha256": "1y141sl7i6qvf96kakhr4c2a6nmi3r0rb4x2nzklfg7axirxs3z7" + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-pgp_2.12_1.0/2.3.1/sbt-pgp_2.12_1.0-2.3.1.pom", + "sha256": "1wafm4nh745brnfl7j096ipv7bnlbjz15ky91klhqzipi9kqkh13" }, { "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-release_2.12_1.0/1.4.0/sbt-release_2.12_1.0-1.4.0.jar", @@ -313,6 +277,22 @@ "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt-release_2.12_1.0/1.4.0/sbt-release_2.12_1.0-1.4.0.pom", "sha256": "1cvr3dcz5y6p0185nzkh2cnbbkl3im4m0rrsy7gi91258cr4qgd8" }, + { + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt2-compat_2.12_1.0/0.1.0/sbt2-compat_2.12_1.0-0.1.0.jar", + "sha256": "04c4hpgb4fj7vksmbvajiq46y656ws99ld7ssgsv3lnpz2b6wa85" + }, + { + "url": "https://repo1.maven.org/maven2/com/github/sbt/sbt2-compat_2.12_1.0/0.1.0/sbt2-compat_2.12_1.0-0.1.0.pom", + "sha256": "0nr44871r2w2rvvyfbhlwdyfmr2481gf07scap6jap5bf5alxfq6" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.36.0/error_prone_annotations-2.36.0.jar", + "sha256": "19b1xm9iqflhy9sjdx54dj4c88m76rn0fniwj14s5j8b1ckhwi3p" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.36.0/error_prone_annotations-2.36.0.pom", + "sha256": "170y6fync895g3bq48rccjrbfiw4vkhiwl67ajvxcxjzr0vzv76p" + }, { "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.4.0/error_prone_annotations-2.4.0.jar", "sha256": "0a5287bvcaaqvx6y90zn7bqnjwrxb26k1ps9w25jwrha4d40cajz" @@ -321,33 +301,69 @@ "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.4.0/error_prone_annotations-2.4.0.pom", "sha256": "0ks7330dr2ys6jb0zj3pgx8aadw7mj2d4n21117kv80f4vx4p9by" }, + { + "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_parent/2.36.0/error_prone_parent-2.36.0.pom", + "sha256": "1p4lf3dkm231daa9j1b77sv71lm03v1nyyb9x54fnqgddf5gqk1s" + }, { "url": "https://repo1.maven.org/maven2/com/google/errorprone/error_prone_parent/2.4.0/error_prone_parent-2.4.0.pom", "sha256": "16l69qpwxaxk1qk18s15sadr3yykkj11rlp01p42lxfslk6s1pfy" }, { - "url": "https://repo1.maven.org/maven2/com/google/guava/guava-parent/18.0/guava-parent-18.0.pom", - "sha256": "0rmia42xirzgk3r2h1syzs7n7ib1nv84yzh87yizcmz7jn4crb54" + "url": "https://repo1.maven.org/maven2/com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.jar", + "sha256": "1jsrdcj3w6i1inyq1w6pa1186bjklh5gwvgxgkfmb3wvn433kz6b" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.pom", + "sha256": "1fzkhbwfp8v2gqkpjnzwjzpm3bf94ajm8bbj2hzd8hkdgzryyjy5" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/guava-parent/26.0-android/guava-parent-26.0-android.pom", + "sha256": "016c2q03chipb3a9ij2d0dmsrf2yyaj8rz0skj4cx5m9djs8lsgq" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/guava-parent/33.4.0-android/guava-parent-33.4.0-android.pom", + "sha256": "10vxghwxwlzhkssk518553ri8bgs8qrbkr0ffay6yn9623kfs83j" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/guava-parent/33.4.8-jre/guava-parent-33.4.8-jre.pom", + "sha256": "081skr4flwymwy66ixpf9phamimiff3blh7xywyl855yl6cm2g50" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/guava/33.4.8-jre/guava-33.4.8-jre.jar", + "sha256": "1v8pwq7myh336an1g0628s991qx5nf9ddzcd8r6jyqpxcxzzbmzk" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/guava/33.4.8-jre/guava-33.4.8-jre.pom", + "sha256": "1g7fgdsrwpz9yhp40lc3hcn1ms63kw32irlz6kwqqb7jdr5msdh4" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", + "sha256": "169zydsbk48cs370lpdq5l69qgqjsq7z7ppzprzsa2i3shvs0wmk" + }, + { + "url": "https://repo1.maven.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.pom", + "sha256": "16v7p0wgzi5wijl596ggcawcs1gyn5mzgqcw0xalwg8m4vdv3m0q" }, { - "url": "https://repo1.maven.org/maven2/com/google/guava/guava/18.0/guava-18.0.jar", - "sha256": "16bgkzvk3sprn5rs9hfcpvz9vgyhy40zni1amffcxrfj0gygnr6n" + "url": "https://repo1.maven.org/maven2/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.jar", + "sha256": "0r3w1akyg7acr51c7q0pgjzi3zdvq82alx2dzm7s9p3x8rria948" }, { - "url": "https://repo1.maven.org/maven2/com/google/guava/guava/18.0/guava-18.0.pom", - "sha256": "0dlqf4baf5h1j9b2a1i96n5l3i21vm7r3xync305sszpfqfxchz7" + "url": "https://repo1.maven.org/maven2/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.pom", + "sha256": "05sawwanafn7h9yqgf3pqnig90z3ggfyd62627fqkbb8w4wx1cr3" }, { - "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs-parent/1.1/jimfs-parent-1.1.pom", - "sha256": "1g96idyskcl3ryfc3fd5lq12ay5f4nbars3h528yzq2p3rsma5f7" + "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs-parent/1.3.1/jimfs-parent-1.3.1.pom", + "sha256": "14bdflj3qsam41xlmq4m9pi0kq18vj118jkc4m2xsqx9x8v2ap2i" }, { - "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs/1.1/jimfs-1.1.jar", - "sha256": "1pbx4lf1y24bgdfaf9bw9p0abnm7mlxhnlc7jfpk1af0swl8x0n4" + "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs/1.3.1/jimfs-1.3.1.jar", + "sha256": "051gj0v8ghmwqrv7pd76qbllazaik7q3lm589iizjfypsjl5s7mk" }, { - "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs/1.1/jimfs-1.1.pom", - "sha256": "0i3sgapm9r7wgmrm0qlb2hzarm6q6irfbjnz5x3ppw92v5f6xa7g" + "url": "https://repo1.maven.org/maven2/com/google/jimfs/jimfs/1.3.1/jimfs-1.3.1.pom", + "sha256": "0xs80cxr1s120ivkjyjw7myvwzyvrr4lwacq54c6cswlfj6wh9cs" }, { "url": "https://repo1.maven.org/maven2/com/googlecode/javaewah/JavaEWAH/1.1.13/JavaEWAH-1.1.13.jar", @@ -422,12 +438,12 @@ "sha256": "0wa1i57cqzhjg0rgvnfpqq6696dnikk12l8ilrj0p7grxrk0mwm8" }, { - "url": "https://repo1.maven.org/maven2/com/networknt/json-schema-validator/1.5.9/json-schema-validator-1.5.9.jar", - "sha256": "1r4s1lbd59r0502x6455x93l9x82i96rgqsf8lks06zxg3mvmf70" + "url": "https://repo1.maven.org/maven2/com/networknt/json-schema-validator/3.0.7/json-schema-validator-3.0.7.jar", + "sha256": "0p5yp5jv18j1j4j0wmb294na7f8c5vd5qwrv40xkapw4c370pf97" }, { - "url": "https://repo1.maven.org/maven2/com/networknt/json-schema-validator/1.5.9/json-schema-validator-1.5.9.pom", - "sha256": "1317kkkdaamv7c18kli3yiiw4spbf0j7wiqgz3mrhcszshkx7r0k" + "url": "https://repo1.maven.org/maven2/com/networknt/json-schema-validator/3.0.7/json-schema-validator-3.0.7.pom", + "sha256": "0js3nzx7hg4krbb2hqmz6l0piwa7ynn04dgl4rl1h95p3k8imgsv" }, { "url": "https://repo1.maven.org/maven2/com/orrsella/sbt-stats_2.12_1.0/1.0.7/sbt-stats-1.0.7.jar", @@ -438,20 +454,20 @@ "sha256": "0fa4ai72jjvvar6jzxwis6k4xbnn26b3wphh5yg5jl0pg50ksy3s" }, { - "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_2.13/1.1.10/magnolia_2.13-1.1.10.jar", - "sha256": "1s9bdbh8zwhqazdh7y04809k6zxy25cq8sgnfzp2cxs29s8h18nw" + "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_2.13/1.1.14/magnolia_2.13-1.1.14.jar", + "sha256": "12gkl1pgflr076f762saqqw3arpfhlwl3rh5kjs8xya7hf95l2vj" }, { - "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_2.13/1.1.10/magnolia_2.13-1.1.10.pom", - "sha256": "18nj02ni93pafpq9bzfx4ghviq7ssx9wp1lz1fmj8m95713r0z2w" + "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_2.13/1.1.14/magnolia_2.13-1.1.14.pom", + "sha256": "1rzdk431xdvpj44k68qd7m244k090m48jzk6plh2bsqhd6gvgz17" }, { - "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_sjs1_2.13/1.1.10/magnolia_sjs1_2.13-1.1.10.jar", - "sha256": "04zar68f9mzmv1gjgw9ihz6dizhkhgq588d0fgqljc3yc3rwcy43" + "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_sjs1_2.13/1.1.14/magnolia_sjs1_2.13-1.1.14.jar", + "sha256": "1if0zwwxawhlkha8zkvsbd85pmdwl9c7qdcqkr99gbwpr242say0" }, { - "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_sjs1_2.13/1.1.10/magnolia_sjs1_2.13-1.1.10.pom", - "sha256": "1wn167cxxrwill80visq6ny6dsx2jmhjis33y2j7dx04jm8z65bl" + "url": "https://repo1.maven.org/maven2/com/softwaremill/magnolia1_2/magnolia_sjs1_2.13/1.1.14/magnolia_sjs1_2.13-1.1.14.pom", + "sha256": "19hal7kqqx7kmmy4afy4phsy3rj3wsf6f6rx1n28akz754ngxxzj" }, { "url": "https://repo1.maven.org/maven2/com/squareup/okhttp3/okhttp/3.14.0/okhttp-3.14.0.jar", @@ -494,20 +510,12 @@ "sha256": "08p5xrn5lkmhrrwfiswdfqjv914b1w11l2jbnywfw2ka9mgjqyh5" }, { - "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.2/config-1.4.2.jar", - "sha256": "1m9gw71dzsqxr2dcybn0il4vmvxkmifzsa352ilq6z9qni4w4xh0" - }, - { - "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.2/config-1.4.2.pom", - "sha256": "0hjc6y8ybwqifwlzk3xsw3xsiyb39084vsgfqbgrxgpiqx5nx8h1" - }, - { - "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.3/config-1.4.3.jar", - "sha256": "1dnzlmm1bpzcxwk5ypj0vw69q02zvjpvbq335mqic977bhc4rnla" + "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.5/config-1.4.5.jar", + "sha256": "1hr6qn90hs89zskirp6aa72la81zrscxwsrskm07559anbzhljsa" }, { - "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.3/config-1.4.3.pom", - "sha256": "1wqg0fhiv3jbblzi00vc1ig6lad5hc08i9nv01yzh3r2vnlsyzmn" + "url": "https://repo1.maven.org/maven2/com/typesafe/config/1.4.5/config-1.4.5.pom", + "sha256": "1m9fw7i6lk4hdyjwgsnlwf86lail90dyqf1pcpqdb2zcq3p5acpz" }, { "url": "https://repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.2.2/ssl-config-core_2.12-0.2.2.jar", @@ -518,28 +526,28 @@ "sha256": "13wkl4kp07z6sb4ipp8jjk51ngg56nwvprsg3lxwpwig6ns9c7jj" }, { - "url": "https://repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1.jar", - "sha256": "0bpc6gxa3p5lrhx3h8d24s28frrh8vsqqkdgs7iwdlfx5r46d4b2" + "url": "https://repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.7.1/ssl-config-core_2.12-0.7.1.jar", + "sha256": "12j6c9qgmph9bx5l02gh97l3jqclkwfwvblxv50b5d808nqsqsii" }, { - "url": "https://repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.6.1/ssl-config-core_2.12-0.6.1.pom", - "sha256": "01hb0az1g582vmcivmmwcn72prahwm7lixmgagc4myhvdn4k9vqd" + "url": "https://repo1.maven.org/maven2/com/typesafe/ssl-config-core_2.12/0.7.1/ssl-config-core_2.12-0.7.1.pom", + "sha256": "02nil6r4gdja05p4zm3q2r823v7aa9flcpbblcxha1m8ccy37fmx" }, { - "url": "https://repo1.maven.org/maven2/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar", - "sha256": "1r550hnq20fcpngy87j35bni5cxj53dfx4113srd3jr2bysvz1zc" + "url": "https://repo1.maven.org/maven2/commons-codec/commons-codec/1.19.0/commons-codec-1.19.0.jar", + "sha256": "0pxgf1q8hn8s0df81mb0cz34rsgxkl6fw9r9aff5x1anypj82f2w" }, { - "url": "https://repo1.maven.org/maven2/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.pom", - "sha256": "0a5dj2sjxrwns6i59skhhg4nfk8m1j1s0bx0f51wcz4x5zcxs9mq" + "url": "https://repo1.maven.org/maven2/commons-codec/commons-codec/1.19.0/commons-codec-1.19.0.pom", + "sha256": "0d6aky5sf170xbr4bpxwq0i59k0ljmgryrf560qwiq1xlagjdwz0" }, { - "url": "https://repo1.maven.org/maven2/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar", - "sha256": "0m71akmdfpr7ri41mj0fl20l622g2vpwl9qcpcpgv35nw4pg32m5" + "url": "https://repo1.maven.org/maven2/commons-io/commons-io/2.20.0/commons-io-2.20.0.jar", + "sha256": "0wnx4p60q448z12sczy2slnkznplz3l8zrv4y6vqdd9wzshbp46z" }, { - "url": "https://repo1.maven.org/maven2/commons-io/commons-io/2.15.1/commons-io-2.15.1.pom", - "sha256": "07grsggh2na4ykr3cc0my7m3s4c0rad81f5k81bynnb75gw1l6hp" + "url": "https://repo1.maven.org/maven2/commons-io/commons-io/2.20.0/commons-io-2.20.0.pom", + "sha256": "0aissgdzalxzxdnjafmi1xspm4dxymc5q6hqifdfx461f88gigdx" }, { "url": "https://repo1.maven.org/maven2/dev/zio/izumi-reflect-thirdparty-boopickle-shaded_2.13/3.0.5/izumi-reflect-thirdparty-boopickle-shaded_2.13-3.0.5.jar", @@ -606,404 +614,416 @@ "sha256": "07rbgagk1lj52gkm0rhh94nzg2x7rqggabncrbfyw02zdrmp276x" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_2.13/1.2.24/distage-core-api_2.13-1.2.24.jar", - "sha256": "1jlpwsa2scqbhn0shipyy8avag6lzv8k3dqrbya4g752bkbgk5zn" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_2.13/1.2.25/distage-core-api_2.13-1.2.25.jar", + "sha256": "0ng81gac7q7956hrjal5bppikfrn7vk67dszznv9djazmmzk8nnj" + }, + { + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_2.13/1.2.25/distage-core-api_2.13-1.2.25.pom", + "sha256": "1l01d1d87x25637a0bsi4icdxdgd231a52an5hg5bk3pj5rvmvsk" + }, + { + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_sjs1_2.13/1.2.25/distage-core-api_sjs1_2.13-1.2.25.jar", + "sha256": "0f2dd7rkvp0sgxxzbzspjpc4m9ss3qy7mpcwyamzyp4swryk6r3m" + }, + { + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_sjs1_2.13/1.2.25/distage-core-api_sjs1_2.13-1.2.25.pom", + "sha256": "0hnbs5g9i5457vsvswbw6icv8wzf3ynas6w9f5hiichr54q5x36z" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_2.13/1.2.24/distage-core-api_2.13-1.2.24.pom", - "sha256": "1s1fzrpv3gayzfkj462v2k921rdlbglw31dzn7g6cp373ck7m5zs" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-proxy-bytebuddy_2.13/1.2.25/distage-core-proxy-bytebuddy_2.13-1.2.25.jar", + "sha256": "1n82vb24xkgbi9fik6xa06r3zqca73vd0iyag6mvlzb2jxjfvbbr" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_sjs1_2.13/1.2.24/distage-core-api_sjs1_2.13-1.2.24.jar", - "sha256": "0i2mmxpicdqz6k494n6p1mrvnn66hpsh7bhz8r2b50ghl44rcdns" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-proxy-bytebuddy_2.13/1.2.25/distage-core-proxy-bytebuddy_2.13-1.2.25.pom", + "sha256": "1wm7yh22an3kd1j5pi2zv0hivy2ns4rcrzqhdslx5855nz30wsrf" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-api_sjs1_2.13/1.2.24/distage-core-api_sjs1_2.13-1.2.24.pom", - "sha256": "1nq90zj3qyn6h4vh8ihymcssmcw9i7ah6qnpi3vv6mwnppcfz7ww" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_2.13/1.2.25/distage-core_2.13-1.2.25.jar", + "sha256": "11z5i62z061zhllch0qhp06nik2mlhg95j4r1bv0j076g7qdcvcr" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-proxy-bytebuddy_2.13/1.2.24/distage-core-proxy-bytebuddy_2.13-1.2.24.jar", - "sha256": "0vgf6y99cpfbir47zx2lyqypaq0za97z2glbv2dvfnas3qczii84" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_2.13/1.2.25/distage-core_2.13-1.2.25.pom", + "sha256": "05d0mir09v6zqhicryny9aj3lswbwn9q20rcdxq5m4v96m7yk1zw" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core-proxy-bytebuddy_2.13/1.2.24/distage-core-proxy-bytebuddy_2.13-1.2.24.pom", - "sha256": "137qvzykqsfypsb11y44lah14197sq4nyi0l3fxnn4p55g99jggq" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_sjs1_2.13/1.2.25/distage-core_sjs1_2.13-1.2.25.jar", + "sha256": "0ji9x2mydzsrn8wp0nhx4sx3r4h0zqqv25ii5rhzk32g3qxfd3r2" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_2.13/1.2.24/distage-core_2.13-1.2.24.jar", - "sha256": "1qsjycaskymqkqjqm0nl3pw4bm7050gli3ifqq493d2xx37xg8nr" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_sjs1_2.13/1.2.25/distage-core_sjs1_2.13-1.2.25.pom", + "sha256": "1nvbzkrh2rdm9m14v8ny749xsvrrhnzkzqhqwv3rk7m392i3nnsi" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_2.13/1.2.24/distage-core_2.13-1.2.24.pom", - "sha256": "12xcf94paki2wzp610aas8la8654z3h502f3bddz3sc1755pc3y9" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-config_2.13/1.2.25/distage-extension-config_2.13-1.2.25.jar", + "sha256": "0ls4s4n4d5w5j18jg7366nngvqaw4rs7pr4z1bmcmckyw0099xa0" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_sjs1_2.13/1.2.24/distage-core_sjs1_2.13-1.2.24.jar", - "sha256": "1jy6bkm2rkh9iarnvfz4vmbm4zbqk68ar43vdk20gwqrpn3yl5hi" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-config_2.13/1.2.25/distage-extension-config_2.13-1.2.25.pom", + "sha256": "0n5iy71phdhkjcr7w1s95cc1jlwg8n55djlxzrdqcajv4l038zqv" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-core_sjs1_2.13/1.2.24/distage-core_sjs1_2.13-1.2.24.pom", - "sha256": "0pcgpb2fv881w07y5kb2w19lcpjd6ajl9phaqwdl4src6gcn1x5w" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-logstage_2.13/1.2.25/distage-extension-logstage_2.13-1.2.25.jar", + "sha256": "0dcxkkvlrwq0cj0zysabzdv8ksqiyrh0k8l97dr7a9hppfwz70i8" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-config_2.13/1.2.24/distage-extension-config_2.13-1.2.24.jar", - "sha256": "1mspsp2n7g2b9ing49kcixw3392775wwzkgcpylyfqyww3d44p1c" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-logstage_2.13/1.2.25/distage-extension-logstage_2.13-1.2.25.pom", + "sha256": "0wzc9hmxmmd53c36fgspdifscsgv2ifkjpjnpw0a9cjb5c9xn9zn" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-config_2.13/1.2.24/distage-extension-config_2.13-1.2.24.pom", - "sha256": "1xxsj9rbcfl3r3rj40i58lvh87q3bp761fb16hq8cdzmyw79ii9h" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-plugins_2.13/1.2.25/distage-extension-plugins_2.13-1.2.25.jar", + "sha256": "12kxnvgbvpgd3vd7bjvyrrbqq7jxyz512czf01qbg0l93n561fbk" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-logstage_2.13/1.2.24/distage-extension-logstage_2.13-1.2.24.jar", - "sha256": "05hdc0jgf9im4vr3fd6vwac3vvswzk5kkdlx8rq3b1civ0afrixl" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-plugins_2.13/1.2.25/distage-extension-plugins_2.13-1.2.25.pom", + "sha256": "12b3jlvf98zpaa9da2zfx9rw5f58lalj7kl9qj5ycmgp9msxv38i" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-logstage_2.13/1.2.24/distage-extension-logstage_2.13-1.2.24.pom", - "sha256": "14z18w068k708294kgb019jaq0i702by722sqdndaa98122pcrjj" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework-api_2.13/1.2.25/distage-framework-api_2.13-1.2.25.jar", + "sha256": "0y5wshp3x8b9hk558z3fc5krabnz9vl3rf4v07dr99041mq6nkri" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-plugins_2.13/1.2.24/distage-extension-plugins_2.13-1.2.24.jar", - "sha256": "0lrzq80fg60dnwi7vhkpapyqcqd397p6pscm07qhmhi7chjzi1d2" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework-api_2.13/1.2.25/distage-framework-api_2.13-1.2.25.pom", + "sha256": "0z6w88fzmkp7p5cz4mwp4ibcrf0lv74ygsncqws4x6ja3vgwf5zr" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-extension-plugins_2.13/1.2.24/distage-extension-plugins_2.13-1.2.24.pom", - "sha256": "1mmlgz4s342zj0549w5bjy474w36sm5zac3plzz8gl8kib6j0p4p" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework_2.13/1.2.25/distage-framework_2.13-1.2.25.jar", + "sha256": "0xvkxfy8rhmylphvbgxibb4jif17166lyyfgdb6cs6cxigf6ww16" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework-api_2.13/1.2.24/distage-framework-api_2.13-1.2.24.jar", - "sha256": "0llfmn2sawac9al04q1s9v7krjn7047lbimr14y694zp4s145lnp" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework_2.13/1.2.25/distage-framework_2.13-1.2.25.pom", + "sha256": "0l48zp0pjy18p11yifbxdmpy20zxhdp3kfwlk5rxwq95k2x31ixl" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework-api_2.13/1.2.24/distage-framework-api_2.13-1.2.24.pom", - "sha256": "1lph34z431p64axwmxzlhiv9fws6hx2wf0fzpap4xgj3jqzhfz1n" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-core_2.13/1.2.25/distage-testkit-core_2.13-1.2.25.jar", + "sha256": "1wcz294smpf45k2s3bwglw5r9izvcjabvvac9w03a1y6wckp0s37" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework_2.13/1.2.24/distage-framework_2.13-1.2.24.jar", - "sha256": "00glza5xz7if9b1qzq3lj5a2qj243n7z3mncz0yrcyyyfbwzbq6l" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-core_2.13/1.2.25/distage-testkit-core_2.13-1.2.25.pom", + "sha256": "0mckhzfg7hvfaj24m7wpqqivppcy14yyfm2iz47s8gavc96yzmxd" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-framework_2.13/1.2.24/distage-framework_2.13-1.2.24.pom", - "sha256": "0w5f7jh03i0mzjxlkbm2bij0m9sn2nhkgwxhlv30blgg29b8zka1" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-scalatest_2.13/1.2.25/distage-testkit-scalatest_2.13-1.2.25.jar", + "sha256": "0c6anrj24jvmgwinc4b8b3sv7hn60pxgi0lnkkjqmwwmqq9pm1vx" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-core_2.13/1.2.24/distage-testkit-core_2.13-1.2.24.jar", - "sha256": "12n4clsiq7fvpzdl58v7wh5qbl44xf97d1smwsq07385y25p36zz" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-scalatest_2.13/1.2.25/distage-testkit-scalatest_2.13-1.2.25.pom", + "sha256": "1lkldmlpfi2ip9p4x18sz8lad9p4yr93kxlgwsg6v1ijpwxllcwv" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-core_2.13/1.2.24/distage-testkit-core_2.13-1.2.24.pom", - "sha256": "1sfykf6jig7rs0qvbhbls5dl2z96lsqkqsq76yb62ra0nc2mb4qf" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_2.13/1.2.25/fundamentals-basics_2.13-1.2.25.jar", + "sha256": "0nd7r01xlajyxa8cfj4n951kablsfpll5qqpfl7i6xz7fy5zb92r" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-scalatest_2.13/1.2.24/distage-testkit-scalatest_2.13-1.2.24.jar", - "sha256": "1mlpsm3fs5kgxccyrkb1ysd2jxpd4lar5h865bx87vkwd2rls4dj" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_2.13/1.2.25/fundamentals-basics_2.13-1.2.25.pom", + "sha256": "0bnxl930q1hzsgcv6bxz13jqn5874kxnhzadchhcq91ickl6kmgd" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/distage-testkit-scalatest_2.13/1.2.24/distage-testkit-scalatest_2.13-1.2.24.pom", - "sha256": "1xwbv0g5sijl31hmikxz8r122226ci6yjd0ln01xcm7hzys49k2b" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_sjs1_2.13/1.2.25/fundamentals-basics_sjs1_2.13-1.2.25.jar", + "sha256": "09z47ybvac656gvrvx02x68vyx36apnl4r7izzm6zfccnr6habkj" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_2.13/1.2.24/fundamentals-basics_2.13-1.2.24.jar", - "sha256": "1b1bqyi3cln9pfa2mgr5x20ydsg40qj3x4528i3ww6p0hwnm2qas" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_sjs1_2.13/1.2.25/fundamentals-basics_sjs1_2.13-1.2.25.pom", + "sha256": "06pjk2jvspw6w24s14jv9j35m300bx3jr48ssr9wyg8sq1kpwzhm" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_2.13/1.2.24/fundamentals-basics_2.13-1.2.24.pom", - "sha256": "04zbsqc11v5b5k6kx11g3xc3w66j9bfibsjkff7zrlq440q1ipp8" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_2.13/1.2.25/fundamentals-bio_2.13-1.2.25.jar", + "sha256": "04xh2hf6k4hv1ll71vrpcdvx0x5ga5j29h2ibvahs2yymmm71bdg" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_sjs1_2.13/1.2.24/fundamentals-basics_sjs1_2.13-1.2.24.jar", - "sha256": "1pbv2x7j4n22hhs7lnn10mazpk52jxdlxy07qwp5h8m3zni34bii" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_2.13/1.2.25/fundamentals-bio_2.13-1.2.25.pom", + "sha256": "1zzh2av9z5b29a6nma59xh6w12qj7mmk7c788bkvfk5jpy4bbs0g" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-basics_sjs1_2.13/1.2.24/fundamentals-basics_sjs1_2.13-1.2.24.pom", - "sha256": "1dxmk7ma7zrak0m1jfncwfxaa0sc9p05dbd4sf5pjklg2cfs8a4m" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_sjs1_2.13/1.2.25/fundamentals-bio_sjs1_2.13-1.2.25.jar", + "sha256": "13cqdlm9s8h5s944vcrb7fsr757gfrzvxp448wc0dipqwx4x3q9l" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_2.13/1.2.24/fundamentals-bio_2.13-1.2.24.jar", - "sha256": "0ncx8kmsr5awlr0vq9ldai2a3i29dskcyrf76jv20isydph58m71" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_sjs1_2.13/1.2.25/fundamentals-bio_sjs1_2.13-1.2.25.pom", + "sha256": "1b303hsdfryc4f3y558n6gki11733cqsgqqw64fvw8pv4is3i3vp" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_2.13/1.2.24/fundamentals-bio_2.13-1.2.24.pom", - "sha256": "1sy895q2pv0yxhr449m60in0yxyrfkl66qbsr15csh787jzcxf0g" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_2.13/1.2.25/fundamentals-collections_2.13-1.2.25.jar", + "sha256": "1hx2lsh3qcsa3lsndj0pkx5zaq5iivschfj3qrsbh7hnw3yk3cx4" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_sjs1_2.13/1.2.24/fundamentals-bio_sjs1_2.13-1.2.24.jar", - "sha256": "0q9926q08pymkz88zhifyyj5b89absc5z60j6108djbdcc2hlc3n" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_2.13/1.2.25/fundamentals-collections_2.13-1.2.25.pom", + "sha256": "1c40szmcy7y1yljp3yi5501jw2amiy7niapk7d22gzx9dr3lbbrq" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-bio_sjs1_2.13/1.2.24/fundamentals-bio_sjs1_2.13-1.2.24.pom", - "sha256": "0bdh7xv5v3r5zrhj7dp4j9scxxip9q641v6mg5as36a4z48sj1pw" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_sjs1_2.13/1.2.25/fundamentals-collections_sjs1_2.13-1.2.25.jar", + "sha256": "0jmwr20iknabmjxcbijyrmqj8j2k8lr3dgnh7zx689b7v0l9rdyg" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_2.13/1.2.24/fundamentals-collections_2.13-1.2.24.jar", - "sha256": "1idcxib840162kk3anh1zcdrzdbix54fbd8ziggjsr005jibw65h" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_sjs1_2.13/1.2.25/fundamentals-collections_sjs1_2.13-1.2.25.pom", + "sha256": "1dv6k5w5ss4izlnavb2i8iy64d6mj75xvl8fdkyr4r3r4dy0vgmm" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_2.13/1.2.24/fundamentals-collections_2.13-1.2.24.pom", - "sha256": "0y5a71lnd8l9krq5i6wwrkwm4n4n1pzslr1yrq6drl6sj6ypmh3s" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_2.13/1.2.25/fundamentals-functional_2.13-1.2.25.jar", + "sha256": "1j19zqbdrd58wac9409hwc47bmldl9hw4ln589kjwc4rr539zm5i" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_sjs1_2.13/1.2.24/fundamentals-collections_sjs1_2.13-1.2.24.jar", - "sha256": "1nwykgwcaaa06irww6qyxfbggs07pkn6i5dr408z0mra12j2jp6s" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_2.13/1.2.25/fundamentals-functional_2.13-1.2.25.pom", + "sha256": "1qf4q3c59m9c66lj8z8a7ifkl1fimb2ywaa6via4md413rp5akfj" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-collections_sjs1_2.13/1.2.24/fundamentals-collections_sjs1_2.13-1.2.24.pom", - "sha256": "05cz5xy6vk1ryyjyw4z046qyhrwlzrsik22r1mqf6s8zvsbzjaai" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_sjs1_2.13/1.2.25/fundamentals-functional_sjs1_2.13-1.2.25.jar", + "sha256": "0jh5pnvp95nf95mcs25zdl5wmyzmdsi737572xrgqz108902169q" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_2.13/1.2.24/fundamentals-functional_2.13-1.2.24.jar", - "sha256": "12hgypzjvdwxvlxhw0m6lzasamdwzhzbmgsl985ychrqnxwvp6fb" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_sjs1_2.13/1.2.25/fundamentals-functional_sjs1_2.13-1.2.25.pom", + "sha256": "0k72nz2wd6c3kqm04aisfk9dnnskyf5cghmwwh16glwrgrcckrgq" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_2.13/1.2.24/fundamentals-functional_2.13-1.2.24.pom", - "sha256": "1bk27xiciafsshwnd63hsx5828dryqiggbwsf67nm2a6bacxwa33" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_2.13/1.2.25/fundamentals-functoid_2.13-1.2.25.jar", + "sha256": "1pn064jhr1221600l7xzmlgpvcy6ki92nsniwm6fcmbyfr4idm1r" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_sjs1_2.13/1.2.24/fundamentals-functional_sjs1_2.13-1.2.24.jar", - "sha256": "05s1l83ri0fb171nmc4cipw14gsxrirp8m14lin1ndcpqfk43cwm" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_2.13/1.2.25/fundamentals-functoid_2.13-1.2.25.pom", + "sha256": "1b9adphf14lzf8zyqir7s7dm4fmq6lkfmm1bsapmpppjwymxbsm3" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functional_sjs1_2.13/1.2.24/fundamentals-functional_sjs1_2.13-1.2.24.pom", - "sha256": "1x5qzdc5dx2mshq2zvypgrrmb697167x022a3245872xim66g9b7" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_sjs1_2.13/1.2.25/fundamentals-functoid_sjs1_2.13-1.2.25.jar", + "sha256": "0cha7392gh6fmnl242ilmhzml8qx9d3jrc7hj80q2i4ppa36nf22" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_2.13/1.2.24/fundamentals-functoid_2.13-1.2.24.jar", - "sha256": "0vb9cbanha75dbcb2i50mc3q0xgxsmzn2rp8172bbz7qq8x9i25l" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_sjs1_2.13/1.2.25/fundamentals-functoid_sjs1_2.13-1.2.25.pom", + "sha256": "19h9y60pp7n49a5sg4jv17kqn822sqphrg912ps06h55544iq7rn" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_2.13/1.2.24/fundamentals-functoid_2.13-1.2.24.pom", - "sha256": "08i1s6pznqikclkrx6lwcv42hpfgajkvpp6wck8k4cfbdcg71j3p" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_2.13/1.2.25/fundamentals-language_2.13-1.2.25.jar", + "sha256": "0lmirlc0qpsb9i1simbczjq94vbhr13zyz7szhmm08pmnwqra18q" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_sjs1_2.13/1.2.24/fundamentals-functoid_sjs1_2.13-1.2.24.jar", - "sha256": "13hkg7f158zz363cykilnmr5d23cb37vsa9vp7yry91aislsg6gs" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_2.13/1.2.25/fundamentals-language_2.13-1.2.25.pom", + "sha256": "01ra023lnf229shdvrkwpk18ly41dwp8z7bv447sl56w198wzalz" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-functoid_sjs1_2.13/1.2.24/fundamentals-functoid_sjs1_2.13-1.2.24.pom", - "sha256": "1ics9xmcgqhncsb2flj3bwk0b1aj0ay8cica5jbb9c6rnc8adpq0" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_sjs1_2.13/1.2.25/fundamentals-language_sjs1_2.13-1.2.25.jar", + "sha256": "0j5xw8yg1z230z778avalwdbq24x7rlrfs7x6hk7w4sk2inxgjgj" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_2.13/1.2.24/fundamentals-language_2.13-1.2.24.jar", - "sha256": "1k61v9gknxncy8hzcg9jqig2q7pnsm44h3gyr4myjg9lccpgasff" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_sjs1_2.13/1.2.25/fundamentals-language_sjs1_2.13-1.2.25.pom", + "sha256": "1vhl73zymwkwxx459fwmlknbzfzvf0d0l2p62d38j7qz47hjw93y" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_2.13/1.2.24/fundamentals-language_2.13-1.2.24.pom", - "sha256": "074c8wbkwqqman3q9djmp403q8cd1gm4yqr6kgi74r6nxhs3k219" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_2.13/1.2.25/fundamentals-literals_2.13-1.2.25.jar", + "sha256": "0kfga2rdr6ipb5lyakj09wa7wlfxmqq9lmc54rkxn2xjam9y22m7" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_sjs1_2.13/1.2.24/fundamentals-language_sjs1_2.13-1.2.24.jar", - "sha256": "0yca5hm54nykxc9yn3p6gp24w209790afc5wkm5lxpcgs8f41g0m" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_2.13/1.2.25/fundamentals-literals_2.13-1.2.25.pom", + "sha256": "1mwglm18nqgnz23za3296sjd1ib59knyc7fwi6nav8240r0kf87r" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-language_sjs1_2.13/1.2.24/fundamentals-language_sjs1_2.13-1.2.24.pom", - "sha256": "0bd10famxybk5nx8qiva0n402v3iyi533z437c7nq4dryk05pv6a" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_sjs1_2.13/1.2.25/fundamentals-literals_sjs1_2.13-1.2.25.jar", + "sha256": "1fii1sgn5ma1k4dml784a9w27rqhy7vz1rp2lds7aglwfc569gyw" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_2.13/1.2.24/fundamentals-literals_2.13-1.2.24.jar", - "sha256": "0kxrra93hfy0scb8xs2xd6i8b2jvx18n42535zjcldqvd9navjdk" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_sjs1_2.13/1.2.25/fundamentals-literals_sjs1_2.13-1.2.25.pom", + "sha256": "0nrk26y3sakcbyyvhci6r5yf800vqgmmn647zmq70rn02adsamxq" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_2.13/1.2.24/fundamentals-literals_2.13-1.2.24.pom", - "sha256": "0jx3c2y4askj3vgl1sj65rgnskfqy4kmy84kxzyqjkdikgdcvlrl" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_2.13/1.2.25/fundamentals-orphans_2.13-1.2.25.jar", + "sha256": "00bc035p5abh5i7j4mrx36a7bm88i85fljkwmgx5jiw94qda5whc" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_sjs1_2.13/1.2.24/fundamentals-literals_sjs1_2.13-1.2.24.jar", - "sha256": "1xvw6aidzn9y42r3yjj01vg0jb7i8lzmflif9l41acf1x5y8jbc4" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_2.13/1.2.25/fundamentals-orphans_2.13-1.2.25.pom", + "sha256": "1vw4v0sq0ali7y9z927gcg8n2044jd8ybw3l8d7s3714c0i619b3" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-literals_sjs1_2.13/1.2.24/fundamentals-literals_sjs1_2.13-1.2.24.pom", - "sha256": "05kqnangdbajgdrwq7gnjj5g0knwknajmn49d9a1w2hnjp3grk4z" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_sjs1_2.13/1.2.25/fundamentals-orphans_sjs1_2.13-1.2.25.jar", + "sha256": "0pwljlczaaidjp6p2i1fz5bh42g4w5w97vxrvw6am92xala5n2wn" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_2.13/1.2.24/fundamentals-orphans_2.13-1.2.24.jar", - "sha256": "16hh01v362v3xnjhqm9azp17k273byaj6d4202appi87kblkjial" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_sjs1_2.13/1.2.25/fundamentals-orphans_sjs1_2.13-1.2.25.pom", + "sha256": "0jfy530w5pnavckbqh5g6wzkbkbwagy0jdichlwkf0xc2zg5dqc5" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_2.13/1.2.24/fundamentals-orphans_2.13-1.2.24.pom", - "sha256": "02lk6jrdnl56gsadi395b7vf3ncp5y463k0kxa898kdm1rnlr8hw" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_2.13/1.2.25/fundamentals-platform_2.13-1.2.25.jar", + "sha256": "1jrvxcm7bz57hcy6fidx5fdkbc6p0mrqv2sjr7jxbph0b8qw64s3" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_sjs1_2.13/1.2.24/fundamentals-orphans_sjs1_2.13-1.2.24.jar", - "sha256": "0w093yvra7ig5pg88l9ds93xyq6ca8xbj28s95x41byn187c5jhn" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_2.13/1.2.25/fundamentals-platform_2.13-1.2.25.pom", + "sha256": "0yqn52ihalvyz2ivxhcvyyfa8isc7hb1x116dawxhc1qz46fipdc" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-orphans_sjs1_2.13/1.2.24/fundamentals-orphans_sjs1_2.13-1.2.24.pom", - "sha256": "0849qchn4k7jvlmklpz8yx34hz829nw2szx9si6w6d8j45zasb9a" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_sjs1_2.13/1.2.25/fundamentals-platform_sjs1_2.13-1.2.25.jar", + "sha256": "08x3k0bq5pd46z816hmb8f0bhk52kdiagqzgs8yhfb7ib86awi8f" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_2.13/1.2.24/fundamentals-platform_2.13-1.2.24.jar", - "sha256": "1iccy35hfk0cn93l3sn938flc05xshhjqq3gi0g77lsaqg9m66yk" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_sjs1_2.13/1.2.25/fundamentals-platform_sjs1_2.13-1.2.25.pom", + "sha256": "1j73vc07n46ix6np5255xsbvq4g37bk28yhl7pid40gq3hsqrcgv" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_2.13/1.2.24/fundamentals-platform_2.13-1.2.24.pom", - "sha256": "17j8prybm0jw5cn79q38w7hzqmdrad40wzf2gpqaa6jk66a2yndk" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-core_2.13/1.2.25/logstage-core_2.13-1.2.25.jar", + "sha256": "0id8r3k1wys03y2n77gvhc74p4f9g8j92hsfl51hnajz87g8ysp4" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_sjs1_2.13/1.2.24/fundamentals-platform_sjs1_2.13-1.2.24.jar", - "sha256": "0rq47w18jdyfrzc7xk4i0yaxdm8b2d8lvcird4pgnxpr2v3474kn" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-core_2.13/1.2.25/logstage-core_2.13-1.2.25.pom", + "sha256": "1p0nfz516rlqj0mcph8daq9vmlzavxghzmcgi4wsxgd92s7l9i4l" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/fundamentals-platform_sjs1_2.13/1.2.24/fundamentals-platform_sjs1_2.13-1.2.24.pom", - "sha256": "08zg09larz67w22l7y477lqv18f5xzs2w6794x3samfcyn1sz3qp" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-rendering-circe_2.13/1.2.25/logstage-rendering-circe_2.13-1.2.25.jar", + "sha256": "1skmd360mv2zhvnfsfylsqw3rrz0snl42z64arbsffyw8xqwca1p" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-core_2.13/1.2.24/logstage-core_2.13-1.2.24.jar", - "sha256": "09i3001zd39wkr5vgjlb1gw823hmqw656hby74njzr4agx5djfh8" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-rendering-circe_2.13/1.2.25/logstage-rendering-circe_2.13-1.2.25.pom", + "sha256": "0c89581cma6zyb2xpl1pmp23mwdmgli92rv32i44mr36kwzar3xb" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-core_2.13/1.2.24/logstage-core_2.13-1.2.24.pom", - "sha256": "0yfabphrwiy3hm5v0c4hcm3pfn25gx2q4skg9mvpqkbrva6j45m2" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/sbt/sbt-izumi_2.12_1.0/0.0.121/sbt-izumi_2.12_1.0-0.0.121.jar", + "sha256": "0h2r362yn7bnapdfb0nddvdqqza1vxdxw9maqd3912dp8k6l5xxl" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-rendering-circe_2.13/1.2.24/logstage-rendering-circe_2.13-1.2.24.jar", - "sha256": "0l9wf6vs9xfkx08aygrzmlvsfv24hi33qxa674bwxg5a90y50x6l" + "url": "https://repo1.maven.org/maven2/io/7mind/izumi/sbt/sbt-izumi_2.12_1.0/0.0.121/sbt-izumi_2.12_1.0-0.0.121.pom", + "sha256": "1j5ind59rv9lgbqmq894s0xmgcvyvk3bnpv9xzn9yv5bp6qn618w" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/logstage-rendering-circe_2.13/1.2.24/logstage-rendering-circe_2.13-1.2.24.pom", - "sha256": "1442vkfd8fmzas7zyi97zwxjfgg3478g6v5dm0rzf31zyg0r1wj0" + "url": "https://repo1.maven.org/maven2/io/airlift/airbase/112/airbase-112.pom", + "sha256": "1kh44d6aqs2wh4nzi1q9h2y1zy3zmd3w0s3vsm5v8znrrc2d3k0q" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/sbt/sbt-izumi_2.12_1.0/0.0.101/sbt-izumi_2.12_1.0-0.0.101.jar", - "sha256": "1l5g7jvnfv7dci90da22pdasaff5iijw9p8wj5i9336bnpdcxb88" + "url": "https://repo1.maven.org/maven2/io/airlift/aircompressor/0.27/aircompressor-0.27.jar", + "sha256": "1r756mf3033gdkij3m639rm79phf101phj4krfq3pxi8g89z7gpx" }, { - "url": "https://repo1.maven.org/maven2/io/7mind/izumi/sbt/sbt-izumi_2.12_1.0/0.0.101/sbt-izumi_2.12_1.0-0.0.101.pom", - "sha256": "1rsw9yipidk2sy904yniwfnw3rnd6q4ydcdygz2shy0x6lf4dgg4" + "url": "https://repo1.maven.org/maven2/io/airlift/aircompressor/0.27/aircompressor-0.27.pom", + "sha256": "0ha4k5y50jd6dfkdww3hc750423cc4rr7izs7ggknmiqps4xmb2s" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.1/circe-core_2.13-0.14.1.jar", - "sha256": "1pryhlab3x212mx5ibh87wsia6cpy3hnpcxwaw572ahcbgnns6cw" + "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.16/circe-core_2.13-0.14.16.jar", + "sha256": "10n7zm243sgzlk1jq0px70kylyrmry1qzck8i5841z2w6f6z5lgw" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.1/circe-core_2.13-0.14.1.pom", - "sha256": "1wdydzb78b1ir0ni4pplzm2zpyfbs3s5sd2bajqpvdxyi2xscpn3" + "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.16/circe-core_2.13-0.14.16.pom", + "sha256": "0r32m7z9ixhxa63k3ghsf37gakxzj6cq019hx6rcp78p14gaz812" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.14/circe-core_2.13-0.14.14.jar", - "sha256": "1alpiwp8pc8qr8wwnkxpissylnvvcmzc59bq3d2m5k9g3jl2czly" + "url": "https://repo1.maven.org/maven2/io/circe/circe-core_sjs1_2.13/0.14.16/circe-core_sjs1_2.13-0.14.16.jar", + "sha256": "128bg67i2bafnk0h690rhik35fbjwsrbflcr3p4vvxlbmxzb2282" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_2.13/0.14.14/circe-core_2.13-0.14.14.pom", - "sha256": "14lgy5bdshss4snl6pr00jnz6s7j7837gmq0rahmapy9812jx1i9" + "url": "https://repo1.maven.org/maven2/io/circe/circe-core_sjs1_2.13/0.14.16/circe-core_sjs1_2.13-0.14.16.pom", + "sha256": "03l8qpqysh6bd9g7ch64jkhyijka2x0g3bad98wdz896fri9dyab" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_sjs1_2.13/0.14.1/circe-core_sjs1_2.13-0.14.1.jar", - "sha256": "1f5wv6w66dxnxmpv3cn8mqx8z480qkv1h6q65qbm2iqn8jglqrgk" + "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_2.13/0.14.16/circe-generic_2.13-0.14.16.jar", + "sha256": "1ks4c63053bw578m6fvmmfsb5c4agn4hcyx047rar80pl4yvlrrv" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-core_sjs1_2.13/0.14.1/circe-core_sjs1_2.13-0.14.1.pom", - "sha256": "0k8jimpywncsik0k0al1g505zqsxfvggvynzaj8dxwc7k25hsmw6" + "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_2.13/0.14.16/circe-generic_2.13-0.14.16.pom", + "sha256": "1xa1qzf0cw43vwalbzy9ackfppp7nxs2hvc42n9j688gldhhyzma" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_2.13/0.14.1/circe-generic_2.13-0.14.1.jar", - "sha256": "1rp7mnq2ziwdpjq46f51qlhnjsd9bvy7yywhcmhjs2y78mpwjr56" + "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_sjs1_2.13/0.14.16/circe-generic_sjs1_2.13-0.14.16.jar", + "sha256": "1w4zwihbarfz0giv8ckxans67vw77k28w064cvfhz3ngz4sfh4wc" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_2.13/0.14.1/circe-generic_2.13-0.14.1.pom", - "sha256": "00sms7x5plw8jlzpp3j0b29wcy0abac1yl43fzkzl42cya7k9gmy" + "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_sjs1_2.13/0.14.16/circe-generic_sjs1_2.13-0.14.16.pom", + "sha256": "17grkvb0gxrzy28m9rxc5d81zdh0rx13h7df713w559a25lm7r9z" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_sjs1_2.13/0.14.1/circe-generic_sjs1_2.13-0.14.1.jar", - "sha256": "1jha4mmm1mqnjf3m672nbvjw0csfhbxgrr5ply4qs8n1nhpybiy0" + "url": "https://repo1.maven.org/maven2/io/circe/circe-jawn_2.13/0.14.16/circe-jawn_2.13-0.14.16.jar", + "sha256": "0r2zf8qh2c4x260rb2x6py99g92qax51s6qcpzk2gg9yl3ffyfp0" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-generic_sjs1_2.13/0.14.1/circe-generic_sjs1_2.13-0.14.1.pom", - "sha256": "0bhcaivk8cidap74qk33c73la6pvrazjwx4mv45z6yg4n8gpvrc4" + "url": "https://repo1.maven.org/maven2/io/circe/circe-jawn_2.13/0.14.16/circe-jawn_2.13-0.14.16.pom", + "sha256": "1kq8i70ac95qdx9amm3vai3jc3z2fvcd61jxrrgrcbj2sc5fprpg" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-jawn_2.13/0.14.1/circe-jawn_2.13-0.14.1.jar", - "sha256": "0m0q7w7kxcrpzhjrjr9ac6ggsx4szds5bsfddxlr3lxkjchx12r7" + "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.16/circe-numbers_2.13-0.14.16.jar", + "sha256": "1sjh7x35pm51zyqpb7lbfbs1d1z9kn2rqk1kpzk588gbqx2h5jzp" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-jawn_2.13/0.14.1/circe-jawn_2.13-0.14.1.pom", - "sha256": "1yalkh2qbh2cw46w5p2sb0vflz2d03311zrkrv0l28p5bhl7zf9v" + "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.16/circe-numbers_2.13-0.14.16.pom", + "sha256": "0qgs53zhl3hln3rn4ch69yqvvzb83wnfpspbzs6ldcj7lkvipqfk" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.1/circe-numbers_2.13-0.14.1.jar", - "sha256": "0ki5g4y84cbrpr6c1ab4v3l9f8m80ks6l47xafpbrf6jnmfygf03" + "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_sjs1_2.13/0.14.16/circe-numbers_sjs1_2.13-0.14.16.jar", + "sha256": "0h7zc8bv8ljam9j21h197zns3wrn15mwr1c59gr50nadi7snpvh7" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.1/circe-numbers_2.13-0.14.1.pom", - "sha256": "02xaclv1wch2fhfccgrz8nx2lfwjlv4srhx64y00jcpsx05hik9a" + "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_sjs1_2.13/0.14.16/circe-numbers_sjs1_2.13-0.14.16.pom", + "sha256": "15jbg0vrwvrr0q8iyssshxwykrk6cklxwdlw88a6zfjslxnmw5fm" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.14/circe-numbers_2.13-0.14.14.jar", - "sha256": "0w8m57q3ppzqifnmj9bxgba76s4k0637yd45kwv1k75mjlm728m8" + "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_2.13/0.14.16/circe-parser_2.13-0.14.16.jar", + "sha256": "013zx1cm6lwyq005a3iqrxpdlsgra4lfhrwbaniw24yrp4zww6rr" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_2.13/0.14.14/circe-numbers_2.13-0.14.14.pom", - "sha256": "0dgjhbvqqdffjqsy7lm2fzl27f9xkfp8y71v1x3p0mcmjpcms062" + "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_2.13/0.14.16/circe-parser_2.13-0.14.16.pom", + "sha256": "1q46cs5y1p972bafrnvgjwsfwpfjwc8f4nvpg611vx02qnw8gh22" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_sjs1_2.13/0.14.1/circe-numbers_sjs1_2.13-0.14.1.jar", - "sha256": "1ypmhydw3k1z88m63w3fdwn4bgyr4zcqywad68jw6ddhhwllklkb" + "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_sjs1_2.13/0.14.16/circe-parser_sjs1_2.13-0.14.16.jar", + "sha256": "1i8a5yszlcfpck422kd4g9p31vnjc0zxw6grgjbfwrgmr93z862x" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-numbers_sjs1_2.13/0.14.1/circe-numbers_sjs1_2.13-0.14.1.pom", - "sha256": "0w0dylwnmk08kkwwi8wij4fvhxx83pa34601326qvvirblpdz46l" + "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_sjs1_2.13/0.14.16/circe-parser_sjs1_2.13-0.14.16.pom", + "sha256": "0mlq639shpnfi9yh8k3j429hddvjdvd9gwkqd26ww06x0l9mv5yw" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_2.13/0.14.1/circe-parser_2.13-0.14.1.jar", - "sha256": "0rmg68n8k3wpsq2frpk98l2cwal10lby63pznw3p7mwwzi5kns4d" + "url": "https://repo1.maven.org/maven2/io/circe/circe-scalajs_sjs1_2.13/0.14.16/circe-scalajs_sjs1_2.13-0.14.16.jar", + "sha256": "020f48mhpi04gfqsxlrv3kdqgjh7ilv22d94p303m82cisf3930k" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_2.13/0.14.1/circe-parser_2.13-0.14.1.pom", - "sha256": "10fx92bgb16m1bs8i20kdbfl84q231fxawkqfiyf5wy5kvifryh1" + "url": "https://repo1.maven.org/maven2/io/circe/circe-scalajs_sjs1_2.13/0.14.16/circe-scalajs_sjs1_2.13-0.14.16.pom", + "sha256": "0wlsddz7r0ad3f13j2005m933mrw4308xlr84x8vsancgzzcnyfy" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_sjs1_2.13/0.14.1/circe-parser_sjs1_2.13-0.14.1.jar", - "sha256": "1hpi9gizbr8gagwrx7c888y4hazq0880h01g5bcd7c2ks7w2axbz" + "url": "https://repo1.maven.org/maven2/io/get-coursier/cache-util/2.1.24/cache-util-2.1.24.jar", + "sha256": "116zdsjkp5m359p53vw09y3pd78fpwqg52gda3vfg41vn6pl2xs4" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-parser_sjs1_2.13/0.14.1/circe-parser_sjs1_2.13-0.14.1.pom", - "sha256": "1xx98pl920c8gpvfaa8zy1hv5x5abjkmz4rd2lvpf2dx4w04xy2s" + "url": "https://repo1.maven.org/maven2/io/get-coursier/cache-util/2.1.24/cache-util-2.1.24.pom", + "sha256": "0pl4hfgvrai1vd0j37z80yg5yrj31lvj8xbmrsmz39vavv1pb3f5" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-scalajs_sjs1_2.13/0.14.1/circe-scalajs_sjs1_2.13-0.14.1.jar", - "sha256": "05r2dwhaw4i034l33iyzxck3hi9lbhvlx0xk997ig541nd0yj5n2" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-cache_2.12/2.1.24/coursier-cache_2.12-2.1.24.jar", + "sha256": "1zhhsf4s2czgx6wypq3zcwga3mxihcm36mic3sk1y9321ss55a7x" }, { - "url": "https://repo1.maven.org/maven2/io/circe/circe-scalajs_sjs1_2.13/0.14.1/circe-scalajs_sjs1_2.13-0.14.1.pom", - "sha256": "0da3z7lrq25jba6lgjcllyb2vcqyp81aml0g5di9wa800mmgzyca" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-cache_2.12/2.1.24/coursier-cache_2.12-2.1.24.pom", + "sha256": "1ac4cc8lxk7q3kfr7gsvbi7r8a7zpqmm7fyc97wq2l0wkb8rsdd5" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-cache_2.12/2.1.9/coursier-cache_2.12-2.1.9.jar", - "sha256": "01hvfbanmia5k1azjzqj3dfz6aih39kby6wjn1bmkdzlrfqyg427" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-core_2.12/2.1.24/coursier-core_2.12-2.1.24.jar", + "sha256": "0akx869vz66skzqz9g4d9n96a42vnksv97jipvcaay8bvda2zfl5" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-cache_2.12/2.1.9/coursier-cache_2.12-2.1.9.pom", - "sha256": "12l8vhlawyqjl63zy5dcx4ijlbbvag97y2lmri7mg46ya8wsbvpb" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-core_2.12/2.1.24/coursier-core_2.12-2.1.24.pom", + "sha256": "0znqrmvhgb05j9fmrhy4jjrxcgvw8q4dq6y0wdjl9qq2haz3hgcd" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-core_2.12/2.1.9/coursier-core_2.12-2.1.9.jar", - "sha256": "01y9l8vs8rv6gdqn6w8jj3jajbl3cvpxxwy3f48kmc09q7jhawfb" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-proxy-setup/2.1.24/coursier-proxy-setup-2.1.24.jar", + "sha256": "0nyfldys0y56mqxdj9hm2kws4w79aclv86v7rgjf5igy4m1zlzdx" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-core_2.12/2.1.9/coursier-core_2.12-2.1.9.pom", - "sha256": "0zkxca2c4q7b6spd3qrz4p5kfda3rg47s3wdw22ajagynd5wqci1" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-proxy-setup/2.1.24/coursier-proxy-setup-2.1.24.pom", + "sha256": "1bd4w01npb3mb05p7d5vf7jcafnqgh37hlwvg77bqi369wsb5wg9" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-proxy-setup/2.1.9/coursier-proxy-setup-2.1.9.jar", - "sha256": "1ch9x1jy1fx94xdi0aqps59vbvxarshnbxdhip44x9s55v1dmx4x" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-util_2.12/2.1.24/coursier-util_2.12-2.1.24.jar", + "sha256": "1cgwpnaq840lmnrj4zyqaywwxc9jcwi78ac2n9narlgpvc12kl1h" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-proxy-setup/2.1.9/coursier-proxy-setup-2.1.9.pom", - "sha256": "0rizm989n29qh8n3kiib7yb48njmr7m5sl9p1a2ij83ll6x3c722" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-util_2.12/2.1.24/coursier-util_2.12-2.1.24.pom", + "sha256": "0rwrafvni4fwgmy7mxfgdj5wlqqggw4985g353fd7815phpm9vm5" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-util_2.12/2.1.9/coursier-util_2.12-2.1.9.jar", - "sha256": "1hy6xp3skbwgkaf2xfn1hk99ysk47l1sy4y5xc04935nckg9ig63" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier_2.12/2.1.24/coursier_2.12-2.1.24.jar", + "sha256": "15s1mcrzrq6767mmb26zdqhxl7iq1y1h9hv0dsrjqibl8cy3pagm" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier-util_2.12/2.1.9/coursier-util_2.12-2.1.9.pom", - "sha256": "1rd0vkrsyn418xx1kc1gdbsf6qvgxzvw04an7lq4n4l6w1g3n1hc" + "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier_2.12/2.1.24/coursier_2.12-2.1.24.pom", + "sha256": "0b9pvwwyp7fdxj661jprhjlh3g83xr21954qdgyj3lfj601g9kjs" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier_2.12/2.1.9/coursier_2.12-2.1.9.jar", - "sha256": "13im2ixls3jkf5m46g8xckg94g29zchwj6nqanxb8i3l2z3ja4jw" + "url": "https://repo1.maven.org/maven2/io/get-coursier/dependency_2.12/0.3.2/dependency_2.12-0.3.2.jar", + "sha256": "0vi6dms1hp5lfi6szk0ig27xvaxj8lnr04a660bqkr6y2dy5i4ji" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/coursier_2.12/2.1.9/coursier_2.12-2.1.9.pom", - "sha256": "0j601rnvmg55lkpfjp0gjfffj8rq7vp82569xm1h83b9zmchf7af" + "url": "https://repo1.maven.org/maven2/io/get-coursier/dependency_2.12/0.3.2/dependency_2.12-0.3.2.pom", + "sha256": "0la1fvk0mqm83lf7yl7570pwf2hd76mlpr7d3qxwk81rn1xs71h8" }, { "url": "https://repo1.maven.org/maven2/io/get-coursier/jniutils/windows-jni-utils/0.3.3/windows-jni-utils-0.3.3.jar", @@ -1014,12 +1034,12 @@ "sha256": "0jwicb6hwgmy3wdskhch53xgi9918i3cnbj4pqa3fgv8j87r0lxd" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/lm-coursier-shaded_2.12/2.1.10/lm-coursier-shaded_2.12-2.1.10.jar", - "sha256": "02l53nqrzzzbh1f020mvgkcqq67vssbz4ak8sg3zz985xwr95yl1" + "url": "https://repo1.maven.org/maven2/io/get-coursier/lm-coursier-shaded_2.12/2.1.13/lm-coursier-shaded_2.12-2.1.13.jar", + "sha256": "0i3zjl3phr4dlwnz0ahpy3iirwxnlqm9z4wrnmjv48vxwbjw249z" }, { - "url": "https://repo1.maven.org/maven2/io/get-coursier/lm-coursier-shaded_2.12/2.1.10/lm-coursier-shaded_2.12-2.1.10.pom", - "sha256": "1bv8hi33p4apg701cwvyb3gxck76xjjjz5wac9hrvgkrvkj9qn3g" + "url": "https://repo1.maven.org/maven2/io/get-coursier/lm-coursier-shaded_2.12/2.1.13/lm-coursier-shaded_2.12-2.1.13.pom", + "sha256": "1ik0fiiza6j2dcv6n0rycl72viiip7ilaj47jmlfrlfz1flcxhy4" }, { "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/concurrent-reference-hash-map/1.1.0/concurrent-reference-hash-map-1.1.0.jar", @@ -1038,20 +1058,28 @@ "sha256": "1da82q5yvpqrr5z5j7l9jswxlipfj75i0bai716jy6d9507qn4pi" }, { - "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/windows-ansi/windows-ansi/0.0.5/windows-ansi-0.0.5.jar", - "sha256": "0s8zqjj9rk8hjyx7ci4n16x0557f8i2nmcv5j75d8w4l361b1b7r" + "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/is-terminal/0.1.1/is-terminal-0.1.1.jar", + "sha256": "1ynkdanng015cba3simndxzl91px4fdjvar87nslgzq17c2mnwvk" }, { - "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/windows-ansi/windows-ansi/0.0.5/windows-ansi-0.0.5.pom", - "sha256": "10i494981h5xxp4045lipcg20fv31dinl8b6kr95h018gkzpmdpw" + "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/is-terminal/0.1.1/is-terminal-0.1.1.pom", + "sha256": "08k1xv5wwzvd0llsap8p909m7v18wwzl8kzvyaf2in0i2wi6qrkb" }, { - "url": "https://repo1.maven.org/maven2/io/github/classgraph/classgraph/4.8.181/classgraph-4.8.181.jar", - "sha256": "0ig55j2swii7z6sl267d8y82jnscrs0kf97cnvxga3kid5nl79k2" + "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/windows-ansi/windows-ansi/0.0.6/windows-ansi-0.0.6.jar", + "sha256": "10m41mgqkab9ixlf1i07wga08lxan37i68dq3y5zr53wjyxvsz87" }, { - "url": "https://repo1.maven.org/maven2/io/github/classgraph/classgraph/4.8.181/classgraph-4.8.181.pom", - "sha256": "0j7if1i1nm8qyxhrc1f7pzq0dxh1yk6bi1s5vy80fr5c96pinglj" + "url": "https://repo1.maven.org/maven2/io/github/alexarchambault/windows-ansi/windows-ansi/0.0.6/windows-ansi-0.0.6.pom", + "sha256": "0phx047vnmnk9m5i2rifiml6491lm9k0pz71fbfxmhq2h93y81gy" + }, + { + "url": "https://repo1.maven.org/maven2/io/github/classgraph/classgraph/4.8.195/classgraph-4.8.195.jar", + "sha256": "01v8pzqcwshxyqgqiq082qvc2wgki6b7291b3wqqq83x30z0v0l4" + }, + { + "url": "https://repo1.maven.org/maven2/io/github/classgraph/classgraph/4.8.195/classgraph-4.8.195.pom", + "sha256": "1b1c3hibqlvry1swfrl6gk2gy7ck9v7wjvh1l7zgiz1f3mb3hcck" }, { "url": "https://repo1.maven.org/maven2/io/github/cquiroz/cldr-api_sjs1_2.13/4.5.0/cldr-api_sjs1_2.13-4.5.0.jar", @@ -1078,24 +1106,24 @@ "sha256": "13vnc8jqq9iyjlpcyla2ln4cvp6lqba7iv47cnf4k5sh2kw2az9z" }, { - "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils-parent/4.15/java-diff-utils-parent-4.15.pom", - "sha256": "10jwpg1gs5hrx6n2c3kzb45swchzlk04xlfa4dmvq6gsgi5d9rq9" + "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils-parent/4.16/java-diff-utils-parent-4.16.pom", + "sha256": "16jhasj5k9nj8vafczdh8bfjfjzjmlxr57p1gg2yia2kisjlsd54" }, { - "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils/4.15/java-diff-utils-4.15.jar", - "sha256": "1dz65rc211nwh94qxg9j00iq2byll5m81rlafyr2v29slbinjk4n" + "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils/4.16/java-diff-utils-4.16.jar", + "sha256": "0vws743vn4dvlr7q070scnqy3piqfknar8w0ywkllsk71l1h6132" }, { - "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils/4.15/java-diff-utils-4.15.pom", - "sha256": "0bpr7wxcvaywpzmpg505ynpkyddai2lfds53jjk1mdv2x1h2mpqs" + "url": "https://repo1.maven.org/maven2/io/github/java-diff-utils/java-diff-utils/4.16/java-diff-utils-4.16.pom", + "sha256": "1pirrwr2n81yim158w2g2dibskjcg9i62l93hl9i24qq3s55jka3" }, { - "url": "https://repo1.maven.org/maven2/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.jar", - "sha256": "16xbi6mz75r1maw116njab6ln7hm9mhwrxshav4msmfdbccsajz0" + "url": "https://repo1.maven.org/maven2/jakarta/platform/jakarta.jakartaee-bom/9.1.0/jakarta.jakartaee-bom-9.1.0.pom", + "sha256": "1968aj356nkny3q8g9ral25nlxr07addf9i8as1bpz8rc8kf166z" }, { - "url": "https://repo1.maven.org/maven2/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.pom", - "sha256": "15vby0b3gwm1x6g46l98k6j48a18hfp2nyixhpj7hvj0r98s5926" + "url": "https://repo1.maven.org/maven2/jakarta/platform/jakartaee-api-parent/9.1.0/jakartaee-api-parent-9.1.0.pom", + "sha256": "1h2r4v4fm9b7bjr3fiqgpy96b3cb18rn4yvrnn22g016f142qw57" }, { "url": "https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.jar", @@ -1126,24 +1154,20 @@ "sha256": "0gkbjk8hddfmv72s0cwbgzg7qan1045dpfb3r8yfgc0mpzflfk2h" }, { - "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna-platform/5.12.0/jna-platform-5.12.0.jar", - "sha256": "07fwck08fb0725dc5mslwgnxnzzg2ma3kaizycjfflhfml2wkjm8" - }, - { - "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna-platform/5.12.0/jna-platform-5.12.0.pom", - "sha256": "1nsxfy915a1a87843q962pdlgm4dm04k7lgfxjk62wzc9ys01ngw" + "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna-platform/5.19.0/jna-platform-5.19.0.jar", + "sha256": "0w4qancvhkd65dkgg4f4fa65mj4cgw2cxkbfp5s1x71z1xxfpkj4" }, { - "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.12.0/jna-5.12.0.jar", - "sha256": "1dwp2a3blsspsqf8jsks509aj9fci9z2bgsfymddjs2cp7v14qgr" + "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna-platform/5.19.0/jna-platform-5.19.0.pom", + "sha256": "06ks5fr87zjbj9gdry9k35swmx9clcvrdf0lhc3x0s48slxl3rp2" }, { - "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.12.0/jna-5.12.0.pom", - "sha256": "04rkdd4n24f077i6mcbmqnk9zgnjbjir1bvh8xjwk0g0fw4msjqm" + "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.19.0/jna-5.19.0.jar", + "sha256": "0fmzk4rjy34ckw8vlzl1jbf20asf0c306ayvzcdsmn1gkvrj4lgh" }, { - "url": "https://repo1.maven.org/maven2/net/java/jvnet-parent/3/jvnet-parent-3.pom", - "sha256": "0nj7958drckwf634cw9gmwgmdi302bya7bas16bbzp9rzag7ix9h" + "url": "https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.19.0/jna-5.19.0.pom", + "sha256": "06lsp1pvwcza1q7fzrphvwx690p13v6siqg4vj1k64wrszmh4zqa" }, { "url": "https://repo1.maven.org/maven2/net/openhft/java-parent-pom/1.1.28/java-parent-pom-1.1.28.pom", @@ -1162,72 +1186,52 @@ "sha256": "1ilgfbyir6d670v97x564cmdsi1lfhlbm7gv3bw5adhrcdlljj89" }, { - "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-launcher/1.10.14/ant-launcher-1.10.14.jar", - "sha256": "19aiv6a1ncr6zy0wbszpw9n51gvs6icbbyzki0w3jkm2lwjrg47h" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-launcher/1.10.14/ant-launcher-1.10.14.pom", - "sha256": "1g1b9gfhhpc59ys4bld6lnm10md3i31m4dkrarrp1sz94d0sm7cw" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-parent/1.10.14/ant-parent-1.10.14.pom", - "sha256": "07rk3dkbb7hlwny12z6syyxg9n6brxi1bwmfxxqc5237c1m105h8" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/ant/ant/1.10.14/ant-1.10.14.jar", - "sha256": "0wcnirn9a76sh6ab6y3dn69zz41wqjs5v8frc4nh9hg47ljdkfsc" + "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-launcher/1.10.17/ant-launcher-1.10.17.jar", + "sha256": "0s45mv89zx14lnvvkkvw7q6d5nszyl1s1w84h1ird8qfvh3dxqzn" }, { - "url": "https://repo1.maven.org/maven2/org/apache/ant/ant/1.10.14/ant-1.10.14.pom", - "sha256": "0p7n7bp3zpycjha9iznh9hhbnvbn9ac9vrl8i8x74i8wdfg2d91g" + "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-launcher/1.10.17/ant-launcher-1.10.17.pom", + "sha256": "1kayg4472zqnqdp7g73dr107vgqzv7qmk0y4x9qrxg8kbnivc07l" }, { - "url": "https://repo1.maven.org/maven2/org/apache/apache/13/apache-13.pom", - "sha256": "07c4yg52q1qiz2b982pcsiwf9ahmpil4jy7lpqvi5m0z6sq3slgz" + "url": "https://repo1.maven.org/maven2/org/apache/ant/ant-parent/1.10.17/ant-parent-1.10.17.pom", + "sha256": "1r592l5aswsglmwp47a0b232a71x8rlslhw6l0s1mqdy683zg7ls" }, { - "url": "https://repo1.maven.org/maven2/org/apache/apache/23/apache-23.pom", - "sha256": "07298c2jfkdb6gaxidig2pnl0ckdjfi9qqy5z9vnbwr30r76445w" + "url": "https://repo1.maven.org/maven2/org/apache/ant/ant/1.10.17/ant-1.10.17.jar", + "sha256": "1112kfnrq4np2v5kpqigykyl48bramkdw76jld3imx1p53h95rlb" }, { - "url": "https://repo1.maven.org/maven2/org/apache/apache/30/apache-30.pom", - "sha256": "1n1rhakhvwwps7rcgjq0sb43n90xliqkis7g2jrzq3cw78wlmpb3" + "url": "https://repo1.maven.org/maven2/org/apache/ant/ant/1.10.17/ant-1.10.17.pom", + "sha256": "0ay6ixv0ajbbddvdp5idqpwz60hvh7sydrxacxlxgz70wa4fp9k3" }, { - "url": "https://repo1.maven.org/maven2/org/apache/apache/31/apache-31.pom", - "sha256": "1k4ic8p4a92j8jnywmfk6r0igy78h69pp4i4z6pl5h39mag0qpam" + "url": "https://repo1.maven.org/maven2/org/apache/apache/35/apache-35.pom", + "sha256": "1f180w05f61bcxfv4pbckz32yv57a86j6c5nx2wfhdj1276psaga" }, { "url": "https://repo1.maven.org/maven2/org/apache/apache/6/apache-6.pom", "sha256": "1csvz8bb2l6fk5ks4gqj0mjmiaczb010km0b5lv0rx0kdq4vbv8j" }, { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-compress/1.26.1/commons-compress-1.26.1.jar", - "sha256": "015v301vbf3ac9yxkfvckxg72mzhglj401aabchbffvwyd05vfr7" + "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-compress/1.28.0/commons-compress-1.28.0.jar", + "sha256": "1jj28qd19wkqsfkxn68m49kd8jyff3ylmg1rk9jg6ml4452jjlp1" }, { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-compress/1.26.1/commons-compress-1.26.1.pom", - "sha256": "0vmh8m4p19x8vik6xk8j4yvczrb2il91krnz80w6zjc83l18li2z" + "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-compress/1.28.0/commons-compress-1.28.0.pom", + "sha256": "0jn6y2nsgknnd1nan4mzrs9469jaq57rgbcfxg88inijsrw4qgq3" }, { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar", - "sha256": "0g71vlcc83l9vzyj6id3dxcm218f4yn5jma6pjssnjc9wqzbz5kv" + "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.18.0/commons-lang3-3.18.0.jar", + "sha256": "08074gh9l34gqr2xrkbi2mcar0ykmmcc2ph19fvany60426sxvjf" }, { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.pom", - "sha256": "17p7m1ij6nnw26fy08d3idsqqylcwcb71y3bj27w4dxd7f33h10i" + "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/3.18.0/commons-lang3-3.18.0.pom", + "sha256": "05lax6986q9rshgr5aayn9pbi2ia0wdk71nakny4cvbg7cvln9da" }, { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-parent/64/commons-parent-64.pom", - "sha256": "1nyd5j77k69j2fc3jrrm2bxamvsp42cnys9lsx77ndg8jj4n66bg" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-parent/65/commons-parent-65.pom", - "sha256": "0vp3ydd8srs5vic36vdhkwi3zyq8b7xh6bkz5cx93b76q9glkwvc" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-parent/66/commons-parent-66.pom", - "sha256": "176vm1r1v8l2h0m4igk3pf2f7y80agkn7lb0hr01ysz58v46vza8" + "url": "https://repo1.maven.org/maven2/org/apache/commons/commons-parent/85/commons-parent-85.pom", + "sha256": "1df7dn90ifnm7nbixbrnkcb6dy9axlkz5riibnv6ps9700ngz2fi" }, { "url": "https://repo1.maven.org/maven2/org/apache/geronimo/genesis/genesis-default-flava/2.0/genesis-default-flava-2.0.pom", @@ -1242,64 +1246,40 @@ "sha256": "1rns4dljk93pwg2j45yfrsf24b3291bfw23ksxc12qrlsjfjzvdr" }, { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.3.5/httpclient-4.3.5.jar", - "sha256": "1ipqxr7qsin53q1j6xw5aa3kqciv2fxazmm9axzkzpl5rqdm515i" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.3.5/httpclient-4.3.5.pom", - "sha256": "06i4wzqw3av62dl24cmlfr7i5d6jw4yznvpsiml8lvqflqavk7r2" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcomponents-client/4.3.5/httpcomponents-client-4.3.5.pom", - "sha256": "0i1613qibifvq3l06c3cdx844w7rc59nkv04f59jr1dcrgvlmn8w" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcomponents-core/4.3.2/httpcomponents-core-4.3.2.pom", - "sha256": "01hqbi67cckbfsqxbdxdsskb0gbhm1g68x9r7bz1q5njglfc6gma" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar", - "sha256": "12sjy6s54xgcjjnb6rbqrylj1vvb601czr6sak88jvrmw8h27l5b" + "url": "https://repo1.maven.org/maven2/org/apache/groovy/groovy-bom/4.0.27/groovy-bom-4.0.27.pom", + "sha256": "1yznz54562c4kbadkd3s8fiym3fq92rx4l74nckivrnzpx9fni5a" }, { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.pom", - "sha256": "12gl5lp47w0wx5wx7xg5rg7jym6p8g2rb2rn43w6limr8mgk7rfz" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.25.4/log4j-api-2.25.4.jar", + "sha256": "079lmxnhjg3v7r1sf2jav08pfb7b8ww0wzhivqam49s7y2kl5dn4" }, { - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/project/7/project-7.pom", - "sha256": "1hs8m8cgyypd6vb882zjyns58nhzrkm7cpbb0kg5i9amhm1blvix" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.25.4/log4j-api-2.25.4.pom", + "sha256": "0mbbw2s47585qcp2bi5z3lxrza1y50prf25x398fpnrh2kd46fll" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.17.1/log4j-api-2.17.1.jar", - "sha256": "1r0x2sb93rm97lh515a17iwx9rmh0ckq5580in4b3f2gmg4a9n5h" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-bom/2.25.4/log4j-bom-2.25.4.pom", + "sha256": "0m5fld0fv7fm1cm5wqmap51kgmi4zj8342qlw488cak3zcwx64vk" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.17.1/log4j-api-2.17.1.pom", - "sha256": "12p8q4lzjhmv052wr4x1j4zr93dib2qji5q9mq8bwa8b4brwwahy" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.25.4/log4j-core-2.25.4.jar", + "sha256": "1rh0683qh6x9c7dp66186awzf22m57v19lvmxr2glm2x930f5zhh" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.17.1/log4j-core-2.17.1.jar", - "sha256": "0h9x3x8vi34z1w5vzg8rgkpd67x0m3wwg9wl9qvbk03r90iz4ry9" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.25.4/log4j-core-2.25.4.pom", + "sha256": "1ajn7bh7whcf8ywbm17j82i8wrp3wmnxggy59jjayvcnfky77h1l" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.17.1/log4j-core-2.17.1.pom", - "sha256": "1zdak1z5qvvjjcmv1qr6zfh19bqhjk12f29w7kx1nanjskv3pfqb" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j-impl/2.25.4/log4j-slf4j-impl-2.25.4.jar", + "sha256": "1anvbnlvcfwlv9nisp0vnyka5w2318vpjv8iips58gf8zh58miks" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j-impl/2.17.1/log4j-slf4j-impl-2.17.1.jar", - "sha256": "07p4g3ximiddimq21v5hl0l5wj08kpd3a1jkq84n01ymwlh3g879" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j-impl/2.25.4/log4j-slf4j-impl-2.25.4.pom", + "sha256": "07q0bmfdvckn1yg3m4knzkz2h0nr3bq8wf3gmr3ln5lxqb22rq64" }, { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j-impl/2.17.1/log4j-slf4j-impl-2.17.1.pom", - "sha256": "00nyap7qpjz69c45hy0vb4kk925bvi8d0dm8mspc4q0spgn2nh2k" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j/2.17.1/log4j-2.17.1.pom", - "sha256": "02bbh1qgmlj0shklpy3m2gv0yvxdx7srlm9md44wpaa38h1bqyln" - }, - { - "url": "https://repo1.maven.org/maven2/org/apache/logging/logging-parent/3/logging-parent-3.pom", - "sha256": "19r2fzjl4whkrv132fmz62zbk1904sqn9hmnvrhl2k89p312wfkn" + "url": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j/2.25.4/log4j-2.25.4.pom", + "sha256": "1v0d02izbk6pmwxk27ms04j670xfkmhl0dsxqc2gld4fl828kbpq" }, { "url": "https://repo1.maven.org/maven2/org/apache/xbean/xbean-reflect/3.7/xbean-reflect-3.7.jar", @@ -1314,20 +1294,28 @@ "sha256": "1drsqdldqfk529grmgjpshjxa1f72lr1yyh9r4pvc144vqr72wga" }, { - "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.69/bcpg-jdk15on-1.69.jar", - "sha256": "0d1ryqk9w4jwnccirnjsa3r26j2af4z3skz3jh0d064mzpvlz653" + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk18on/1.78.1/bcpg-jdk18on-1.78.1.jar", + "sha256": "0bbrd2dxi3k08j7r3x3pn9dkc4bpw943znav9zirlzkkp0nbnqql" + }, + { + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk18on/1.78.1/bcpg-jdk18on-1.78.1.pom", + "sha256": "0hbjwc9qzcd69jj8qb3c1lpxagwkx9pigwikyjcykpn7bynj7p2v" }, { - "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.69/bcpg-jdk15on-1.69.pom", - "sha256": "0kxdkyv255vk23m8aivw9ilqzs5ym45c0p6wd5wfg7ss77z808ns" + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk18on/1.78.1/bcprov-jdk18on-1.78.1.jar", + "sha256": "1mq2ny0g4fzbdqw1zwn1m0v8aj6645g8mzg16rcapingd9g93mdd" }, { - "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.69/bcprov-jdk15on-1.69.jar", - "sha256": "14m2cfbzmay7i6bvvmd93naj4ah2zw1i0qq2c0jrz69nz4wvssg4" + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk18on/1.78.1/bcprov-jdk18on-1.78.1.pom", + "sha256": "1ljf416s4i7r921wpjgig0ggkzvgpbjznwa374f19vcykw9jv498" }, { - "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.69/bcprov-jdk15on-1.69.pom", - "sha256": "1pkqpakran05h6jis920yg7z4k6jmjfh5cz6dagfgg4m8iqy50gx" + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcutil-jdk18on/1.78.1/bcutil-jdk18on-1.78.1.jar", + "sha256": "0bqxbajaz6rz03hvyfmxymxrhyhksz2p97cdpkiiqxhgggwmdynr" + }, + { + "url": "https://repo1.maven.org/maven2/org/bouncycastle/bcutil-jdk18on/1.78.1/bcutil-jdk18on-1.78.1.pom", + "sha256": "04jkzbkyw02mympbw9p468qr4li1vi3vfdm4nn4wihn48p5ma7bl" }, { "url": "https://repo1.maven.org/maven2/org/checkerframework/checker-qual/3.4.1/checker-qual-3.4.1.jar", @@ -1338,12 +1326,12 @@ "sha256": "0x4k1glx55qppc9cnri07va54fd20wg3hmgvj4s7rdrgjg8c0wc1" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-archiver/4.9.0/plexus-archiver-4.9.0.jar", - "sha256": "0gm0d1mf9jarz4llx1kpvhsmk9djlm007pna97qhqqgq0gnpwk89" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-archiver/4.10.0/plexus-archiver-4.10.0.jar", + "sha256": "16kr6mjsfqzdcrmarz37fiijakxa70gblaz8kacrk4d3yi7q21sc" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-archiver/4.9.0/plexus-archiver-4.9.0.pom", - "sha256": "002azqcifgwpfk6y15l1pc8x88cj0lqgjr5c8zwvc1bchhvpl47z" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-archiver/4.10.0/plexus-archiver-4.10.0.pom", + "sha256": "1jg6mh2lrk83j3zr2c0lsrp77j8vwwcsyvagnww3v99xscgh1jlk" }, { "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-classworlds/2.6.0/plexus-classworlds-2.6.0.jar", @@ -1366,32 +1354,28 @@ "sha256": "0g43m8a24620ammdgdjc19j6fkzyzjrx9npwjqjvms3dpjaawaf1" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-io/3.4.1/plexus-io-3.4.1.jar", - "sha256": "1i621m8m3wa7g38ryckvpihcbkzi13gqznsdc1b8dyb855yf0fxc" - }, - { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-io/3.4.1/plexus-io-3.4.1.pom", - "sha256": "11ws5dcx9rvf5xi1izp430zhrcddr7s8y6jc1m6lpj7f1dx09mkw" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-io/3.5.0/plexus-io-3.5.0.jar", + "sha256": "0gka0chxlppfajpvhpgd8jy1k661w04l0b0i533f86ng2a4x4pln" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-utils/4.0.0/plexus-utils-4.0.0.jar", - "sha256": "02ysq5l3vn0lb0pswwrc0zz2rlk25pr7a60zd665qvlcnh1xf317" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-io/3.5.0/plexus-io-3.5.0.pom", + "sha256": "1n69ik94d7684h08nvv3zx247wg1wklff6fy96k4pgx04g96rf7m" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-utils/4.0.0/plexus-utils-4.0.0.pom", - "sha256": "0aa1v823zz1isb26b2bq35852s4wyjnw6bpxzxxhxskd89wm8k54" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-utils/4.0.1/plexus-utils-4.0.1.jar", + "sha256": "12zs28cy95fdjigvdg1azfs3d1vn9vaf4x2rcg8d54ci8d2crfcn" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/10/plexus-10.pom", - "sha256": "07rhc9hrzsc85j3wp25w6az2vz2axg3irk4prqra372b0qhwbadv" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus-utils/4.0.1/plexus-utils-4.0.1.pom", + "sha256": "1hcc6wz6xrdn0jr6msc1bxb54k0xkjxfp0acci1axsyibjlkahmw" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/13/plexus-13.pom", - "sha256": "1lxbappkpngcsfcvnr8q1a9a6b6jbc9ykfnm0gmncv4n13f4anap" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/17/plexus-17.pom", + "sha256": "19zfgnyh8c52a0fy6s5q2i22hvlirfzl3g95pc7zbir7cgk6wlli" }, { - "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/15/plexus-15.pom", - "sha256": "07f2i6kdlrx8zij86r6b2b0y24hs7i3i02bpqq2hyjk6ffz0zp4n" + "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/18/plexus-18.pom", + "sha256": "1zfq7lya61jq8pf5ccr5758jn22gqjsgqyfh92yfbfchi2ffhgml" }, { "url": "https://repo1.maven.org/maven2/org/codehaus/plexus/plexus/5.1/plexus-5.1.pom", @@ -1402,61 +1386,25 @@ "sha256": "15qkv5dpqghvxzrly085kmikhva2lk50893i434m9pcq0hhg4zvk" }, { - "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit-parent/5.13.1.202206130422-r/org.eclipse.jgit-parent-5.13.1.202206130422-r.pom", - "sha256": "10xwy6ga3fmigjy4fspa7zp5akrxvhc4j5pfhmvsai4gxmvgqrfy" + "url": "https://repo1.maven.org/maven2/org/eclipse/ee4j/project/1.0.7/project-1.0.7.pom", + "sha256": "01gi321g5j0d4s2byxdxa2awz8xk7r44mc5yxxb3bbnb8ad06p10" }, { - "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/5.13.1.202206130422-r/org.eclipse.jgit-5.13.1.202206130422-r.jar", - "sha256": "0p05nlp24nxs2sa7andnvil3yhv5axg9y281cx4w2mcfdid3cyfn" + "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit-parent/5.13.3.202401111512-r/org.eclipse.jgit-parent-5.13.3.202401111512-r.pom", + "sha256": "1jcw9dm119y51dfmd1kyvpcbc99kgb77z050kd1n40h53kcpkxw7" }, { - "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/5.13.1.202206130422-r/org.eclipse.jgit-5.13.1.202206130422-r.pom", - "sha256": "05c5hx4w1ay8q7xy5l2gln9rkn0jm6r4zlmnwbm7hrckkjzvxi1w" + "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/5.13.3.202401111512-r/org.eclipse.jgit-5.13.3.202401111512-r.jar", + "sha256": "1fziyw7dg116xw504njg2990zhhha7ddr8f2z1wc76q8ig179jys" }, { - "url": "https://repo1.maven.org/maven2/org/fusesource/fusesource-pom/1.11/fusesource-pom-1.11.pom", - "sha256": "1f4a3vpmb40rc328wljavfli0dqgjwjh87rx7d03vpikbqrvhpb9" + "url": "https://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/5.13.3.202401111512-r/org.eclipse.jgit-5.13.3.202401111512-r.pom", + "sha256": "1pwnazw2gnwrm1x0pzwjcx8q4lppjww8ig2azmy0qj1pdgpsfxla" }, { "url": "https://repo1.maven.org/maven2/org/fusesource/fusesource-pom/1.12/fusesource-pom-1.12.pom", "sha256": "18fczpk892xjn3yghs1iwnszykxczdx6bimiq40ppvywm86rc3f4" }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/fusesource-pom/1.9/fusesource-pom-1.9.pom", - "sha256": "0qq3qrc75wz8iilmwsybhqp4qpj336dp0x3bgyrb5swis1yjq0k9" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/hawtbuf/hawtbuf-project/1.9/hawtbuf-project-1.9.pom", - "sha256": "1yjca5xk7wvf5g7jkkym09qcib7f1sk45iiy7ilpmlrrdz3hl5ph" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/hawtbuf/hawtbuf-proto/1.9/hawtbuf-proto-1.9.jar", - "sha256": "1g509kxdx328nbhy6psqphj5mjwsk9wv2y09zm0qfkmm916c2cbv" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/hawtbuf/hawtbuf-proto/1.9/hawtbuf-proto-1.9.pom", - "sha256": "1mq8sp0rrkls1ylhvzrb4jvqhv8cs8mi58i87ssiyzrbf5hrfhaq" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/hawtbuf/hawtbuf/1.9/hawtbuf-1.9.jar", - "sha256": "11hd6vjzg2w4npbqbdm2r5xm09hiqxvv5i3rxxzdvsz921kchmx0" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/hawtbuf/hawtbuf/1.9/hawtbuf-1.9.pom", - "sha256": "1g7an5rn5s4gpf4n5sz758yrbrbvml5aaqfmw9mxpvfwl38gz23x" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/jansi/jansi-project/1.18/jansi-project-1.18.pom", - "sha256": "03c0kqxmgvfs7jnp75pv89a0fh9cbmd668asj9cm87zx7j4kdj02" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/jansi/jansi/1.18/jansi-1.18.jar", - "sha256": "16r2ynk177hk26zjrcwxnfiv01zifsfp0m6n7cd7lz3ncpy697hh" - }, - { - "url": "https://repo1.maven.org/maven2/org/fusesource/jansi/jansi/1.18/jansi-1.18.pom", - "sha256": "0jzvcni37vlgf7q341ffb6754505fzdji52qh5ppnk9a5jf633yx" - }, { "url": "https://repo1.maven.org/maven2/org/fusesource/jansi/jansi/2.4.1/jansi-2.4.1.jar", "sha256": "1hfwq8dk1wjwpsxw7hxlvvf652rgsscz19kapmmzm3y5kmd7fpif" @@ -1465,14 +1413,6 @@ "url": "https://repo1.maven.org/maven2/org/fusesource/jansi/jansi/2.4.1/jansi-2.4.1.pom", "sha256": "0fxvn4z0gkf3gkgi23jivzx6llg79arh9c7vg63rymfkliwxk61z" }, - { - "url": "https://repo1.maven.org/maven2/org/iq80/snappy/snappy/0.4/snappy-0.4.jar", - "sha256": "1jds257xy63x383vb1dziafv9ziq0z9f9xhz7q3ddsaca1ywi826" - }, - { - "url": "https://repo1.maven.org/maven2/org/iq80/snappy/snappy/0.4/snappy-0.4.pom", - "sha256": "12nh4mxvlsj3q3s2wjzcb8ssim8c9rj9allsnzcljh8y24bww2d7" - }, { "url": "https://repo1.maven.org/maven2/org/jline/jline-builtins/3.27.1/jline-builtins-3.27.1.jar", "sha256": "11wdim6bbmx4f8vbjv96ni8abzkv1n1w21mnbvz2m06w06968nid" @@ -1493,6 +1433,14 @@ "url": "https://repo1.maven.org/maven2/org/jline/jline-parent/3.27.1/jline-parent-3.27.1.pom", "sha256": "108maqyh9szm5z4fxrp30mq38l30inh3gmwdnkfj9ki0dh3zsr79" }, + { + "url": "https://repo1.maven.org/maven2/org/jline/jline-parent/3.29.0/jline-parent-3.29.0.pom", + "sha256": "13ljg5ny6hd6mq9b19ariqif0pawwc0c75mi0fpdg9vhv39yy8kr" + }, + { + "url": "https://repo1.maven.org/maven2/org/jline/jline-parent/4.4.5/jline-parent-4.4.5.pom", + "sha256": "0lvngg0bcim3ba2pb48kh4ajl0qiy3g90v1yir0zxnp8w633z7yq" + }, { "url": "https://repo1.maven.org/maven2/org/jline/jline-reader/3.27.1/jline-reader-3.27.1.jar", "sha256": "0y256g9zrf322ycl58z5y7xgky4cjgv7jcq19shby1dxma0b3pgy" @@ -1526,44 +1474,52 @@ "sha256": "15yj6hzy5hmsc0c2sggb8z35vypz63nzhhv4g5wsz9ifladffy44" }, { - "url": "https://repo1.maven.org/maven2/org/jline/jline/3.26.3/jline-3.26.3.jar", - "sha256": "1gajplr3sr4ylb9cllmqwp4kqwv701nsyv5axg6mhiii971iccgj" + "url": "https://repo1.maven.org/maven2/org/jline/jline/3.29.0/jline-3.29.0-jdk8.jar", + "sha256": "0168g6xq2r9d1k02nziq2dbhj9byyz00j312kwvrbps2fr4809pd" }, { - "url": "https://repo1.maven.org/maven2/org/jline/jline/3.26.3/jline-3.26.3.pom", - "sha256": "18lhdplknihyikkz0dpig43mrfgmqkb3b2kn6vysy91yy025gyxa" + "url": "https://repo1.maven.org/maven2/org/jline/jline/3.29.0/jline-3.29.0.pom", + "sha256": "0w3a2ayis8vwbv5qwm9q61fhj3i82gvd9mjkqfjiyyh0k053jh6y" }, { - "url": "https://repo1.maven.org/maven2/org/jline/jline/3.27.1/jline-3.27.1-jdk8.jar", - "sha256": "1c2xak5l8am75bax874sjshb1z89vy8dq4g7w9flajki7pzml5lx" + "url": "https://repo1.maven.org/maven2/org/jline/jline/4.4.5/jline-4.4.5.jar", + "sha256": "034wgyhw10psanpiz1b0ml68b1i0xb2vrl0x3kajim50727vbn4d" }, { - "url": "https://repo1.maven.org/maven2/org/jline/jline/3.27.1/jline-3.27.1.pom", - "sha256": "0rxd52c0g8bpx440zaxw9lvjgjbkzvdyg2rcl2qxwicgdlvmjd9z" + "url": "https://repo1.maven.org/maven2/org/jline/jline/4.4.5/jline-4.4.5.pom", + "sha256": "01v8p6ah760zsy89r314vknh7w1krh8k8x3cr8ysylkah04abnk1" }, { - "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.10.0/junit-bom-5.10.0.pom", - "sha256": "1crhinxwbf70waclyiavgmk6bgnqbvz15frjzisppz7rjj4ds1p0" + "url": "https://repo1.maven.org/maven2/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar", + "sha256": "1azvzpm53qwcqq6bnxz4dzdgvj6dw6dd8a9psgj82xsmwxmnxb8z" }, { - "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.10.1/junit-bom-5.10.1.pom", - "sha256": "1wn3gcrspfigcz4g3dj6ian01hc5iabj0jzzfyahc82bdwlb1i11" + "url": "https://repo1.maven.org/maven2/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.pom", + "sha256": "19ik8hran7c46lmw72rp1sb6b17y1lnmw309s91iy8cm7fd95ayd" }, { "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.10.2/junit-bom-5.10.2.pom", "sha256": "0s4r7bjcs9072a9gm2d92hy9zzib55icqn76zw655xmhlh2dk78n" }, { - "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.9.3/junit-bom-5.9.3.pom", - "sha256": "1mrf6kf0y6bic98n30w8s9gnxykgzv5j8mx1bh745wkjkv6jj0sd" + "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.13.1/junit-bom-5.13.1.pom", + "sha256": "1wv578305gnn94gsa0m6cpkabxq5d9dxflgy7za2wmrhm0g4as7s" }, { - "url": "https://repo1.maven.org/maven2/org/msgpack/msgpack-core/0.9.6/msgpack-core-0.9.6.jar", - "sha256": "1vh0clrr8iibhgr5zy8zh2y1h7a3pnd5bjq655k8napmzpwi30ba" + "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.13.2/junit-bom-5.13.2.pom", + "sha256": "0jabffd1dp1q4abz63z0icf017y214gd8lcp5avz8kpxnd7i1ca3" }, { - "url": "https://repo1.maven.org/maven2/org/msgpack/msgpack-core/0.9.6/msgpack-core-0.9.6.pom", - "sha256": "038mpm09kr0sxd9b539dnfa7k69flkvly2wnkxq1r9cdfdlgxhim" + "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/5.8.0-M1/junit-bom-5.8.0-M1.pom", + "sha256": "1qnndyikv81s2q43rqg4q37k446r36b0dc8ag65dk2y6zwr4853p" + }, + { + "url": "https://repo1.maven.org/maven2/org/junit/junit-bom/6.0.3/junit-bom-6.0.3.pom", + "sha256": "11q2ackhwdq5amcpky6wdffsrbp8qz01aazvj6ybxl80vjjvcmd6" + }, + { + "url": "https://repo1.maven.org/maven2/org/mockito/mockito-bom/4.11.0/mockito-bom-4.11.0.pom", + "sha256": "1nli2kb2f3afaagdzi9yk7m6l025m7g8q62zxml7z3wqdis1llyq" }, { "url": "https://repo1.maven.org/maven2/org/portable-scala/portable-scala-reflect_sjs1_2.13/1.1.3/portable-scala-reflect_sjs1_2.13-1.1.3.jar", @@ -1574,12 +1530,12 @@ "sha256": "0d2k2j1p4lap87ydfpbspzvas8hpaws8v4z828pi83hgwmhfi5p3" }, { - "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-crossproject_2.12_1.0/1.3.2/sbt-crossproject_2.12_1.0-1.3.2.jar", - "sha256": "123j9lhv03gimvz7f00160zsshy22xzbnmfhcwdzkvqaqxgvaq7l" + "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-crossproject_2.12_1.0/1.4.0/sbt-crossproject_2.12_1.0-1.4.0.jar", + "sha256": "1kx8wp65vzgz3njph6g58fs1vjmhff52p5xxxam789wkf1j593j1" }, { - "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-crossproject_2.12_1.0/1.3.2/sbt-crossproject_2.12_1.0-1.3.2.pom", - "sha256": "12v127n33ij4alkdaf35hyr2ih3kfxv7zwnr21j647icgir4ldz0" + "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-crossproject_2.12_1.0/1.4.0/sbt-crossproject_2.12_1.0-1.4.0.pom", + "sha256": "11md8hpx9axidwjg9amijgxvgmjzia0v12qj8877pzqlgx1498xb" }, { "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-platform-deps_2.12_1.0/1.0.2/sbt-platform-deps_2.12_1.0-1.0.2.jar", @@ -1590,12 +1546,12 @@ "sha256": "1qqimp1fzbw16amq39rs3iw80136dig6k2rkrjbi44jfc4qglkkh" }, { - "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-scalajs-crossproject_2.12_1.0/1.3.2/sbt-scalajs-crossproject_2.12_1.0-1.3.2.jar", - "sha256": "1bv712ywbp3cf085xbiql6jj5j4fysxgxd96m4w0h86hh5yx6gsd" + "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-scalajs-crossproject_2.12_1.0/1.4.0/sbt-scalajs-crossproject_2.12_1.0-1.4.0.jar", + "sha256": "0cab3rdpp0spnp14jaxfq1g2943p3c561x5rsfzbssqnc0msg70p" }, { - "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-scalajs-crossproject_2.12_1.0/1.3.2/sbt-scalajs-crossproject_2.12_1.0-1.3.2.pom", - "sha256": "04a12lvipzan03m89ayf27jil6nv0gd1rr7qjskicrg8lw2mcqjw" + "url": "https://repo1.maven.org/maven2/org/portable-scala/sbt-scalajs-crossproject_2.12_1.0/1.4.0/sbt-scalajs-crossproject_2.12_1.0-1.4.0.pom", + "sha256": "0nc8nhfbq2y4zw96wgmz31v62ygywd1rmnrh6hhy1l01kalmv0p2" }, { "url": "https://repo1.maven.org/maven2/org/reactivestreams/reactive-streams/1.0.2/reactive-streams-1.0.2.jar", @@ -1614,124 +1570,124 @@ "sha256": "00n6a4yqaycxlf1dhn0sdsh7ag3vz47m21ybyqrph9glfmqldvfc" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/sbt-scalajs_2.12_1.0/1.19.0/sbt-scalajs_2.12_1.0-1.19.0.jar", - "sha256": "0wdnbx0cx3lm0xffwpzhm4vwpvvq04mkg86yvgwnj9h5ir1wi5b7" + "url": "https://repo1.maven.org/maven2/org/scala-js/sbt-scalajs_2.12_1.0/1.22.0/sbt-scalajs_2.12_1.0-1.22.0.jar", + "sha256": "1ynfaysvd1krhpbxz6zcish60a4frjlqwr2l5zzgcy64x7j5qxgk" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/sbt-scalajs_2.12_1.0/1.19.0/sbt-scalajs_2.12_1.0-1.19.0.pom", - "sha256": "1w8khz9639j01p4y26slvzsq03kr1h1ls6534q1391ymhdmb76i3" + "url": "https://repo1.maven.org/maven2/org/scala-js/sbt-scalajs_2.12_1.0/1.22.0/sbt-scalajs_2.12_1.0-1.22.0.pom", + "sha256": "1srwf9qp2if0h52n03z4ymg7b9lgiz9m8b1lh8xx2fag1pg6nv9k" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-compiler_2.13.16/1.19.0/scalajs-compiler_2.13.16-1.19.0.jar", - "sha256": "1mg6jawh8j29cv6ir0j51hw27cx0hq5851shlc4bqs8lff1smfrg" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-compiler_2.13.18/1.22.0/scalajs-compiler_2.13.18-1.22.0.jar", + "sha256": "14z759j8cxvqph85q9d2bpg1yl2c7fx8nv139ccsvipxgaa0c083" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-compiler_2.13.16/1.19.0/scalajs-compiler_2.13.16-1.19.0.pom", - "sha256": "187znax64zzmcil5pgb10qsmg4ji828517w7aclw2icfsw65cg8j" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-compiler_2.13.18/1.22.0/scalajs-compiler_2.13.18-1.22.0.pom", + "sha256": "08f3yrlss1skdyid09vnhlb90d6cpm6sxc9d653kwgbcjamhz5vj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-env-nodejs_2.12/1.4.0/scalajs-env-nodejs_2.12-1.4.0.jar", - "sha256": "0f2yfzgd8bqdxd3yrb4pcbry11lhpdm7lf57gfsrs63wkszqz6lf" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-env-nodejs_2.12/1.6.0/scalajs-env-nodejs_2.12-1.6.0.jar", + "sha256": "1mw7nr93lbp63fp6wrld2mfn9rzlrrqx11x8pa7dx3gg5xj88zp5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-env-nodejs_2.12/1.4.0/scalajs-env-nodejs_2.12-1.4.0.pom", - "sha256": "1k1ma7ls287ii525f20whj1k0g52gkbxf1bqwnwn55kmxqakbipr" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-env-nodejs_2.12/1.6.0/scalajs-env-nodejs_2.12-1.6.0.pom", + "sha256": "1brf0harc42jb0vcxafc9caj1m93bmjpa2p55jnnq05m45cycxb5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-ir_2.12/1.19.0/scalajs-ir_2.12-1.19.0.jar", - "sha256": "1rb87xdy0dgdckcmn5zizbi3ri49km04shixm53lk5swywayfi59" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-ir_2.12/1.22.0/scalajs-ir_2.12-1.22.0.jar", + "sha256": "0ihixijiv8m9k3jbcavd999ayyb4k608wxs5wc6jhqr433yksxyp" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-ir_2.12/1.19.0/scalajs-ir_2.12-1.19.0.pom", - "sha256": "0bsk035hbjfk6n4h3maircq9dc1vjspjwfbfgfpr5f2s4m9ymgl0" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-ir_2.12/1.22.0/scalajs-ir_2.12-1.22.0.pom", + "sha256": "11n1r91ikhpdi597zvc0fl4nnjjvjpnsal9s03773c0fcykpk3m2" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-javalib/1.19.0/scalajs-javalib-1.19.0.jar", - "sha256": "1fydnq5k71z9gfcll27prxmklimvacas7f9rd55iy343dj6lfprx" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-javalib/1.22.0/scalajs-javalib-1.22.0.jar", + "sha256": "1bwwzgsrrxqc3cksf730df5phabq1amxaazzqv36prji5jzl7p5f" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-javalib/1.19.0/scalajs-javalib-1.19.0.pom", - "sha256": "0aqj8xlxa98csl8rn2139sjdy786lxd0kpyf5cgcd9ijkgwzxrl3" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-javalib/1.22.0/scalajs-javalib-1.22.0.pom", + "sha256": "12qjbkr5kh4i1pvq9k1yqag9rv1lifl33jv5f4nzg9mr0kqvrjgw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-js-envs_2.12/1.4.0/scalajs-js-envs_2.12-1.4.0.jar", - "sha256": "19biamq6bh6j6ldmknyid02v7pbbfvr3vvhl24fi14av6qsiinn6" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-js-envs_2.12/1.6.0/scalajs-js-envs_2.12-1.6.0.jar", + "sha256": "1miwxyiz83k63s64dw0i7dxvhl0y8h3mbl0795s5lmzhg0rx6g5p" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-js-envs_2.12/1.4.0/scalajs-js-envs_2.12-1.4.0.pom", - "sha256": "0lyk39xn39lmlq5rym0m2j0mg82jd2h4zxgprivycmh9n9qaaimh" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-js-envs_2.12/1.6.0/scalajs-js-envs_2.12-1.6.0.pom", + "sha256": "1b27ll3km1d6nvxzh702p9prs7rc87dvn6q132wvcp6sqi3r2mz2" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-library_2.13/1.19.0/scalajs-library_2.13-1.19.0.jar", - "sha256": "1g9wbhvy8pmbxhsipdhv8fjx75vfxlhqwcq70y6g7sb4vn26sz51" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-library_2.13/1.22.0/scalajs-library_2.13-1.22.0.jar", + "sha256": "1pif36jii8a86gpig4dfzginx2hm5b64vq79x653naln8najklmj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-library_2.13/1.19.0/scalajs-library_2.13-1.19.0.pom", - "sha256": "0k5vm1yw6dx73zi9kribd4sfklkqf9aa2am4a25dnhi9k7nknawm" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-library_2.13/1.22.0/scalajs-library_2.13-1.22.0.pom", + "sha256": "0bw6ignws55m5pywpx861w19rcsb8qpwc4zb2njmcalr2a9di2g5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-linker-interface_2.12/1.19.0/scalajs-linker-interface_2.12-1.19.0.jar", - "sha256": "1a713pljmrzsyfmx8k3gy8aha6gwmm1s3wx64ghvsic5bykk4vhq" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-linker-interface_2.12/1.22.0/scalajs-linker-interface_2.12-1.22.0.jar", + "sha256": "0l865n319v98ibgpsmb9k735zca3xjn1z2ap6wjdmya27kr0p2hg" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-linker-interface_2.12/1.19.0/scalajs-linker-interface_2.12-1.19.0.pom", - "sha256": "0hnjw94pp649l59s7wac979zkl1n3rwds3clfz7yzs1czk3yvg9d" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-linker-interface_2.12/1.22.0/scalajs-linker-interface_2.12-1.22.0.pom", + "sha256": "1zj51ch5cxz7cnd7dfc026p6fda82yihl38si29dmvrfrvmcng0k" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-logging_2.12/1.1.1/scalajs-logging_2.12-1.1.1.jar", - "sha256": "1hk395i0vcqh0gwygpzpsb7iq42jw7w8r0nx2ym8g01kzz52x7f5" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-logging_2.12/1.2.0/scalajs-logging_2.12-1.2.0.jar", + "sha256": "0ij39l7sz6pa3ilcnn9npw2j0w2ip74iw2mc3fzm85nmlw86ji8m" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-logging_2.12/1.1.1/scalajs-logging_2.12-1.1.1.pom", - "sha256": "0im3b34y67hw2jdh1hm4v0hbc9b3bwx74z3g9djpv8hqsqy4adzl" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-logging_2.12/1.2.0/scalajs-logging_2.12-1.2.0.pom", + "sha256": "1agdw8wrmmm8hq808795lmzrc2w9v9h4rlz0w5alpim77p7aavqf" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-sbt-test-adapter_2.12/1.19.0/scalajs-sbt-test-adapter_2.12-1.19.0.jar", - "sha256": "1lz2g780yjqcxkgmalwcasvx0wai35fawrbg0bzgiw5183y63px9" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-sbt-test-adapter_2.12/1.22.0/scalajs-sbt-test-adapter_2.12-1.22.0.jar", + "sha256": "1g3bpn72b7jmqvrqcmhf02gbmy67xlhdd5mlxdh4wbs2596crxbj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-sbt-test-adapter_2.12/1.19.0/scalajs-sbt-test-adapter_2.12-1.19.0.pom", - "sha256": "02ppw0x9z98dddw1y031q6dvm6sd4sdvylvwkw9lw196b6m05q6f" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-sbt-test-adapter_2.12/1.22.0/scalajs-sbt-test-adapter_2.12-1.22.0.pom", + "sha256": "0cg59k86rkij6ljjrzyr7z08h93pf9zc6qbv7x5lk7ayby8c6y8b" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-scalalib_2.13/2.13.16%2B1.19.0/scalajs-scalalib_2.13-2.13.16%2B1.19.0.jar", - "sha256": "14i71p0b0lsqv5223ixl882yagbj1pgmk5hz4d90v53ncp0q6h0d" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-scalalib_2.13/2.13.18%2B1.22.0/scalajs-scalalib_2.13-2.13.18%2B1.22.0.jar", + "sha256": "1blxn7av6vk6lqx5cg3mfa13hv90a1zg8x7ngbbhl15sv5d0fd72" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-scalalib_2.13/2.13.16%2B1.19.0/scalajs-scalalib_2.13-2.13.16%2B1.19.0.pom", - "sha256": "0fginjwgibzkln069rc7snjjk1425kjn5a777c1yv75fgm237j6y" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-scalalib_2.13/2.13.18%2B1.22.0/scalajs-scalalib_2.13-2.13.18%2B1.22.0.pom", + "sha256": "0pf4mrvb3slsvg65rfdr0lvn10hw3kdkr5w15039lw481gh38y6n" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-bridge_2.13/1.19.0/scalajs-test-bridge_2.13-1.19.0.jar", - "sha256": "1457hyx214aq5j4walanb4l45pb4axpdwdb4118n43pmyp9jvq68" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-bridge_2.13/1.22.0/scalajs-test-bridge_2.13-1.22.0.jar", + "sha256": "15w8rvswff2gb8cyxpkxa8n5vjbbz4ji6nci2jjwicrdrwqq3w5w" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-bridge_2.13/1.19.0/scalajs-test-bridge_2.13-1.19.0.pom", - "sha256": "1i8hy81igx4fycpfnk4gbd61qg7licw34v5yl74j3mp4lsp8nx2m" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-bridge_2.13/1.22.0/scalajs-test-bridge_2.13-1.22.0.pom", + "sha256": "0qx0jvw2l33p0w39157if0wcb3g6cj0hdq7cp6ahiclv5b4a7i5i" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-interface_2.13/1.19.0/scalajs-test-interface_2.13-1.19.0.jar", - "sha256": "01l5mnjia5d9cdjpaa3g6s37v09ckms1gbw3w5mvigslyb5d44x9" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-interface_2.13/1.22.0/scalajs-test-interface_2.13-1.22.0.jar", + "sha256": "1p075janr9y32crfsl4gbx09rh8api0rr6jx63c45qzr1p03b7c6" }, { - "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-interface_2.13/1.19.0/scalajs-test-interface_2.13-1.19.0.pom", - "sha256": "0fvzvd9j15d6bi1pf5zsc25s962w5bs386i3w4qj1fnhi51z9h8f" + "url": "https://repo1.maven.org/maven2/org/scala-js/scalajs-test-interface_2.13/1.22.0/scalajs-test-interface_2.13-1.22.0.pom", + "sha256": "1v814d6c194jjlcbybp008iiw9kl5d73wjdacw8hfp36y1mkihi4" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.11.0/scala-collection-compat_2.12-2.11.0.jar", - "sha256": "0xd6kvk2pwdwn94xsndlnxh9q7nl3lc7s70vgln392hl99h71wbv" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.12.0/scala-collection-compat_2.12-2.12.0.jar", + "sha256": "1cvwqdgcai0yz6mr0p3rkrab0bb56bialw3rcs9lf7ly77jca68n" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.11.0/scala-collection-compat_2.12-2.11.0.pom", - "sha256": "11skcckwa0vfj7m32kkn4mb08v32ra9h07a8n30jdx4av5vyxkzz" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.12.0/scala-collection-compat_2.12-2.12.0.pom", + "sha256": "0jzdz4mn4wcby91bdjpqyny8dbrpaqw0l58v9f7md863g09m3hq2" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.13.0/scala-collection-compat_2.12-2.13.0.jar", - "sha256": "043ny4v403rnb281ywplc7v5dbzw48hl0gny7vyplya6pwikbsx0" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.14.0/scala-collection-compat_2.12-2.14.0.jar", + "sha256": "1rcj9bcpfh5h8w2z9ldb06pppxhzgx0gz7ah05qhm55pkmqw3ms0" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.13.0/scala-collection-compat_2.12-2.13.0.pom", - "sha256": "0ziwpj7ccjm4rgzi39npzqas2cph47mcmrapa3a86h79nf45fg3l" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.12/2.14.0/scala-collection-compat_2.12-2.14.0.pom", + "sha256": "0d37csmn6zg2byds8x26xwz7mwyf0dv3zix667w0jnis3lgj6cvx" }, { "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-collection-compat_2.13/2.13.0/scala-collection-compat_2.13-2.13.0.jar", @@ -1758,24 +1714,20 @@ "sha256": "1xd28a31hc1bfq0kaks3bm72lbwykqsrivlg5ab8r6wvydw0cl55" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_2.13/2.4.0/scala-parser-combinators_2.13-2.4.0.jar", - "sha256": "0pclw1s7f8m0jf7fmf8y34dff09rgrni57ksj1qcfjzx47fcqvg3" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_2.13/2.5.0/scala-parser-combinators_2.13-2.5.0.jar", + "sha256": "1psrdhvwlb5rd96395mci29c0z2p74jmbj8qfwx3i2jv6dvn3hgc" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_2.13/2.4.0/scala-parser-combinators_2.13-2.4.0.pom", - "sha256": "1x9z57wzcmqh260m827siyim7v96c3f3l7rfkwyx0rvw01xlb6v8" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_2.13/2.5.0/scala-parser-combinators_2.13-2.5.0.pom", + "sha256": "1lipw3851fvbbz6yj66kdrwfjdjz2c4m4bmjcs1pya99b5jfk9mr" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_sjs1_2.13/2.4.0/scala-parser-combinators_sjs1_2.13-2.4.0.jar", - "sha256": "1k8imdwai5dkcijq765l71nzm340zmfx20lrlvp7zfj3dfshk19r" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_sjs1_2.13/2.5.0/scala-parser-combinators_sjs1_2.13-2.5.0.jar", + "sha256": "1hl9b97dnijzs68pldr3r21r79vbv2dfs2ccxxw0bl1f6qgcj2vd" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_sjs1_2.13/2.4.0/scala-parser-combinators_sjs1_2.13-2.4.0.pom", - "sha256": "1q6zfq1lj4mrf9wfgzhg9j3lhfpgmp7l0piijkfscmdmv83x6yv5" - }, - { - "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.12/2.2.0/scala-xml_2.12-2.2.0.jar", - "sha256": "0bsq2lc4f74v9hi9hgbd57wl43q0g8vmpfnfkacp5sngga5zg4px" + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-parser-combinators_sjs1_2.13/2.5.0/scala-parser-combinators_sjs1_2.13-2.5.0.pom", + "sha256": "0kfr01rk22qrw5b9k1s2fg49bfdcyxcsv0h5zfhan1kyjg8wz92j" }, { "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.12/2.2.0/scala-xml_2.12-2.2.0.pom", @@ -1789,6 +1741,14 @@ "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.12/2.3.0/scala-xml_2.12-2.3.0.pom", "sha256": "0imam95qbykymqb8c4zllj4p7lckvp30g027icrycd0zi1gn9g25" }, + { + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.12/2.4.0/scala-xml_2.12-2.4.0.jar", + "sha256": "0kicn00whjx0glqc03r81c90grh62xfd17122g312ldg4lr19fml" + }, + { + "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.12/2.4.0/scala-xml_2.12-2.4.0.pom", + "sha256": "0prf380z03ffa4qdiinjgsir34qbfak5511h3hb315qhxxrf4bj7" + }, { "url": "https://repo1.maven.org/maven2/org/scala-lang/modules/scala-xml_2.13/2.1.0/scala-xml_2.13-2.1.0.jar", "sha256": "0fh53zjqwxcia35x84a98zdzw09hwlc9qsnyf12p3vhm67wwn8ni" @@ -1798,144 +1758,148 @@ "sha256": "066is8nfbnng68jj1p9mvmw7qk3hx9j8m7l6g6bcf8gxwnkq50h4" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.12.20/scala-compiler-2.12.20.jar", - "sha256": "18hzfgijpg3ajfi11lmrp8m2dzsgw3n43i56grqinwk9bkbpd1n8" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.12.21/scala-compiler-2.12.21.jar", + "sha256": "13jlnh6j2mqchb5rx0g6rp8jp79vy85fz52j7gq25sg8syqbc6zv" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.12.20/scala-compiler-2.12.20.pom", - "sha256": "18ya9jnr0v5ccdy8qfsla2fc1ndgnrm539prfdci8fs221qq6jyh" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.12.21/scala-compiler-2.12.21.pom", + "sha256": "1spazd3n25h4i6a94gbllsk7j8j15s1kbg5ygr40xdci8w638ls2" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.13.16/scala-compiler-2.13.16.jar", - "sha256": "1lsj44s9npic5kvwx80i5fwmybvfcv4g4yh8kjx23qwi8mqq56gm" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.13.18/scala-compiler-2.13.18.jar", + "sha256": "08fa5xl4h73zp87xjasxrvvlq8mnmfr4y6cj705d7ap7r8gqj59g" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.13.16/scala-compiler-2.13.16.pom", - "sha256": "1aq6xfknynd6fch7lwm8xdi6xq3qjpfpylbmj6jlwbf229dmajwm" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-compiler/2.13.18/scala-compiler-2.13.18.pom", + "sha256": "0sj8872gbwcpqmih2z1yzipy2zrzx673x63apvkxxi50zbvww0pb" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.12.20/scala-library-2.12.20.jar", - "sha256": "1jzw9hal60mjl6854hzb5av2q13g6fzv042gl8ls6cff9jc8z2jd" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.12.21/scala-library-2.12.21.jar", + "sha256": "032ib6iwcr45gpx1wi9j6s9kcjvfb7gbsf5fxb7x2kfcjl609x72" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.12.20/scala-library-2.12.20.pom", - "sha256": "1j839ahwb4apl9zrkczyjn2vcqxdvmhf2xzi6c58sk0sqns1bhwl" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.12.21/scala-library-2.12.21.pom", + "sha256": "0iv8r841xhp5lasjhzschvwkbkcpkmp0jwz2355xx3k6n6nj5jzh" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.16/scala-library-2.13.16.jar", - "sha256": "1w9hsy7ghibi36nalqpr2j2w06ahh8g1x6y1jwj05d2fkrpjpfqy" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.18/scala-library-2.13.18.jar", + "sha256": "1875qji23ns21hw92qwmishal5wr3dac68smk0kwczdwyxpxk1af" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.16/scala-library-2.13.16.pom", - "sha256": "0lv8m0dsayxkxsjffqnkak0klkfjk1r30vfqd1496c7bjsx74nxj" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-library/2.13.18/scala-library-2.13.18.pom", + "sha256": "07f26k0020azrgjbzjgfvdp6zc5q781s7sjagy2rj8z12j113yy3" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.12.20/scala-reflect-2.12.20.jar", - "sha256": "1yz1h6yb597q8wnpwrsg4grfqdjpnbmjkn9qc3m801d7qz6i86az" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.12.21/scala-reflect-2.12.21.jar", + "sha256": "050cc3s5np5ms71sh3z9r5793l38czq35rwkjxl681csls5s14cb" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.12.20/scala-reflect-2.12.20.pom", - "sha256": "15lfmxj8iqa580vcy68ia6di55798z76l3a5anapx7y4br5w8lb1" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.12.21/scala-reflect-2.12.21.pom", + "sha256": "0rs1gd5wnyln75z689yng1wd3fm2vwpxhqxra2jxivr386m3bp36" }, { "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.13.10/scala-reflect-2.13.10.pom", "sha256": "0977smq1v6r31g1d9c47nxqh8kdhmhgg87ri9sq24rmg6m0c9mbr" }, - { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.13.16/scala-reflect-2.13.16.jar", - "sha256": "07gvf14jmbbpfn4jw1bgya79smn1lchdlg4rmf348in7rbcwqjgv" - }, { "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.13.16/scala-reflect-2.13.16.pom", "sha256": "0q0vlfaczl2jihmi74n0rm324ixdvlw5pmgnwlmanxg63qzq7cb6" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala2-sbt-bridge/2.13.16/scala2-sbt-bridge-2.13.16.jar", - "sha256": "01hr24r0pvid54r4kjskp1xckgqa3zfwapbvdr5jy8n1id6zfvkp" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.13.18/scala-reflect-2.13.18.jar", + "sha256": "16z2sv3xi3q15w0gwyi744ln0bxy45lscnpijpb97b5jh8czydb9" + }, + { + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala-reflect/2.13.18/scala-reflect-2.13.18.pom", + "sha256": "0px7mgpzi0n97f52q804msvfa6iw4ak6z4dgm8684kgmd9c576h3" + }, + { + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala2-sbt-bridge/2.13.18/scala2-sbt-bridge-2.13.18.jar", + "sha256": "0qk242nr46nzpbhx956inh8b5idp18pdyagj04dr0cga5wa64gim" }, { - "url": "https://repo1.maven.org/maven2/org/scala-lang/scala2-sbt-bridge/2.13.16/scala2-sbt-bridge-2.13.16.pom", - "sha256": "18p2d2566b1dbhc9sjadv6d687w3y9w0cxyajrd5cmyslvdlsl8y" + "url": "https://repo1.maven.org/maven2/org/scala-lang/scala2-sbt-bridge/2.13.18/scala2-sbt-bridge-2.13.18.pom", + "sha256": "1j9b9g69003w4gdl04xn06drnn9h6bzjlyn37p9d7yrfyrjfjblb" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/actions_2.12/1.11.7/actions_2.12-1.11.7.jar", - "sha256": "0lcx072xlg53fga0xvz7wzmipxd6mg6y885f8nh4jignbw2v4b49" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/actions_2.12/1.13.0/actions_2.12-1.13.0.jar", + "sha256": "0jifz7y7hsavn0y9jl4m9ig7xjwci9xxnmr1fr8lynj7wpghxs4z" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/actions_2.12/1.11.7/actions_2.12-1.11.7.pom", - "sha256": "0vzr84l5hdgrpp7bvpvrfvwavdx24nsfkm60fqv7zxkvr3w8ksvq" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/actions_2.12/1.13.0/actions_2.12-1.13.0.pom", + "sha256": "0hpfdbgadxfaysn0fycygm86zn94mvmhlw1152jynn7lxn99lj6f" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/collections_2.12/1.11.7/collections_2.12-1.11.7.jar", - "sha256": "1syaxc0y30lv83b59rkz0phcdxikalwcnblvx4szylykfnq3a301" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/collections_2.12/1.13.0/collections_2.12-1.13.0.jar", + "sha256": "09z9pm9mm0v6s22dkwdqqppcha2x1xdk5g3wrm5fkwrif285j74h" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/collections_2.12/1.11.7/collections_2.12-1.11.7.pom", - "sha256": "1ccn0aq3mawnq0qbgqyimzrdagzn9ak2i72nzqhbln33djdqzv1g" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/collections_2.12/1.13.0/collections_2.12-1.13.0.pom", + "sha256": "1p5dyhvffrv51rd4c0lsvha6v4jz93vdvks86i80ancy2l80mjx7" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/command_2.12/1.11.7/command_2.12-1.11.7.jar", - "sha256": "0wxrk4szi14850pg6sg2kq6lbgm1m1gh5f1a7vy6c6c3415f9x6f" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/command_2.12/1.13.0/command_2.12-1.13.0.jar", + "sha256": "0kjrw9nsywxvzjkkmkfbv8mkgdrlxzrzkv2la2p2qxrcryxhwmvl" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/command_2.12/1.11.7/command_2.12-1.11.7.pom", - "sha256": "0f6aibav7v0dwsg1wkj6y56zxc1590lsgvay66l0c8ahbzl9wabc" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/command_2.12/1.13.0/command_2.12-1.13.0.pom", + "sha256": "1r4fsqbsi7jwqwd0fbqz2jj4rhl2p0l9ijxpmnzf14j42q1ibkxz" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-bridge_2.12/1.11.0/compiler-bridge_2.12-1.11.0.jar", - "sha256": "121m9px3970xsmcfqv2vi7r7db0v1rpnvbmhynn0n5kfsm38qqca" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-bridge_2.12/1.12.1/compiler-bridge_2.12-1.12.1.jar", + "sha256": "02ipqbrp0cd7n68b39dlpr1j60r0rzx4crdckhgillv6xysky2zm" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-bridge_2.12/1.11.0/compiler-bridge_2.12-1.11.0.pom", - "sha256": "15dqgpw6midxhp460494sw55bkq872m48f6ilqvd39pws44kcldm" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-bridge_2.12/1.12.1/compiler-bridge_2.12-1.12.1.pom", + "sha256": "0sa39r5ydvf7bfa1k168yiblmn37c7gawvlxh8wz3r867c1pfls9" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-interface/1.11.0/compiler-interface-1.11.0.jar", - "sha256": "1jfaz15b9bpg1s2g7v00c462hzwxph081djk9vk58404b83x299h" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-interface/1.12.1/compiler-interface-1.12.1.jar", + "sha256": "1a973iibm3fjcqf0xkb0kxs6zq1jm1na3gfhhbkz9xy3wvddcwc5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-interface/1.11.0/compiler-interface-1.11.0.pom", - "sha256": "09a7780vavy8myinj7hkjbzkam1h142jm3dv898ad9qq7q24vj6n" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/compiler-interface/1.12.1/compiler-interface-1.12.1.pom", + "sha256": "149ifz7d8cw1lmjc5lb4cv0f3hwj7a6lym37ldzfsyjla5c4d0qg" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/completion_2.12/1.11.7/completion_2.12-1.11.7.jar", - "sha256": "055mbkk4qm3rshgdl41wz7c898fwdf726934kp8x9963z5pnpf4f" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/completion_2.12/1.13.0/completion_2.12-1.13.0.jar", + "sha256": "0066b3gakxxm7ck8l7chqgzwsp2sg1xn90mkhy7n291cwc942piq" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/completion_2.12/1.11.7/completion_2.12-1.11.7.pom", - "sha256": "1bdqm1z34fashpnr4a3azc7w2q6q8gr5fsx92lnm0ky4h6qcr9d7" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/completion_2.12/1.13.0/completion_2.12-1.13.0.pom", + "sha256": "16h8xbvibfy90r6apdp1j0jwsbpz5klnk9iyf9g1w821b9zj0ixp" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/core-macros_2.12/1.11.7/core-macros_2.12-1.11.7.jar", - "sha256": "0jqa2h1026k683039788pip824f87hrwzv5jbz9jjhi06fw5hnwa" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/core-macros_2.12/1.13.0/core-macros_2.12-1.13.0.jar", + "sha256": "176szraw0i2j56la0m1hy3i83gyxrjkp5x8dl61zp9n7kvwdkh29" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/core-macros_2.12/1.11.7/core-macros_2.12-1.11.7.pom", - "sha256": "0882s7m8dsx8bynl6aqk9imzbs3h7xlh6656638qgnrcprgrrrzw" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/core-macros_2.12/1.13.0/core-macros_2.12-1.13.0.pom", + "sha256": "0fh2gkaj55c5pmgmshg2w2f5i7vqgcx165i2s9rp2r92hvidqnc6" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/io_2.12/1.10.5/io_2.12-1.10.5.jar", - "sha256": "02ysz4nsi28qjzip6l3r5h3amkp49fz1rxb8a0rl23436y28655z" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/io_2.12/1.13.0/io_2.12-1.13.0.jar", + "sha256": "1jw83pxv9d2ay44rhl43yfqm546blcms4swb7dw2p36q6sp06lbw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/io_2.12/1.10.5/io_2.12-1.10.5.pom", - "sha256": "04f3dpxsnk8sxakg3dzvsg4bjgpirfww1nycpqx71hpfjwbwm22i" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/io_2.12/1.13.0/io_2.12-1.13.0.pom", + "sha256": "0ansl0vccrhnannmsxrczp6h8kcqwm7h1p2mfj5zg6f4hk8cp6pw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/ipcsocket/ipcsocket/1.6.3/ipcsocket-1.6.3.jar", - "sha256": "1qi6x1wpcb2vd7bcm078p0r3ckpph39z95zkpsr5hqlll63fsfyw" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/ipcsocket/ipcsocket/1.8.0/ipcsocket-1.8.0.jar", + "sha256": "06lfxjz4sdd348s51pgd8x87d604l896fc8zhjzgd3vas31qszsv" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/ipcsocket/ipcsocket/1.6.3/ipcsocket-1.6.3.pom", - "sha256": "1g6vkk6cmfr073i4bhkycdrijb157jp7pgs8dg5fapj8sz8dscbf" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/ipcsocket/ipcsocket/1.8.0/ipcsocket-1.8.0.pom", + "sha256": "11h3zcbn3rnm9hn9swjxjq7g32y6m39xaakkpydqqws8x4f7bwma" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/ivy/ivy/2.3.0-sbt-77cc781d727b367d3761f097d89f5a4762771d41/ivy-2.3.0-sbt-77cc781d727b367d3761f097d89f5a4762771d41.jar", - "sha256": "1zrkispiv5g5i6i75fzyvyz4p8yf0gzxviy33zy488vvvvkp0zxr" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/ivy/ivy/2.3.0-sbt-f686954b0021a5c3245766ced0cdaeca8ba2fd7a/ivy-2.3.0-sbt-f686954b0021a5c3245766ced0cdaeca8ba2fd7a.jar", + "sha256": "0509dm1nyy5n1cwah993yn16mlw7cyry4j1wf79n0ikihjwqps5y" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/ivy/ivy/2.3.0-sbt-77cc781d727b367d3761f097d89f5a4762771d41/ivy-2.3.0-sbt-77cc781d727b367d3761f097d89f5a4762771d41.pom", - "sha256": "047fzj5imqaxqf49a54cy932df6a0vqiqkyfcq2i80swlkjzfvjb" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/ivy/ivy/2.3.0-sbt-f686954b0021a5c3245766ced0cdaeca8ba2fd7a/ivy-2.3.0-sbt-f686954b0021a5c3245766ced0cdaeca8ba2fd7a.pom", + "sha256": "0nw7llz8sy06mmpa3k5kbsyr4bfb8107c0kvlgklccx0k04ihx9h" }, { "url": "https://repo1.maven.org/maven2/org/scala-sbt/jline/jline/2.14.7-sbt-9a88bc413e2b34a4580c001c654d1a7f4f65bf18/jline-2.14.7-sbt-9a88bc413e2b34a4580c001c654d1a7f4f65bf18.jar", @@ -1946,68 +1910,68 @@ "sha256": "09gk17wfl5xl03fzzcp69s7lzbbfm3v7srfvsjld2c2dgf49n9vy" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/launcher-interface/1.5.1/launcher-interface-1.5.1.jar", - "sha256": "1w8q7vcldjbijgsiw94mk02nb92zk45h3qb718qc5bm0biif107s" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/launcher-interface/1.6.2/launcher-interface-1.6.2.jar", + "sha256": "0rmhzvdvp64sz5lan1l2ibx1hsakl052cw5ypp6y3s20acds70a1" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/launcher-interface/1.5.1/launcher-interface-1.5.1.pom", - "sha256": "1hsjsx20ynw4agnckix0m6sihff4am9ijdni9j3l8b9nqjbnsz2g" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/launcher-interface/1.6.2/launcher-interface-1.6.2.pom", + "sha256": "0clx9di1jqz6vmwm37l7kz8jbl4zgic9dcav8pypzm5kljf3x20g" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-core_2.12/1.11.6/librarymanagement-core_2.12-1.11.6.jar", - "sha256": "1wg02xgcswai3r312avx6gih94jisvxqm051s1qbcpma1lqnw4w7" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-core_2.12/1.12.3/librarymanagement-core_2.12-1.12.3.jar", + "sha256": "1ds8kdz2qba0nkfsppsfv2g4v06z3hpa5gn9x8kbmz8nm3j00djv" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-core_2.12/1.11.6/librarymanagement-core_2.12-1.11.6.pom", - "sha256": "17252591ajl4n7a3k9kysdi0732xgl9qdg12xngv6hb9smm9vnd5" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-core_2.12/1.12.3/librarymanagement-core_2.12-1.12.3.pom", + "sha256": "0ia680dj8vrxz27xqs9iiyqhh97jajh9mbka55r0x4mwa546792f" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-ivy_2.12/1.11.6/librarymanagement-ivy_2.12-1.11.6.jar", - "sha256": "0m4h445xvb2z4amdbp4005dqvibar22xmw46icnkszw66p6h4n80" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-ivy_2.12/1.12.3/librarymanagement-ivy_2.12-1.12.3.jar", + "sha256": "0ks6wga2jsik06s2yzms7a0ya10lag3n48hww73lg4d0rbzp5ghc" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-ivy_2.12/1.11.6/librarymanagement-ivy_2.12-1.11.6.pom", - "sha256": "1ld9bfrzv6j8pr6cz304kjib3bkc58xkqsg5z12zicpx6bwxlwxx" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/librarymanagement-ivy_2.12/1.12.3/librarymanagement-ivy_2.12-1.12.3.pom", + "sha256": "0x14rrb83kmxh4ngvkg3b4bm9xv1h49myxfw63m04368jg9b18hn" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/logic_2.12/1.11.7/logic_2.12-1.11.7.jar", - "sha256": "1g42pp57x3k9bglh64i2a7qrjqqr3av9dnpjnia06hfy9cdh4zw9" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/logic_2.12/1.13.0/logic_2.12-1.13.0.jar", + "sha256": "0l39c18m4x90xqrdr4jwmy7n803q3yfgnwi4l5kgp3hz3k484af1" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/logic_2.12/1.11.7/logic_2.12-1.11.7.pom", - "sha256": "10l0wplp7sg2kh1vpiq2hnqyil9g55yhpcp8bckabdv56blxhnag" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/logic_2.12/1.13.0/logic_2.12-1.13.0.pom", + "sha256": "0g47i9940lsz024gprflx0mx6f68rx6v5daqfbsrymsp0n1r3lxw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/main-settings_2.12/1.11.7/main-settings_2.12-1.11.7.jar", - "sha256": "03i388xcpl4k7bh9x4yiq7719r9rqz3hgg5v9yxx3layyx4yy19r" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/main-settings_2.12/1.13.0/main-settings_2.12-1.13.0.jar", + "sha256": "17lidh90ncbdybmcjl1rxqf869rb972qzh8cjvgiisfb2rx363j3" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/main-settings_2.12/1.11.7/main-settings_2.12-1.11.7.pom", - "sha256": "0jwyz20588q3zry38n782cjvw3bwzgilba2lj8v5l2iqn9jff1m7" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/main-settings_2.12/1.13.0/main-settings_2.12-1.13.0.pom", + "sha256": "09zdjkp8i3gb2nnk892y3lxnn4973gqnzrmqacm86qdb996yagfk" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/main_2.12/1.11.7/main_2.12-1.11.7.jar", - "sha256": "18zw8dc59pmijq3gwv425f90p4g3lm057w1m6m05a825wbn7hqwk" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/main_2.12/1.13.0/main_2.12-1.13.0.jar", + "sha256": "1dp3vlngn3ms6bq6mibb5x8q0azna716zy4f7gzhlyrjffhfz0mv" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/main_2.12/1.11.7/main_2.12-1.11.7.pom", - "sha256": "1kdi0wqk18mvnchdbqyfb51gk6kbnhdzmnj73mb9gnak96rlcbys" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/main_2.12/1.13.0/main_2.12-1.13.0.pom", + "sha256": "1gfj9wcf0q1whwibkvsrqfc9qd9g4hzi20chxi9ps9f7cjfj6m68" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/protocol_2.12/1.11.7/protocol_2.12-1.11.7.jar", - "sha256": "1s7nx3v88r13ry5f991j51jcl8dhl97rca1wy6gammqjbrcskpqa" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/protocol_2.12/1.13.0/protocol_2.12-1.13.0.jar", + "sha256": "0wzd39kbg5qg32p1q4dndv8fdfypcd20621l18dagzzdac4qls88" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/protocol_2.12/1.11.7/protocol_2.12-1.11.7.pom", - "sha256": "0hzqqs6mzm4ha2163clcwfkxp08zap1126gcxxzycs0cmkpsd639" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/protocol_2.12/1.13.0/protocol_2.12-1.13.0.pom", + "sha256": "0zx7nl0vb9lbw6jck82daq5sa6acrh0j30knrhxar7llrwlw0l5m" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/run_2.12/1.11.7/run_2.12-1.11.7.jar", - "sha256": "0dky5b0bi10fq0h8yhs27739i5ji04r3wdfp6x4ljnf9y38fwlk5" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/run_2.12/1.13.0/run_2.12-1.13.0.jar", + "sha256": "0a2vf584wg5sp20njqy3jvxpbczj1x3jcsjmlw2x5p91n9c9j2ng" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/run_2.12/1.11.7/run_2.12-1.11.7.pom", - "sha256": "0d90xp5qs40fsxnwhgk24k7ppf655hjh5rx7gcq5ds7gnrrjagqx" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/run_2.12/1.13.0/run_2.12-1.13.0.pom", + "sha256": "0b41mzs7n0lvrpw58w9hr3j4k4lf6kh7f6skqdv1y1x6cvlm8kfi" }, { "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbinary_2.12/0.5.1/sbinary_2.12-0.5.1.jar", @@ -2018,44 +1982,44 @@ "sha256": "0911brj2s04lsh67n5r30n9hn8vr41267h2biy7z1yyymlbynhd3" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt-dependency-tree_2.12_1.0/1.9.9/sbt-dependency-tree_2.12_1.0-1.9.9.jar", - "sha256": "0j7mdwh5is4clbfwwznaj6x7r355z0pcx1i192icyd5vlyigp9fy" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt-dependency-tree_2.12_1.0/1.12.8/sbt-dependency-tree_2.12_1.0-1.12.8.jar", + "sha256": "1qw66bpj3m35l6wzdhil0m7yv38sl1p2lcnd62rsl4skxvy9f96l" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt-dependency-tree_2.12_1.0/1.9.9/sbt-dependency-tree_2.12_1.0-1.9.9.pom", - "sha256": "0bi5675mxd2i9jhd626qaxknfrfxdhyqfm0wsxmrrjdl56mmvj98" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt-dependency-tree_2.12_1.0/1.12.8/sbt-dependency-tree_2.12_1.0-1.12.8.pom", + "sha256": "0sx7aslfj7xapj3wkg0hgpszy33mvkjwsjivrizzxkq07qgsry3x" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt/1.11.7/sbt-1.11.7.jar", - "sha256": "1z1sgwrxma4dm56769k48xgvmi1y401g3xy7c186pp7vk8a1qzp5" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt/1.13.0/sbt-1.13.0.jar", + "sha256": "0znn2k85x8d3d2981v4ylljdcarg985b0xjznwsk0dfza55nf3yb" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt/1.11.7/sbt-1.11.7.pom", - "sha256": "0h78v893ndjkfb8cwl8gn1wilcf7p3gfg8vsk5944z5lv9glqwxv" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/sbt/1.13.0/sbt-1.13.0.pom", + "sha256": "1mwcl33w0g5ip7nzfcmqqdvzd5yhdza2sqd7mx8wlayxgd1rlpjm" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/scripted-plugin_2.12/1.11.7/scripted-plugin_2.12-1.11.7.jar", - "sha256": "0f6p5x9w9b7qa6kwcps9y7i8k3fw8wsqvvpqfxz5p1i763bva00v" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/scripted-plugin_2.12/1.13.0/scripted-plugin_2.12-1.13.0.jar", + "sha256": "1lmzj1jldg9dxs3wqv7sr4ghjdsa6ixxdg3rwzn4z9p3033qd8r2" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/scripted-plugin_2.12/1.11.7/scripted-plugin_2.12-1.11.7.pom", - "sha256": "1gcpfyg71vwjqf8spnwhir741d6l738nbsah824bmvwkpkar91rh" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/scripted-plugin_2.12/1.13.0/scripted-plugin_2.12-1.13.0.pom", + "sha256": "1k8gzyiqysbyix2b3wjy1ixzksgpnjfy9lvlpmnl8d32dc1ymbyh" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/task-system_2.12/1.11.7/task-system_2.12-1.11.7.jar", - "sha256": "1xdc94vdcgc7rp2j7dk5dripvqfj5118mws7gjp77az57mr704bg" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/task-system_2.12/1.13.0/task-system_2.12-1.13.0.jar", + "sha256": "00ybldyg4sjp1qqs48kxjgrfhh9pgx9y3rjlavgvikply8s5dlyv" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/task-system_2.12/1.11.7/task-system_2.12-1.11.7.pom", - "sha256": "1zdii982x6mdzsrc1vsc24740k9232gnxl6m7rhkxb9b0kddim4b" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/task-system_2.12/1.13.0/task-system_2.12-1.13.0.pom", + "sha256": "03w30rd4n7l72hjxgsgvyjj9ba8f90lnr8yi1f4jpq92dwqbzjcq" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/tasks_2.12/1.11.7/tasks_2.12-1.11.7.jar", - "sha256": "0fhkvbhw2vvryv9apsv70zkbqnyv6ygl2qzn01qpv4x9vjmhnfv6" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/tasks_2.12/1.13.0/tasks_2.12-1.13.0.jar", + "sha256": "016f0b52jl2028l1pnyhiwdgzmr27f5n8vs43gdlsan8128w15rh" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/tasks_2.12/1.11.7/tasks_2.12-1.11.7.pom", - "sha256": "1d4qb0vzv15c9njmhxd53nvz22bk7y6yhqygfyn8qj9352lgwpjj" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/tasks_2.12/1.13.0/tasks_2.12-1.13.0.pom", + "sha256": "0sxfjmybfmhx06612pn2iwahwk472vhzvpqabixg7wm3nki72cbr" }, { "url": "https://repo1.maven.org/maven2/org/scala-sbt/template-resolver/0.1/template-resolver-0.1.jar", @@ -2066,12 +2030,12 @@ "sha256": "0nsx88y1b3nqpd6r80bb1rpaaskfhcxdbfaqngykrxz8gvq3i2q3" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/test-agent/1.11.7/test-agent-1.11.7.jar", - "sha256": "1nnbi63ki0hv8hwdc6qa5g3klkyghv1i4qxyiqiprxq5qigsfly3" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/test-agent/1.13.0/test-agent-1.13.0.jar", + "sha256": "04h8qamvdcw26mnjlm5v3p5f9vg1vh7rhnfqiawsmvx1glgm1yg3" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/test-agent/1.11.7/test-agent-1.11.7.pom", - "sha256": "0w67msjb9j2pnckbhd57daa0aj0wwi93hc0mrk25rx9wwafcby7n" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/test-agent/1.13.0/test-agent-1.13.0.pom", + "sha256": "0aim208jf42h7szxpfhbx6hrglc5jxb1wl1f2mgfp9h5kpzxyjs4" }, { "url": "https://repo1.maven.org/maven2/org/scala-sbt/test-interface/1.0/test-interface-1.0.jar", @@ -2082,436 +2046,420 @@ "sha256": "0bknid634xcq5r8fs1bx0ydxs3rhzgmzq1400h7y29n2s2lhdfk1" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/testing_2.12/1.11.7/testing_2.12-1.11.7.jar", - "sha256": "04syffxdnxs5cmhviiffhsh85272gdkrwji80q7x54g8r11258pf" - }, - { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/testing_2.12/1.11.7/testing_2.12-1.11.7.pom", - "sha256": "0nfk61prfy9qwyp1m23llplv63p655i4ypaki2n121cd7w16sz12" - }, - { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-cache_2.12/1.11.7/util-cache_2.12-1.11.7.jar", - "sha256": "02b3z29rfqy6zl8nwdy7as8vwqjgd39wmx1pzwiappx0z3s1x38y" - }, - { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-cache_2.12/1.11.7/util-cache_2.12-1.11.7.pom", - "sha256": "1aqgvhdiq49a4525xgv33ss7lxq9q5kgkdfxbkrlv7aynzry2qz1" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/testing_2.12/1.13.0/testing_2.12-1.13.0.jar", + "sha256": "1r7wlk2hv8ij6591jm3a04ragd9mpcs35h1rc6pm2hq1xvvphpk9" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-control_2.12/1.11.7/util-control_2.12-1.11.7.jar", - "sha256": "1azxmnrpcrrqa9y1aj61h2azlds0l4zpv56bgy08w5q83797ds9f" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/testing_2.12/1.13.0/testing_2.12-1.13.0.pom", + "sha256": "021i4pb9k6jzcvggfa2js8a354wl6lk04niypsvhajg1vs25sgly" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-control_2.12/1.11.7/util-control_2.12-1.11.7.pom", - "sha256": "033awpd5pfc8nhxl006w540j400f3dsyps63wmx1n67fx14c83sd" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-cache_2.12/1.13.0/util-cache_2.12-1.13.0.jar", + "sha256": "1ybdi6xszxfw722ni25kjcla04m1s9mw8fq9y1dchnah1mx47mb6" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-interface/1.11.7/util-interface-1.11.7.jar", - "sha256": "0iz1a96hq70hjag4c1nhyxm4rv9nsa5lb1917mrn8w4rch6k231g" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-cache_2.12/1.13.0/util-cache_2.12-1.13.0.pom", + "sha256": "0f83fc2kd8vgfmwwaqwa8mi0w6vxd6r52h8hz7ndpdxzxip7vsyj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-interface/1.11.7/util-interface-1.11.7.pom", - "sha256": "17mggws1f4zinsrpg3qy1kf75bd3lxy6xjbk2q6kchnhk4a962mx" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-control_2.12/1.13.0/util-control_2.12-1.13.0.jar", + "sha256": "1rfz2v29hqjic1kh4m7rd42p2ndx5d6gdl2g0y1bfa6isj9a9q0z" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-logging_2.12/1.11.7/util-logging_2.12-1.11.7.jar", - "sha256": "14rd8xx7klij8pv7zsw8993x7xm6czv4197rxfgwr8zanks2nq3d" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-control_2.12/1.13.0/util-control_2.12-1.13.0.pom", + "sha256": "1v5arqcx0jdnmxskl1spvcdvjf0qhlqy99zv7qzpg31jv1h8j7p5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-logging_2.12/1.11.7/util-logging_2.12-1.11.7.pom", - "sha256": "0qr2m098gyw8vqqh8fzv5wn5rigqkpvwfw5x4m3rzsfs027a8fr7" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-interface/1.13.0/util-interface-1.13.0.jar", + "sha256": "1qwakjylibkhs2cv2mwlfy4k70vyp787wi2w54v2jm25zdhnlfyi" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-position_2.12/1.11.7/util-position_2.12-1.11.7.jar", - "sha256": "1r99m2nbr4gznpqm56jc203vmkh7si2bsng0m97af05p6kj78mdy" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-interface/1.13.0/util-interface-1.13.0.pom", + "sha256": "1b3nlqv2a2danbk43iqlybh1b70qac6h74q4hilg1dpn4hql3v75" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-position_2.12/1.11.7/util-position_2.12-1.11.7.pom", - "sha256": "044f3s30w2a6f4szyh8sv1xxvhsdsvclllqljrxpg40aa3ih9zz5" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-logging_2.12/1.13.0/util-logging_2.12-1.13.0.jar", + "sha256": "1q2gzddrac5w9xq784f06xh4snaqzllivkyjczrx2s39g3v6ci1c" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-relation_2.12/1.11.7/util-relation_2.12-1.11.7.jar", - "sha256": "1y8zm83c91y5s05w37w6jbvv9c2nm1jha4m3jiypji16pyq4vijh" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-logging_2.12/1.13.0/util-logging_2.12-1.13.0.pom", + "sha256": "17nhrhqjwp7lg8qs7dwyqs59br232al10cnada3n8p30zf0wwmz9" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-relation_2.12/1.11.7/util-relation_2.12-1.11.7.pom", - "sha256": "08rnh8199n0fw5si600kc1qrj7czcni6x1imv53hwlf0yr1f6474" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-position_2.12/1.13.0/util-position_2.12-1.13.0.jar", + "sha256": "16sxm8mgshncrn7pb879z0jwiz46qiygx19gd958i8vgm1smpviw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-tracking_2.12/1.11.7/util-tracking_2.12-1.11.7.jar", - "sha256": "0sp9jl2pnqr5gdl33wl83rrf45izwggmpivfyb7wynhg0hk414py" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-position_2.12/1.13.0/util-position_2.12-1.13.0.pom", + "sha256": "02hc6bm07ygjpg43pl40mbkscsfn8ilwvaqmzlf51na8bd9zm5xj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-tracking_2.12/1.11.7/util-tracking_2.12-1.11.7.pom", - "sha256": "0fv9mg9vihasyvy5x7ax2nx6wq1v0rkmchnpwqkppqak7rkngdjx" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-relation_2.12/1.13.0/util-relation_2.12-1.13.0.jar", + "sha256": "0qnbcjqakzzjq2hjs6anxsb39zqwnfqwa15fg0vkhh9722c5h2xn" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-apiinfo_2.12/1.11.0/zinc-apiinfo_2.12-1.11.0.jar", - "sha256": "0hccl4w60xfiq5xns43la254i9vqb7bfxzgzkd2i4gn8kf8nwav3" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-relation_2.12/1.13.0/util-relation_2.12-1.13.0.pom", + "sha256": "0g0kmrfslv6jihf6vyilqi61qcb4svj9f4r59vln4s3j4f5aam60" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-apiinfo_2.12/1.11.0/zinc-apiinfo_2.12-1.11.0.pom", - "sha256": "1lhakd1v0bw4mga374az1ycyhxwrc60yd1kwp6azs5iq84a822h6" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-tracking_2.12/1.13.0/util-tracking_2.12-1.13.0.jar", + "sha256": "0x166xsx32m8z6gpdprygkhnj5vck08gh8z9c29rswb94s3ifgbj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classfile_2.12/1.11.0/zinc-classfile_2.12-1.11.0.jar", - "sha256": "14sgvsd7csc5y11ig3vpnv1vl80wjkg0xjd0z450xgyqy86ym2yn" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/util-tracking_2.12/1.13.0/util-tracking_2.12-1.13.0.pom", + "sha256": "0lglk2xmv4x9xnf09pjacaj8f92yirg44y01ni2ivr807wb4wv2r" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classfile_2.12/1.11.0/zinc-classfile_2.12-1.11.0.pom", - "sha256": "1ym6589ac8nrsw4bk0qw9cj0di1rjykjxbfa3ypddmdci09lq0cr" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-apiinfo_2.12/1.12.1/zinc-apiinfo_2.12-1.12.1.jar", + "sha256": "06rdqwgncvbkciy6gl5nm9kkmrvvfvz7fxl05bblipwqanlxqv67" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classpath_2.12/1.11.0/zinc-classpath_2.12-1.11.0.jar", - "sha256": "162l8wbkpxnkkn0wzcgcsnbhshjl8lx3sphh68d1r6kcj7p0mklr" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-apiinfo_2.12/1.12.1/zinc-apiinfo_2.12-1.12.1.pom", + "sha256": "08g8id97a034pxnpkh3d1b7dq88kq0wkd9qaaxf55p6k87zdcwi5" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classpath_2.12/1.11.0/zinc-classpath_2.12-1.11.0.pom", - "sha256": "0j7dzsqk80lmskgky56kpcc1zwwkhzkl0sy752fb5y7m0qy9k7a7" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classfile_2.12/1.12.1/zinc-classfile_2.12-1.12.1.jar", + "sha256": "18652dw7r7fgaq7dknza9w9ydc6i6hb16dkxjx65qb2wdy7pkcaj" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile-core_2.12/1.11.0/zinc-compile-core_2.12-1.11.0.jar", - "sha256": "1mnr9vzj1xavh45wnsqz3p5b8m768i8gp1ps1az5xmah9dqv30px" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classfile_2.12/1.12.1/zinc-classfile_2.12-1.12.1.pom", + "sha256": "0y6frqv4caqaar5123vjidjfpfxbinkhrwi7mr27ly4w8z0mxs08" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile-core_2.12/1.11.0/zinc-compile-core_2.12-1.11.0.pom", - "sha256": "15p60z4x029d9q6ambwf119c4i18rspq9jn3lrl0gnzfms1cr107" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classpath_2.12/1.12.1/zinc-classpath_2.12-1.12.1.jar", + "sha256": "0s37a15klir5jmdrax1zwsf1rlpvsaq6wipibv70i4rv3z3dm0xw" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile_2.12/1.11.0/zinc-compile_2.12-1.11.0.jar", - "sha256": "0k49zgdlkjl4sw1cp7c5s1mqy0d0lx9avg3vnh59pi87kkym68iq" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-classpath_2.12/1.12.1/zinc-classpath_2.12-1.12.1.pom", + "sha256": "1yyx79hr75d8q3vn8cwlxiw6il029vdsn54lvy6b23r27gwyyy8s" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile_2.12/1.11.0/zinc-compile_2.12-1.11.0.pom", - "sha256": "0chlmzmlq3lsl2ymk7r0pr4gx9k8kfj00ykzziwvs5q99834p39p" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile-core_2.12/1.12.1/zinc-compile-core_2.12-1.12.1.jar", + "sha256": "1fkdp48rrax7a30sj6fv887zqplsg2jkxbap9q5wclfgwsq9598q" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-core_2.12/1.11.0/zinc-core_2.12-1.11.0.jar", - "sha256": "1acwb9lx1hn6r5f6q8vs55klv2nmsys5zwzsjb7y6prbymmwbqvh" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile-core_2.12/1.12.1/zinc-compile-core_2.12-1.12.1.pom", + "sha256": "1lawgnri7h5l818fxkxm27yvkn2d4pnlra0amvyhwc0nzia2mi9d" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-core_2.12/1.11.0/zinc-core_2.12-1.11.0.pom", - "sha256": "03ziqzpbnww6ibhc3yx09v356782lkkfagsf3341kk1k05l0k8p0" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile_2.12/1.12.1/zinc-compile_2.12-1.12.1.jar", + "sha256": "0fsrwk642xpygp7vvnrpw4i6qrd6xqfj7kkfnvfqzfp7wb2z09gp" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-lm-integration_2.12/1.11.7/zinc-lm-integration_2.12-1.11.7.jar", - "sha256": "06jx79s67yip4r3892jp2kng9y9k5c5ng50c61kc0kgrr73g4mhp" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-compile_2.12/1.12.1/zinc-compile_2.12-1.12.1.pom", + "sha256": "0arnvy6fdb1zk2vfv8nldkwwd94z13krrb3vacphr4rqgpmwa8sd" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-lm-integration_2.12/1.11.7/zinc-lm-integration_2.12-1.11.7.pom", - "sha256": "15z3xc030v5y5a18k1a26pjrj11x35wryxnynbjd5zdwlh2yccz4" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-core_2.12/1.12.1/zinc-core_2.12-1.12.1.jar", + "sha256": "1iyqax01nk2npvq3ns7s824f819wchchr46lnq53npc204abag4v" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist-core-assembly/1.11.0/zinc-persist-core-assembly-1.11.0.jar", - "sha256": "0s2cq7z1n03fxx9wxlpqc1w0j7x5g3da1m1wcxkrip1v6k9ds3ss" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-core_2.12/1.12.1/zinc-core_2.12-1.12.1.pom", + "sha256": "0bqzmyzd0vf8ymybv1vnv8q5ayp924apc0fnxjpsrq8q7fmmbnry" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist-core-assembly/1.11.0/zinc-persist-core-assembly-1.11.0.pom", - "sha256": "0qdciwxvlw6kfz2bd80fkfqwg9ic0mr1kg39dv85g4wssk0cxraj" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-lm-integration_2.12/1.13.0/zinc-lm-integration_2.12-1.13.0.jar", + "sha256": "113x9czz982vniwq7gw7m4bxp8hhbfs7yw74knq98kk4d119024c" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist_2.12/1.11.0/zinc-persist_2.12-1.11.0.jar", - "sha256": "147bnibimmnjznsh2jb85qgrcjgqdyz6v3fbs921hy8pw56wz8k8" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-lm-integration_2.12/1.13.0/zinc-lm-integration_2.12-1.13.0.pom", + "sha256": "17grm4yqsgahfprv81nc03chfni7im0cm84ryps9vdly1xay6cih" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist_2.12/1.11.0/zinc-persist_2.12-1.11.0.pom", - "sha256": "0dwbp6xyja1zspljqgfs0x6gsi8dk1fv8gm00kyw9w07pfr7n50p" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist-core-assembly/1.12.1/zinc-persist-core-assembly-1.12.1.jar", + "sha256": "1nzxsj5bb2j357r2x319d4yyk9i03ryzafsmir5m848dncm9w0hz" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc_2.12/1.11.0/zinc_2.12-1.11.0.jar", - "sha256": "15kvh90c630r7qfdvcja790bdr69yb1lmn29pp31yqyhkdrqy7cf" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist-core-assembly/1.12.1/zinc-persist-core-assembly-1.12.1.pom", + "sha256": "0yba0wdb3bh5alw47b7gahmfszr3h09slj1wdibnvj9sfacmix7w" }, { - "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc_2.12/1.11.0/zinc_2.12-1.11.0.pom", - "sha256": "018rx4404wmf77yfkq3h1xrj72yabvfzivjy173h11flrxcchm9z" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist_2.12/1.12.1/zinc-persist_2.12-1.12.1.jar", + "sha256": "1wjabna8ri5n88sm66y90cv5qzmx8q8kbdcng1iddc3rbwq5gfhx" }, { - "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_2.13/3.2.19/scalactic_2.13-3.2.19.jar", - "sha256": "1bylrj6897947l54y4iwy3y8ygm3rzs5lv61csgf4l6l2zg36z62" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc-persist_2.12/1.12.1/zinc-persist_2.12-1.12.1.pom", + "sha256": "0x2m3xhi4vzyalc693a2qb7img962m3w3pnlw15c56a7p04rxdnl" }, { - "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_2.13/3.2.19/scalactic_2.13-3.2.19.pom", - "sha256": "03gdv1bmr9vzxrhjapfnm76q7halm72d1s5fkmjizkmnf6ap2zkz" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc_2.12/1.12.1/zinc_2.12-1.12.1.jar", + "sha256": "0zl263ay9vq1lhr644m91374xv6pwfpamfcy7lk4hmdq9842kr1z" }, { - "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_sjs1_2.13/3.2.19/scalactic_sjs1_2.13-3.2.19.jar", - "sha256": "17614a7mz5prxy6a79758kjci1d16xabrkmyfj17zjp86vsnzgk1" + "url": "https://repo1.maven.org/maven2/org/scala-sbt/zinc_2.12/1.12.1/zinc_2.12-1.12.1.pom", + "sha256": "02nn2vd9m1j48zakd8nxzjx92b7wxcp0fbcf5v1ykgwzp0smr334" }, { - "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_sjs1_2.13/3.2.19/scalactic_sjs1_2.13-3.2.19.pom", - "sha256": "0dcsawzqjjr3209snz0817wid9rfiymrai85vyz83k0hwv28b3q2" + "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_2.13/3.2.20/scalactic_2.13-3.2.20.jar", + "sha256": "1fms0g7f2plp4ln8177i7rb14kihnb8s652n1r610kmg51n92r5m" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-compatible/3.2.19/scalatest-compatible-3.2.19.jar", - "sha256": "18qdrmrsdsyk6q3fghydf0kykcwf98bxywibghd9xzlnagxbiijx" + "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_2.13/3.2.20/scalactic_2.13-3.2.20.pom", + "sha256": "0khyk6085hpw55qq96yi76zx3rksxjkmnysxglx9k9c7gy1n7a20" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-compatible/3.2.19/scalatest-compatible-3.2.19.pom", - "sha256": "1h53v8a5dl8bw9ihliy71a677hj8x5r4wrwqc2yp5l5n5j90kwz7" + "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_sjs1_2.13/3.2.20/scalactic_sjs1_2.13-3.2.20.jar", + "sha256": "1kkqax8jz8kzq5kdxvj95k6y2qzkhn5mfkmkymbiwi7hxbgn05d8" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_2.13/3.2.19/scalatest-core_2.13-3.2.19.jar", - "sha256": "0di9pyjjhg99d40rbzlpfk4y24v16dyiazzyhcdk9xi9s20h08rh" + "url": "https://repo1.maven.org/maven2/org/scalactic/scalactic_sjs1_2.13/3.2.20/scalactic_sjs1_2.13-3.2.20.pom", + "sha256": "0ri62gl3hwfblsrp413hy7xyhbiibl91ghifzqyv1fk86l2hww0l" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_2.13/3.2.19/scalatest-core_2.13-3.2.19.pom", - "sha256": "0ag64hbpkc2a4kfvzbbdi4i0wilnknk1n9yl8sk1ldmr7d0dvq6b" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-compatible/3.2.20/scalatest-compatible-3.2.20.jar", + "sha256": "0pbm2abv0lpm4l0m5f17j64rmybiqpx05qqmfmzncgbrkvrdnnx9" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_sjs1_2.13/3.2.19/scalatest-core_sjs1_2.13-3.2.19.jar", - "sha256": "10x7r8j8q02nxjr2skfwckgdlvh5z1al2c52hb0y37165jbcn2w2" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-compatible/3.2.20/scalatest-compatible-3.2.20.pom", + "sha256": "0yh83ngrm3i78cf9686cqm1k1vph62yqvkyxyfh1wqjq0xdmsik7" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_sjs1_2.13/3.2.19/scalatest-core_sjs1_2.13-3.2.19.pom", - "sha256": "0c36dvbnn95f19jcnr8aah6l3jvl2r927r6ymv0d43bq7nfl299q" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_2.13/3.2.20/scalatest-core_2.13-3.2.20.jar", + "sha256": "0dj99cx469xbxdh4rhzmyzwm0hwnwjvh3ircab2fz6fhd5ws4adn" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_2.13/3.2.19/scalatest-diagrams_2.13-3.2.19.jar", - "sha256": "1v1vbzndf8x1kl9ivaggj3x45h2sg6nd8l35rpcf2r55sp9r8wm7" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_2.13/3.2.20/scalatest-core_2.13-3.2.20.pom", + "sha256": "190cks2jzn8jxbgcx7py673yndpyph66qd39pv002isrbqmq5cag" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_2.13/3.2.19/scalatest-diagrams_2.13-3.2.19.pom", - "sha256": "0w1p172phmmx9cbcr41hl80qidxqqp5qrixcqark8qyn13q3asxg" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_sjs1_2.13/3.2.20/scalatest-core_sjs1_2.13-3.2.20.jar", + "sha256": "1jrvz924qvrxi7ijg2xdknmgfrdq4h6minz74vlfsm21xyi7h4g1" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_sjs1_2.13/3.2.19/scalatest-diagrams_sjs1_2.13-3.2.19.jar", - "sha256": "1ibvffdyqs1kxwz8jn48d65zv79nxsn3p17qrf8q887ha4hn0hfd" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-core_sjs1_2.13/3.2.20/scalatest-core_sjs1_2.13-3.2.20.pom", + "sha256": "1al4npb9zwkjsl5ygqjxddikbclfb6w0d6pqih485kc0z3fw6v70" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_sjs1_2.13/3.2.19/scalatest-diagrams_sjs1_2.13-3.2.19.pom", - "sha256": "1lvwil6aa84hypq3nmbyq2bdpdd843rmlvkmmvw09jj4jln9q4f0" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_2.13/3.2.20/scalatest-diagrams_2.13-3.2.20.jar", + "sha256": "14lwni80wcf2wcqd6yf4w6zassa65d75hyn0763qx5xkscdavqsk" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_2.13/3.2.19/scalatest-featurespec_2.13-3.2.19.jar", - "sha256": "1vhxx5ay170528s06wnb7xfzaapg00wi5m5lx9pmj294w5mlx92q" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_2.13/3.2.20/scalatest-diagrams_2.13-3.2.20.pom", + "sha256": "0h6sv9c0gnk3sf36w0qzhpa8pgniiyxv2vnh145qi6cvsd6jzlny" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_2.13/3.2.19/scalatest-featurespec_2.13-3.2.19.pom", - "sha256": "06fwfxihgn0pnpxywwvldrllqhb0ispqzxnjpp88r1n2w0likss9" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_sjs1_2.13/3.2.20/scalatest-diagrams_sjs1_2.13-3.2.20.jar", + "sha256": "13lbdpkybwmvib5dqzm7m6cffxkwlyg8lyxly13gg5gkdr0vapm1" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_sjs1_2.13/3.2.19/scalatest-featurespec_sjs1_2.13-3.2.19.jar", - "sha256": "09m1s2wq1mx6sg37hd9dn6brqh3cy6a4680l911n67aabz6abksi" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-diagrams_sjs1_2.13/3.2.20/scalatest-diagrams_sjs1_2.13-3.2.20.pom", + "sha256": "0fw8l7llqs0xsmhy0zgdwi6n8ilsd7xw77gb5v1ig3g03akylppa" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_sjs1_2.13/3.2.19/scalatest-featurespec_sjs1_2.13-3.2.19.pom", - "sha256": "195xbhf3nbj3y3n7qzyqjr74d79wclljkfr04wbdq863pfs7fn26" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_2.13/3.2.20/scalatest-featurespec_2.13-3.2.20.jar", + "sha256": "130bk6snnc1jfmyh5d8jvlhijxpbiki52gl222slwin7175ih42n" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_2.13/3.2.19/scalatest-flatspec_2.13-3.2.19.jar", - "sha256": "0jk5fnip6jmybxp8k741ddicahxlnr7km87kr3yr37n67m12hkfy" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_2.13/3.2.20/scalatest-featurespec_2.13-3.2.20.pom", + "sha256": "0nkkx0fdjiddag21lqnq8q9vzrg6s20zg3sjm2ky21i7f4yi6szv" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_2.13/3.2.19/scalatest-flatspec_2.13-3.2.19.pom", - "sha256": "079zmnls0ly007ww6p5v6ldjkd885rwk7py7p2scvzab9y9z595d" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_sjs1_2.13/3.2.20/scalatest-featurespec_sjs1_2.13-3.2.20.jar", + "sha256": "0gnz1afs1fngx3h2kgch6lfwyk85x6wz11ri4fvhcxs4g1zy5xji" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_sjs1_2.13/3.2.19/scalatest-flatspec_sjs1_2.13-3.2.19.jar", - "sha256": "1dmc2i7jw8xscc2bqn3nrqdgr5scmhyg6lfnsndv7s0mfvsnsaml" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-featurespec_sjs1_2.13/3.2.20/scalatest-featurespec_sjs1_2.13-3.2.20.pom", + "sha256": "0mrzcd0vgx66hmkdwfy6gcnic4hd1vqyq50q7va54014xsmg7fvh" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_sjs1_2.13/3.2.19/scalatest-flatspec_sjs1_2.13-3.2.19.pom", - "sha256": "1lsfbyjs44xgz7v93ssw67by4kpfdr5yspp33lrk745hkgc1c81d" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_2.13/3.2.20/scalatest-flatspec_2.13-3.2.20.jar", + "sha256": "0zv68r8sxk8md9m5amj7r3lx7sszb7p4nwssv2i55sca47dzbwd3" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_2.13/3.2.19/scalatest-freespec_2.13-3.2.19.jar", - "sha256": "12mhjpw34hy3m4ri2ys26wnbqks78cjn0rc6nixi2f6a5i167r7k" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_2.13/3.2.20/scalatest-flatspec_2.13-3.2.20.pom", + "sha256": "11x7mqp1va08dl221fczjbb09c2qjr6zhpydif45kdcmva1fyrli" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_2.13/3.2.19/scalatest-freespec_2.13-3.2.19.pom", - "sha256": "1z6pwwd7b17a60lsiqbki4cqm4da10c8g66sv5wag7ll57acgby5" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_sjs1_2.13/3.2.20/scalatest-flatspec_sjs1_2.13-3.2.20.jar", + "sha256": "0dv7nc5fzdmpg8jw63znkzj169scqq86q4n6cbih3pzinyiqksyr" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_sjs1_2.13/3.2.19/scalatest-freespec_sjs1_2.13-3.2.19.jar", - "sha256": "1d6g509i5v0m33dggz67gzgn8331vkhrshk30llq6rgvmgi8fzi0" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-flatspec_sjs1_2.13/3.2.20/scalatest-flatspec_sjs1_2.13-3.2.20.pom", + "sha256": "00a5hgbbmqa50dmksdb4azzvslv6v38cpwwhzgvcf942zaxmn0zr" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_sjs1_2.13/3.2.19/scalatest-freespec_sjs1_2.13-3.2.19.pom", - "sha256": "00mwv3yb0x49hnmjc4pwm7mcvcn7g71fgi4cq54j5k4xziz5k6nv" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_2.13/3.2.20/scalatest-freespec_2.13-3.2.20.jar", + "sha256": "1v5z4j86n7sfc6yll5rff641bxk71892jcwdaibic34b90rxjfmz" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_2.13/3.2.19/scalatest-funspec_2.13-3.2.19.jar", - "sha256": "0fw9ssks095xm2d3p5kvfj44z0lvj9w2l4wypgpawpbwns0jfs2c" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_2.13/3.2.20/scalatest-freespec_2.13-3.2.20.pom", + "sha256": "0yxmqcihmxh7as0pgxaarh8z6y9pac43f5zqfjs44i5qjxc8kjyi" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_2.13/3.2.19/scalatest-funspec_2.13-3.2.19.pom", - "sha256": "11dmyi9svk7svh58dad2gljmrbmmagxd5hcdk4lw96x08pyiwc3j" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_sjs1_2.13/3.2.20/scalatest-freespec_sjs1_2.13-3.2.20.jar", + "sha256": "19ghclnlmvwdbhskq7z00n9il1186wl3jyd2vwmnbnabwgdzh4vp" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_sjs1_2.13/3.2.19/scalatest-funspec_sjs1_2.13-3.2.19.jar", - "sha256": "1zcfgxcshidj9dy6qcgd86m7nypd11pabnpjm1hwdjb8g540s6w8" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-freespec_sjs1_2.13/3.2.20/scalatest-freespec_sjs1_2.13-3.2.20.pom", + "sha256": "1i3alxzk62m3fsq8jwarjlligipiz8ikysqw6fyz157q0ib7c7nv" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_sjs1_2.13/3.2.19/scalatest-funspec_sjs1_2.13-3.2.19.pom", - "sha256": "1v28ax97ylfghdv8pjfl714zahbn6pdr9ixm2han81camgpbrm4l" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_2.13/3.2.20/scalatest-funspec_2.13-3.2.20.jar", + "sha256": "1yc6bpzw4wz449x57wvn618xmy7gk3f9k2g9fg7vmb0i9bfqkj1n" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_2.13/3.2.19/scalatest-funsuite_2.13-3.2.19.jar", - "sha256": "0phx4ihlkzlnb1qsphclp5kl67dnk9f7cq01705rvmrs34vynslj" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_2.13/3.2.20/scalatest-funspec_2.13-3.2.20.pom", + "sha256": "0fd86yppgq2r6jxy5ip50mpxdbmila3m7wf44v0bvdfal3lryxd4" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_2.13/3.2.19/scalatest-funsuite_2.13-3.2.19.pom", - "sha256": "0d1svav32kp4mh9kwxqkqfwfhr22p6wv6acw1kmcz070nkjigv9j" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_sjs1_2.13/3.2.20/scalatest-funspec_sjs1_2.13-3.2.20.jar", + "sha256": "0257v296f71p48y7nmp80igfz8pj61rpr6cy8lc57smyh5x0y9mg" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_sjs1_2.13/3.2.19/scalatest-funsuite_sjs1_2.13-3.2.19.jar", - "sha256": "0xgkd4a4ybcj9ld8s8lfgkwk5g6krg05j28slfsx78b9dfkji20x" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funspec_sjs1_2.13/3.2.20/scalatest-funspec_sjs1_2.13-3.2.20.pom", + "sha256": "0wqmcvczlrkzn2yj87w7b6siyc5xxy1i1sibhjc2q4gaqlcsg9ln" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_sjs1_2.13/3.2.19/scalatest-funsuite_sjs1_2.13-3.2.19.pom", - "sha256": "09373hkgswrljxsn7lji46mj92l4scwj3jx379ryl53kyji0hj7m" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_2.13/3.2.20/scalatest-funsuite_2.13-3.2.20.jar", + "sha256": "1yx73qlv7bhpd3ikxcanwq68s3cdl6gy6k5f4hsb3hr90cmqdkrm" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_2.13/3.2.19/scalatest-matchers-core_2.13-3.2.19.jar", - "sha256": "18vllw23j7hjpg73rfii0cfmi64x66n162q8bmkm3giz2k0icgq3" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_2.13/3.2.20/scalatest-funsuite_2.13-3.2.20.pom", + "sha256": "1gfjmiy92sxribfjmhx58v8dkdbsma5967j25pp107ykkgn0vl19" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_2.13/3.2.19/scalatest-matchers-core_2.13-3.2.19.pom", - "sha256": "0y9j53vha0v9igiibwlgj3dndqqjy3lqwcb6sny4fqgx0kwp2p4n" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_sjs1_2.13/3.2.20/scalatest-funsuite_sjs1_2.13-3.2.20.jar", + "sha256": "1hxbsydzjnvq579xpcp6bqqndc9ahc7i4jj43j4iz9bx98p4jass" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_sjs1_2.13/3.2.19/scalatest-matchers-core_sjs1_2.13-3.2.19.jar", - "sha256": "1si5b8wfmnfz56dyw9bv4hf45m04vs25mc0hgk4dsjizzqdlmwqg" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-funsuite_sjs1_2.13/3.2.20/scalatest-funsuite_sjs1_2.13-3.2.20.pom", + "sha256": "109qhhypdj7anprz4w772np9djxni4370zaym7x21f34nk0pzk3d" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_sjs1_2.13/3.2.19/scalatest-matchers-core_sjs1_2.13-3.2.19.pom", - "sha256": "1mvfbgbn0fgzzxq484r5k19s2xfxgdlqqhk623jygr3zqp4vva66" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_2.13/3.2.20/scalatest-matchers-core_2.13-3.2.20.jar", + "sha256": "198nj5hqbxnd3vi83qcdqp20pi4ipg588y80c6xrj6hw59ziyc0b" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_2.13/3.2.19/scalatest-mustmatchers_2.13-3.2.19.jar", - "sha256": "0bnkgbkbda4ga82nnrkzfg737rxnjicw6c9z50amzms3530xbfwf" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_2.13/3.2.20/scalatest-matchers-core_2.13-3.2.20.pom", + "sha256": "0bvzgmp1jlbbk7cmskbg40xnfr5d2ql5ipjw86wni5y06ia8ssb5" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_2.13/3.2.19/scalatest-mustmatchers_2.13-3.2.19.pom", - "sha256": "1rmmmlpgc3k5lv97b79b820p0i6zxfy4wfng7vhjjw1f21q2h0cf" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_sjs1_2.13/3.2.20/scalatest-matchers-core_sjs1_2.13-3.2.20.jar", + "sha256": "015qi5miq6rpx6s18gl7byqv5m41x7bch3jngcj8p8br8ab1kv9w" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_sjs1_2.13/3.2.19/scalatest-mustmatchers_sjs1_2.13-3.2.19.jar", - "sha256": "11n8jqfvjwzc06ylip62cypx3zcmbwb3m3nd2b9r22rcqd26i31n" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-matchers-core_sjs1_2.13/3.2.20/scalatest-matchers-core_sjs1_2.13-3.2.20.pom", + "sha256": "040rl010726y5bi6nci2gdj1w4k5m14dlbfrbpq67lqr1wxj7cni" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_sjs1_2.13/3.2.19/scalatest-mustmatchers_sjs1_2.13-3.2.19.pom", - "sha256": "1z9xxwz4vvbh8ir39dv90z4hyfn7y423wqccwjq1ilsr8jai3vp1" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_2.13/3.2.20/scalatest-mustmatchers_2.13-3.2.20.jar", + "sha256": "06b9j5g9i2ikg82d4ixv7dz413jz3l51dbmfgnm6x7iqcl5qbrp6" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_2.13/3.2.19/scalatest-propspec_2.13-3.2.19.jar", - "sha256": "0pa871pbrnxqkcynliixx4kbcchjhy5789hw1f91v1l5fiaps73c" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_2.13/3.2.20/scalatest-mustmatchers_2.13-3.2.20.pom", + "sha256": "0mcsq64ymnk0nc5lisrd2f35xi1lf5vyzvp2f8vjw1phs0q66yyz" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_2.13/3.2.19/scalatest-propspec_2.13-3.2.19.pom", - "sha256": "1g92wc7v8kp3v0zkr865dqrzmr2hwk9qxjnva3sdbwh511jn29h6" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_sjs1_2.13/3.2.20/scalatest-mustmatchers_sjs1_2.13-3.2.20.jar", + "sha256": "12q6cw0jvm5s8cwmdr9z6y31vl4yfrh6xkax0jv46fpa3ldnls06" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_sjs1_2.13/3.2.19/scalatest-propspec_sjs1_2.13-3.2.19.jar", - "sha256": "18qhr3qzjfkbmk24nj5sfinsn7ywmml8rhp85a00af8g85qigmcj" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-mustmatchers_sjs1_2.13/3.2.20/scalatest-mustmatchers_sjs1_2.13-3.2.20.pom", + "sha256": "1w9afhqdni4bjswrx4dsmz2gzk9jmwgqyhmbvpdcgr4s3cpxwzbs" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_sjs1_2.13/3.2.19/scalatest-propspec_sjs1_2.13-3.2.19.pom", - "sha256": "1xm31731zpl6ki880l77g5d60m2zfb4sd0gqy35br8f19m9wr3ph" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_2.13/3.2.20/scalatest-propspec_2.13-3.2.20.jar", + "sha256": "1n7kjsyzhwz469inr2z80fgn831j93cgqg7qfrws55dbgrwh4154" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-refspec_2.13/3.2.19/scalatest-refspec_2.13-3.2.19.jar", - "sha256": "0li6fpgs9bbkmw9rwkql96xpv2znqja6svm95khw36bcyrn86k3s" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_2.13/3.2.20/scalatest-propspec_2.13-3.2.20.pom", + "sha256": "173asmrfq8hqng3b2m9xdwrwddlas2xskml0hzfwpamacwb0pbr1" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-refspec_2.13/3.2.19/scalatest-refspec_2.13-3.2.19.pom", - "sha256": "1pmvh21x2amzvgrwnl4m7v3yh2km2lw2qw4qms5b7g1j7b47nk8c" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_sjs1_2.13/3.2.20/scalatest-propspec_sjs1_2.13-3.2.20.jar", + "sha256": "1yay04m70k82ca6dwmq2qr8ph2szvwm4m5s75fqcdrzfzaqgzzr5" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_2.13/3.2.19/scalatest-shouldmatchers_2.13-3.2.19.jar", - "sha256": "03rwc0mavr1i5qp130s65y5yi33yc4hgd2hhw2m7n9irc1rqsrb4" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-propspec_sjs1_2.13/3.2.20/scalatest-propspec_sjs1_2.13-3.2.20.pom", + "sha256": "1sb20pjzpfhaqg2hcydkmv28l5zpk6gnfdzk7lz9g94mx8jb1d18" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_2.13/3.2.19/scalatest-shouldmatchers_2.13-3.2.19.pom", - "sha256": "0vk71xg54xsy1j7lnxiq89p5z8rg2xsxhsbmy30r6d2mqs0pixcn" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-refspec_2.13/3.2.20/scalatest-refspec_2.13-3.2.20.jar", + "sha256": "0cyyrfz817yx3dw5j4yyzrbc840isj9cb7ffjq09x315n0indil8" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_sjs1_2.13/3.2.19/scalatest-shouldmatchers_sjs1_2.13-3.2.19.jar", - "sha256": "0xy4lagksac10kfxxczwjq5d18897qd8hiq9apnw9y003ql9iwbl" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-refspec_2.13/3.2.20/scalatest-refspec_2.13-3.2.20.pom", + "sha256": "1f6p5jip0mnxcpdwj9zghnaghsfy6vkvl536dygk7nwaysmlbb1y" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_sjs1_2.13/3.2.19/scalatest-shouldmatchers_sjs1_2.13-3.2.19.pom", - "sha256": "0a1i7rlm9gaspg1rx5n5pmq1iqv4srrnzhnns4pb7fg0ivzqa7dr" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_2.13/3.2.20/scalatest-shouldmatchers_2.13-3.2.20.jar", + "sha256": "15drab0a2sn886slja3g4dbqlv5jllj0n9vhjrnv4kay1fzjcx35" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_2.13/3.2.19/scalatest-wordspec_2.13-3.2.19.jar", - "sha256": "14zghl1k70rhd06giz616jgmyga4bcc8654sqb57a9f70lyhy188" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_2.13/3.2.20/scalatest-shouldmatchers_2.13-3.2.20.pom", + "sha256": "08j04r6y5fc13jms03hn35j8ymwpdhr95y1mlbxa99a9zpz1syy4" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_2.13/3.2.19/scalatest-wordspec_2.13-3.2.19.pom", - "sha256": "0xgmlvqm0g49m3j76ji18a4xh2msm2jm4xhhslrdlzghyaacfzl3" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_sjs1_2.13/3.2.20/scalatest-shouldmatchers_sjs1_2.13-3.2.20.jar", + "sha256": "0q7rajk8pyvph42lal3ax84yi2c4bxay26gf68smdw5qv9j1hnmx" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_sjs1_2.13/3.2.19/scalatest-wordspec_sjs1_2.13-3.2.19.jar", - "sha256": "0f1kv2dgryn88k0gql232vyp9a9mh2mx2h8szc3qh44nx2ilsj5x" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-shouldmatchers_sjs1_2.13/3.2.20/scalatest-shouldmatchers_sjs1_2.13-3.2.20.pom", + "sha256": "0gcpcr4dd5dl1imvlc2zaw0zwc9dggr566bwdzq3hfr1sz7czh8x" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_sjs1_2.13/3.2.19/scalatest-wordspec_sjs1_2.13-3.2.19.pom", - "sha256": "1pfw0jm1xwkaivdvd9gqd11f44lh8147msv8fcy7w5yzgkzvdgxp" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_2.13/3.2.20/scalatest-wordspec_2.13-3.2.20.jar", + "sha256": "0hwky1n981d6695gzjn5wn56yr6l90xgpjwb8wx5zlfh6pr39nvq" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_2.13/3.2.19/scalatest_2.13-3.2.19.jar", - "sha256": "0ljs04izjz3h2qjjfbrczz8vfx11vmcvxsqcxwm5pm3jc7qrfzf3" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_2.13/3.2.20/scalatest-wordspec_2.13-3.2.20.pom", + "sha256": "0k6wvw6bv5v3xfmdhshbk61n1lm81y3bmdcx3sg62dd705ph1fk8" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_2.13/3.2.19/scalatest_2.13-3.2.19.pom", - "sha256": "1ard539ljzwsk5lhpjkg09qzjhqky6f7zmyyznlfyd5aqzdh47j2" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_sjs1_2.13/3.2.20/scalatest-wordspec_sjs1_2.13-3.2.20.jar", + "sha256": "03l9apbi12yshfgw2pxmgznhc7c54870rf3h7qnlcvrx3xmw6fib" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_sjs1_2.13/3.2.19/scalatest_sjs1_2.13-3.2.19.jar", - "sha256": "0526396bpmc6599mxh253pld0s3fxd8yp2picczd6fp7jsjfvrzm" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest-wordspec_sjs1_2.13/3.2.20/scalatest-wordspec_sjs1_2.13-3.2.20.pom", + "sha256": "19ww3r0rp62iqnjh0h3alzgv9lka38lad4aymqvy9267wwsxl2rs" }, { - "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_sjs1_2.13/3.2.19/scalatest_sjs1_2.13-3.2.19.pom", - "sha256": "0i5kqj8fppxc2l3c0ar258cjwdm07ifmh9rw48qv3i588pb26qvi" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_2.13/3.2.20/scalatest_2.13-3.2.20.jar", + "sha256": "0jzdwlnvh1pw3vm5fx6spwmgr59fkjxpwgsx8s6c2wcvd34757y8" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/sbt-scoverage_2.12_1.0/2.0.11/sbt-scoverage_2.12_1.0-2.0.11.jar", - "sha256": "172bx65vm4nxh6mf2imfxjj2gk8n714kl32xg4kdl4aq08hgqjw7" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_2.13/3.2.20/scalatest_2.13-3.2.20.pom", + "sha256": "1wz3wb4cry39aykaxrpsb5m7cbjwkz4hx8q4kaa9ywfr0n8q73ad" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/sbt-scoverage_2.12_1.0/2.0.11/sbt-scoverage_2.12_1.0-2.0.11.pom", - "sha256": "1f3g1wi260g5531a2cv47qvqfmz9vl2vs46fm94ffhm7yiijnx5n" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_sjs1_2.13/3.2.20/scalatest_sjs1_2.13-3.2.20.jar", + "sha256": "030syn0fz9q3b7k8nydcpxgn7h5442y190bppiwnbr9bsp1qv38q" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-domain_2.12/2.1.0/scalac-scoverage-domain_2.12-2.1.0.jar", - "sha256": "0a4p5bmq1q2xkwdccfgzi5kws2d4pj25jxkd6sxhcrf3yc0s2799" + "url": "https://repo1.maven.org/maven2/org/scalatest/scalatest_sjs1_2.13/3.2.20/scalatest_sjs1_2.13-3.2.20.pom", + "sha256": "16fs14wn781xc2x29k5jqfa7lhgdn7c0qyb5x8mamlz6syimrxjx" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-domain_2.12/2.1.0/scalac-scoverage-domain_2.12-2.1.0.pom", - "sha256": "15j6i6qla9xnckq88ya1k6164fvqkq551gk2lrgax53w5cl3fsc2" + "url": "https://repo1.maven.org/maven2/org/scoverage/sbt-scoverage_2.12_1.0/2.4.4/sbt-scoverage_2.12_1.0-2.4.4.jar", + "sha256": "1vs4xq7q9j9wq18faiin9mc6a83a9qmdzfjkgcy2n7p5icgpfalc" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-plugin_2.12.19/2.1.0/scalac-scoverage-plugin_2.12.19-2.1.0.jar", - "sha256": "1a4n1as1z775qysb0nkg0k6y53wfvdqkpr1aand8yizhgj677gsx" + "url": "https://repo1.maven.org/maven2/org/scoverage/sbt-scoverage_2.12_1.0/2.4.4/sbt-scoverage_2.12_1.0-2.4.4.pom", + "sha256": "1yzsph5066faq9yqj2ldwi5gka146k7bg367y06ifjyiil72j2m1" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-plugin_2.12.19/2.1.0/scalac-scoverage-plugin_2.12.19-2.1.0.pom", - "sha256": "0ghbh4fwzskk1b2b6b51cbamy7i1rma4ljx3sb6mbg4s05i1pxwl" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-domain_2.12/2.5.2/scalac-scoverage-domain_2.12-2.5.2.jar", + "sha256": "1vl1n2ihxagyb2419hz98fh1s2dw92zixnicd40jnpwkaaygpqvq" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-reporter_2.12/2.1.0/scalac-scoverage-reporter_2.12-2.1.0.jar", - "sha256": "01gn4hshmmzjlqlqmifsibjixingbxipcvdxsj2yq6h68pbgfcrg" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-domain_2.12/2.5.2/scalac-scoverage-domain_2.12-2.5.2.pom", + "sha256": "1chnxj06gzb7pp0wi718611r1d4ky702g3rdjajxhifn4293jypi" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-reporter_2.12/2.1.0/scalac-scoverage-reporter_2.12-2.1.0.pom", - "sha256": "0hj6vnjqyvxlz874ga6m5k7gxff4vm7lirxspzmfdvsbwnqy8s6q" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-reporter_2.12/2.5.2/scalac-scoverage-reporter_2.12-2.5.2.jar", + "sha256": "13zgm0aym3ppw3icgdq29qbkrl4z05znlmj00fn4sgchq5izq0i4" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-serializer_2.12/2.1.0/scalac-scoverage-serializer_2.12-2.1.0.jar", - "sha256": "19sz51wjvm2swqvgx6w3rbqs77140dxznc8dnw35da2y9sd86i9f" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-reporter_2.12/2.5.2/scalac-scoverage-reporter_2.12-2.5.2.pom", + "sha256": "1gmiyqynilr55gmcbir049fspvz56j25378z7wkzz9sxnfglb3xy" }, { - "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-serializer_2.12/2.1.0/scalac-scoverage-serializer_2.12-2.1.0.pom", - "sha256": "1mgiybl4va00j9wxjyvln0gw20lr4g4sd3fpjkzxw93vwlwwhyj8" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-serializer_2.12/2.5.2/scalac-scoverage-serializer_2.12-2.5.2.jar", + "sha256": "0mcc0spv80pqfppcndggm4xiyjvkdww1fwx298gss2y2qh7pz68r" }, { - "url": "https://repo1.maven.org/maven2/org/slf4j/jcl-over-slf4j/1.7.7/jcl-over-slf4j-1.7.7.jar", - "sha256": "1qmw2jhd8wdgsyhbf16nz93dpl9j91737ik7wl135hp1a1cjniy6" - }, - { - "url": "https://repo1.maven.org/maven2/org/slf4j/jcl-over-slf4j/1.7.7/jcl-over-slf4j-1.7.7.pom", - "sha256": "1s0n2k1db2jp4fs8bgbmm4vgyd7ng00xkqbz0x6p4lvrl4zw6i9q" + "url": "https://repo1.maven.org/maven2/org/scoverage/scalac-scoverage-serializer_2.12/2.5.2/scalac-scoverage-serializer_2.12-2.5.2.pom", + "sha256": "1angp9gdg76m1pqb6dp5lkdhj2pj1m6mq0v3zf81p5ixfxc9h8x6" }, { "url": "https://repo1.maven.org/maven2/org/slf4j/slf4j-api/1.7.30/slf4j-api-1.7.30.pom", @@ -2545,21 +2493,17 @@ "url": "https://repo1.maven.org/maven2/org/slf4j/slf4j-parent/1.7.36/slf4j-parent-1.7.36.pom", "sha256": "04p8sy7afq7kbl60810z8pf1hck970cf5v9w9kkcvlydzcvqsf5v" }, - { - "url": "https://repo1.maven.org/maven2/org/slf4j/slf4j-parent/1.7.7/slf4j-parent-1.7.7.pom", - "sha256": "1m1k7nkwc73mr6bza02nznzr9lfryn68k5hw4rz59l38wwyaxzqx" - }, { "url": "https://repo1.maven.org/maven2/org/slf4j/slf4j-parent/2.0.17/slf4j-parent-2.0.17.pom", "sha256": "0fxz5c4fwpal1cp8b4a30bkb5hkyslwvkdsqdq92knyzabl73kcm" }, { - "url": "https://repo1.maven.org/maven2/org/sonatype/buildsupport/buildsupport/6/buildsupport-6.pom", - "sha256": "1ip0q7ljnyc4a7ycj8y2rsr883wz6zq143vp5snpmfkz07j98204" + "url": "https://repo1.maven.org/maven2/org/snakeyaml/snakeyaml-engine/3.0.1/snakeyaml-engine-3.0.1.jar", + "sha256": "156cjjcr2lyhadsz9489ajyx8scsksymrsaq5vh1a8hi0s48qqqb" }, { - "url": "https://repo1.maven.org/maven2/org/sonatype/buildsupport/public-parent/6/public-parent-6.pom", - "sha256": "0jiav99cxh4lrxzimkn8mijlbbgz38v3iiz24zpyi8218nklr2a6" + "url": "https://repo1.maven.org/maven2/org/snakeyaml/snakeyaml-engine/3.0.1/snakeyaml-engine-3.0.1.pom", + "sha256": "1ndn2kgdzb3hzkg876qn8dxgr3gk7gcjap9qhfpz93mgf7y9qm83" }, { "url": "https://repo1.maven.org/maven2/org/sonatype/oss/oss-parent/5/oss-parent-5.pom", @@ -2574,12 +2518,8 @@ "sha256": "0yl2hbwz2kn1hll1i00ddzn8f89bfdcjwdifz0pj2j15k1gjch7v" }, { - "url": "https://repo1.maven.org/maven2/org/sonatype/spice/zapper/spice-zapper/1.3/spice-zapper-1.3.jar", - "sha256": "1h5hyp8iily4gy62dhj0k7niqvblq51yrvq1h512w0jvj977i36k" - }, - { - "url": "https://repo1.maven.org/maven2/org/sonatype/spice/zapper/spice-zapper/1.3/spice-zapper-1.3.pom", - "sha256": "1jlgg61r3yl8gs0jzg3xgvy54qbryk40slfkz0fh427x7riby8bg" + "url": "https://repo1.maven.org/maven2/org/springframework/spring-framework-bom/5.3.39/spring-framework-bom-5.3.39.pom", + "sha256": "12qlqqhynnm7f4rnji47wgiwp4pnz0wdpqf73ax7qxkm7q4q3m7n" }, { "url": "https://repo1.maven.org/maven2/org/tukaani/xz/1.9/xz-1.9.jar", @@ -2598,20 +2538,12 @@ "sha256": "1s0d6nmzs35699vap9x1gqkwkwbdlq8gjcqbdnr999sjwkzm5w9k" }, { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_2.13/2.6.1/cats-core_2.13-2.6.1.jar", - "sha256": "16ml6z1hghgwq48adk53dl6csdy2vkwr5mr4h0y2lw48ma15jg96" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_2.13/2.6.1/cats-core_2.13-2.6.1.pom", - "sha256": "0ch8d549775n5hp8iaiz1ljrqssp4gbdd4zfywks3g3bw02lg8zb" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_sjs1_2.13/2.6.1/cats-core_sjs1_2.13-2.6.1.jar", - "sha256": "1kg1yfw1l7qpm7wsz1gr1wdnb59fk7smdcrx77nx6yc3llc6wbca" + "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_sjs1_2.13/2.13.0/cats-core_sjs1_2.13-2.13.0.jar", + "sha256": "1fadrrcr100wh9k70i2fas21qa8c6zgl9kdpk2hxp2k01ag8hgkp" }, { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_sjs1_2.13/2.6.1/cats-core_sjs1_2.13-2.6.1.pom", - "sha256": "1s01m1z8xpnd9ygkaqsxw7gka33zxa6a4473zzfsddfx206118hp" + "url": "https://repo1.maven.org/maven2/org/typelevel/cats-core_sjs1_2.13/2.13.0/cats-core_sjs1_2.13-2.13.0.pom", + "sha256": "1qb7342fhvjhkvi1hajjlkamnlg71bqkh4l3gp53qw1l05yi42z6" }, { "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_2.13/2.13.0/cats-kernel_2.13-2.13.0.jar", @@ -2622,164 +2554,80 @@ "sha256": "1kp66wd6r25rs0gm9d27if9jmxd1gmm6vgbqiys8kvwx0slw2jv3" }, { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_2.13/2.6.1/cats-kernel_2.13-2.6.1.jar", - "sha256": "0g0ks3w8gpk7h38959n6b06b438xf7w79av1c196l6q7w3v16ps8" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_2.13/2.6.1/cats-kernel_2.13-2.6.1.pom", - "sha256": "1hwk9l6h7ak7xfl71h1f0fb2jm09vlcp2qkrnghhhlh1ibi81rgr" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_sjs1_2.13/2.6.1/cats-kernel_sjs1_2.13-2.6.1.jar", - "sha256": "15155ldgpgy5xqrl8ag8z88nk24nhrj54k3p61pqjfqym4q6bp98" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_sjs1_2.13/2.6.1/cats-kernel_sjs1_2.13-2.6.1.pom", - "sha256": "086iawardgg1jr6rb4097dgc4xym912d4shbpvc5cw8jxp1bywsa" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/jawn-parser_2.13/1.1.2/jawn-parser_2.13-1.1.2.jar", - "sha256": "0w0k2dvad6z56hna83r42wr950jn3m7iavww3981wyk6llbzw447" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/jawn-parser_2.13/1.1.2/jawn-parser_2.13-1.1.2.pom", - "sha256": "1yiviwpd3slgdvk8xyhckl3p3llsdl4ik3111hpp7ix455f35w6n" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/kind-projector_2.13.16/0.13.3/kind-projector_2.13.16-0.13.3.jar", - "sha256": "14id6lxzpdddy1l0j2rizxmzafwydi2s19h57wacv0msvrafr7sn" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/kind-projector_2.13.16/0.13.3/kind-projector_2.13.16-0.13.3.pom", - "sha256": "107a8g9x6fzfs7l94ksxmg80mizs9jag0g3fm3ccli8f40ags2k3" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/simulacrum-scalafix-annotations_2.13/0.5.4/simulacrum-scalafix-annotations_2.13-0.5.4.jar", - "sha256": "1s0ji6lljjqhqs4l4dx9q7niwpm8lac9j9x5m0bybzxp5axyvzhf" - }, - { - "url": "https://repo1.maven.org/maven2/org/typelevel/simulacrum-scalafix-annotations_2.13/0.5.4/simulacrum-scalafix-annotations_2.13-0.5.4.pom", - "sha256": "1irj37c0qw1ij367xcsl08ahd5gwvk3ggh0m5mzddxgc1mrhbdxa" - }, - { - "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/config_2.12/0.2.1/config_2.12-0.2.1.jar", - "sha256": "1g2knf6sg63w3xq7y0nsjhf3hhl8zmbza69bx1vzsr068nrsd83a" - }, - { - "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/config_2.12/0.2.1/config_2.12-0.2.1.pom", - "sha256": "1lmzwrrl7l97h1qlwj6c8133hh4bkslb88yvwkpkzdgnr54x7x0w" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-codec_2.12/23.11.3/airframe-codec_2.12-23.11.3.jar", - "sha256": "0bipr90j86i27hsgilpqmjiyx6blzpx4r683ck3zs5smln1mb5kh" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-codec_2.12/23.11.3/airframe-codec_2.12-23.11.3.pom", - "sha256": "025b0zvchxk0hwwkqyq8jxqkqvqbkqgmpp96fs5hpz3pq9p8ymhk" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-control_2.12/23.11.3/airframe-control_2.12-23.11.3.jar", - "sha256": "19x05xzz5nhl7g5ya22aw1iygmnw1h8v0xfmkkpiq25zvw6w0a3g" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-control_2.12/23.11.3/airframe-control_2.12-23.11.3.pom", - "sha256": "0fk24nq36wp3v3p39pm6k2y2nvxv2sqvw03c8pw6zplgff80p2w3" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-di-macros_2.12/23.11.3/airframe-di-macros_2.12-23.11.3.jar", - "sha256": "18yjqnsx1c84k4zkhzm0csq041nf0jl43qj5jlmgnm48xa6z0d3d" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-di-macros_2.12/23.11.3/airframe-di-macros_2.12-23.11.3.pom", - "sha256": "0kvk1mwkdqy3fhin6210j465cwg3hxkvb435r5k6v7z6pxlmk4sq" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-http_2.12/23.11.3/airframe-http_2.12-23.11.3.jar", - "sha256": "1y48n2wqyaba2c3d9kcpjdch39zmh86sckr0rb1is3a6qyrr824k" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-http_2.12/23.11.3/airframe-http_2.12-23.11.3.pom", - "sha256": "130npvrjxy4minlrjgk4aq1gchr038jl1785mzkfnikcsq6vh1n0" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-json_2.12/23.11.3/airframe-json_2.12-23.11.3.jar", - "sha256": "188s3ryk58vnhn24ivrcmgsvp2qda0p1if1ckpymlw7wpz906d9z" - }, - { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-json_2.12/23.11.3/airframe-json_2.12-23.11.3.pom", - "sha256": "08y0illi91p3d1s0syb468csqq3zgbdyi9njxwf71xn820jjk2zh" + "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_sjs1_2.13/2.13.0/cats-kernel_sjs1_2.13-2.13.0.jar", + "sha256": "0q0c837m4872i8na4ik4cl9vvwfxjc8mr3sga601pnzm0lyxcchj" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-log_2.12/23.11.3/airframe-log_2.12-23.11.3.jar", - "sha256": "0fkbq8cyrdi3v2zfn9ckppspzh0k44yf0rg7znwlcpxxclyh6j6m" + "url": "https://repo1.maven.org/maven2/org/typelevel/cats-kernel_sjs1_2.13/2.13.0/cats-kernel_sjs1_2.13-2.13.0.pom", + "sha256": "0005jgn7xzj5a97qkpdpcg2hqj0vqrynws6d483qb02d48k50bhn" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-log_2.12/23.11.3/airframe-log_2.12-23.11.3.pom", - "sha256": "1sjlx388a65g7wi94bbx1mcqbw2k4c4jn3qxp3qnglx7bgvjp0ms" + "url": "https://repo1.maven.org/maven2/org/typelevel/jawn-parser_2.13/1.7.0/jawn-parser_2.13-1.7.0.jar", + "sha256": "0zc6g48rsqfgq18h2r23lhyvi25krhjz8hylp4p2q93z1cpi63jq" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-metrics_2.12/23.11.3/airframe-metrics_2.12-23.11.3.jar", - "sha256": "0x4fp6i1rniyvypvkj0777f9lm36dpmziznawi6yl2z5y8hahqg7" + "url": "https://repo1.maven.org/maven2/org/typelevel/jawn-parser_2.13/1.7.0/jawn-parser_2.13-1.7.0.pom", + "sha256": "0w75v9c3gg17fl07h1bnld5k6avqh1w5qnapj1az79ivgazcriqp" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-metrics_2.12/23.11.3/airframe-metrics_2.12-23.11.3.pom", - "sha256": "059yyjy1ga56bk2hisrmk4qx9bxd2mrp1fzrnnak7d05vz0czm9w" + "url": "https://repo1.maven.org/maven2/org/typelevel/kind-projector_2.13.18/0.13.4/kind-projector_2.13.18-0.13.4.jar", + "sha256": "1h76viwqryiamb8ah0pgp284i7ig79r9h33zbk6319g1m8vw5fp4" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-msgpack_2.12/23.11.3/airframe-msgpack_2.12-23.11.3.jar", - "sha256": "0bv1s39pbrnzm2fqz223gzx548fcbrzh9kjxwfhhhkcsvglzywvm" + "url": "https://repo1.maven.org/maven2/org/typelevel/kind-projector_2.13.18/0.13.4/kind-projector_2.13.18-0.13.4.pom", + "sha256": "1zzf3myk3yhc2q245wq1idbgy1whv4hy0w12brh5ic1b34q6sxv1" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-msgpack_2.12/23.11.3/airframe-msgpack_2.12-23.11.3.pom", - "sha256": "032i1m9fmgzy8dj0jkd7k961vmp084mc9izlbipc7shfhr1aidc3" + "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/config_2.12/1.1.3/config_2.12-1.1.3.jar", + "sha256": "0m4lvlr55mx99fpqzivq5ib3mpdpmxbfg43zgsfzylhhmim1vd6p" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-rx_2.12/23.11.3/airframe-rx_2.12-23.11.3.jar", - "sha256": "1prri81h1ng0km1a7bwjmqrr8m49w1s1hn8i9fw080mprbkf8kbz" + "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/config_2.12/1.1.3/config_2.12-1.1.3.pom", + "sha256": "1002d3b0h5nb8j9kpl2am8bqmvls49jkxcb74j7xh3ww748aw895" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-rx_2.12/23.11.3/airframe-rx_2.12-23.11.3.pom", - "sha256": "0c672sy621ds7fsmvgrpawvvmcgm04k0xa0ifrfl0cnkn7bj1xxd" + "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/specification-level_2.12/1.1.3/specification-level_2.12-1.1.3.jar", + "sha256": "1bc0ww3g5x4yqjmygfw2nhadhmlclx0ij3xkpp7sm52ml7l8yx54" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-surface_2.12/23.11.3/airframe-surface_2.12-23.11.3.jar", - "sha256": "1qj4c2g56h6r609wnw5m65i13dpvbw6hqlmj74bzhh7d199wbar4" + "url": "https://repo1.maven.org/maven2/org/virtuslab/scala-cli/specification-level_2.12/1.1.3/specification-level_2.12-1.1.3.pom", + "sha256": "0nzskxfqzrdqwd31nr3v1ffb1a086ck2x2ir3ip5yvbr3m8zvy3w" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-surface_2.12/23.11.3/airframe-surface_2.12-23.11.3.pom", - "sha256": "1lz8q4nkwlhl3ln2z6765h06ys37yl6r60bgr7djgky6izly3mx8" + "url": "https://repo1.maven.org/maven2/tools/jackson/core/jackson-core/3.2.1/jackson-core-3.2.1.jar", + "sha256": "150zmyjqcv2wx4d61k34qk7zkwjbcjsiz9qngql06x101fkam216" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-ulid_2.12/23.11.3/airframe-ulid_2.12-23.11.3.jar", - "sha256": "07qg7b46i6x1bblmapdy6vyv6lcx3yb5kpj772blviydkzqybarc" + "url": "https://repo1.maven.org/maven2/tools/jackson/core/jackson-core/3.2.1/jackson-core-3.2.1.pom", + "sha256": "1al9yynp6wkvq7ddjmaz4b0lz6fsmgb3c5i0zjw3wr0qsgsd89zr" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe-ulid_2.12/23.11.3/airframe-ulid_2.12-23.11.3.pom", - "sha256": "1gyph68ldpd989z5rqv3179g7srv8xydxkzv2bs4gq50ib8whdhb" + "url": "https://repo1.maven.org/maven2/tools/jackson/core/jackson-databind/3.2.1/jackson-databind-3.2.1.jar", + "sha256": "06dmc8r1jqgqp133gfz1ai6s5rw7f91kmlvbx8p9x0xi4rb5q04d" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe_2.12/23.11.3/airframe_2.12-23.11.3.jar", - "sha256": "12krpxbr7vidywgls3061vwrvx7m0p4ma1xdzq8nc1f4hsr4k7y6" + "url": "https://repo1.maven.org/maven2/tools/jackson/core/jackson-databind/3.2.1/jackson-databind-3.2.1.pom", + "sha256": "1bpryx08sxvg7jixm8h61yy2dgz0n1jhn9mwafx69yw41qrwa3mm" }, { - "url": "https://repo1.maven.org/maven2/org/wvlet/airframe/airframe_2.12/23.11.3/airframe_2.12-23.11.3.pom", - "sha256": "0nar599bd4p53cxskr8abmqxlg68198arlzz00w0zvpmbghr5bxw" + "url": "https://repo1.maven.org/maven2/tools/jackson/dataformat/jackson-dataformat-yaml/3.2.1/jackson-dataformat-yaml-3.2.1.jar", + "sha256": "0kl5akkh21py98f5h6ash5dvd98n7yjvzdkrf1j0yr5bkb54i9xn" }, { - "url": "https://repo1.maven.org/maven2/org/xerial/sbt/sbt-sonatype_2.12_1.0/3.10.0/sbt-sonatype_2.12_1.0-3.10.0.jar", - "sha256": "1p7j8sq3mnrcksaym7zsdrjdl39z50q321nm7r40pxxd6ldq8bvm" + "url": "https://repo1.maven.org/maven2/tools/jackson/dataformat/jackson-dataformat-yaml/3.2.1/jackson-dataformat-yaml-3.2.1.pom", + "sha256": "1n53ibf323iavmg7fgr9f8mz9w8k3iiqx75zgvj0h1x4hp9mw6sx" }, { - "url": "https://repo1.maven.org/maven2/org/xerial/sbt/sbt-sonatype_2.12_1.0/3.10.0/sbt-sonatype_2.12_1.0-3.10.0.pom", - "sha256": "1r4a66ww35gqvq1v3glj8n7qdgl9dr9vdr8fsf8h3b4cnvqcrab5" + "url": "https://repo1.maven.org/maven2/tools/jackson/dataformat/jackson-dataformats-text/3.2.1/jackson-dataformats-text-3.2.1.pom", + "sha256": "0mdfyg9w2r2i8xn0ybi8fncdyqf4wrf7pp2wxlb49fnrmxwjf7w2" }, { - "url": "https://repo1.maven.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar", - "sha256": "0ilizwqd5z16ijx0h829pgaagnjq09ppw41c9jyn08v5dgk6z9v3" + "url": "https://repo1.maven.org/maven2/tools/jackson/jackson-base/3.2.1/jackson-base-3.2.1.pom", + "sha256": "1wq7x8k3mmxfcqhs79nwk91ha8adfpbj551faihsw5dqkvihc10b" }, { - "url": "https://repo1.maven.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.pom", - "sha256": "0bsqwf9ac3fl6l3bgnylycbqpq9d6b535gjjwd4h06rk0rd2cnhg" + "url": "https://repo1.maven.org/maven2/tools/jackson/jackson-bom/3.2.1/jackson-bom-3.2.1.pom", + "sha256": "13xbw593l7rc9qfyvdw1jzsq1rmz2sgmv75fab7l0l3h4fzfvqv5" } ] } From 1ca62fd44b9caef108531ea837d123f45c6ad982 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Wed, 23 Sep 2026 22:24:46 +0100 Subject: [PATCH 37/48] fix(csharp): rewrite deduplicated method I/O types in obsolete service signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `deduplicate` on (the CLI default), a type whose schema is unchanged across versions is emitted once in the latest namespace and references from older namespaces are rewritten to the surviving twin. That rewrite happens in `CSBaboonTranslator.renderType`, i.e. in the final `mapRender` over the `CSValue` nodes of the emitted tree. Services are never deduplicated, so an unchanged service is re-emitted per version — and its method signature mixed the two worlds. The parameter stayed a tree and was rewritten correctly, but the return type was flattened to a fully-qualified string (`csFqName` in `CSDefnTranslator`, `renderFq` in `CSServiceWiringTranslator`) before the renderer ran, so it kept the obsolete version's namespace and named a type no file declares: public Task> M(Repro.Svc.Svc.M.In arg); // CS0234 The obsolete wiring's result declarations had the same defect. The trigger is the method I/O types being unchanged, not the service itself: a service that gains a method while an existing method's inline types stay identical breaks the same way. The flattening is there because the result container is a user-configurable `$error`/`$success` pattern string. `ResolvedServiceResult.renderReturnTypeTree` and `expandPattern` now splice that pattern around the argument trees instead of around rendered strings, so the type nodes survive to the renderer. Call sites pass `.fullyQualified` trees, which keeps the rendered shape identical for every type that is not deduplicated away — verified byte-identical output for the whole shared model-dir matrix in the default and the errors/async configurations. C#-only: `deduplicate` has no consumer outside `CSTypeInfo`, and the other seven backends keep the string-based `renderReturnType`. Regression test: `CSharpDedupServiceSignatureTest` over the new isolated fixture `baboon-compiler/src/test/resources/dedup-service-ok/` (outside the shared model-dir, per the D9 note in CLAUDE.md). Co-Authored-By: Claude Opus 5 (1M context) --- .../CSharpDedupServiceSignatureTest.scala | 183 ++++++++++++++++++ .../translator/ServiceResultResolver.scala | 55 ++++++ .../translator/csharp/CSDefnTranslator.scala | 30 +-- .../csharp/CSServiceWiringTranslator.scala | 20 +- .../dedup-service-ok/dedup-service-1.baboon | 29 +++ .../dedup-service-ok/dedup-service-2.baboon | 12 ++ defects.md | 11 ++ 7 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/CSharpDedupServiceSignatureTest.scala create mode 100644 baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-1.baboon create mode 100644 baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-2.baboon diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/CSharpDedupServiceSignatureTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/CSharpDedupServiceSignatureTest.scala new file mode 100644 index 000000000..e50921af7 --- /dev/null +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/CSharpDedupServiceSignatureTest.scala @@ -0,0 +1,183 @@ +package io.septimalmind.baboon.tests + +import distage.plugins.PluginBase +import io.septimalmind.baboon.* +import io.septimalmind.baboon.CompilerTarget.CSTarget +import io.septimalmind.baboon.parser.model.FSPath +import io.septimalmind.baboon.parser.model.issues.BaboonIssue +import io.septimalmind.baboon.tests.BaboonTest.BaboonTestModule +import io.septimalmind.baboon.translator.BaboonAbstractTranslator +import io.septimalmind.baboon.typer.model.BaboonFamily +import izumi.distage.plugins.PluginConfig +import izumi.distage.testkit.model.TestConfig +import izumi.functional.bio.Error2 +import izumi.functional.bio.unsafe.UnsafeInstances +import izumi.fundamentals.collections.nonempty.{NEList, NEString} +import izumi.fundamentals.platform.files.IzFiles +import izumi.fundamentals.platform.resources.IzResources +import izumi.reflect.TagKK + +/** With `deduplicate` on, a type whose schema is unchanged across versions is emitted once, + * in the latest namespace, and the older namespaces reference that surviving twin. + * Services are never deduplicated, so an unchanged service is re-emitted per version and + * its method signature mixes both worlds: it must name the surviving twin for EVERY + * referenced type. The return type used to be flattened to a fully-qualified string inside + * `CSDefnTranslator`/`CSServiceWiringTranslator` before the renderer could rewrite it, so + * the old-version interface declared `…v1_0_0.Svc.M.Out` — a type no file declares (CS0234) + * — while the parameter, which stayed a tree, correctly pointed at the latest namespace. + */ +final class CSharpDedupServiceSignatureTest extends CSharpDedupServiceSignatureTestBase[Either] + +abstract class CSharpDedupServiceSignatureTestBase[F[+_, +_]: Error2: TagKK: BaboonTestModule] extends BaboonTest[F] { + + private val csTarget: CSTarget = CSTarget( + id = "C#", + output = OutputOptions( + safeToRemoveExtensions = Set.empty, + runtime = RuntimeGenOpt.With, + generateConversions = true, + output = FSPath.parse(NEString.unsafeFrom("./target/baboon-cs-dedup-service-test/")), + fixturesOutput = None, + testsOutput = None, + ), + generic = GenericOptions( + codecTestIterations = 0 + ), + language = CSOptions( + obsoleteErrors = false, + writeEvolutionDict = false, + wrappedAdtBranchCodecs = false, + disregardImplicitUsings = true, + omitMostRecentVersionSuffixFromPaths = true, + omitMostRecentVersionSuffixFromNamespaces = true, + enableDeprecatedEncoders = false, + generateIndexWriters = true, + generateJsonCodecs = true, + generateUebaCodecs = true, + generateJsonCodecsByDefault = true, + generateUebaCodecsByDefault = true, + deduplicate = true, + // Errors mode: the return type becomes `Either<$error, $success>`, which is the shape + // that used to be assembled from pre-rendered strings. + serviceResult = ServiceResultConfig( + noErrors = false, + resultType = Some("Either"), + pattern = Some("<$error,$success>"), + hkt = None, + ), + serviceContext = ServiceContextConfig.default, + pragmas = Map.empty, + generateDomainFacade = false, + asyncServices = true, + generateMcpServer = false, + ), + ) + + private val baseModule: distage.Module = + new BaboonModuleJvm[Either]( + CompilerOptions( + debug = false, + individualInputs = Set.empty, + directoryInputs = Set(FSPath.parse(NEString.unsafeFrom("./baboon-compiler/src/test/resources/dedup-service-ok"))), + metaWriteEvolutionJsonTo = None, + lockfile = Some(FSPath.parse(NEString.unsafeFrom("./target/baboon-dedup-service.lock"))), + emitOnly = None, + targets = Seq(csTarget), + ), + UnsafeInstances.Lawless_ParallelErrorAccumulatingOpsEither, + ) + + private val translatorModule: distage.Module = new BaboonJvmCSModule[Either](csTarget) + private val combinedModule: distage.Module = baseModule overriddenBy translatorModule + + override protected def config: TestConfig = super.config.copy( + pluginConfig = PluginConfig.const(combinedModule.morph[PluginBase]), + activation = super.config.activation + BaboonModeAxis.Compiler, + ) + + private def loadFamily(loader: BaboonLoader[F]): F[NEList[BaboonIssue], BaboonFamily] = { + val root = IzResources + .getPath("dedup-service-ok") + .getOrElse(throw new AssertionError("dedup-service-ok fixture not found")) + .asInstanceOf[IzResources.LoadablePathReference] + .path + val baboons = + if (root.toFile.isDirectory) + IzFiles.walk(root.toFile).toList.filter(p => p.toFile.isFile && p.toFile.getName.endsWith(".baboon")) + else List(root) + loader.load(baboons) + } + + private val obsoleteNs = "Dedup.Service.v1_0_0" + + "dedup-service-ok fixture, C# target with deduplication" should { + + "not emit the deduplicated method I/O types into the obsolete version's namespace" in { + (loader: BaboonLoader[F], translator: BaboonAbstractTranslator[F]) => + for { + family <- loadFamily(loader) + srcs <- translator.translate(family) + } yield { + val declarations = srcs.files.iterator.collect { + case (path, of) if of.content.contains(s"namespace $obsoleteNs") && of.content.contains("record Out") => path + }.toList + + // Premise of the test: the emitter really does drop them from 1.0.0. + assert( + declarations.isEmpty, + s"Expected the 1.0.0 method I/O types to be deduplicated away, but they are declared in: $declarations", + ) + } + } + + "reference the surviving latest-namespace twin from the obsolete service interface" in { + (loader: BaboonLoader[F], translator: BaboonAbstractTranslator[F]) => + for { + family <- loadFamily(loader) + srcs <- translator.translate(family) + } yield { + val all = srcs.files.iterator.map { case (path, of) => (path, of.content) }.toList + + val interface = all.collectFirst { + case (_, c) if c.contains(s"namespace $obsoleteNs") && c.contains("interface ISvc") => c + }.getOrElse(fail(s"Obsolete ISvc interface not found. Paths: ${all.map(_._1)}")) + + val signature = interface.linesIterator.find(_.contains(" M(")).getOrElse( + fail(s"Method M not found in the obsolete ISvc interface.\n$interface") + ) + + assert( + !signature.contains(s"$obsoleteNs.Svc.M."), + s"The obsolete interface must not name method I/O types that were deduplicated away.\n$signature", + ) + assert( + signature.contains("Dedup.Service.Svc.M.Out") && + signature.contains("Dedup.Service.Svc.M.Err") && + signature.contains("Dedup.Service.Svc.M.In"), + s"The obsolete interface must reference the surviving latest-namespace twins.\n$signature", + ) + } + } + + "reference the surviving latest-namespace twin from the obsolete wiring" in { + (loader: BaboonLoader[F], translator: BaboonAbstractTranslator[F]) => + for { + family <- loadFamily(loader) + srcs <- translator.translate(family) + } yield { + val all = srcs.files.iterator.map { case (path, of) => (path, of.content) }.toList + + val wiring = all.collectFirst { + case (_, c) if c.contains(s"namespace $obsoleteNs") && c.contains("class SvcWiring") => c + }.getOrElse(fail(s"Obsolete SvcWiring not found. Paths: ${all.map(_._1)}")) + + val declarations = wiring.linesIterator.filter(_.contains("> output;")).toList + assert(declarations.nonEmpty, s"No wiring result declaration found.\n$wiring") + assert( + declarations.forall(!_.contains(s"$obsoleteNs.Svc.M.")), + s"The obsolete wiring must not name method I/O types that were deduplicated away.\n${declarations.mkString("\n")}", + ) + } + } + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceResultResolver.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceResultResolver.scala index c321d2bb6..9714b5a2f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceResultResolver.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/ServiceResultResolver.scala @@ -2,6 +2,8 @@ package io.septimalmind.baboon.translator import io.septimalmind.baboon.{HktConfig, ServiceResultConfig} import io.septimalmind.baboon.typer.model.Domain +import izumi.fundamentals.platform.strings.TextTree +import izumi.fundamentals.platform.strings.TextTree.* case class ResolvedServiceResult( noErrors: Boolean, @@ -30,6 +32,53 @@ case class ResolvedServiceResult( } } + /** Tree-preserving counterpart of [[renderReturnType]]: `None` means the method returns + * the unit type, otherwise the returned tree keeps the language-value nodes of + * `outType`/`errType` instead of flattening them to text. + * + * Backends whose renderer post-processes type nodes MUST use this form — the C# renderer + * rewrites references to deduplicated types to their surviving twin at render time, and a + * type flattened to a string here escapes that rewrite and names a type that was never + * emitted. + */ + def renderReturnTypeTree[T]( + outType: Option[TextTree[T]], + errType: Option[TextTree[T]], + unitType: TextTree[T], + ): Option[TextTree[T]] = { + val success = outType.getOrElse(unitType) + + if (noErrors || errType.isEmpty) { + outType + } else { + val container = expandPattern(errType.get, success) + (hkt, container) match { + case (Some(h), Some(c)) => Some(Seq(TextTree.text[T](h.name), c).join("")) + case (None, Some(c)) => Some(Seq(TextTree.text[T](resultType.getOrElse("")), c).join("")) + case _ => outType + } + } + } + + /** Expands the configured `$error`/`$success` pattern, splicing the argument trees in place + * of the placeholders. `None` when no pattern is configured. + */ + def expandPattern[T](error: TextTree[T], success: TextTree[T]): Option[TextTree[T]] = { + pattern.map { + p => + val values = Map( + ServiceResultResolver.errorPlaceholder -> error, + ServiceResultResolver.successPlaceholder -> success, + ) + val matches = ServiceResultResolver.placeholders.findAllMatchIn(p).toList + val spliced: (List[TextTree[T]], Int) = matches.foldLeft((List.empty[TextTree[T]], 0)) { + case ((acc, pos), m) => + (acc ++ List(TextTree.text[T](p.substring(pos, m.start)), values(m.matched)), m.end) + } + (spliced._1 :+ TextTree.text[T](p.substring(spliced._2))).join("") + } + } + def traitTypeParam: Option[String] = { if (noErrors) None else hkt.map(h => s"${h.name}${h.signature}") @@ -37,6 +86,12 @@ case class ResolvedServiceResult( } object ServiceResultResolver { + val errorPlaceholder: String = "$error" + val successPlaceholder: String = "$success" + + private[translator] val placeholders = + s"${_root_.java.util.regex.Pattern.quote(errorPlaceholder)}|${_root_.java.util.regex.Pattern.quote(successPlaceholder)}".r + private val pragmaPrefix = Map( "scala" -> "scala.service.result.", "cs" -> "cs.service.result.", diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala index d27ae3820..a022d29a4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala @@ -5,6 +5,7 @@ import io.septimalmind.baboon.CompilerTarget.CSTarget import io.septimalmind.baboon.parser.model.issues.BaboonIssue import io.septimalmind.baboon.translator.{IdentifierFieldKind, ServiceMethodPlan} import io.septimalmind.baboon.translator.{ResolvedServiceContext, ServiceContextResolver, ServiceResultResolver} +import io.septimalmind.baboon.translator.FQNSymbol.* import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.{CSPackageId, CSType, CSTypeOrigin} import io.septimalmind.baboon.typer.model.* @@ -509,21 +510,22 @@ object CSDefnTranslator { val methods = service.methods.map { m => val plan = new ServiceMethodPlan(m, tpe => domainTypes.asCsRef(tpe), resolved, CSTypes.escapeCsKeyword(m.name.name.capitalize)) - val out = plan.output - val err = plan.error - val csFqName: CSValue => String = { - case t: CSValue.CSType => (t.pkg.parts :+ t.name).mkString(".") - case t: CSValue.CSTypeName => t.name + // Tree form, not a rendered FQN: `CSBaboonTranslator.renderType` rewrites references + // to deduplicated types to their surviving twin, and a type flattened to a string + // here would escape that rewrite and name a type that was never emitted. + // `.fullyQualified` keeps the rendered shape the same as the flattened FQN was. + val syncRet = resolved.renderReturnTypeTree( + plan.output.map(_.fullyQualified), + plan.error.map(_.fullyQualified), + q"void", + ) + val retType = (target.language.asyncServices, syncRet) match { + case (true, Some(ret)) => q"System.Threading.Tasks.Task<$ret>" + case (true, None) => q"System.Threading.Tasks.Task" + case (false, Some(ret)) => ret + case (false, None) => q"void" } - val outStr = out.map(_.mapRender(csFqName)).getOrElse("") - val errStr = err.map(_.mapRender(csFqName)) - val syncRet = resolved.renderReturnType(outStr, errStr, "void") - val retStr = - if (target.language.asyncServices) { - if (syncRet == "void") "System.Threading.Tasks.Task" - else s"System.Threading.Tasks.Task<$syncRet>" - } else syncRet - val methodEx = q"""public $retStr ${plan.methodName}($ctxParam${plan.input} arg);""" + val methodEx = q"""public $retType ${plan.methodName}($ctxParam${plan.input} arg);""" prependDocs(m.docs, methodEx) }.join("\n") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala index 55e249bc7..ddf37ca35 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala @@ -2,6 +2,7 @@ package io.septimalmind.baboon.translator.csharp import io.septimalmind.baboon.CompilerTarget.CSTarget import io.septimalmind.baboon.translator.{ResolvedServiceContext, ResolvedServiceResult, ServiceContextResolver, ServiceMethodPlan, ServiceResultResolver} +import io.septimalmind.baboon.translator.FQNSymbol.* import io.septimalmind.baboon.translator.csharp.CSTypes.* import io.septimalmind.baboon.translator.csharp.CSValue.CSTypeOrigin import io.septimalmind.baboon.typer.model.* @@ -522,7 +523,7 @@ object CSServiceWiringTranslator { val callStep = if (hasErrType) { val errType = plan.error.get - q"""${ct("BaboonWiringError", renderFq(outType))} output; + q"""${ctTree("BaboonWiringError", outType)} output; |try |{ | var callResult = await impl.${plan.methodName}(${ctxArgPass}decoded); @@ -534,7 +535,7 @@ object CSServiceWiringTranslator { | return rt.Fail<$baboonWiringError, $wireType>(new $baboonWiringError.CallFailed(method, ex)); |}""".stripMargin } else { - q"""${ct("BaboonWiringError", renderFq(outType))} output; + q"""${ctTree("BaboonWiringError", outType)} output; |try |{ | var callResultValue = await impl.${plan.methodName}(${ctxArgPass}decoded); @@ -738,9 +739,14 @@ object CSServiceWiringTranslator { private def ct(error: String, success: String): String = renderContainer(error, success) - private def renderFq(tree: TextTree[CSValue]): String = tree.mapRender { - case t: CSValue.CSType => (t.pkg.parts :+ t.name).mkString(".") - case t: CSValue.CSTypeName => t.name + /** Result container around a generated type. The type stays a tree node: rendering it here + * would bypass `CSBaboonTranslator.renderType`, which rewrites references to deduplicated + * types to their surviving twin, and would name a type that was never emitted. + * `.fullyQualified` keeps the rendered shape the same as a flattened FQN was. + */ + private def ctTree(error: String, success: TextTree[CSValue]): TextTree[CSValue] = { + val expanded = resolved.expandPattern(TextTree.text[CSValue](error), success.fullyQualified) + q"${resolved.resultType.get}${expanded.get}" } private def generateErrorsJsonMethod(service: Typedef.Service): TextTree[CSValue] = { @@ -756,7 +762,7 @@ object CSServiceWiringTranslator { if (isAsync) generateErrorsJsonCaseAsync(plan, inRef, decodeIn) else { val decodeStep = - q"""${ct("BaboonWiringError", renderFq(inRef))} input; + q"""${ctTree("BaboonWiringError", inRef)} input; |try |{ | var wire = $BaboonTools.ParseWireJson(data); @@ -896,7 +902,7 @@ object CSServiceWiringTranslator { if (isAsync) generateErrorsUebaCaseAsync(plan, inRef, decodeIn) else { val decodeStep = - q"""${ct("BaboonWiringError", renderFq(inRef))} input; + q"""${ctTree("BaboonWiringError", inRef)} input; |try |{ | var ms = new $memoryStream(data); diff --git a/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-1.baboon b/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-1.baboon new file mode 100644 index 000000000..3e0b8660d --- /dev/null +++ b/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-1.baboon @@ -0,0 +1,29 @@ +// C# deduplication fixture for the service-signature emission test. +// +// `Svc` is declared identically in both versions and its inline method I/O types are +// therefore unchanged, so the C# backend emits them ONCE, in the latest namespace, and +// drops them from `Dedup-Service-1.0.0/`. The 1.0.0 service interface is still emitted +// (services are never deduplicated), so its method signature must reference the +// surviving latest-namespace types — every reference in it, not just the parameter. +// +// `D` changes between the versions purely so that 1.0.0 is not itself deduplicated away. +// +// Lives OUTSIDE the shared `baboon/` model-dir on purpose: the language stubs scan that +// directory wholesale (see the D9 note in CLAUDE.md) and this fixture exists only for +// the JVM emission test. + +model dedup.service + +version "1.0.0" + +root data D { + a: i32 +} + +root service Svc { + def m ( + data in { x: i32 } + data out { y: i32 } + data err { code: i32 } + ) +} diff --git a/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-2.baboon b/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-2.baboon new file mode 100644 index 000000000..b31a31694 --- /dev/null +++ b/baboon-compiler/src/test/resources/dedup-service-ok/dedup-service-2.baboon @@ -0,0 +1,12 @@ +model dedup.service + +version "2.0.0" + +import "1.0.0" { * } without { + D +} + +root data D { + a: i32 + b: opt[str] +} diff --git a/defects.md b/defects.md index 232995bd8..35e4de9e7 100644 --- a/defects.md +++ b/defects.md @@ -10,6 +10,17 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved --- +## FIELD-2026-09-23 (user reports, C# backend) + +### [FIELD-2026-09-23-D01] C# service signature of an obsolete version names deduplicated-away method I/O types (CS0234) +**Status:** resolved +**Severity:** major +**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala:513-521` (service case); `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala:741-745` (`renderFq`) +**Description:** With `deduplicate` on (the CLI default), a type whose schema is unchanged across versions is emitted once in the latest namespace; references from older namespaces are rewritten to the surviving twin by `CSBaboonTranslator.renderType` -> `CSTypeTranslator.isUpgradeable`, which runs in the final `mapRender` over the emitted tree. Services are never deduplicated (`CSTypeInfo.canBeUpgradedTo` returns `None` for `Typedef.Service`), so an unchanged service is re-emitted per version. Its method signature mixed the two worlds: the parameter stayed a `TextTree` node and was rewritten correctly, but the return type was flattened to a fully-qualified string by `csFqName`/`renderFq` before the renderer ran, so it kept the obsolete version's namespace. The obsolete interface declared `...v1_0_0.Svc.M.Out` (and `.Err`), a type no file declares, and the C# build failed with CS0234. The same flattening affected the obsolete `SvcWiring` result declarations. The trigger is the METHOD I/O types being unchanged, not the service: reproduced both with an identical service and with a service that gains a method while `m`'s inline types stay identical. C#-only, since `deduplicate` has no consumer outside `CSTypeInfo`. +**Fix:** Added `ResolvedServiceResult.renderReturnTypeTree` / `expandPattern` (`ServiceResultResolver.scala`), which splice the `$error`/`$success` pattern around the argument trees instead of around rendered strings. `CSDefnTranslator` and `CSServiceWiringTranslator.ctTree` now build the container from trees (`.fullyQualified`, so the rendered shape is unchanged for every non-deduplicated type) and the upgrade rewrite applies to the return type as it always did to the parameter. The seven non-C# backends keep the string-based `renderReturnType` — none of them rewrite type nodes at render time. Verified byte-identical output for the whole shared model-dir matrix in both default and errors/async configurations. Regression test: `CSharpDedupServiceSignatureTest` over the new isolated fixture `baboon-compiler/src/test/resources/dedup-service-ok/`. + +--- + ## MFACADE-PR-3 ### [MFACADE-PR-3-D01] C# reader throws OverflowException on out-of-byte numeric `$mv` From e187f0abc6147b9a6f5790a797a873b56071e143 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Wed, 23 Sep 2026 22:25:03 +0100 Subject: [PATCH 38/48] fix(runtime): follow the conversion chain in C# IsDeprecated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BaboonCodecsFacade.IsDeprecated` is the predicate behind the `Deprecated` arm of `ConvertClassified`: it answers whether a chain of registered conversions leads from a stored value's type to the latest version. It did not walk the chain. For every newer registered version it looked for a conversion whose `TypeFrom` is the ORIGINAL value's type and never moved to the type each step produces, so in a three-version lineage a 1.0.0 value matched at the 2.0.0 step and missed at the 3.0.0 step (whose registry holds `v2_0_0.X -> X`). The method returned true while `Convert` succeeded, and callers saw `Deprecated` for a perfectly convertible value. Advancing the type is necessary but not sufficient. Deduplicated codegen emits conversions that reach the latest CLASS several versions early — `Convert__T1_D2__From__1_0_0` in `testpkg.pkg0` is registered under 2.0.0 yet targets the 3.0.0 class, and 3.0.0 registers nothing for that type — and a conversion's `VersionTo()` is the registry's version, not the produced class's, so a strict per-version lockstep still reports a live type as deprecated. The walk is now reachability over the registered conversion graph following each conversion's `TypeTo`, with a visited set. A type that implements `IBaboonGeneratedLatest`, or one that a conversion of the newest registered version accepts, completes the path; the latter preserves the previous answer for a partially registered facade. Converters are still never executed. `AbstractBaboonConversions.FindConversions(Type)` is the type-keyed overload the walk needs, since it holds no instance of the intermediate types. C#-only: no other backend runtime has `IsDeprecated`. Regression test: `test/cs-stub/BaboonTests/FacadeDeprecationTests.cs` — real generated `fwde2e.chain` codegen for the conversion-at-every-step shape, plus synthetic registries for the reach-latest-early, ADT-member and dead-end shapes. Their converters throw from `DoConvert`, which pins the "without executing converters" half of the contract. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 67 ++++-- .../baboon-runtime/cs/BaboonConversions.cs | 10 + defects.md | 7 + .../BaboonTests/FacadeDeprecationTests.cs | 205 ++++++++++++++++++ 4 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 test/cs-stub/BaboonTests/FacadeDeprecationTests.cs diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index f4ea3c05f..1fa8665f5 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -965,8 +965,9 @@ public BaboonConvertResult ConvertClassified(TFrom value) /// /// Walks the registered conversion chain without executing converters: returns true - /// when any step is missing the type (no path to latest). Unknown domains and already-latest - /// models return false. + /// when no chain of conversions leads from the value's type to the latest version. Unknown + /// domains, already-latest models and values with no newer registered version return + /// false. /// public bool IsDeprecated(IBaboonGenerated value) { @@ -978,35 +979,65 @@ public bool IsDeprecated(IBaboonGenerated value) return false; } - var currentType = value.GetType(); - var adtType = (value as IBaboonAdtMemberMeta)?.BaboonAdtType(); - + var newer = new List(); foreach (var registered in versions) { - var toVersion = registered.Id; if (dvFrom.Version >= registered.Version) continue; - - if (!_versionsConversions.TryGetValue(toVersion, out var lazyConversions)) + if (!_versionsConversions.TryGetValue(registered.Id, out var lazyConversions)) { + // A newer version is registered but carries no conversions: no path can be established. return true; } - var typeConversions = lazyConversions.Value.FindConversions(value); - var hasMatch = false; - foreach (var typeConversion in typeConversions) + newer.Add(lazyConversions.Value); + } + + // Nothing newer is registered, so there is no later version this value could be deprecated by. + if (newer.Count == 0) return false; + + // Reachability over the registered conversion graph, following the type each step + // PRODUCES. A step may cover several versions at once: when consecutive versions share + // a type (deduplicated codegen) the conversion registered for the earlier version + // already yields the later version's class and no conversion is registered in between, + // so a strict per-version lockstep would report a live type as deprecated. + var visited = new HashSet(); + var pending = new Queue(); + + var valueType = value.GetType(); + visited.Add(valueType); + pending.Enqueue(valueType); + + // ADT conversions are registered on the ADT type, not on its branches. + if (value is IBaboonAdtMemberMeta adtMember) + { + var adtType = adtMember.BaboonAdtType(); + if (visited.Add(adtType)) pending.Enqueue(adtType); + } + + var lastIndex = newer.Count - 1; + while (pending.Count > 0) + { + var current = pending.Dequeue(); + if (typeof(IBaboonGeneratedLatest).IsAssignableFrom(current)) return false; + + for (var i = 0; i <= lastIndex; i++) { - var typeFrom = typeConversion.TypeFrom(); - if (typeFrom == currentType || (adtType != null && typeFrom == adtType)) + var conversions = newer[i].FindConversions(current); + if (conversions.Count == 0) continue; + + // A conversion into the newest registered version completes the path even when + // that version is not the latest one the model knows about. + if (i == lastIndex) return false; + + foreach (var conversion in conversions) { - hasMatch = true; - break; + var next = conversion.TypeTo(); + if (visited.Add(next)) pending.Enqueue(next); } } - - if (!hasMatch) return true; } - return false; + return true; } private static bool IsAdtConversion(IBaboonGenerated instance, IConversion conversion) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs index caa4990d3..e0ff572cc 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonConversions.cs @@ -227,6 +227,16 @@ public IReadOnlyList FindConversions(IBaboonGenerated value) return new List(); } + /// + /// Conversions declared for . Type-keyed counterpart of + /// for callers that walk the conversion + /// graph without holding an instance of every intermediate type. + /// + public IReadOnlyList FindConversions(Type typeFrom) + { + return _convsWild.TryGetValue(typeFrom, out var tpeConv) ? tpeConv : new List(); + } + public TTo ConvertWithContext(T? c, TFrom from) { diff --git a/defects.md b/defects.md index 35e4de9e7..be74a0cea 100644 --- a/defects.md +++ b/defects.md @@ -19,6 +19,13 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved **Description:** With `deduplicate` on (the CLI default), a type whose schema is unchanged across versions is emitted once in the latest namespace; references from older namespaces are rewritten to the surviving twin by `CSBaboonTranslator.renderType` -> `CSTypeTranslator.isUpgradeable`, which runs in the final `mapRender` over the emitted tree. Services are never deduplicated (`CSTypeInfo.canBeUpgradedTo` returns `None` for `Typedef.Service`), so an unchanged service is re-emitted per version. Its method signature mixed the two worlds: the parameter stayed a `TextTree` node and was rewritten correctly, but the return type was flattened to a fully-qualified string by `csFqName`/`renderFq` before the renderer ran, so it kept the obsolete version's namespace. The obsolete interface declared `...v1_0_0.Svc.M.Out` (and `.Err`), a type no file declares, and the C# build failed with CS0234. The same flattening affected the obsolete `SvcWiring` result declarations. The trigger is the METHOD I/O types being unchanged, not the service: reproduced both with an identical service and with a service that gains a method while `m`'s inline types stay identical. C#-only, since `deduplicate` has no consumer outside `CSTypeInfo`. **Fix:** Added `ResolvedServiceResult.renderReturnTypeTree` / `expandPattern` (`ServiceResultResolver.scala`), which splice the `$error`/`$success` pattern around the argument trees instead of around rendered strings. `CSDefnTranslator` and `CSServiceWiringTranslator.ctTree` now build the container from trees (`.fullyQualified`, so the rendered shape is unchanged for every non-deduplicated type) and the upgrade rewrite applies to the return type as it always did to the parameter. The seven non-C# backends keep the string-based `renderReturnType` — none of them rewrite type nodes at render time. Verified byte-identical output for the whole shared model-dir matrix in both default and errors/async configurations. Regression test: `CSharpDedupServiceSignatureTest` over the new isolated fixture `baboon-compiler/src/test/resources/dedup-service-ok/`. +### [FIELD-2026-09-23-D02] `BaboonCodecsFacade.IsDeprecated` reports live types as deprecated (walk never advances the type) +**Status:** resolved +**Severity:** major +**Location:** `baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs:971-1010` +**Description:** For every newer registered version the walk looked for a conversion whose `TypeFrom` is the ORIGINAL value's type; it never moved to the type each step produces. From a 1.0.0 value in a three-version lineage, the 2.0.0 step matches and the 3.0.0 step does not (the 3.0.0 registry holds `v2_0_0.X -> X`), so the method returned `true` while `Convert` succeeded — `ConvertClassified` then reported `Deprecated` for a perfectly convertible value. Two registry shapes were affected: a conversion at every step (DTO and ADT alike), and a conversion that reaches the latest CLASS several versions early, which is what deduplicated codegen emits (`Convert__T1_D2__From__1_0_0` in `testpkg.pkg0` is registered under 2.0.0 yet targets the 3.0.0 class, and 3.0.0 registers nothing for that type — a conversion's `VersionTo()` is the registry's version, not the produced class's, so advancing the version alone does not fix this shape). C#-only: no other backend runtime has `IsDeprecated`. +**Fix:** Rewrote the walk as reachability over the registered conversion graph, following each conversion's `TypeTo`, with a visited set; a type that implements `IBaboonGeneratedLatest`, or one that a conversion of the newest registered version accepts, completes the path. Converters are still never executed. Added the type-keyed `AbstractBaboonConversions.FindConversions(Type)` overload for callers that have no instance of the intermediate types. Regression test: `test/cs-stub/BaboonTests/FacadeDeprecationTests.cs` — real generated `fwde2e.chain` codegen for the every-step shape, plus synthetic registries (whose converters throw, pinning the "does not execute converters" half of the contract) for the reach-latest-early, ADT-member and dead-end shapes. + --- ## MFACADE-PR-3 diff --git a/test/cs-stub/BaboonTests/FacadeDeprecationTests.cs b/test/cs-stub/BaboonTests/FacadeDeprecationTests.cs new file mode 100644 index 000000000..0c1d04c4b --- /dev/null +++ b/test/cs-stub/BaboonTests/FacadeDeprecationTests.cs @@ -0,0 +1,205 @@ +// `BaboonCodecsFacade.IsDeprecated` answers "does a chain of registered conversions lead +// from this stored value's type to the latest version" — it is the predicate that drives +// the `Deprecated` arm of `ConvertClassified`. It must walk the chain, i.e. follow the type +// each step produces, not keep looking for conversions out of the original type. +// +// Generated symbols (Fwde2e.Chain.*) are produced by `mdl :build :test-gen-regular-adt`; +// running `dotnet test` straight from the source tree may fail with missing symbols. +// +// Two levels of coverage: +// +// * `fwde2e.chain` (1.0.0 -> 2.0.0 -> 3.0.0, a conversion registered at every step) is +// real generated code, so the walk is exercised against a registry the compiler built. +// +// * The synthetic registries below reproduce the two remaining registry shapes the +// compiler emits, which no shared fixture combines with auto-derivable conversions: +// a conversion that reaches the latest CLASS several versions early (C# deduplicates +// types that are identical across versions, so no conversion is registered for the +// versions in between — e.g. `Convert__T1_D2__From__1_0_0` in `testpkg.pkg0` is +// registered under 2.0.0 yet targets `Testpkg.Pkg0.T1_D2`, the 3.0.0 class), and a +// type that is simply gone from the latest version. Their converters throw from +// `DoConvert`, which pins the "without executing converters" half of the contract. +#nullable enable + +// Old-version generated types carry [Obsolete] — naming them is the point of these tests. +#pragma warning disable CS0618 + +using System; +using System.Collections.Generic; +using Baboon.Runtime.Shared; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class FacadeDeprecationTests + { + private sealed class NoRequiredConversions + : Fwde2e.Chain.RequiredConversions, Fwde2e.Chain.v2_0_0.RequiredConversions + { + } + + private static BaboonCodecsFacade ChainFacade() + { + var facade = new Fwde2e.Chain.DomainFwde2eChainFacade(); + var required = new NoRequiredConversions(); + facade.RegisterConversions( + new BaboonDomainVersion("fwde2e.chain", "2.0.0"), + () => new Fwde2e.Chain.v2_0_0.BaboonConversions(required)); + facade.RegisterConversions( + new BaboonDomainVersion("fwde2e.chain", "3.0.0"), + () => new Fwde2e.Chain.BaboonConversions(required)); + return facade; + } + + [Test] + public void IsDeprecated_False_WhenEveryStepHasAConversion() + { + var facade = ChainFacade(); + var value = new Fwde2e.Chain.v1_0_0.ChainAppend(42); + + var converted = facade.Convert(value); + Assert.That(converted, Is.InstanceOf.Right>(), + $"Convert must succeed for a 1.0.0 ChainAppend; got {converted}"); + + Assert.That(facade.IsDeprecated(value), Is.False, + "ChainAppend has a registered conversion at every version step, so a path to latest exists."); + } + + [Test] + public void IsDeprecated_False_ForLatestValue() + { + var facade = ChainFacade(); + var value = new Fwde2e.Chain.ChainAppend(42, null, null); + + Assert.That(facade.IsDeprecated(value), Is.False, + "A value that is already at the latest version is never deprecated."); + } + + // ---- synthetic registries ------------------------------------------------------- + + private const string SyntheticDomain = "synthetic.deprecation"; + + private abstract class Stub : IBaboonGenerated + { + private readonly string _version; + + protected Stub(string version) => _version = version; + + public string BaboonDomainVersion() => _version; + public string BaboonDomainIdentifier() => SyntheticDomain; + public IReadOnlyList BaboonSameInVersions() => new List { _version }; + public IReadOnlyDictionary BaboonForwardReadable() => new Dictionary(); + public IReadOnlyDictionary BaboonMinReaderVersions() => new Dictionary(); + public string BaboonTypeIdentifier() => $"{SyntheticDomain}/:#{GetType().Name}"; + } + + private sealed class DtoV1 : Stub { public DtoV1() : base("1.0.0") {} } + private sealed class DtoV2 : Stub { public DtoV2() : base("2.0.0") {} } + private sealed class DtoV3 : Stub, IBaboonGeneratedLatest { public DtoV3() : base("3.0.0") {} } + + private sealed class GoneV1 : Stub { public GoneV1() : base("1.0.0") {} } + private sealed class GoneV2 : Stub { public GoneV2() : base("2.0.0") {} } + + private abstract class AdtV1 : Stub + { + protected AdtV1() : base("1.0.0") {} + + public sealed class Branch : AdtV1, IBaboonAdtMemberMeta + { + public string BaboonAdtTypeIdentifier() => $"{SyntheticDomain}/:#AdtV1"; + public Type BaboonAdtType() => typeof(AdtV1); + } + } + + private abstract class AdtV2 : Stub { protected AdtV2() : base("2.0.0") {} } + private abstract class AdtV3 : Stub, IBaboonGeneratedLatest { protected AdtV3() : base("3.0.0") {} } + + /// Converting must never happen while classifying: any execution fails the test. + private sealed class NeverRuns : AbstractConversion + { + private readonly string _from; + private readonly string _to; + + public NeverRuns(string from, string to) + { + _from = from; + _to = to; + } + + public override string VersionFrom() => _from; + public override string VersionTo() => _to; + public override string TypeId() => $"{SyntheticDomain}/:#{typeof(TTo).Name}"; + + protected override TTo DoConvert(TCtx? context, AbstractBaboonConversions conversions, TFrom from) + where TCtx : default => + throw new AssertionException("IsDeprecated must not execute converters."); + } + + private sealed class Registry : AbstractBaboonConversions + { + private readonly string _versionTo; + + public Registry(string versionTo, params IConversion[] conversions) + { + _versionTo = versionTo; + foreach (var conversion in conversions) Register(conversion); + } + + public override List VersionsFrom() => new List(); + public override string VersionTo() => _versionTo; + } + + private static BaboonCodecsFacade SyntheticFacade(Registry to2, Registry to3) + { + var facade = new BaboonCodecsFacade(); + facade.RegisterConversions(new BaboonDomainVersion(SyntheticDomain, "2.0.0"), () => to2); + facade.RegisterConversions(new BaboonDomainVersion(SyntheticDomain, "3.0.0"), () => to3); + return facade; + } + + [Test] + public void IsDeprecated_False_WhenOneConversionReachesTheLatestClassEarly() + { + // Deduplicated codegen: 2.0.0 and 3.0.0 share the class, so the 2.0.0 conversion + // already produces the latest type and 3.0.0 registers nothing for it. + var facade = SyntheticFacade( + new Registry("2.0.0", new NeverRuns("1.0.0", "2.0.0")), + new Registry("3.0.0")); + + Assert.That(facade.IsDeprecated(new DtoV1()), Is.False, + "The 2.0.0 conversion already produces the latest class; no 3.0.0 step is needed."); + } + + [Test] + public void IsDeprecated_False_ForAdtMemberWithAConversionAtEveryStep() + { + var facade = SyntheticFacade( + new Registry("2.0.0", new NeverRuns("1.0.0", "2.0.0")), + new Registry("3.0.0", new NeverRuns("2.0.0", "3.0.0"))); + + Assert.That(facade.IsDeprecated(new AdtV1.Branch()), Is.False, + "ADT conversions are registered on the ADT type; the walk must follow them from the branch."); + } + + [Test] + public void IsDeprecated_True_WhenTheChainDeadEndsBeforeLatest() + { + var facade = SyntheticFacade( + new Registry("2.0.0", new NeverRuns("1.0.0", "2.0.0")), + new Registry("3.0.0")); + + Assert.That(facade.IsDeprecated(new GoneV1()), Is.True, + "GoneV2 is not a latest type and nothing converts it further: the type is gone in 3.0.0."); + } + + [Test] + public void IsDeprecated_True_WhenNoConversionExistsAtAll() + { + var facade = SyntheticFacade(new Registry("2.0.0"), new Registry("3.0.0")); + + Assert.That(facade.IsDeprecated(new GoneV1()), Is.True, + "No conversion leads out of the value's type."); + } + } +} From 95106338e71b6ccc48bad40d72e1dfed797133e9 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 11:28:49 +0100 Subject: [PATCH 39/48] fix(runtime)!: thread the codec context through the facades' JSON encode and cross-format helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BaboonCodecContext` carries `useIndices`, `forwardWritePolicy`, `envelopeVersion` and `facade`. The first three are UEBA-only for encoding — `BaboonTypeMeta.from` computes `$rv` unconditionally, so JSON needs no policy input. `facade` is not: the `any` JSON encoder needs it whenever a field holds an `AnyOpaqueUeba`, because that payload has to be transcoded through `facade.uebaToJson`. `encodeToJson` hardcoded a facade-less context, so encoding such a value failed with Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueJson directly. — advice the signature made impossible to follow, while the sibling `encodeToBin(ctx, …)` in the same class was configurable. Kotlin and Kotlin-KMP alone already took a context here; the other eight runtimes were inconsistent with Kotlin and with their own binary path. The existing `AnyRoundTrip*` cross-format suites call the generated codec directly with `withFacade`, never the facade, which is why this was never caught. `jsonToUebaBytes` / `uebaToJson` had the same hole in all ten runtimes — hardcoded `Compact` on both legs — so a payload whose content carries a nested `any` in the opposite wire form failed one level down, and `jsonToUebaBytes` could never emit indexed UEBA. Those are the very methods `ctx.facade` dispatches into, so this is the recursion step of the same defect. All three entry points now take a codec context and use it, and the `any` runtime codecs pass their own `ctx` into the facade call so nested transcoding inherits the facade. BREAKING CHANGE: the context is a required leading parameter on `encodeToJson` (plus Scala's `encodeToJsonString` and Rust's `encode_to_json_with_override` / `_with_declared_trait`), `jsonToUebaBytes` and `uebaToJson`. No ctx-less overload is kept: `encodeToBin` already forces the parameter, and an implicit default is exactly the footgun that hid this. Call sites pass `Compact` unless they need the facade. Also corrects the Python facade's `ctx: str` annotations on `encode_to_bin` and `_encode_to_bin_stream` — the value is a `BaboonCodecContext`, as the private `_bin_type_meta` already declared and as `ctx.use_indices` requires. Decode entry points still pin `Compact` deliberately: the UEBA reader takes indexing from the wire header, `IndexElementsCount` is a generated constant, and the `any` decoders take no context, so no facade is needed to read. Recorded as a note in the ledger rather than changed. Rust's JSON side still refuses `AnyOpaque::Ueba` outright — a serde-side limitation predating this change, left alone. Regression tests: `EncodeToJson_WithFacadeContext_TranscodesUebaAnyPayloads` and `EncodeToJson_WithoutFacadeContext_StillReportsTheMissingFacade` in `test/cs-stub/BaboonTests/AnyRoundTripTests.cs`. In the other backends the signature change is itself the guard — a facade-less JSON encode no longer compiles. Verified with `mdl --seq :build :test` (200/200). Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon-runtime/cs/BaboonAnyBinCodec.cs | 2 +- .../baboon-runtime/cs/BaboonAnyJsonCodec.cs | 2 +- .../baboon-runtime/cs/BaboonCodecsFacade.cs | 22 +++++--- .../dart/baboon_any_opaque.dart | 2 + .../dart/baboon_codecs_facade.dart | 13 +++-- .../java/BaboonAnyBinCodec.java | 2 +- .../java/BaboonAnyJsonCodec.java | 2 +- .../java/BaboonCodecsFacade.java | 28 +++++----- .../kotlin-common/BaboonAnyJsonCodec.kt | 2 +- .../kotlin-kmp/BaboonAnyBinCodec.kt | 2 +- .../kotlin-kmp/BaboonCodecsFacade.kt | 10 ++-- .../kotlin/BaboonAnyBinCodec.kt | 2 +- .../kotlin/BaboonCodecsFacade.kt | 10 ++-- .../python/baboon_codecs_facade.py | 17 +++--- .../baboon-runtime/rust/any_opaque.rs | 2 +- .../rust/baboon_codecs_facade.rs | 19 ++++--- .../scala/BaboonAnyBinCodec.scala | 2 +- .../scala/BaboonAnyJsonCodec.scala | 2 +- .../scala/BaboonCodecsFacade.scala | 26 ++++----- .../swift/BaboonCodecsFacade.swift | 13 +++-- .../swift/baboon_any_field_codec.swift | 2 + .../typescript/BaboonAnyOpaque.ts | 4 +- .../typescript/BaboonCodecsFacade.ts | 17 +++--- .../python/PyJsonCodecGenerator.scala | 2 +- .../python/PyUEBACodecGenerator.scala | 2 +- defects.md | 25 +++++++++ test/conv-test-cs/ConvTest/UnitTest1.cs | 2 +- test/cs-stub/BaboonTests/AnyMetaCodecTests.cs | 22 ++++---- test/cs-stub/BaboonTests/AnyRoundTripTests.cs | 54 +++++++++++++++++++ .../BaboonTests/FacadeLatestMethodsTests.cs | 2 +- .../test/runtime/any_meta_codec_test.dart | 18 ++++--- .../test/java/runtime/AnyMetaCodecTest.java | 14 ++--- .../java/runtime/FacadeDecodeLatestTest.java | 2 +- .../test/kotlin/runtime/AnyMetaCodecTest.kt | 18 ++++--- .../test/kotlin/runtime/AnyMetaCodecTest.kt | 18 ++++--- .../RuntimeTests/test_any_meta_codec.py | 9 ++-- .../RuntimeTests/test_domain_facade.py | 2 +- .../test_recursive_tree_round_trip.py | 2 +- .../runtime/test_facade_cross_format.py | 6 ++- .../ReviewTests/test_versioned_facade.py | 5 +- test/rs-stub/tests/any_meta_codec_tests.rs | 25 ++++----- test/rs-stub/tests/any_round_trip_tests.rs | 6 +-- .../baboon_codecs_facade_latest_tests.rs | 2 +- .../tests/baboon_type_meta_codec_tests.rs | 2 +- .../tests/min_reader_tiers_fail_fast_tests.rs | 4 +- .../test/scala/runtime/AnyMetaCodecSpec.scala | 19 ++++--- .../runtime/ForwardCompatEnvelopeSpec.scala | 22 ++++---- .../runtime/ForwardCompatRenameSpec.scala | 2 +- .../RuntimeTests/AnyMetaCodecTests.swift | 5 +- .../src/runtime-tests/AnyMetaCodec.test.ts | 18 +++---- .../BaboonCodecsFacadeLatest.test.ts | 2 +- .../ForwardCompatEnvelope.test.ts | 3 +- 52 files changed, 326 insertions(+), 190 deletions(-) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs index 5bd7c5825..e2c37a105 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyBinCodec.cs @@ -30,7 +30,7 @@ public static void Encode( { var f = ctx.Facade ?? throw new BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueUeba directly."); - var anyConvResult = f.JsonToUebaBytes(anyJson.Meta, anyJson.Json, staticDomain, staticVersion, staticTypeid); + var anyConvResult = f.JsonToUebaBytes(ctx, anyJson.Meta, anyJson.Json, staticDomain, staticVersion, staticTypeid); if (anyConvResult is Either.Left anyConvL) { throw anyConvL.Value; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs index 220247db9..75be3c983 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonAnyJsonCodec.cs @@ -31,7 +31,7 @@ public static JToken Encode( { var f = ctx.Facade ?? throw new BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode(), or supply AnyOpaqueJson directly."); - var anyConvResult = f.UebaToJson(anyUeba.Meta, anyUeba.Bytes, staticDomain, staticVersion, staticTypeid); + var anyConvResult = f.UebaToJson(ctx, anyUeba.Meta, anyUeba.Bytes, staticDomain, staticVersion, staticTypeid); if (anyConvResult is Either.Left anyConvL) { throw anyConvL.Value; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs index 1fa8665f5..883e51226 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs +++ b/baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs @@ -444,6 +444,7 @@ public Either DecodeAny(AnyOpaque opaque /// static context it has, or accept the all-null semantics (= no static fallback). /// public Either JsonToUebaBytes( + BaboonCodecContext ctx, AnyMeta meta, JToken json, string? staticDomain = null, @@ -476,7 +477,7 @@ public Either JsonToUebaBytes( IBaboonGenerated typed; try { - typed = jsonCodec.Decode(BaboonCodecContext.Compact, json); + typed = jsonCodec.Decode(ctx, json); } catch (Exception e) { @@ -490,7 +491,7 @@ public Either JsonToUebaBytes( { using var ms = new MemoryStream(); using var writer = new BinaryWriter(ms); - binCodec.Encode(BaboonCodecContext.Compact, writer, typed); + binCodec.Encode(ctx, writer, typed); writer.Flush(); return Either.Right(ms.ToArray()); } @@ -508,6 +509,7 @@ public Either JsonToUebaBytes( /// static-fallback contract. /// public Either UebaToJson( + BaboonCodecContext ctx, AnyMeta meta, byte[] bytes, string? staticDomain = null, @@ -543,7 +545,7 @@ public Either UebaToJson( { using var ms = new MemoryStream(bytes); using var reader = new BinaryReader(ms); - typed = binCodec.Decode(BaboonCodecContext.Compact, reader); + typed = binCodec.Decode(ctx, reader); } catch (Exception e) { @@ -555,7 +557,7 @@ public Either UebaToJson( try { - var json = jsonCodec.Encode(BaboonCodecContext.Compact, typed); + var json = jsonCodec.Encode(ctx, typed); return Either.Right(json); } catch (Exception e) @@ -679,24 +681,28 @@ public Either DecodeFromBinLatest(byte[] bytes) /// Encodes a Baboon model to JSON. The result is a JObject containing type metadata /// fields and a $c key with the encoded content. /// + /// Codec context. Must carry a facade (BaboonCodecContext.WithFacade) + /// to encode any fields holding a UEBA payload, which have to be transcoded to JSON. /// The model to encode. /// JSON token with metadata and encoded content. /// Encoding failed. - public Either EncodeToJson(T value) + public Either EncodeToJson(BaboonCodecContext ctx, T value) where T : IBaboonGenerated { - return EncodeToJson(value, null); + return EncodeToJson(ctx, value, null); } /// /// Encodes a Baboon model to JSON. The result is a JObject containing type metadata /// fields and a $c key with the encoded content. /// + /// Codec context. Must carry a facade (BaboonCodecContext.WithFacade) + /// to encode any fields holding a UEBA payload, which have to be transcoded to JSON. /// The model to encode. /// Optional override for the embedded type metadata. /// JSON token with metadata and encoded content. /// Encoding failed. - public Either EncodeToJson(T value, BaboonTypeMeta? typeMetaOverride) + public Either EncodeToJson(BaboonCodecContext ctx, T value, BaboonTypeMeta? typeMetaOverride) where T : IBaboonGenerated { var typeMeta = BaboonTypeMeta.From(value, typeof(T)); @@ -709,7 +715,7 @@ public Either EncodeToJson(T value, BaboonTypeM var codec = codecResult.GetRight(); try { - var content = codec.Encode(BaboonCodecContext.Compact, value); + var content = codec.Encode(ctx, value); var metaJson = (typeMetaOverride ?? typeMeta).WriteJson(); if (metaJson is not JObject metaObj) { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart index 3b6df0d95..4154a0a98 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_any_opaque.dart @@ -50,6 +50,7 @@ Object? encodeAnyJsonField( // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. final anyFacade = anyFacadeBase as BaboonCodecsFacade; final anyConvResult = anyFacade.uebaToJson( + ctx, meta, bytes, staticDomain: staticDomain, @@ -128,6 +129,7 @@ void encodeAnyUebaField( // which only accepts BaboonCodecsFacadeBase, but real callers pass BaboonCodecsFacade. final anyFacade = anyFacadeBase as BaboonCodecsFacade; final anyConvResult = anyFacade.jsonToUebaBytes( + ctx, meta, json, staticDomain: staticDomain, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart index c9576b5c3..4b1afbd98 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart +++ b/baboon-compiler/src/main/resources/baboon-runtime/dart/baboon_codecs_facade.dart @@ -292,6 +292,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { /// PR-19-D02: pass `useAdtIdentifier=true` when encoding through an ADT-typed reference. BaboonEither> encodeToJson( + BaboonCodecContext ctx, BaboonGenerated value, { BaboonTypeMeta? typeMetaOverride, bool useAdtIdentifier = false, @@ -309,7 +310,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { final codec = (codecResult as BaboonRight).value as BaboonJsonCodecBase; try { - final content = codec.encode(BaboonCodecContext.compact, value); + final content = codec.encode(ctx, value); final metaJson = (typeMetaOverride ?? typeMeta).writeJson(); // PR-08-D06 analog: meta JSON must be a plain map for `$c` insertion to work. The runtime // helper `BaboonTypeMetaCodec.writeJson` always returns one; assert defensively. @@ -418,6 +419,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { /// the wire `meta` (variants B/C/D1/D2/D3 — codec-generation-time knowledge); wire data wins /// when both are present (override semantics). See PR-06-D01. BaboonEither jsonToUebaBytes( + BaboonCodecContext ctx, AnyMeta meta, Object? json, { String? staticDomain, @@ -444,7 +446,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { final BaboonGenerated typed; try { - typed = jsonCodec.decode(BaboonCodecContext.compact, json); + typed = jsonCodec.decode(ctx, json); } catch (e) { return BaboonLeft(BaboonDecoderFailure( "jsonToUebaBytes: cannot decode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", @@ -454,7 +456,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { try { final writer = BaboonBinWriter(); - binCodec.encode(BaboonCodecContext.compact, writer, typed); + binCodec.encode(ctx, writer, typed); return BaboonRight(writer.toBytes()); } catch (e) { return BaboonLeft(BaboonEncoderFailure( @@ -466,6 +468,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { /// Cross-format helper symmetric to [jsonToUebaBytes]. BaboonEither uebaToJson( + BaboonCodecContext ctx, AnyMeta meta, Uint8List bytes, { String? staticDomain, @@ -492,7 +495,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { final BaboonGenerated typed; try { - typed = binCodec.decode(BaboonCodecContext.compact, BaboonBinReader(bytes)); + typed = binCodec.decode(ctx, BaboonBinReader(bytes)); } catch (e) { return BaboonLeft(BaboonDecoderFailure( "uebaToJson: cannot decode UEBA payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", @@ -501,7 +504,7 @@ class BaboonCodecsFacade extends BaboonCodecsFacadeBase { } try { - return BaboonRight(jsonCodec.encode(BaboonCodecContext.compact, typed)); + return BaboonRight(jsonCodec.encode(ctx, typed)); } catch (e) { return BaboonLeft(BaboonEncoderFailure( "uebaToJson: cannot encode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java index 56f079aa4..692b89834 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyBinCodec.java @@ -31,7 +31,7 @@ public static void encode( throw new BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly."); } - var anyConvResult = anyFacade.jsonToUebaBytes(anyJson.meta(), anyJson.json(), staticDomain, staticVersion, staticTypeid); + var anyConvResult = anyFacade.jsonToUebaBytes(ctx, anyJson.meta(), anyJson.json(), staticDomain, staticVersion, staticTypeid); if (anyConvResult instanceof BaboonEither.Left anyConvL) { throw anyConvL.value(); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java index e7c2190e4..fe5c99ee4 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonAnyJsonCodec.java @@ -30,7 +30,7 @@ public static JsonNode encode( throw new BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly."); } - var anyConvResult = anyFacade.uebaToJson(anyUeba.meta(), anyUeba.bytes(), staticDomain, staticVersion, staticTypeid); + var anyConvResult = anyFacade.uebaToJson(ctx, anyUeba.meta(), anyUeba.bytes(), staticDomain, staticVersion, staticTypeid); if (anyConvResult instanceof BaboonEither.Left anyConvL) { throw anyConvL.value(); } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java index d32668938..45ee69701 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java +++ b/baboon-compiler/src/main/resources/baboon-runtime/java/BaboonCodecsFacade.java @@ -287,13 +287,13 @@ public BaboonEither decodeFromBin(byte[] return decodeFromBin(new LEDataInputStream(new ByteArrayInputStream(bytes))); } - public BaboonEither encodeToJson(BaboonGenerated value) { - return encodeToJson(value, null); + public BaboonEither encodeToJson(BaboonCodecContext ctx, BaboonGenerated value) { + return encodeToJson(ctx, value, null); } @SuppressWarnings("unchecked") public BaboonEither encodeToJson( - BaboonGenerated value, BaboonTypeMeta typeMetaOverride + BaboonCodecContext ctx, BaboonGenerated value, BaboonTypeMeta typeMetaOverride ) { BaboonTypeMeta typeMeta = BaboonTypeMeta.from(value, value.getClass()); BaboonEither codecResult = getJsonCodec(typeMeta, true); @@ -303,7 +303,7 @@ public BaboonEither encodeToJson( BaboonJsonCodec codec = (BaboonJsonCodec) ((BaboonEither.Right) codecResult).value(); try { - JsonNode content = codec.encode(BaboonCodecContext.Compact, value); + JsonNode content = codec.encode(ctx, value); JsonNode metaJson = (typeMetaOverride != null ? typeMetaOverride : typeMeta).writeJson(); if (!(metaJson instanceof ObjectNode metaObj)) { return BaboonEither.left(new BaboonCodecException.EncoderFailure( @@ -408,7 +408,7 @@ public BaboonEither decodeAny(AnyOpaque o */ @SuppressWarnings("unchecked") public BaboonEither jsonToUebaBytes( - AnyMeta meta, JsonNode json, String staticDomain, String staticVersion, String staticTypeid + BaboonCodecContext ctx, AnyMeta meta, JsonNode json, String staticDomain, String staticVersion, String staticTypeid ) { BaboonEither metaResult = buildSyntheticTypeMeta(meta, staticDomain, staticVersion, staticTypeid); if (metaResult instanceof BaboonEither.Left l) { @@ -430,7 +430,7 @@ public BaboonEither jsonToUebaBytes( BaboonGenerated typed; try { - typed = jsonCodec.decode(BaboonCodecContext.Compact, json); + typed = jsonCodec.decode(ctx, json); } catch (Exception e) { return BaboonEither.left(new BaboonCodecException.DecoderFailure( "jsonToUebaBytes: cannot decode JSON payload of type [" + typeMeta.domainIdentifier() + "." + typeMeta.typeIdentifier() + "] of version '" + typeMeta.domainVersion() + "'.", @@ -440,7 +440,7 @@ public BaboonEither jsonToUebaBytes( try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); LEDataOutputStream writer = new LEDataOutputStream(baos); - binCodec.encode(BaboonCodecContext.Compact, writer, typed); + binCodec.encode(ctx, writer, typed); writer.flush(); return BaboonEither.right(baos.toByteArray()); } catch (Exception e) { @@ -450,14 +450,14 @@ public BaboonEither jsonToUebaBytes( } } - public BaboonEither jsonToUebaBytes(AnyMeta meta, JsonNode json) { - return jsonToUebaBytes(meta, json, null, null, null); + public BaboonEither jsonToUebaBytes(BaboonCodecContext ctx, AnyMeta meta, JsonNode json) { + return jsonToUebaBytes(ctx, meta, json, null, null, null); } /** Cross-format helper symmetric to {@link #jsonToUebaBytes}. See its doc for static-fallback contract. */ @SuppressWarnings("unchecked") public BaboonEither uebaToJson( - AnyMeta meta, byte[] bytes, String staticDomain, String staticVersion, String staticTypeid + BaboonCodecContext ctx, AnyMeta meta, byte[] bytes, String staticDomain, String staticVersion, String staticTypeid ) { BaboonEither metaResult = buildSyntheticTypeMeta(meta, staticDomain, staticVersion, staticTypeid); if (metaResult instanceof BaboonEither.Left l) { @@ -480,7 +480,7 @@ public BaboonEither uebaToJson( BaboonGenerated typed; try { LEDataInputStream reader = new LEDataInputStream(new ByteArrayInputStream(bytes)); - typed = binCodec.decode(BaboonCodecContext.Compact, reader); + typed = binCodec.decode(ctx, reader); } catch (Exception e) { return BaboonEither.left(new BaboonCodecException.DecoderFailure( "uebaToJson: cannot decode UEBA payload of type [" + typeMeta.domainIdentifier() + "." + typeMeta.typeIdentifier() + "] of version '" + typeMeta.domainVersion() + "'.", @@ -488,7 +488,7 @@ public BaboonEither uebaToJson( } try { - return BaboonEither.right(jsonCodec.encode(BaboonCodecContext.Compact, typed)); + return BaboonEither.right(jsonCodec.encode(ctx, typed)); } catch (Exception e) { return BaboonEither.left(new BaboonCodecException.EncoderFailure( "uebaToJson: cannot encode JSON payload of type [" + typeMeta.domainIdentifier() + "." + typeMeta.typeIdentifier() + "] of version '" + typeMeta.domainVersion() + "'.", @@ -496,8 +496,8 @@ public BaboonEither uebaToJson( } } - public BaboonEither uebaToJson(AnyMeta meta, byte[] bytes) { - return uebaToJson(meta, bytes, null, null, null); + public BaboonEither uebaToJson(BaboonCodecContext ctx, AnyMeta meta, byte[] bytes) { + return uebaToJson(ctx, meta, bytes, null, null, null); } /** diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt index 664919274..99906754a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-common/BaboonAnyJsonCodec.kt @@ -26,7 +26,7 @@ object BaboonAnyJsonCodec { val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." ) - val anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid) + val anyConvResult = anyFacade.uebaToJson(ctx, value.meta, value.bytes, staticDomain, staticVersion, staticTypeid) when (anyConvResult) { is Either.Left -> throw anyConvResult.value is Either.Right -> anyConvResult.value diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt index a8c5a8fa4..ba2b06f44 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonAnyBinCodec.kt @@ -23,7 +23,7 @@ object BaboonAnyBinCodec { val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." ) - val anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid) + val anyConvResult = anyFacade.jsonToUebaBytes(ctx, value.meta, value.json, staticDomain, staticVersion, staticTypeid) when (anyConvResult) { is Either.Left -> throw anyConvResult.value is Either.Right -> anyConvResult.value diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt index b108e72db..14738d53c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin-kmp/BaboonCodecsFacade.kt @@ -456,6 +456,7 @@ open class BaboonCodecsFacade { * re-encode via UEBA. Static fallbacks fill components missing from the wire `meta` (variants * B/C/D1/D2/D3). Wire data wins (override semantics). See PR-06-D01. */ fun jsonToUebaBytes( + ctx: BaboonCodecContext, meta: AnyMeta, json: JsonElement, staticDomain: String? = null, @@ -473,7 +474,7 @@ open class BaboonCodecsFacade { @Suppress("UNCHECKED_CAST") val binCodec = getBinCodec(typeMeta, exact = false) as BaboonBinCodec val typed = try { - jsonCodec.decode(BaboonCodecContext.Compact, json) + jsonCodec.decode(ctx, json) } catch (e: Throwable) { return Either.Left( BaboonCodecException.DecoderFailure( @@ -484,7 +485,7 @@ open class BaboonCodecsFacade { } try { val writer = BaboonBinaryWriter() - binCodec.encode(BaboonCodecContext.Compact, writer, typed) + binCodec.encode(ctx, writer, typed) Either.Right(writer.toByteArray()) } catch (e: Throwable) { Either.Left( @@ -503,6 +504,7 @@ open class BaboonCodecsFacade { /** Cross-format helper symmetric to `jsonToUebaBytes`. */ fun uebaToJson( + ctx: BaboonCodecContext, meta: AnyMeta, bytes: ByteArray, staticDomain: String? = null, @@ -521,7 +523,7 @@ open class BaboonCodecsFacade { val jsonCodec = getJsonCodec(typeMeta, exact = false) as BaboonJsonCodec val typed = try { val reader = BaboonBinaryReader(bytes) - binCodec.decode(BaboonCodecContext.Compact, reader) + binCodec.decode(ctx, reader) } catch (e: Throwable) { return Either.Left( BaboonCodecException.DecoderFailure( @@ -531,7 +533,7 @@ open class BaboonCodecsFacade { ) } try { - Either.Right(jsonCodec.encode(BaboonCodecContext.Compact, typed)) + Either.Right(jsonCodec.encode(ctx, typed)) } catch (e: Throwable) { Either.Left( BaboonCodecException.EncoderFailure( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt index 8a9b1cf4b..2db46f536 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonAnyBinCodec.kt @@ -25,7 +25,7 @@ object BaboonAnyBinCodec { val anyFacade = ctx.facade ?: throw BaboonCodecException.EncoderFailure( "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." ) - val anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid) + val anyConvResult = anyFacade.jsonToUebaBytes(ctx, value.meta, value.json, staticDomain, staticVersion, staticTypeid) when (anyConvResult) { is Either.Left -> throw anyConvResult.value is Either.Right -> anyConvResult.value diff --git a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt index eedc03050..a2334625c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt +++ b/baboon-compiler/src/main/resources/baboon-runtime/kotlin/BaboonCodecsFacade.kt @@ -474,6 +474,7 @@ open class BaboonCodecsFacade { * supply all three. */ fun jsonToUebaBytes( + ctx: BaboonCodecContext, meta: AnyMeta, json: JsonElement, staticDomain: String? = null, @@ -491,7 +492,7 @@ open class BaboonCodecsFacade { @Suppress("UNCHECKED_CAST") val binCodec = getBinCodec(typeMeta, exact = false) as BaboonBinCodec val typed = try { - jsonCodec.decode(BaboonCodecContext.Compact, json) + jsonCodec.decode(ctx, json) } catch (e: Throwable) { return Either.Left( BaboonCodecException.DecoderFailure( @@ -503,7 +504,7 @@ open class BaboonCodecsFacade { try { val baos = java.io.ByteArrayOutputStream() val out = LEDataOutputStream(baos) - out.use { binCodec.encode(BaboonCodecContext.Compact, it, typed) } + out.use { binCodec.encode(ctx, it, typed) } Either.Right(baos.toByteArray()) } catch (e: Throwable) { Either.Left( @@ -525,6 +526,7 @@ open class BaboonCodecsFacade { * the registered UEBA codec, then re-encode it via the registered JSON codec. */ fun uebaToJson( + ctx: BaboonCodecContext, meta: AnyMeta, bytes: ByteArray, staticDomain: String? = null, @@ -543,7 +545,7 @@ open class BaboonCodecsFacade { val jsonCodec = getJsonCodec(typeMeta, exact = false) as BaboonJsonCodec val typed = try { val reader = LEDataInputStream(java.io.ByteArrayInputStream(bytes)) - binCodec.decode(BaboonCodecContext.Compact, reader) + binCodec.decode(ctx, reader) } catch (e: Throwable) { return Either.Left( BaboonCodecException.DecoderFailure( @@ -553,7 +555,7 @@ open class BaboonCodecsFacade { ) } try { - Either.Right(jsonCodec.encode(BaboonCodecContext.Compact, typed)) + Either.Right(jsonCodec.encode(ctx, typed)) } catch (e: Throwable) { Either.Left( BaboonCodecException.EncoderFailure( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py index 050392dcd..16c4f95c2 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py @@ -106,7 +106,7 @@ def try_convert(self, value: TI, target_type: Type[TO]) -> Optional[TO]: return None def encode_to_bin(self, - ctx: str, + ctx: BaboonCodecContext, value: TI, writer: Optional[LEDataOutputStream] = None, type_meta_override: Optional[BaboonTypeMeta] = None) -> bytes: @@ -115,7 +115,7 @@ def encode_to_bin(self, return output_stream.stream.getvalue() def _encode_to_bin_stream(self, - ctx: str, + ctx: BaboonCodecContext, writer: LEDataOutputStream, value: TI, type_meta_override: Optional[BaboonTypeMeta] = None): @@ -184,12 +184,13 @@ def decode_from_bin_latest_bytes(self, data: bytes, target_type: Type[TO]) -> TO return self.convert(baboon, target_type) def encode_to_json(self, + ctx: BaboonCodecContext, value: BaboonGenerated, type_meta_override: Optional[BaboonTypeMeta] = None) -> str: type_meta = BaboonTypeMeta.from_instance(value) try: codec = self._get_json_codec(type_meta, exact=True) - model_json = codec.encode(BaboonCodecContext.default(), value) + model_json = codec.encode(ctx, value) meta_json_dict = BaboonTypeMetaCodec.write_json(type_meta_override or type_meta) result = { **meta_json_dict, @@ -476,6 +477,7 @@ def _decode_any(self, opaque: AnyOpaque, native_json: bool) -> BaboonEither: def json_to_ueba_bytes( self, + ctx: BaboonCodecContext, meta: AnyMeta, json_value, static_domain: Optional[str] = None, @@ -509,7 +511,7 @@ def json_to_ueba_bytes( # fixture pattern). Accept both: dump parsed values, pass strings through. # Future hygiene tracked in M25-N03 (align Python JSON-codec interface). wire = json_value if isinstance(json_value, str) else json.dumps(json_value) - typed = json_codec.decode(BaboonCodecContext.Compact, wire) + typed = json_codec.decode(ctx, wire) except Exception as e: return BaboonLeft( BaboonCodecException.DecoderFailure( @@ -523,7 +525,7 @@ def json_to_ueba_bytes( try: buf = BytesIO() writer = LEDataOutputStream(buf) - bin_codec.encode(BaboonCodecContext.Compact, writer, typed) + bin_codec.encode(ctx, writer, typed) return BaboonRight(buf.getvalue()) except Exception as e: return BaboonLeft( @@ -537,6 +539,7 @@ def json_to_ueba_bytes( def ueba_to_json( self, + ctx: BaboonCodecContext, meta: AnyMeta, ueba_bytes: bytes, static_domain: Optional[str] = None, @@ -559,7 +562,7 @@ def ueba_to_json( try: reader = LEDataInputStream(BytesIO(ueba_bytes)) - typed = bin_codec.decode(BaboonCodecContext.Compact, reader) + typed = bin_codec.decode(ctx, reader) except Exception as e: return BaboonLeft( BaboonCodecException.DecoderFailure( @@ -571,7 +574,7 @@ def ueba_to_json( ) try: - return BaboonRight(json_codec.encode(BaboonCodecContext.Compact, typed)) + return BaboonRight(json_codec.encode(ctx, typed)) except Exception as e: return BaboonLeft( BaboonCodecException.EncoderFailure( diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index ef6d45755..34fd51922 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -139,7 +139,7 @@ pub mod any_field_codec { std::io::ErrorKind::InvalidData, "Cannot encode AnyOpaque::Json into UEBA without a facade reference. Construct the codec context via BaboonCodecContext::with_facade(use_indices, facade), or supply AnyOpaque::Ueba directly." ))?; - std::borrow::Cow::Owned(f.json_to_ueba_bytes(&j.meta, &j.json, static_domain, static_version, static_typeid) + std::borrow::Cow::Owned(f.json_to_ueba_bytes(ctx, &j.meta, &j.json, static_domain, static_version, static_typeid) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}", e)))?) } }; diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs index fbafb64b4..cb0c52153 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs @@ -562,6 +562,7 @@ impl BaboonCodecsFacade { /// override semantics. Without the static fallback, only variant A would work — see PR-06-D01. pub fn json_to_ueba_bytes( &self, + ctx: &BaboonCodecContext, meta: &AnyMeta, json: &serde_json::Value, static_domain: Option<&str>, @@ -572,7 +573,7 @@ impl BaboonCodecsFacade { self.build_synthetic_type_meta(meta, static_domain, static_version, static_typeid)?; let json_codec = self.get_json_codec(&type_meta, false)?; let bin_codec = self.get_bin_codec(&type_meta, false)?; - let typed = json_codec.decode_json_dyn(&BaboonCodecContext::Compact, json).map_err(|e| { + let typed = json_codec.decode_json_dyn(ctx, json).map_err(|e| { BaboonCodecError::decoder_failure(format!( "json_to_ueba_bytes: cannot decode JSON payload of type [{}.{}] of version '{}': {}", type_meta.domain_identifier, type_meta.type_identifier, type_meta.domain_version, e @@ -580,7 +581,7 @@ impl BaboonCodecsFacade { })?; let mut buf = Vec::new(); bin_codec - .encode_dyn(&BaboonCodecContext::Compact, &mut buf, typed.as_ref()) + .encode_dyn(ctx, &mut buf, typed.as_ref()) .map_err(|e| { BaboonCodecError::encoder_failure(format!( "json_to_ueba_bytes: cannot encode UEBA payload of type [{}.{}] of version '{}': {}", @@ -593,6 +594,7 @@ impl BaboonCodecsFacade { /// Symmetric to `json_to_ueba_bytes`. See its documentation for the static-fallback contract. pub fn ueba_to_json( &self, + ctx: &BaboonCodecContext, meta: &AnyMeta, bytes: &[u8], static_domain: Option<&str>, @@ -604,14 +606,14 @@ impl BaboonCodecsFacade { let bin_codec = self.get_bin_codec(&type_meta, false)?; let json_codec = self.get_json_codec(&type_meta, false)?; let mut cursor = Cursor::new(bytes); - let typed = bin_codec.decode_dyn(&BaboonCodecContext::Compact, &mut cursor).map_err(|e| { + let typed = bin_codec.decode_dyn(ctx, &mut cursor).map_err(|e| { BaboonCodecError::decoder_failure(format!( "ueba_to_json: cannot decode UEBA payload of type [{}.{}] of version '{}': {}", type_meta.domain_identifier, type_meta.type_identifier, type_meta.domain_version, e )) })?; let json = json_codec - .encode_json_dyn(&BaboonCodecContext::Compact, typed.as_ref()) + .encode_json_dyn(ctx, typed.as_ref()) .map_err(|e| { BaboonCodecError::encoder_failure(format!( "ueba_to_json: cannot encode JSON payload of type [{}.{}] of version '{}': {}", @@ -990,14 +992,16 @@ impl BaboonCodecsFacade { pub fn encode_to_json_with_override( &self, + ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn, type_meta_override: Option<&BaboonTypeMeta>, ) -> Result { - self.encode_to_json_with_declared_trait(value, type_meta_override, false) + self.encode_to_json_with_declared_trait(ctx, value, type_meta_override, false) } pub fn encode_to_json_with_declared_trait( &self, + ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn, type_meta_override: Option<&BaboonTypeMeta>, is_adt_trait: bool, @@ -1005,7 +1009,7 @@ impl BaboonCodecsFacade { let type_meta = self.type_meta_from(value, is_adt_trait)?; let codec = self.get_json_codec(&type_meta, true)?; let content = codec - .encode_json_dyn(&BaboonCodecContext::Compact, value) + .encode_json_dyn(ctx, value) .map_err(|e| { BaboonCodecError::encoder_failure(format!( "Cannot encode to json form type [{}] of version '{}': {}", @@ -1022,9 +1026,10 @@ impl BaboonCodecsFacade { pub fn encode_to_json( &self, + ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn, ) -> Result { - self.encode_to_json_with_override(value, None) + self.encode_to_json_with_override(ctx, value, None) } // --- Bin/JSON decode entry points --- diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala index 4a333cf4f..5872f329d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyBinCodec.scala @@ -26,7 +26,7 @@ object BaboonAnyBinCodec { "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueUeba directly." ) ) - f.jsonToUebaBytes(anyJson.meta, anyJson.json, staticDomain, staticVersion, staticTypeid) match { + f.jsonToUebaBytes(ctx, anyJson.meta, anyJson.json, staticDomain, staticVersion, staticTypeid) match { case Right(b) => b case Left(e) => throw e } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala index bb54c9402..ebc8f50d7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonAnyJsonCodec.scala @@ -25,7 +25,7 @@ object BaboonAnyJsonCodec { "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into encode(), or supply AnyOpaqueJson directly." ) ) - f.uebaToJson(anyUeba.meta, anyUeba.bytes, staticDomain, staticVersion, staticTypeid) match { + f.uebaToJson(ctx, anyUeba.meta, anyUeba.bytes, staticDomain, staticVersion, staticTypeid) match { case Right(j) => j case Left(e) => throw e } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala index 3589776a5..fefcc0484 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala +++ b/baboon-compiler/src/main/resources/baboon-runtime/scala/BaboonCodecsFacade.scala @@ -230,6 +230,7 @@ package baboon.runtime.shared { // generator always supplies all three. User code calling this directly must provide whatever // static context it has, or accept the all-`None` semantics (= no static fallback). def jsonToUebaBytes( + ctx: BaboonCodecContext, meta: AnyMeta, json: Json, staticDomain: Option[String] = None, @@ -241,7 +242,7 @@ package baboon.runtime.shared { jsonCodec <- getJsonCodec(typeMeta, exact = false) binCodec <- getBinCodec(typeMeta, exact = false) typed <- jsonCodec - .decode(BaboonCodecContext.Compact, json).left.map( + .decode(ctx, json).left.map( e => BaboonCodecException.DecoderFailure( s"jsonToUebaBytes: cannot decode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", @@ -252,7 +253,7 @@ package baboon.runtime.shared { val baos = new ByteArrayOutputStream() val out = new LEDataOutputStream(baos) try { - binCodec.encode(BaboonCodecContext.Compact, out, typed) + binCodec.encode(ctx, out, typed) } finally { out.close() } @@ -271,6 +272,7 @@ package baboon.runtime.shared { // registered UEBA codec, then re-encode it via the registered JSON codec. See `jsonToUebaBytes` // for the static-fallback contract. def uebaToJson( + ctx: BaboonCodecContext, meta: AnyMeta, bytes: Array[Byte], staticDomain: Option[String] = None, @@ -282,14 +284,14 @@ package baboon.runtime.shared { binCodec <- getBinCodec(typeMeta, exact = false) jsonCodec <- getJsonCodec(typeMeta, exact = false) typed <- binCodec - .decode(BaboonCodecContext.Compact, new LEDataInputStream(new ByteArrayInputStream(bytes))).left.map( + .decode(ctx, new LEDataInputStream(new ByteArrayInputStream(bytes))).left.map( e => BaboonCodecException.DecoderFailure( s"uebaToJson: cannot decode UEBA payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", e, ) ) - json <- Try(jsonCodec.encode(BaboonCodecContext.Compact, typed)).toEither.left.map( + json <- Try(jsonCodec.encode(ctx, typed)).toEither.left.map( e => BaboonCodecException.EncoderFailure( s"uebaToJson: cannot encode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.", @@ -374,11 +376,11 @@ package baboon.runtime.shared { decodeFromBin(bytes).flatMap(baboon => convert[BaboonGenerated, T](baboon)) } - def encodeToJson[T <: BaboonGenerated: ClassTag](value: T, typeMetaOverride: Option[BaboonTypeMeta]): BaboonValue[Json] = { + def encodeToJson[T <: BaboonGenerated: ClassTag](ctx: BaboonCodecContext, value: T, typeMetaOverride: Option[BaboonTypeMeta]): BaboonValue[Json] = { val typeMeta = BaboonTypeMeta.from(value) (for { jsonCodec <- getJsonCodec(typeMeta, exact = true).toTry - jsonContent = jsonCodec.encode(BaboonCodecContext.Compact, value) + jsonContent = jsonCodec.encode(ctx, value) metaJson = typeMetaOverride.getOrElse(typeMeta).writeJson result = metaJson.mapObject(_.add(CONTENT_JSON_KEY, jsonContent)) } yield result).toEither.left.map( @@ -390,16 +392,16 @@ package baboon.runtime.shared { ) } - def encodeToJson[T <: BaboonGenerated: ClassTag](value: T): BaboonValue[Json] = { - encodeToJson(value, None) + def encodeToJson[T <: BaboonGenerated: ClassTag](ctx: BaboonCodecContext, value: T): BaboonValue[Json] = { + encodeToJson(ctx, value, None) } - def encodeToJsonString[T <: BaboonGenerated: ClassTag](value: T, typeMetaOverride: Option[BaboonTypeMeta]): BaboonValue[String] = { - encodeToJson(value, typeMetaOverride).map(_.noSpaces) + def encodeToJsonString[T <: BaboonGenerated: ClassTag](ctx: BaboonCodecContext, value: T, typeMetaOverride: Option[BaboonTypeMeta]): BaboonValue[String] = { + encodeToJson(ctx, value, typeMetaOverride).map(_.noSpaces) } - def encodeToJsonString[T <: BaboonGenerated: ClassTag](value: T): BaboonValue[String] = { - encodeToJsonString(value, None) + def encodeToJsonString[T <: BaboonGenerated: ClassTag](ctx: BaboonCodecContext, value: T): BaboonValue[String] = { + encodeToJsonString(ctx, value, None) } def decodeFromJson(value: Json): BaboonValue[Option[BaboonGenerated]] = { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift index fef4905b0..219c733d7 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/BaboonCodecsFacade.swift @@ -324,6 +324,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { // PR-19-D02: pass `useAdtIdentifier=true` when encoding through an ADT-typed reference. public func encodeToJson( + _ ctx: BaboonCodecContext, _ value: Any, typeMetaOverride: BaboonTypeMeta? = nil, useAdtIdentifier: Bool = false @@ -344,7 +345,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { nil )) } - let content = jsonEncoder.encodeAnyValue(BaboonCodecContext.compact, value) + let content = jsonEncoder.encodeAnyValue(ctx, value) var metaJson = (typeMetaOverride ?? typeMeta).writeJson() metaJson[CONTENT_JSON_KEY] = content return .success(metaJson) @@ -462,6 +463,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { // the wire `meta` (variants B/C/D1/D2/D3 — codec-generation-time knowledge); wire data wins // when both are present (override semantics). See PR-06-D01. public func jsonToUebaBytes( + _ ctx: BaboonCodecContext, _ meta: AnyMeta, _ json: Any?, staticDomain: String? = nil, @@ -499,7 +501,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { let typed: Any do { - typed = try jsonCodec.decodeAnyValue(BaboonCodecContext.compact, json as Any) + typed = try jsonCodec.decodeAnyValue(ctx, json as Any) } catch { return .failure(.decoderFailure( "jsonToUebaBytes: cannot decode JSON payload of type [\(typeMeta.domainIdentifier).\(typeMeta.typeIdentifier)] of version '\(typeMeta.domainVersion)'.", @@ -508,11 +510,12 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { } let writer = BaboonBinWriter() - binCodec.encodeAnyValue(BaboonCodecContext.compact, writer, typed) + binCodec.encodeAnyValue(ctx, writer, typed) return .success(writer.toData()) } public func uebaToJson( + _ ctx: BaboonCodecContext, _ meta: AnyMeta, _ bytes: Data, staticDomain: String? = nil, @@ -550,7 +553,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { let typed: Any do { - typed = try binCodec.decodeAnyValue(BaboonCodecContext.compact, BaboonBinReader(bytes)) + typed = try binCodec.decodeAnyValue(ctx, BaboonBinReader(bytes)) } catch { return .failure(.decoderFailure( "uebaToJson: cannot decode UEBA payload of type [\(typeMeta.domainIdentifier).\(typeMeta.typeIdentifier)] of version '\(typeMeta.domainVersion)'.", @@ -558,7 +561,7 @@ open class BaboonCodecsFacade: BaboonCodecsFacadeBase { )) } - return .success(jsonCodec.encodeAnyValue(BaboonCodecContext.compact, typed)) + return .success(jsonCodec.encodeAnyValue(ctx, typed)) } // Synthesise a `BaboonTypeMeta` from an `AnyMeta` plus optional static fallbacks. `AnyMeta` diff --git a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_any_field_codec.swift b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_any_field_codec.swift index af9e2be23..e390a6532 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_any_field_codec.swift +++ b/baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_any_field_codec.swift @@ -37,6 +37,7 @@ public enum BaboonAnyJsonFieldCodec { ) } let anyConvResult = anyFacade.uebaToJson( + ctx, uebaMeta, uebaBytes, staticDomain: staticDomain, @@ -133,6 +134,7 @@ public enum BaboonAnyUebaFieldCodec { ) } let anyConvResult = anyFacade.jsonToUebaBytes( + ctx, jsonMeta, jsonValue, staticDomain: staticDomain, diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts index 17ea79bbd..341beb438 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonAnyOpaque.ts @@ -327,7 +327,7 @@ export function encodeAnyJsonField( "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueJson directly." ); } - const anyConvResult = anyFacade.uebaToJson(value.meta, value.bytes, staticDomain, staticVersion, staticTypeid); + const anyConvResult = anyFacade.uebaToJson(ctx, value.meta, value.bytes, staticDomain, staticVersion, staticTypeid); if (anyConvResult.tag === "Left") { throw anyConvResult.value; } @@ -384,7 +384,7 @@ export function encodeAnyUebaField( "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.withFacade(useIndices, facade) into encode(), or supply anyOpaqueUeba directly." ); } - const anyConvResult = anyFacade.jsonToUebaBytes(value.meta, value.json, staticDomain, staticVersion, staticTypeid); + const anyConvResult = anyFacade.jsonToUebaBytes(ctx, value.meta, value.json, staticDomain, staticVersion, staticTypeid); if (anyConvResult.tag === "Left") { throw anyConvResult.value; } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts index dc1b035b7..a854a2087 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonCodecsFacade.ts @@ -6,8 +6,8 @@ // * `encodeToJson` / `decodeFromJson` // * `convert` cross-version (stub — see Java PR-17-D05 note) // * `decodeAny(opaque)` -// * `jsonToUebaBytes(meta, json, staticDomain?, staticVersion?, staticTypeid?)` (PR-06-D01) -// * `uebaToJson(meta, bytes, staticDomain?, staticVersion?, staticTypeid?)` (symmetric) +// * `jsonToUebaBytes(ctx, meta, json, staticDomain?, staticVersion?, staticTypeid?)` (PR-06-D01) +// * `uebaToJson(ctx, meta, bytes, staticDomain?, staticVersion?, staticTypeid?)` (symmetric) // // Defects addressed: // PR-06-D01 cross-format helpers accept static fallbacks; wire-`meta.X` overrides `staticX` @@ -254,6 +254,7 @@ export class BaboonCodecsFacade { * generics, so the caller must opt in. Default `false` preserves concrete-branch semantics. */ public encodeToJson( + ctx: BaboonCodecContext, value: BaboonGenerated, typeMetaOverride?: BaboonTypeMeta, useAdtIdentifier: boolean = false, @@ -264,7 +265,7 @@ export class BaboonCodecsFacade { const codec = codecResult.value as BaboonJsonCodec; try { - const content = codec.encode(BaboonCodecContext.Compact, value); + const content = codec.encode(ctx, value); const metaJson = (typeMetaOverride !== undefined ? typeMetaOverride : typeMeta).writeJson(); // PR-08-D06 analog: if the meta JSON were ever a non-object, the `$c` set would // silently lose the key. `BaboonTypeMetaCodec.writeJson` always returns a plain @@ -375,6 +376,7 @@ export class BaboonCodecsFacade { * semantics — wire wins). Without static fallback only variant A would work — PR-06-D01. */ public jsonToUebaBytes( + ctx: BaboonCodecContext, meta: AnyMeta, json: unknown, staticDomain?: string, @@ -395,7 +397,7 @@ export class BaboonCodecsFacade { let typed: BaboonGenerated; try { - typed = jsonCodec.decode(BaboonCodecContext.Compact, json); + typed = jsonCodec.decode(ctx, json); } catch (e) { return leftCodecException(new BaboonDecoderFailure( `jsonToUebaBytes: cannot decode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.`, @@ -409,7 +411,7 @@ export class BaboonCodecsFacade { // The interface declaration in `BaboonSharedRuntime.ts` is misleading; the codegen // (TsUEBACodecGenerator) emits `(ctx, value, writer)` and the interface should match. // Argument order corrected here so cross-format JSON→UEBA conversion works. - binCodec.encode(BaboonCodecContext.Compact, typed, writer); + binCodec.encode(ctx, typed, writer); return right(writer.toBytes()); } catch (e) { return leftCodecException(new BaboonEncoderFailure( @@ -421,6 +423,7 @@ export class BaboonCodecsFacade { /** Cross-format helper symmetric to {@link jsonToUebaBytes}. See its doc for static-fallback contract. */ public uebaToJson( + ctx: BaboonCodecContext, meta: AnyMeta, bytes: Uint8Array, staticDomain?: string, @@ -442,7 +445,7 @@ export class BaboonCodecsFacade { let typed: BaboonGenerated; try { const reader = new BaboonBinReader(bytes); - typed = binCodec.decode(BaboonCodecContext.Compact, reader); + typed = binCodec.decode(ctx, reader); } catch (e) { return leftCodecException(new BaboonDecoderFailure( `uebaToJson: cannot decode UEBA payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.`, @@ -451,7 +454,7 @@ export class BaboonCodecsFacade { } try { - return right(jsonCodec.encode(BaboonCodecContext.Compact, typed)); + return right(jsonCodec.encode(ctx, typed)); } catch (e) { return leftCodecException(new BaboonEncoderFailure( `uebaToJson: cannot encode JSON payload of type [${typeMeta.domainIdentifier}.${typeMeta.typeIdentifier}] of version '${typeMeta.domainVersion}'.`, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala index edcfadba1..f343acd9b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyJsonCodecGenerator.scala @@ -453,7 +453,7 @@ final class PyJsonCodecGenerator( | raise $baboonCodecException.EncoderFailure( | "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.with_facade(use_indices, facade) into encode(), or supply AnyOpaqueJson directly." | ) - | any_conv_result = context.facade.ueba_to_json(value.meta, value.bytes, static_domain=static_domain, static_version=static_version, static_typeid=static_typeid) + | any_conv_result = context.facade.ueba_to_json(context, value.meta, value.bytes, static_domain=static_domain, static_version=static_version, static_typeid=static_typeid) | if isinstance(any_conv_result, $baboonLeftType): | raise any_conv_result.value | any_inner = $pyJsonLoads(any_conv_result.value) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala index b5fee6bfb..1bb83c95b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyUEBACodecGenerator.scala @@ -487,7 +487,7 @@ class PyUEBACodecGenerator( | raise $baboonCodecException.EncoderFailure( | "Cannot encode AnyOpaqueJson into UEBA without a facade reference. Pass BaboonCodecContext.with_facade(use_indices, facade) into encode(), or supply AnyOpaqueUeba directly." | ) - | any_conv_result = ctx.facade.json_to_ueba_bytes(value.meta, value.json, static_domain=static_domain, static_version=static_version, static_typeid=static_typeid) + | any_conv_result = ctx.facade.json_to_ueba_bytes(ctx, value.meta, value.json, static_domain=static_domain, static_version=static_version, static_typeid=static_typeid) | if isinstance(any_conv_result, $baboonLeftType): | raise any_conv_result.value | any_blob = any_conv_result.value diff --git a/defects.md b/defects.md index be74a0cea..0ff303b10 100644 --- a/defects.md +++ b/defects.md @@ -10,6 +10,31 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved --- +## FIELD-2026-09-24 (user report + facade review, all backends) + +### [FIELD-2026-09-24-D01] Facade JSON encode entry points take no codec context, so `any` fields holding the other wire form cannot be encoded +**Status:** resolved +**Severity:** major +**Location:** `encodeToJson` in `baboon-compiler/src/main/resources/baboon-runtime/{cs,scala,java,python,rust,typescript,dart,swift}/BaboonCodecsFacade.*`; `jsonToUebaBytes`/`uebaToJson` in all ten runtimes (the two Kotlin ones included) +**Description:** `BaboonCodecContext` carries `UseIndices`, `ForwardWritePolicy`, `EnvelopeVersion` and `Facade`. The first three are UEBA-only for encoding (`BaboonTypeMeta.From` computes `$rv` unconditionally, so JSON needs no policy input); `Facade` is not — `BaboonAnyJsonCodec.Encode` requires it whenever an `any` field holds an `AnyOpaqueUeba`, because the payload has to be transcoded through `facade.uebaToJson`. `encodeToJson` hardcoded a facade-less context (`Compact` / `default()`), so encoding such a value failed with `EncoderFailure: Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode()` — advice the signature made impossible to follow, while the sibling `encodeToBin(ctx, …)` in the same class was configurable. Kotlin and Kotlin-KMP alone already took a context on `encodeToJson`; the other eight were inconsistent with both Kotlin and their own binary path. The cross-format helpers `jsonToUebaBytes`/`uebaToJson` had the same hole in all ten runtimes (hardcoded `Compact` on both legs), so a payload whose content contains a nested `any` in the opposite wire form failed one level down, and `jsonToUebaBytes` could never emit indexed UEBA. Reproduced in the C# stub against the `any-ok` fixture: `facade.EncodeToJson(holderWithUebaAny)` returned `Left(EncoderFailure)` while the generated codec called directly with `WithFacade` succeeded — which is why the existing `AnyRoundTrip*` cross-format suites (they call `Holder_JsonCodec.Instance.Encode(ctxWithFacade, …)`, never the facade) never caught it. +**Fix:** Threaded a codec context through `encodeToJson` (and Scala's `encodeToJsonString`, Rust's `encode_to_json_with_override`/`_with_declared_trait`) and through `jsonToUebaBytes`/`uebaToJson` in all ten runtimes, using it in place of the hardcoded constant; the `any` runtime codecs now pass their own `ctx` into the facade call, so nested transcoding inherits the facade. Breaking API change, deliberately: the context is a required leading parameter, matching `encodeToBin` and Kotlin's existing `encodeToJson`, with no ctx-less overload left to fall into. Rust's JSON side still refuses `AnyOpaque::Ueba` outright (serde-side limitation predating this change) and is untouched. Regression tests: `EncodeToJson_WithFacadeContext_TranscodesUebaAnyPayloads` and `EncodeToJson_WithoutFacadeContext_StillReportsTheMissingFacade` in `test/cs-stub/BaboonTests/AnyRoundTripTests.cs`. + +### [FIELD-2026-09-24-D02] Python facade annotates the codec context as `str` +**Status:** resolved +**Severity:** minor +**Location:** `baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py` (`encode_to_bin`, `_encode_to_bin_stream`) +**Description:** Both declared `ctx: str`. The value is a `BaboonCodecContext` — the private `_bin_type_meta(value, ctx: BaboonCodecContext)` annotates it correctly and reads `ctx.envelope_version` / `ctx.use_indices`, which would raise `AttributeError` on a `str`. Wrong annotation on a public method: type-checkers and IDEs actively mislead callers. +**Fix:** Corrected both annotations to `BaboonCodecContext`. + +### [FIELD-2026-09-24-N01] Facade decode entry points also pin a facade-less context (assessed, no change) +**Status:** resolved (note-only; no functional change) +**Severity:** nit +**Location:** `decodeFromBin`, `decodeFromJson`, `decodeAny` and their `*Latest` variants, all runtimes +**Description:** These hardcode `Compact` too, but the read path does not need the context: the UEBA reader takes indexing from the wire header byte (`ReadIndexEntries` reads `header & 1`), `IndexElementsCount` is a generated constant, and the `any` decoders take no context at all (`Decode(expectedKind, wire)`), so no facade is needed to decode. The only effect is that the decoder's `if (ctx.UseIndices)` index-count assertion never fires on a facade decode — a validation the caller cannot switch on. +**Fix:** None. Recorded so a future reader does not re-derive the analysis; revisit only if a decode-side context knob is introduced. + +--- + ## FIELD-2026-09-23 (user reports, C# backend) ### [FIELD-2026-09-23-D01] C# service signature of an obsolete version names deduplicated-away method I/O types (CS0234) diff --git a/test/conv-test-cs/ConvTest/UnitTest1.cs b/test/conv-test-cs/ConvTest/UnitTest1.cs index b857803df..fe57b5ff7 100644 --- a/test/conv-test-cs/ConvTest/UnitTest1.cs +++ b/test/conv-test-cs/ConvTest/UnitTest1.cs @@ -119,7 +119,7 @@ public void Test_DecodeFromJsonString_Preserves_Tsu_Tso_And_DateLike_Str() () => Convtest.Testpkg.BaboonCodecsUeba.Instance); // ENCODE to a JSON *string* (full meta envelope with the $c content key). - var encoded = facade.EncodeToJson(original); + var encoded = facade.EncodeToJson(BaboonCodecContext.Compact, original); Assert.That(encoded.IsRight, Is.True, "precondition: encode must succeed"); var wireString = encoded.GetRight().ToString(); diff --git a/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs b/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs index 698ffe93b..2c442fce1 100644 --- a/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs +++ b/test/cs-stub/BaboonTests/AnyMetaCodecTests.cs @@ -528,7 +528,7 @@ public void JsonToUebaBytes_Left_OnIncompleteMeta() { var facade = new BaboonCodecsFacade(); var meta = new AnyMeta(0x01, null, null, "T"); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull()); + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull()); Assert.That(result, Is.InstanceOf.Left>()); var msg = ((Either.Left)result).Value.Message; Assert.That(msg, Does.Contain("domain")); @@ -540,7 +540,7 @@ public void UebaToJson_Left_OnIncompleteMeta() { var facade = new BaboonCodecsFacade(); var meta = new AnyMeta(0x01, null, null, "T"); - var result = facade.UebaToJson(meta, Array.Empty()); + var result = facade.UebaToJson(BaboonCodecContext.Compact, meta, Array.Empty()); Assert.That(result, Is.InstanceOf.Left>()); var msg = ((Either.Left)result).Value.Message; Assert.That(msg, Does.Contain("domain")); @@ -552,7 +552,7 @@ public void JsonToUebaBytes_Left_OnNoCodecRegistered() { var facade = new BaboonCodecsFacade(); var meta = new AnyMeta(0x07, "com.example.unknown", "1.0.0", "Unknown"); - var result = facade.JsonToUebaBytes(meta, new JObject { ["x"] = 1 }); + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, new JObject { ["x"] = 1 }); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; Assert.That(err, Is.InstanceOf()); @@ -563,7 +563,7 @@ public void UebaToJson_Left_OnNoCodecRegistered() { var facade = new BaboonCodecsFacade(); var meta = new AnyMeta(0x07, "com.example.unknown", "1.0.0", "Unknown"); - var result = facade.UebaToJson(meta, Array.Empty()); + var result = facade.UebaToJson(BaboonCodecContext.Compact, meta, Array.Empty()); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; Assert.That(err, Is.InstanceOf()); @@ -577,7 +577,7 @@ public void JsonToUebaBytes_StaticFallback_VariantB_DomainFromStatic() var facade = new BaboonCodecsFacade(); // kind 0x03 (B): no domain on wire, version + typeid present. var meta = new AnyMeta(0x03, null, "1.0.0", "T"); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull(), staticDomain: "com.example.unknown"); + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull(), staticDomain: "com.example.unknown"); // Should reach codec lookup (CodecNotFound), not the missing-meta DecoderFailure. Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; @@ -591,7 +591,7 @@ public void JsonToUebaBytes_StaticFallback_VariantC_DomainAndVersionFromStatic() var facade = new BaboonCodecsFacade(); // kind 0x01 (C): only typeid on wire. var meta = new AnyMeta(0x01, null, null, "T"); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull(), + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull(), staticDomain: "com.example.unknown", staticVersion: "1.0.0"); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; @@ -604,7 +604,7 @@ public void JsonToUebaBytes_StaticFallback_VariantD1_TypeidFromStatic() var facade = new BaboonCodecsFacade(); // kind 0x06 (D1): domain + version on wire, typeid absent. var meta = new AnyMeta(0x06, "com.example.unknown", "1.0.0", null); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull(), staticTypeid: "Inner"); + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull(), staticTypeid: "Inner"); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; Assert.That(err, Is.InstanceOf()); @@ -616,7 +616,7 @@ public void JsonToUebaBytes_StaticFallback_VariantD3_AllThreeFromStatics() var facade = new BaboonCodecsFacade(); // kind 0x00 (D3): nothing on wire. var meta = new AnyMeta(0x00, null, null, null); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull(), + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull(), staticDomain: "com.example.unknown", staticVersion: "1.0.0", staticTypeid: "Inner"); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; @@ -628,7 +628,7 @@ public void UebaToJson_StaticFallback_VariantD3_AllThreeFromStatics() { var facade = new BaboonCodecsFacade(); var meta = new AnyMeta(0x00, null, null, null); - var result = facade.UebaToJson(meta, Array.Empty(), + var result = facade.UebaToJson(BaboonCodecContext.Compact, meta, Array.Empty(), staticDomain: "com.example.unknown", staticVersion: "1.0.0", staticTypeid: "Inner"); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; @@ -642,7 +642,7 @@ public void JsonToUebaBytes_StaticFallback_MetaWinsOverStatic() // All three present on wire; statics differ — wire wins, so resolution must reference // meta.domain ("metawins"), not the static ("staticloses"). var meta = new AnyMeta(0x07, "com.example.metawins", "1.0.0", "MetaT"); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull(), + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull(), staticDomain: "com.example.staticloses", staticVersion: "9.9.9", staticTypeid: "StaticT"); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; @@ -685,7 +685,7 @@ public void GetCodec_NonExact_AtMaxVersion_RoutesToExactLookup_SingleVersionDoma () => new StubUebaCodecs(), () => new StubMeta()); var meta = new AnyMeta(0x07, "com.example.single", "1.0.0", "Unknown"); - var result = facade.JsonToUebaBytes(meta, JValue.CreateNull()); + var result = facade.JsonToUebaBytes(BaboonCodecContext.Compact, meta, JValue.CreateNull()); Assert.That(result, Is.InstanceOf.Left>()); var err = ((Either.Left)result).Value; Assert.That(err, Is.InstanceOf()); diff --git a/test/cs-stub/BaboonTests/AnyRoundTripTests.cs b/test/cs-stub/BaboonTests/AnyRoundTripTests.cs index 11cff33ed..d36ad1a2c 100644 --- a/test/cs-stub/BaboonTests/AnyRoundTripTests.cs +++ b/test/cs-stub/BaboonTests/AnyRoundTripTests.cs @@ -246,6 +246,60 @@ public void CrossFormat_UebaHolderToJson_DecodeRoundTrip() Assert.That(decoded.FCurrentUnderlying.Meta.Kind, Is.EqualTo((byte)0x00)); // D3: kind=0x00, statics filled } + // The facade's own JSON entry point must be able to do what the generated codec can: + // `BaboonAnyJsonCodec.Encode` needs `ctx.Facade` to transcode a UEBA-form `any` payload, + // and before `EncodeToJson` took a context there was no way to supply one — the encode + // failed with "Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass + // BaboonCodecContext.WithFacade(...) into Encode()", advice the signature made impossible + // to follow. The Compact case below pins the other half: the diagnostic still fires when + // the caller genuinely passes a facade-less context. + [Test] + public void EncodeToJson_WithFacadeContext_TranscodesUebaAnyPayloads() + { + var facade = FreshFacade(); + var innerBytes = InnerToUebaBytes(SampleInner); + var crossable = BuildUebaHolder() with + { + FAny = new AnyOpaqueUeba(new AnyMeta(0x07, DomainId, VersionStr, InnerType), innerBytes), + FDomainThis = new AnyOpaqueUeba(new AnyMeta(0x03, null, VersionStr, InnerType), innerBytes), + FDomainCurrent = new AnyOpaqueUeba(new AnyMeta(0x01, null, null, InnerType), innerBytes), + FOpt = new AnyOpaqueUeba(new AnyMeta(0x07, DomainId, VersionStr, InnerType), innerBytes), + FMapValue = new Dictionary { { "k1", new AnyOpaqueUeba(new AnyMeta(0x07, DomainId, VersionStr, InnerType), innerBytes) } }, + }; + + var withFacade = facade.EncodeToJson(BaboonCodecContext.WithFacade(useIndices: false, facade), crossable); + Assert.That(withFacade, Is.InstanceOf.Right>(), + $"EncodeToJson must transcode UEBA `any` payloads when the context carries a facade; got {withFacade}"); + + var envelope = ((Either.Right)withFacade).Value; + var decoded = facade.DecodeFromJson(envelope); + Assert.That(decoded, Is.InstanceOf.Right>(), $"{decoded}"); + var holder = ((Either.Right)decoded).Value as My.Ok.Holder; + Assert.That(holder, Is.Not.Null); + Assert.That(holder!.FAny, Is.InstanceOf(), "the transcoded field must come back as JSON-form"); + } + + [Test] + public void EncodeToJson_WithoutFacadeContext_StillReportsTheMissingFacade() + { + var facade = FreshFacade(); + var innerBytes = InnerToUebaBytes(SampleInner); + var crossable = BuildUebaHolder() with + { + FAny = new AnyOpaqueUeba(new AnyMeta(0x07, DomainId, VersionStr, InnerType), innerBytes), + }; + + var result = facade.EncodeToJson(BaboonCodecContext.Compact, crossable); + Assert.That(result, Is.InstanceOf.Left>(), + "a facade-less context cannot transcode a UEBA `any` payload"); + var chain = new List(); + for (Exception? e = ((Either.Left)result).Value; e != null; e = e.InnerException) + { + chain.Add(e.Message); + } + Assert.That(string.Join(" || ", chain), Does.Contain("without a facade reference")); + } + [Test] public void CrossFormat_D3IsolatedField_StaticFallbacksResolveEndToEnd() { diff --git a/test/cs-stub/BaboonTests/FacadeLatestMethodsTests.cs b/test/cs-stub/BaboonTests/FacadeLatestMethodsTests.cs index d44efb204..40a2d075e 100644 --- a/test/cs-stub/BaboonTests/FacadeLatestMethodsTests.cs +++ b/test/cs-stub/BaboonTests/FacadeLatestMethodsTests.cs @@ -56,7 +56,7 @@ public void DecodeFromBinLatest_ByteArray_ReturnsRightWithOriginalValue() public void DecodeFromJsonLatest_JToken_ReturnsRightWithOriginalValue() { var f = FreshFacade(); - var encodeResult = f.EncodeToJson(SampleInner); + var encodeResult = f.EncodeToJson(BaboonCodecContext.Compact, SampleInner); Assert.That(encodeResult, Is.InstanceOf.Right>(), $"EncodeToJson must succeed; got {encodeResult}"); var json = ((Either.Right)encodeResult).Value; diff --git a/test/dt-stub/test/runtime/any_meta_codec_test.dart b/test/dt-stub/test/runtime/any_meta_codec_test.dart index e899063fe..b1ad27304 100644 --- a/test/dt-stub/test/runtime/any_meta_codec_test.dart +++ b/test/dt-stub/test/runtime/any_meta_codec_test.dart @@ -364,21 +364,21 @@ void main() { test('jsonToUebaBytes returns Left on incomplete meta (PR-06-D01)', () { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x00, null, null, null); - final r = facade.jsonToUebaBytes(meta, {}); + final r = facade.jsonToUebaBytes(BaboonCodecContext.compact, meta, {}); expect(r, isA()); }); test('uebaToJson returns Left on incomplete meta', () { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x00, null, null, null); - final r = facade.uebaToJson(meta, Uint8List.fromList([1, 2, 3])); + final r = facade.uebaToJson(BaboonCodecContext.compact, meta, Uint8List.fromList([1, 2, 3])); expect(r, isA()); }); test('jsonToUebaBytes returns Left on no codec', () { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x07, 'dom', '1.0.0', 'T'); - final r = facade.jsonToUebaBytes(meta, {}); + final r = facade.jsonToUebaBytes(BaboonCodecContext.compact, meta, {}); expect(r, isA()); expect((r as BaboonLeft).value, isA()); }); @@ -386,7 +386,7 @@ void main() { test('uebaToJson returns Left on no codec', () { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x07, 'dom', '1.0.0', 'T'); - final r = facade.uebaToJson(meta, Uint8List.fromList([0])); + final r = facade.uebaToJson(BaboonCodecContext.compact, meta, Uint8List.fromList([0])); expect(r, isA()); expect((r as BaboonLeft).value, isA()); }); @@ -407,6 +407,7 @@ void main() { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x00, null, null, null); final r = facade.jsonToUebaBytes( + BaboonCodecContext.compact, meta, {}, staticDomain: 'dom', @@ -423,6 +424,7 @@ void main() { final facade = BaboonCodecsFacade(); final meta = AnyMeta(0x07, 'dom-wire', '1.0.0', 'T'); final r = facade.jsonToUebaBytes( + BaboonCodecContext.compact, meta, {}, staticDomain: 'dom-static', @@ -458,7 +460,7 @@ void main() { final any = AnyMeta(0x07, 'dom', '1.0.0', 'T'); final payload = Uint8List.fromList([0x42]); - final r = facade.uebaToJson(any, payload); + final r = facade.uebaToJson(BaboonCodecContext.compact, any, payload); expect(r, isA()); }); @@ -518,7 +520,7 @@ void main() { test('encodeToJson defaults to concrete branch typeid', () { final facade = makeFacade(); - final r = facade.encodeToJson(_StubAdtBranchGenerated()); + final r = facade.encodeToJson(BaboonCodecContext.compact, _StubAdtBranchGenerated()); expect(r, isA()); final wire = (r as BaboonRight).value as Map; expect(wire[r'$t'], 'BranchT'); @@ -526,7 +528,7 @@ void main() { test('encodeToJson with useAdtIdentifier=true uses the ADT typeid', () { final facade = makeFacade(); - final r = facade.encodeToJson(_StubAdtBranchGenerated(), useAdtIdentifier: true); + final r = facade.encodeToJson(BaboonCodecContext.compact, _StubAdtBranchGenerated(), useAdtIdentifier: true); expect(r, isA()); final wire = (r as BaboonRight).value as Map; expect(wire[r'$t'], 'AdtT'); @@ -769,7 +771,7 @@ void main() { meta: () => _StubMeta(['1.0.0']), ); - final encResult = facade.encodeToJson(_StubGeneratedLatest()); + final encResult = facade.encodeToJson(BaboonCodecContext.compact, _StubGeneratedLatest()); expect(encResult, isA()); final wire = (encResult as BaboonRight).value as Map; diff --git a/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java b/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java index c0fed7868..98ba82206 100644 --- a/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java +++ b/test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java @@ -321,7 +321,7 @@ void codecContext_withFacadeExposesFacade() { void jsonToUebaBytes_leftOnIncompleteMeta() { BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x00, null, null, null); - BaboonEither r = facade.jsonToUebaBytes(meta, NF.objectNode()); + BaboonEither r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, NF.objectNode()); assertInstanceOf(BaboonEither.Left.class, r); } @@ -329,7 +329,7 @@ void jsonToUebaBytes_leftOnIncompleteMeta() { void uebaToJson_leftOnIncompleteMeta() { BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x00, null, null, null); - BaboonEither r = facade.uebaToJson(meta, new byte[]{1, 2, 3}); + BaboonEither r = facade.uebaToJson(BaboonCodecContext.Compact, meta, new byte[]{1, 2, 3}); assertInstanceOf(BaboonEither.Left.class, r); } @@ -337,7 +337,7 @@ void uebaToJson_leftOnIncompleteMeta() { void jsonToUebaBytes_leftOnNoCodec() { BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x07, "dom", "1.0.0", "T"); - BaboonEither r = facade.jsonToUebaBytes(meta, NF.objectNode()); + BaboonEither r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, NF.objectNode()); assertInstanceOf(BaboonEither.Left.class, r); BaboonCodecException e = ((BaboonEither.Left) r).value(); assertInstanceOf(BaboonCodecException.CodecNotFound.class, e); @@ -347,7 +347,7 @@ void jsonToUebaBytes_leftOnNoCodec() { void uebaToJson_leftOnNoCodec() { BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x07, "dom", "1.0.0", "T"); - BaboonEither r = facade.uebaToJson(meta, new byte[]{1, 2, 3}); + BaboonEither r = facade.uebaToJson(BaboonCodecContext.Compact, meta, new byte[]{1, 2, 3}); assertInstanceOf(BaboonEither.Left.class, r); BaboonCodecException e = ((BaboonEither.Left) r).value(); assertInstanceOf(BaboonCodecException.CodecNotFound.class, e); @@ -370,7 +370,7 @@ void staticFallback_kindD3_filledByStatics() { // registered) but it should fail with CodecNotFound, not "AnyMeta requires ...". BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x00, null, null, null); - BaboonEither r = facade.jsonToUebaBytes(meta, NF.objectNode(), "dom", "1.0.0", "T"); + BaboonEither r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, NF.objectNode(), "dom", "1.0.0", "T"); assertInstanceOf(BaboonEither.Left.class, r); BaboonCodecException e = ((BaboonEither.Left) r).value(); assertInstanceOf(BaboonCodecException.CodecNotFound.class, e); @@ -382,7 +382,7 @@ void staticFallback_overridesByWire() { // (no codec registered) but the failure must mention the wire-derived domain. BaboonCodecsFacade facade = new BaboonCodecsFacade(); AnyMeta meta = new AnyMeta((byte) 0x07, "dom-wire", "1.0.0", "T"); - BaboonEither r = facade.jsonToUebaBytes(meta, NF.objectNode(), "dom-static", "0.9.0", "Other"); + BaboonEither r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, NF.objectNode(), "dom-static", "0.9.0", "Other"); assertInstanceOf(BaboonEither.Left.class, r); assertTrue(((BaboonEither.Left) r).value().getMessage().contains("dom-wire")); } @@ -406,7 +406,7 @@ void getCodec_singleVersionDomain_routesToExact() { // Non-exact lookup at version 1.0.0 must succeed (PR-07-D02 fix). AnyMeta any = new AnyMeta((byte) 0x07, "dom", "1.0.0", "T"); byte[] payload = new byte[]{0x42}; - BaboonEither r = facade.uebaToJson(any, payload); + BaboonEither r = facade.uebaToJson(BaboonCodecContext.Compact, any, payload); assertInstanceOf(BaboonEither.Right.class, r); } diff --git a/test/jv-stub/src/test/java/runtime/FacadeDecodeLatestTest.java b/test/jv-stub/src/test/java/runtime/FacadeDecodeLatestTest.java index dbc63b8f1..ee85cd2ce 100644 --- a/test/jv-stub/src/test/java/runtime/FacadeDecodeLatestTest.java +++ b/test/jv-stub/src/test/java/runtime/FacadeDecodeLatestTest.java @@ -47,7 +47,7 @@ private static byte[] encodeInnerToBin(my.ok.Inner inner) throws Exception { private static JsonNode encodeInnerToJson(my.ok.Inner inner) { BaboonCodecsFacade facade = freshFacade(); - BaboonEither r = facade.encodeToJson(inner); + BaboonEither r = facade.encodeToJson(BaboonCodecContext.Compact, inner); assertInstanceOf(BaboonEither.Right.class, r, "encodeToJson must succeed"); return ((BaboonEither.Right) r).value(); } diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt index 58fdd1fa3..9d2c92b9a 100644 --- a/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/AnyMetaCodecTest.kt @@ -353,7 +353,7 @@ class AnyMetaCodecTest { fun jsonToUebaBytes_left_onIncompleteMeta() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x01.toByte(), null, null, "T") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x")) assertTrue(result is Either.Left) val msg = (result as Either.Left).value.message ?: "" assertTrue(msg.contains("domain")) @@ -364,7 +364,7 @@ class AnyMetaCodecTest { fun uebaToJson_left_onIncompleteMeta() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x01.toByte(), null, null, "T") - val result = facade.uebaToJson(meta, ByteArray(0)) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, ByteArray(0)) assertTrue(result is Either.Left) val msg = (result as Either.Left).value.message ?: "" assertTrue(msg.contains("domain")) @@ -375,7 +375,7 @@ class AnyMetaCodecTest { fun jsonToUebaBytes_left_onNoCodecRegistered() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x07.toByte(), "com.example.unknown", "1.0.0", "Unknown") - val result = facade.jsonToUebaBytes(meta, buildJsonObject { put("x", 1) }) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, buildJsonObject { put("x", 1) }) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue( @@ -388,7 +388,7 @@ class AnyMetaCodecTest { fun uebaToJson_left_onNoCodecRegistered() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x07.toByte(), "com.example.unknown", "1.0.0", "Unknown") - val result = facade.uebaToJson(meta, ByteArray(0)) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, ByteArray(0)) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) @@ -401,7 +401,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() // kind 0x03 (B): no domain on wire, version + typeid present. val meta = AnyMeta(0x03.toByte(), null, "1.0.0", "T") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x"), staticDomain = "com.example.unknown") + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown") // Should reach codec lookup (CodecNotFound), not the missing-meta DecoderFailure. assertTrue(result is Either.Left) val err = (result as Either.Left).value @@ -417,6 +417,7 @@ class AnyMetaCodecTest { // kind 0x01 (C): only typeid on wire. val meta = AnyMeta(0x01.toByte(), null, null, "T") val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -431,7 +432,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() // kind 0x06 (D1): domain + version on wire, typeid absent. val meta = AnyMeta(0x06.toByte(), "com.example.unknown", "1.0.0", null) - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x"), staticTypeid = "Inner") + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticTypeid = "Inner") assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) @@ -443,6 +444,7 @@ class AnyMetaCodecTest { // kind 0x00 (D3): nothing on wire. val meta = AnyMeta(0x00.toByte(), null, null, null) val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -458,6 +460,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x00.toByte(), null, null, null) val result = facade.uebaToJson( + BaboonCodecContext.Compact, meta, ByteArray(0), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -475,6 +478,7 @@ class AnyMetaCodecTest { // meta.domain ("metawins"), not the static ("staticloses"). val meta = AnyMeta(0x07.toByte(), "com.example.metawins", "1.0.0", "MetaT") val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.staticloses", staticVersion = "9.9.9", @@ -514,7 +518,7 @@ class AnyMetaCodecTest { { StubMeta() }, ) val meta = AnyMeta(0x07.toByte(), "com.example.single", "1.0.0", "Unknown") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x")) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) diff --git a/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt b/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt index e4a56dd48..1da9fad33 100644 --- a/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt +++ b/test/kt-stub/src/test/kotlin/runtime/AnyMetaCodecTest.kt @@ -361,7 +361,7 @@ class AnyMetaCodecTest { fun jsonToUebaBytes_left_onIncompleteMeta() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x01.toByte(), null, null, "T") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x")) assertTrue(result is Either.Left) val msg = (result as Either.Left).value.message ?: "" assertTrue(msg.contains("domain")) @@ -372,7 +372,7 @@ class AnyMetaCodecTest { fun uebaToJson_left_onIncompleteMeta() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x01.toByte(), null, null, "T") - val result = facade.uebaToJson(meta, ByteArray(0)) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, ByteArray(0)) assertTrue(result is Either.Left) val msg = (result as Either.Left).value.message ?: "" assertTrue(msg.contains("domain")) @@ -383,7 +383,7 @@ class AnyMetaCodecTest { fun jsonToUebaBytes_left_onNoCodecRegistered() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x07.toByte(), "com.example.unknown", "1.0.0", "Unknown") - val result = facade.jsonToUebaBytes(meta, buildJsonObject { put("x", 1) }) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, buildJsonObject { put("x", 1) }) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue( @@ -396,7 +396,7 @@ class AnyMetaCodecTest { fun uebaToJson_left_onNoCodecRegistered() { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x07.toByte(), "com.example.unknown", "1.0.0", "Unknown") - val result = facade.uebaToJson(meta, ByteArray(0)) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, ByteArray(0)) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) @@ -409,7 +409,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() // kind 0x03 (B): no domain on wire, version + typeid present. val meta = AnyMeta(0x03.toByte(), null, "1.0.0", "T") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x"), staticDomain = "com.example.unknown") + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown") // Should reach codec lookup (CodecNotFound), not the missing-meta DecoderFailure. assertTrue(result is Either.Left) val err = (result as Either.Left).value @@ -425,6 +425,7 @@ class AnyMetaCodecTest { // kind 0x01 (C): only typeid on wire. val meta = AnyMeta(0x01.toByte(), null, null, "T") val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -439,7 +440,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() // kind 0x06 (D1): domain + version on wire, typeid absent. val meta = AnyMeta(0x06.toByte(), "com.example.unknown", "1.0.0", null) - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x"), staticTypeid = "Inner") + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticTypeid = "Inner") assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) @@ -451,6 +452,7 @@ class AnyMetaCodecTest { // kind 0x00 (D3): nothing on wire. val meta = AnyMeta(0x00.toByte(), null, null, null) val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -466,6 +468,7 @@ class AnyMetaCodecTest { val facade = BaboonCodecsFacade() val meta = AnyMeta(0x00.toByte(), null, null, null) val result = facade.uebaToJson( + BaboonCodecContext.Compact, meta, ByteArray(0), staticDomain = "com.example.unknown", staticVersion = "1.0.0", @@ -483,6 +486,7 @@ class AnyMetaCodecTest { // meta.domain ("metawins"), not the static ("staticloses"). val meta = AnyMeta(0x07.toByte(), "com.example.metawins", "1.0.0", "MetaT") val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, JsonPrimitive("x"), staticDomain = "com.example.staticloses", staticVersion = "9.9.9", @@ -522,7 +526,7 @@ class AnyMetaCodecTest { { StubMeta() }, ) val meta = AnyMeta(0x07.toByte(), "com.example.single", "1.0.0", "Unknown") - val result = facade.jsonToUebaBytes(meta, JsonPrimitive("x")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, JsonPrimitive("x")) assertTrue(result is Either.Left) val err = (result as Either.Left).value assertTrue(err is BaboonCodecException.CodecNotFound) diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_any_meta_codec.py b/test/py-stub/BaboonTests/RuntimeTests/test_any_meta_codec.py index f018e4c83..e9a282f45 100644 --- a/test/py-stub/BaboonTests/RuntimeTests/test_any_meta_codec.py +++ b/test/py-stub/BaboonTests/RuntimeTests/test_any_meta_codec.py @@ -8,6 +8,7 @@ import unittest from io import BytesIO +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext from BaboonDefinitions.Generated.baboon_any_opaque import ( ANY_CONTENT_KEY, ANY_DOMAIN_KEY, @@ -428,11 +429,11 @@ def test_decode_any_returns_left_for_partial_meta(self): def test_json_to_ueba_bytes_with_full_meta(self): facade = _make_facade() meta = AnyMeta(0x07, "smoke.dom", "1.0.0", "smoke.dom/:#Stub") - result = facade.json_to_ueba_bytes(meta, {"x": "hi"}) + result = facade.json_to_ueba_bytes(BaboonCodecContext.default(), meta, {"x": "hi"}) self.assertIsInstance(result, BaboonRight, msg=str(result)) # Round-trip back via ueba_to_json. Real codegen's encode returns a JSON-text # string (pydantic model_dump_json), so ueba_to_json's BaboonRight wraps a string. - rev = facade.ueba_to_json(meta, result.value) + rev = facade.ueba_to_json(BaboonCodecContext.default(), meta, result.value) self.assertIsInstance(rev, BaboonRight) self.assertEqual(json.loads(rev.value), {"x": "hi"}) @@ -441,6 +442,7 @@ def test_json_to_ueba_bytes_with_static_fallbacks(self): facade = _make_facade() meta = AnyMeta(0x00, None, None, None) result = facade.json_to_ueba_bytes( + BaboonCodecContext.default(), meta, {"x": "fallback"}, static_domain="smoke.dom", @@ -454,6 +456,7 @@ def test_static_fallback_does_not_override_wire_meta(self): facade = _make_facade() meta = AnyMeta(0x07, "smoke.dom", "1.0.0", "smoke.dom/:#Stub") result = facade.json_to_ueba_bytes( + BaboonCodecContext.default(), meta, {"x": "wire-wins"}, static_domain="other.dom", # would fail if it overrode @@ -465,7 +468,7 @@ def test_static_fallback_does_not_override_wire_meta(self): def test_ueba_to_json_unknown_domain_returns_left(self): facade = _make_facade() meta = AnyMeta(0x07, "unknown.dom", "1.0.0", "unknown.dom/:#X") - result = facade.ueba_to_json(meta, b"\x00") + result = facade.ueba_to_json(BaboonCodecContext.default(), meta, b"\x00") self.assertIsInstance(result, BaboonLeft) def test_pr_07_d02_single_version_domain_resolves(self): diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py b/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py index d8cac5758..dbf7221e4 100644 --- a/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py +++ b/test/py-stub/BaboonTests/RuntimeTests/test_domain_facade.py @@ -22,7 +22,7 @@ def test_generated_metadata_contract(self): def test_latest_json_round_trip(self): facade = DomainMyOkFacade() sample = Inner(x=42) - self.assertEqual(sample, facade.decode_from_json_latest(facade.encode_to_json(sample), Inner)) + self.assertEqual(sample, facade.decode_from_json_latest(facade.encode_to_json(BaboonCodecContext.default(), sample), Inner)) def test_decode_any_native_json(self): sample = Inner(x=42) diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_recursive_tree_round_trip.py b/test/py-stub/BaboonTests/RuntimeTests/test_recursive_tree_round_trip.py index d8cc36fb9..4cad55c52 100644 --- a/test/py-stub/BaboonTests/RuntimeTests/test_recursive_tree_round_trip.py +++ b/test/py-stub/BaboonTests/RuntimeTests/test_recursive_tree_round_trip.py @@ -38,7 +38,7 @@ def test_round_trips_recursive_tree_bin(self): def test_round_trips_recursive_tree_json(self): facade = DomainRecursiveTreeFacade() sample = self._sample() - encoded = facade.encode_to_json(sample) + encoded = facade.encode_to_json(BaboonCodecContext.default(), sample) decoded = facade.decode_from_json(encoded) self.assertEqual(decoded, sample) diff --git a/test/py-stub/BaboonTests/runtime/test_facade_cross_format.py b/test/py-stub/BaboonTests/runtime/test_facade_cross_format.py index 1072a4107..065cb4877 100644 --- a/test/py-stub/BaboonTests/runtime/test_facade_cross_format.py +++ b/test/py-stub/BaboonTests/runtime/test_facade_cross_format.py @@ -19,6 +19,8 @@ import unittest +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext + from BaboonDefinitions.Generated.baboon_any_opaque import AnyMeta from BaboonDefinitions.Generated.baboon_codecs_facade import BaboonCodecsFacade from BaboonDefinitions.Generated.baboon_runtime_shared import BaboonDomainVersion, BaboonMeta @@ -70,7 +72,7 @@ def test_json_to_ueba_bytes_accepts_parsed_dict_payload(self): meta = AnyMeta(0x07, DOMAIN_ID, VERSION_STR, INNER_TYPE) dict_payload = {"x": 42} - result = facade.json_to_ueba_bytes(meta, dict_payload) + result = facade.json_to_ueba_bytes(BaboonCodecContext.default(), meta, dict_payload) self.assertIsInstance(result, BaboonRight, msg=f"expected BaboonRight, got {result!r}") self.assertIsInstance(result.value, bytes) @@ -87,7 +89,7 @@ def test_json_to_ueba_bytes_round_trips_through_inner_ueba(self): facade = _fresh_facade() meta = AnyMeta(0x07, DOMAIN_ID, VERSION_STR, INNER_TYPE) - result = facade.json_to_ueba_bytes(meta, {"x": 42}) + result = facade.json_to_ueba_bytes(BaboonCodecContext.default(), meta, {"x": 42}) self.assertIsInstance(result, BaboonRight, msg=f"{result!r}") buf = BytesIO() diff --git a/test/py-stub/ReviewTests/test_versioned_facade.py b/test/py-stub/ReviewTests/test_versioned_facade.py index edf4236e6..a7a767772 100644 --- a/test/py-stub/ReviewTests/test_versioned_facade.py +++ b/test/py-stub/ReviewTests/test_versioned_facade.py @@ -1,6 +1,7 @@ import json import unittest +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext from BaboonDefinitions.Generated.baboon_codecs_facade import BaboonTypeMeta, BaboonTypeMetaCodec from BaboonDefinitions.Generated.review.python.facade.domain_facade import DomainReviewPythonFacadeFacade from BaboonDefinitions.Generated.review.python.facade.baboon_runtime import BaboonConversions, RequiredConversions @@ -42,7 +43,7 @@ def test_latest_decode_runs_generated_conversion(self): def test_latest_exact_roundtrip_preserves_legacy_text_payload(self): facade = DomainReviewPythonFacadeFacade() value = Evolving(value=8, extra="current") - wire = facade.encode_to_json(value) + wire = facade.encode_to_json(BaboonCodecContext.default(), value) self.assertIsInstance(json.loads(wire)["$c"], str) self.assertEqual(value, facade.decode_from_json_latest(wire, Evolving)) @@ -55,6 +56,6 @@ def fail(): facade = DomainReviewPythonFacadeFacade() facade.register(BaboonDomainVersion("review.python.facade", "2.0.0"), codecs_json=fail) with self.assertRaises(BaboonCodecException) as caught: - facade.encode_to_json(Evolving(value=8, extra=None)) + facade.encode_to_json(BaboonCodecContext.default(), Evolving(value=8, extra=None)) self.assertIn("EncoderFailure", str(caught.exception)) self.assertIs(cause, caught.exception.__cause__) diff --git a/test/rs-stub/tests/any_meta_codec_tests.rs b/test/rs-stub/tests/any_meta_codec_tests.rs index 29a5ac5c4..f4d55c60c 100644 --- a/test/rs-stub/tests/any_meta_codec_tests.rs +++ b/test/rs-stub/tests/any_meta_codec_tests.rs @@ -319,7 +319,7 @@ fn json_to_ueba_bytes_left_on_incomplete_meta_with_no_static() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x00, None, None, None); let result = - facade.json_to_ueba_bytes(&meta, &serde_json::Value::Null, None, None, None); + facade.json_to_ueba_bytes(&BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, None, None, None); match result { Err(BaboonCodecError::DecoderFailure { message, .. }) => { assert!(message.contains("domain")); @@ -334,7 +334,7 @@ fn json_to_ueba_bytes_left_on_incomplete_meta_with_no_static() { fn ueba_to_json_left_on_incomplete_meta_with_no_static() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x00, None, None, None); - let result = facade.ueba_to_json(&meta, &[], None, None, None); + let result = facade.ueba_to_json(&BaboonCodecContext::Compact, &meta, &[], None, None, None); assert!(matches!(result, Err(BaboonCodecError::DecoderFailure { .. }))); } @@ -343,7 +343,7 @@ fn json_to_ueba_bytes_left_on_no_codec_registered() { // All meta complete; facade empty; expect CodecNotFound. let facade = BaboonCodecsFacade::new(); let meta = mk(0x07, Some("dom"), Some("1.0.0"), Some("T")); - let result = facade.json_to_ueba_bytes(&meta, &serde_json::Value::Null, None, None, None); + let result = facade.json_to_ueba_bytes(&BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, None, None, None); assert!(matches!(result, Err(BaboonCodecError::CodecNotFound { .. }))); } @@ -351,7 +351,7 @@ fn json_to_ueba_bytes_left_on_no_codec_registered() { fn ueba_to_json_left_on_no_codec_registered() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x07, Some("dom"), Some("1.0.0"), Some("T")); - let result = facade.ueba_to_json(&meta, &[], None, None, None); + let result = facade.ueba_to_json(&BaboonCodecContext::Compact, &meta, &[], None, None, None); assert!(matches!(result, Err(BaboonCodecError::CodecNotFound { .. }))); } @@ -363,7 +363,7 @@ fn json_to_ueba_bytes_static_fallback_b_resolves() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x03, None, Some("1.0.0"), Some("T")); let result = - facade.json_to_ueba_bytes(&meta, &serde_json::Value::Null, Some("static.dom"), None, None); + facade.json_to_ueba_bytes(&BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, Some("static.dom"), None, None); assert!(matches!(result, Err(BaboonCodecError::CodecNotFound { .. }))); } @@ -373,6 +373,7 @@ fn json_to_ueba_bytes_static_fallback_d3_resolves() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x00, None, None, None); let result = facade.json_to_ueba_bytes( + &BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, Some("static.dom"), @@ -386,7 +387,7 @@ fn json_to_ueba_bytes_static_fallback_d3_resolves() { fn ueba_to_json_static_fallback_d3_resolves() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x00, None, None, None); - let result = facade.ueba_to_json(&meta, &[], Some("static.dom"), Some("1.0.0"), Some("T")); + let result = facade.ueba_to_json(&BaboonCodecContext::Compact, &meta, &[], Some("static.dom"), Some("1.0.0"), Some("T")); assert!(matches!(result, Err(BaboonCodecError::CodecNotFound { .. }))); } @@ -397,7 +398,7 @@ fn cross_format_wire_meta_wins_over_static() { let facade = BaboonCodecsFacade::new(); let meta = mk(0x07, Some("wire.dom"), Some("1.0.0"), Some("T")); let result = - facade.json_to_ueba_bytes(&meta, &serde_json::Value::Null, Some("static.dom"), None, None); + facade.json_to_ueba_bytes(&BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, Some("static.dom"), None, None); match result { Err(BaboonCodecError::CodecNotFound { message }) => { assert!(message.contains("wire.dom"), "expected wire override; message: {}", message); @@ -459,7 +460,7 @@ fn single_version_domain_get_codec_regression() { // path). Without the !exact && model == max arm, this would yield "Unsupported domain // version" rather than the expected codec-table CodecNotFound. let meta = mk(0x07, Some("my.ok"), Some("1.0.0"), Some("Inner")); - let result = facade.json_to_ueba_bytes(&meta, &serde_json::Value::Null, None, None, None); + let result = facade.json_to_ueba_bytes(&BaboonCodecContext::Compact, &meta, &serde_json::Value::Null, None, None, None); match result { Err(BaboonCodecError::CodecNotFound { message }) => { assert!( @@ -710,7 +711,7 @@ fn pr11_d02_decode_from_json_round_trip() { let facade = BaboonCodecsFacade::new(); register_sample_v1(&facade); let value = SampleV1 { payload: 99 }; - let json = facade.encode_to_json(&value).expect("encode_to_json"); + let json = facade.encode_to_json(&BaboonCodecContext::Compact, &value).expect("encode_to_json"); let decoded = facade .decode_from_json(&json) .expect("decode_from_json") @@ -737,7 +738,7 @@ fn pr11_d02_decode_from_json_str_works() { let facade = BaboonCodecsFacade::new(); register_sample_v1(&facade); let value = SampleV1 { payload: 5 }; - let json = facade.encode_to_json(&value).expect("encode_to_json"); + let json = facade.encode_to_json(&BaboonCodecContext::Compact, &value).expect("encode_to_json"); let s = serde_json::to_string(&json).unwrap(); let decoded = facade .decode_from_json_str(&s) @@ -827,7 +828,7 @@ fn pr11_d04_min_compat_from_same_in_versions_first() { let facade = BaboonCodecsFacade::new(); register_sample_v2(&facade); let value = SampleV2 { payload: 11 }; - let json = facade.encode_to_json(&value).expect("encode_to_json"); + let json = facade.encode_to_json(&BaboonCodecContext::Compact, &value).expect("encode_to_json"); let obj = json.as_object().expect("object"); // SampleV2: domain_version=2.0.0, same_in[0]=1.0.0 → $uv must be present and == 1.0.0 assert_eq!(obj.get("$d").and_then(|v| v.as_str()), Some("my.dom")); @@ -841,7 +842,7 @@ fn pr11_d04_no_uv_when_min_compat_equals_current() { let facade = BaboonCodecsFacade::new(); register_sample_v1(&facade); let value = SampleV1 { payload: 1 }; - let json = facade.encode_to_json(&value).expect("encode_to_json"); + let json = facade.encode_to_json(&BaboonCodecContext::Compact, &value).expect("encode_to_json"); let obj = json.as_object().expect("object"); // SampleV1: same_in[0]=1.0.0 == current → $uv suppressed assert!(obj.get("$uv").is_none(), "$uv must be omitted when min-compat == current"); diff --git a/test/rs-stub/tests/any_round_trip_tests.rs b/test/rs-stub/tests/any_round_trip_tests.rs index 144c3194a..30a2cae36 100644 --- a/test/rs-stub/tests/any_round_trip_tests.rs +++ b/test/rs-stub/tests/any_round_trip_tests.rs @@ -322,19 +322,19 @@ fn cross_format_ueba_holder_to_json_decode_round_trip() { // f_any — variant A, all wire-meta present. let typed_meta_a = AnyMeta::new(0x07, Some(DOMAIN_ID.into()), Some(VERSION_STR.into()), Some(INNER_TYPE.into())).unwrap(); let json_a = facade - .ueba_to_json(&typed_meta_a, &inner_bytes, None, None, None) + .ueba_to_json(&BaboonCodecContext::Compact, &typed_meta_a, &inner_bytes, None, None, None) .expect("ueba_to_json A"); assert_eq!(json_a, inner_to_json(&sample_inner()), "A cross-convert must yield Inner JSON"); // f_underlying — variant D1, statics fill in typeid only. let json_d1 = facade - .ueba_to_json(&meta_d1(), &inner_bytes, None, None, Some(INNER_TYPE)) + .ueba_to_json(&BaboonCodecContext::Compact, &meta_d1(), &inner_bytes, None, None, Some(INNER_TYPE)) .expect("ueba_to_json D1"); assert_eq!(json_d1, inner_to_json(&sample_inner()), "D1 cross-convert must yield Inner JSON"); // f_current_underlying — variant D3, statics fill in domain/version/typeid. let json_d3 = facade - .ueba_to_json(&meta_d3(), &inner_bytes, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE)) + .ueba_to_json(&BaboonCodecContext::Compact, &meta_d3(), &inner_bytes, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE)) .expect("ueba_to_json D3"); assert_eq!(json_d3, inner_to_json(&sample_inner()), "D3 cross-convert must yield Inner JSON"); } diff --git a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs index 4757a0e2a..7a501d034 100644 --- a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs +++ b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs @@ -210,7 +210,7 @@ fn decode_from_json_latest_round_trips_to_expected_inner() { let facade = fresh_facade(); let val = InnerDyn(sample_inner()); let json_envelope = facade - .encode_to_json(&val) + .encode_to_json(&BaboonCodecContext::Compact, &val) .expect("encode_to_json InnerDyn"); let result = facade diff --git a/test/rs-stub/tests/baboon_type_meta_codec_tests.rs b/test/rs-stub/tests/baboon_type_meta_codec_tests.rs index 882f5f178..737e94d19 100644 --- a/test/rs-stub/tests/baboon_type_meta_codec_tests.rs +++ b/test/rs-stub/tests/baboon_type_meta_codec_tests.rs @@ -138,7 +138,7 @@ fn type_meta_write_json_emits_mv_as_numeric_1() { let value = TestVal { x: 42 }; let json = facade - .encode_to_json(&value) + .encode_to_json(&BaboonCodecContext::Compact, &value) .expect("encode_to_json"); let obj = json.as_object().expect("envelope must be a JSON object"); diff --git a/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs b/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs index e3d6795fc..0dc7d9ea0 100644 --- a/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs +++ b/test/rs-stub/tests/min_reader_tiers_fail_fast_tests.rs @@ -87,10 +87,10 @@ fn facade() -> BaboonCodecsFacade { #[test] fn json_bound_missing_is_an_encoder_failure() { - let err = facade().encode_to_json(&NoTiers::none()).err().expect("a value without the json-additive tier must not be silently encoded"); + let err = facade().encode_to_json(&BaboonCodecContext::Compact, &NoTiers::none()).err().expect("a value without the json-additive tier must not be silently encoded"); assert!(format!("{:?}", err).contains("json-additive"), "got {:?}", err); // with the JSON bound present the value encodes normally - assert!(facade().encode_to_json(&NoTiers::json_only()).is_ok()); + assert!(facade().encode_to_json(&BaboonCodecContext::Compact, &NoTiers::json_only()).is_ok()); } #[test] diff --git a/test/sc-stub/src/test/scala/runtime/AnyMetaCodecSpec.scala b/test/sc-stub/src/test/scala/runtime/AnyMetaCodecSpec.scala index 512003c07..41f7068f6 100644 --- a/test/sc-stub/src/test/scala/runtime/AnyMetaCodecSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/AnyMetaCodecSpec.scala @@ -239,7 +239,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { test("jsonToUebaBytes Left path: incomplete meta") { val facade = new BaboonCodecsFacade {} val meta = AnyMeta(0x01.toByte, None, None, Some("T")) // kind C: only typeid - val result = facade.jsonToUebaBytes(meta, Json.Null) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, Json.Null) assert(result.isLeft, s"expected Left, got $result") val msg = result.swap.toOption.get.getMessage assert(msg.contains("domain"), s"expected message to mention 'domain': $msg") @@ -249,7 +249,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { test("uebaToJson Left path: incomplete meta") { val facade = new BaboonCodecsFacade {} val meta = AnyMeta(0x01.toByte, None, None, Some("T")) // kind C: only typeid - val result = facade.uebaToJson(meta, Array.empty[Byte]) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, Array.empty[Byte]) assert(result.isLeft, s"expected Left, got $result") val msg = result.swap.toOption.get.getMessage assert(msg.contains("domain"), s"expected message to mention 'domain': $msg") @@ -260,7 +260,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { val facade = new BaboonCodecsFacade {} // Complete meta but no domain registered → CodecNotFound from getCodec. val meta = AnyMeta(0x07.toByte, Some("com.example.unknown"), Some("1.0.0"), Some("Unknown")) - val result = facade.jsonToUebaBytes(meta, Json.obj("x" -> Json.fromInt(1))) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, Json.obj("x" -> Json.fromInt(1))) assert(result.isLeft, s"expected Left, got $result") assert(result.swap.toOption.get.isInstanceOf[BaboonCodecException.CodecNotFound], s"expected CodecNotFound, got ${result.swap.toOption.get}") } @@ -268,7 +268,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { test("uebaToJson Left path: no codec registered") { val facade = new BaboonCodecsFacade {} val meta = AnyMeta(0x07.toByte, Some("com.example.unknown"), Some("1.0.0"), Some("Unknown")) - val result = facade.uebaToJson(meta, Array.empty[Byte]) + val result = facade.uebaToJson(BaboonCodecContext.Compact, meta, Array.empty[Byte]) assert(result.isLeft, s"expected Left, got $result") assert(result.swap.toOption.get.isInstanceOf[BaboonCodecException.CodecNotFound], s"expected CodecNotFound, got ${result.swap.toOption.get}") } @@ -289,7 +289,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { val facade = new BaboonCodecsFacade {} // kind 0x03 (B): no domain on wire, version + typeid present. val meta = AnyMeta(0x03.toByte, None, Some("1.0.0"), Some("T")) - val result = facade.jsonToUebaBytes(meta, Json.Null, staticDomain = Some("com.example.unknown")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, Json.Null, staticDomain = Some("com.example.unknown")) // Should reach codec lookup (fail with CodecNotFound), not the missing-meta DecoderFailure. assert(result.isLeft, s"expected Left, got $result") assert( @@ -302,7 +302,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { val facade = new BaboonCodecsFacade {} // kind 0x05 is reserved; use kind 0x01 (only typeid) and supply both staticDomain + staticVersion. val meta = AnyMeta(0x01.toByte, None, None, Some("T")) - val result = facade.jsonToUebaBytes(meta, Json.Null, staticDomain = Some("com.example.unknown"), staticVersion = Some("1.0.0")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, Json.Null, staticDomain = Some("com.example.unknown"), staticVersion = Some("1.0.0")) assert(result.isLeft, s"expected Left, got $result") assert( result.swap.toOption.get.isInstanceOf[BaboonCodecException.CodecNotFound], @@ -314,7 +314,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { val facade = new BaboonCodecsFacade {} // kind 0x06 (D1): domain + version on wire, typeid absent. val meta = AnyMeta(0x06.toByte, Some("com.example.unknown"), Some("1.0.0"), None) - val result = facade.jsonToUebaBytes(meta, Json.Null, staticTypeid = Some("Inner")) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, Json.Null, staticTypeid = Some("Inner")) assert(result.isLeft, s"expected Left, got $result") assert( result.swap.toOption.get.isInstanceOf[BaboonCodecException.CodecNotFound], @@ -327,6 +327,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { // kind 0x00 (D3): nothing on wire. val meta = AnyMeta(0x00.toByte, None, None, None) val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, Json.Null, staticDomain = Some("com.example.unknown"), @@ -345,6 +346,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { // meta has all three present; statics differ — meta should win, so resolution targets meta.domain. val meta = AnyMeta(0x07.toByte, Some("com.example.metawins"), Some("1.0.0"), Some("MetaT")) val result = facade.jsonToUebaBytes( + BaboonCodecContext.Compact, meta, Json.Null, staticDomain = Some("com.example.staticloses"), @@ -366,6 +368,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { val facade = new BaboonCodecsFacade {} val meta = AnyMeta(0x00.toByte, None, None, None) val result = facade.uebaToJson( + BaboonCodecContext.Compact, meta, Array.empty[Byte], staticDomain = Some("com.example.unknown"), @@ -416,7 +419,7 @@ class AnyMetaCodecSpec extends AnyFunSuite { Some("my.ok/:#Inner"), ) val innerJson = my.ok.Inner_JsonCodec.encode(BaboonCodecContext.Compact, my.ok.Inner(7)) - val result = facade.jsonToUebaBytes(meta, innerJson) + val result = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, innerJson) assert(result.isRight, s"expected Right(bytes) for single-version domain lookup, got: $result") } diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala index fc44379b4..41a00ed64 100644 --- a/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatEnvelopeSpec.scala @@ -34,21 +34,21 @@ class ForwardCompatEnvelopeSpec extends AnyFunSuite { private def field(json: Json, key: String): Option[String] = json.hcursor.downField(key).as[String].toOption test("writer publishes $rv only where the json-additive bound is older than the identical bound") { - val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + val appended = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get assert(field(appended, "$v").contains("2.0.0")) assert(field(appended, "$uv").isEmpty, "identical-min equals $v and is elided") assert(field(appended, "$rv").contains("1.0.0")) - val inserted = writer.encodeToJson(fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get + val inserted = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get assert(field(inserted, "$rv").contains("1.0.0")) // unchanged type: byte-identical back to 1.0.0, so $uv carries the bound and $rv is elided - val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + val stable = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("same")).toTry.get assert(field(stable, "$uv").contains("1.0.0")) assert(field(stable, "$rv").isEmpty) // grown enum: not forward-readable at all, no bounds published - val enumHost = writer.encodeToJson(fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + val enumHost = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get assert(field(enumHost, "$uv").isEmpty) assert(field(enumHost, "$rv").isEmpty) @@ -59,36 +59,36 @@ class ForwardCompatEnvelopeSpec extends AnyFunSuite { test("Tolerant old reader decodes newer json-additive payloads with its own codec, dropping unknown fields") { val reader = oldReader(ForwardReadPolicy.Tolerant) - val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + val appended = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get val decoded = reader.decodeFromJson(appended).toTry.get assert(decoded.contains(fwde2e.fwd.v1_0_0.FwdAppendVar(42, "hello"))) - val inserted = writer.encodeToJson(fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get + val inserted = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdMidInsert(7, Some(99), "zed")).toTry.get assert(reader.decodeFromJson(inserted).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdMidInsert(7, "zed"))) // byte-identical types keep working as before ($uv path) - val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + val stable = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("same")).toTry.get assert(reader.decodeFromJson(stable).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdStable("same"))) } test("Lossless old reader refuses newer payloads unless byte-identical") { val reader = oldReader(ForwardReadPolicy.Lossless) - val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get + val appended = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdAppendVar(42, "hello", Some("appended"))).toTry.get // the facade reports "no codec resolves" as Right(None) (Option contract), not Left assert(reader.decodeFromJson(appended).toTry.get.isEmpty, "Lossless must not honour $rv") - val stable = writer.encodeToJson(fwde2e.fwd.FwdStable("same")).toTry.get + val stable = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdStable("same")).toTry.get assert(reader.decodeFromJson(stable).toTry.get.contains(fwde2e.fwd.v1_0_0.FwdStable("same"))) } test("no published bound: the old reader refuses under either policy") { - val enumHost = writer.encodeToJson(fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get + val enumHost = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdEnumHost(fwde2e.fwd.FwdEnumGrows.C)).toTry.get assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromJson(enumHost).toTry.get.isEmpty) assert(oldReader(ForwardReadPolicy.Lossless).decodeFromJson(enumHost).toTry.get.isEmpty) } test("envelope round-trips $rv through readMeta") { - val appended = writer.encodeToJson(fwde2e.fwd.FwdAppendVar(1, "b", Some("t"))).toTry.get + val appended = writer.encodeToJson(BaboonCodecContext.Compact, fwde2e.fwd.FwdAppendVar(1, "b", Some("t"))).toTry.get val meta = BaboonTypeMeta.readMeta(appended).get assert(meta.domainVersion == "2.0.0") assert(meta.domainVersionMinCompat == "2.0.0") diff --git a/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala b/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala index a4d04b63e..8d7847c3d 100644 --- a/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala +++ b/test/sc-stub/src/test/scala/runtime/ForwardCompatRenameSpec.scala @@ -82,7 +82,7 @@ class ForwardCompatRenameSpec extends AnyFunSuite { } test("JSON is not readable across the rename under either policy") { - val json = writer.encodeToJson(current).toTry.get + val json = writer.encodeToJson(BaboonCodecContext.Compact, current).toTry.get assert(oldReader(ForwardReadPolicy.Tolerant).decodeFromJson(json).toTry.get.isEmpty) assert(oldReader(ForwardReadPolicy.Lossless).decodeFromJson(json).toTry.get.isEmpty) } diff --git a/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift b/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift index bbe7f1dd7..40bf410dc 100644 --- a/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift +++ b/test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift @@ -471,6 +471,7 @@ final class AnyMetaCodecTests: XCTestCase { let m = try AnyMeta(kind: 0x00, domain: nil, version: nil, typeid: nil) let f = BaboonCodecsFacade() let result = f.jsonToUebaBytes( + BaboonCodecContext.compact, m, [String: Any](), staticDomain: "d", staticVersion: "1.0.0", staticTypeid: "T" ) @@ -485,7 +486,7 @@ final class AnyMetaCodecTests: XCTestCase { func testJsonToUebaBytes_failsWhenStaticsMissing() throws { let m = try AnyMeta(kind: 0x00, domain: nil, version: nil, typeid: nil) let f = BaboonCodecsFacade() - let result = f.jsonToUebaBytes(m, [String: Any]()) + let result = f.jsonToUebaBytes(BaboonCodecContext.compact, m, [String: Any]()) switch result { case .success: XCTFail("Expected failure") case .failure(let e): @@ -500,6 +501,7 @@ final class AnyMetaCodecTests: XCTestCase { let m = try AnyMeta(kind: 0x07, domain: "wire", version: "1.0.0", typeid: "T") let f = BaboonCodecsFacade() let result = f.jsonToUebaBytes( + BaboonCodecContext.compact, m, [String: Any](), staticDomain: "static", staticVersion: "1.0.0", staticTypeid: "T" ) @@ -515,6 +517,7 @@ final class AnyMetaCodecTests: XCTestCase { let m = try AnyMeta(kind: 0x00, domain: nil, version: nil, typeid: nil) let f = BaboonCodecsFacade() let result = f.uebaToJson( + BaboonCodecContext.compact, m, Data(), staticDomain: "d", staticVersion: "1.0.0", staticTypeid: "T" ) diff --git a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts index cdc28242b..350dc0ed4 100644 --- a/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts +++ b/test/ts-stub/src/runtime-tests/AnyMetaCodec.test.ts @@ -297,21 +297,21 @@ describe("BaboonCodecsFacade cross-format helpers (incomplete meta / no codec)", test("jsonToUebaBytes returns Left on incomplete meta (PR-06-D01)", () => { const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x00, null, null, null); - const r = facade.jsonToUebaBytes(meta, {}); + const r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, {}); expect(r.tag).toBe("Left"); }); test("uebaToJson returns Left on incomplete meta", () => { const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x00, null, null, null); - const r = facade.uebaToJson(meta, new Uint8Array([1, 2, 3])); + const r = facade.uebaToJson(BaboonCodecContext.Compact, meta, new Uint8Array([1, 2, 3])); expect(r.tag).toBe("Left"); }); test("jsonToUebaBytes returns Left on no codec", () => { const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x07, "dom", "1.0.0", "T"); - const r = facade.jsonToUebaBytes(meta, {}); + const r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, {}); expect(r.tag).toBe("Left"); if (r.tag === "Left") expect(r.value).toBeInstanceOf(BaboonCodecNotFound); }); @@ -319,7 +319,7 @@ describe("BaboonCodecsFacade cross-format helpers (incomplete meta / no codec)", test("uebaToJson returns Left on no codec", () => { const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x07, "dom", "1.0.0", "T"); - const r = facade.uebaToJson(meta, new Uint8Array([1, 2, 3])); + const r = facade.uebaToJson(BaboonCodecContext.Compact, meta, new Uint8Array([1, 2, 3])); expect(r.tag).toBe("Left"); if (r.tag === "Left") expect(r.value).toBeInstanceOf(BaboonCodecNotFound); }); @@ -341,7 +341,7 @@ describe("Static-fallback semantics", () => { // complete) rather than "AnyMeta requires ...". const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x00, null, null, null); - const r = facade.jsonToUebaBytes(meta, {}, "dom", "1.0.0", "T"); + const r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, {}, "dom", "1.0.0", "T"); expect(r.tag).toBe("Left"); if (r.tag === "Left") expect(r.value).toBeInstanceOf(BaboonCodecNotFound); }); @@ -349,7 +349,7 @@ describe("Static-fallback semantics", () => { test("wire meta domain overrides static (PR-06-D01: wire wins)", () => { const facade = new BaboonCodecsFacade(); const meta = createAnyMeta(0x07, "dom-wire", "1.0.0", "T"); - const r = facade.jsonToUebaBytes(meta, {}, "dom-static", "0.9.0", "Other"); + const r = facade.jsonToUebaBytes(BaboonCodecContext.Compact, meta, {}, "dom-static", "0.9.0", "Other"); expect(r.tag).toBe("Left"); if (r.tag === "Left") expect(r.value.message).toContain("dom-wire"); }); @@ -425,7 +425,7 @@ describe("getCodec single-version-domain (PR-07-D02)", () => { const any = createAnyMeta(0x07, "dom", "1.0.0", "T"); const payload = new Uint8Array([0x42]); - const r = facade.uebaToJson(any, payload); + const r = facade.uebaToJson(BaboonCodecContext.Compact, any, payload); expect(r.tag).toBe("Right"); }); @@ -494,7 +494,7 @@ describe("encodeToBin / encodeToJson useAdtIdentifier plumbing (PR-19-D02)", () test("encodeToJson defaults to concrete branch typeid", () => { const facade = makeFacade(); - const r = facade.encodeToJson(new StubAdtBranchGenerated()); + const r = facade.encodeToJson(BaboonCodecContext.Compact, new StubAdtBranchGenerated()); expect(r.tag).toBe("Right"); if (r.tag !== "Right") return; expect(r.value["$t"]).toBe("BranchT"); @@ -502,7 +502,7 @@ describe("encodeToBin / encodeToJson useAdtIdentifier plumbing (PR-19-D02)", () test("encodeToJson with useAdtIdentifier=true uses the ADT typeid", () => { const facade = makeFacade(); - const r = facade.encodeToJson(new StubAdtBranchGenerated(), undefined, true); + const r = facade.encodeToJson(BaboonCodecContext.Compact, new StubAdtBranchGenerated(), undefined, true); expect(r.tag).toBe("Right"); if (r.tag !== "Right") return; expect(r.value["$t"]).toBe("AdtT"); diff --git a/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts b/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts index 8353c0339..ca35574b3 100644 --- a/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts +++ b/test/ts-stub/src/runtime-tests/BaboonCodecsFacadeLatest.test.ts @@ -85,7 +85,7 @@ function encodeInnerViaBin(facade: BaboonCodecsFacade): Uint8Array { } function encodeInnerViaJson(facade: BaboonCodecsFacade): Record { - const result = facade.encodeToJson(SAMPLE_INNER); + const result = facade.encodeToJson(BaboonCodecContext.Compact, SAMPLE_INNER); if (result.tag !== "Right") throw new Error("encodeToJson failed: " + JSON.stringify(result)); return result.value; } diff --git a/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts b/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts index dccc1857f..1f1908990 100644 --- a/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts +++ b/test/ts-stub/src/runtime-tests/ForwardCompatEnvelope.test.ts @@ -13,6 +13,7 @@ import { AbstractBaboonJsonCodecs, AbstractBaboonUebaCodecs, BaboonBinCodec, + BaboonCodecContext, BaboonDomainVersion, BaboonEither, BaboonJsonCodec, @@ -82,7 +83,7 @@ function unwrap(e: BaboonEither): R { } function envelope(value: FwdAppendVar | FwdMidInsert | FwdStable | FwdEnumHost): Record { - return unwrap(writer.encodeToJson(value)) as Record; + return unwrap(writer.encodeToJson(BaboonCodecContext.Compact, value)) as Record; } describe('JSON envelope $rv (fwde2e.fwd)', () => { From 9c670c52a1442b720669aab0021a090e9dbefc48 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 13:06:10 +0100 Subject: [PATCH 40/48] fix(rust): give the JSON encode path a codec context so `any` payloads can transcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the facade context threading. Rust is the only backend whose JSON codec is not generated: `RsJsonCodecGenerator` emitted serde helpers only, and the dyn adapter read fn encode_json_dyn(&self, _ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn) -> ... { serde_json::to_value(v) } so the context arrived and died there. `serde::Serialize::serialize(&self, S)` has nowhere to put one, which means `Serialize for AnyOpaque` can reach neither the facade nor the per-field static fallbacks and errors out on the `Ueba` branch. Reproduced against the `any-ok` fixture: `facade.encode_to_json` failed identically with and without a facade in the context. A scoped thread-local carrying the facade would have been ~40 lines and no codegen, and it was rejected: `decode_any_field` yields the WIRE meta, which for variants B/C/D is deliberately incomplete, so an ambient facade covers variant A only and fails the realistic decode-UEBA-then-encode-JSON path for the other five — with no kind validation either. The statics live at the field site, so the fix has to live there too. Every generated type now gets a context-carrying inherent encoder, mirroring the UEBA side's `encode_ueba(ctx, writer)`: pub fn encode_json(&self, ctx: &BaboonCodecContext) -> Result Any-bearing types first rewrite their `any` slots to the JSON branch through the new `any_field_codec::resolve_any_field_for_json` — which checks the declared kind byte and passes the same static-fallback table `RsUEBACodecGenerator.anyStaticFallbacks` computes — and then hand the value to serde unchanged. Renames, hex bytes, decimal-as-number, timestamps, user map-key adapters and ADT wrapping are therefore still produced by the derive and cannot drift. Types with no `any` below them emit a one-line `serde_json::to_value` passthrough. Containment is a least fixpoint over the typespace, iterative rather than recursive because recursive DTOs make the reference graph cyclic. `to_json` / `to_json_value` stay context-less and keep refusing UEBA-form payloads: they have no facade to reach, and silently emitting bytes would break cross-language readers. Rust's JSON side still cannot be driven from `serde_json::to_value` for such values — that remains the documented raw-serde behaviour, pinned by the existing fail-fast test. Tests: `test/rs-stub/tests/facade_json_any_ctx_tests.rs` covers all six DSL variants plus the opt/lst/map-value nested positions transcoding through the facade, the facade-less error, the kind-mismatch rejection, and a drift guard asserting `encode_json` is byte-identical to `serde_json::to_value` wherever no transcoding happens. Verified with `mdl --seq :ci` — 206/206, cross-language acceptance matrix included. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon-runtime/rust/any_opaque.rs | 37 ++++ .../translator/rust/RsBaboonTranslator.scala | 4 +- .../rust/RsJsonCodecGenerator.scala | 194 +++++++++++++++++- defects.md | 7 + .../tests/facade_json_any_ctx_tests.rs | 175 ++++++++++++++++ 5 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 test/rs-stub/tests/facade_json_any_ctx_tests.rs diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index 34fd51922..272d90f53 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -202,6 +202,43 @@ pub mod any_field_codec { wire.read_exact(&mut any_blob)?; Ok(crate::any_opaque::AnyOpaqueUeba::new(any_meta, any_blob)) } + + /// Resolves an `any` slot into its JSON-form branch ahead of serde. + /// + /// `serde::Serialize` has no room for a codec context, so `Serialize for AnyOpaque` cannot + /// reach the facade a UEBA-form payload needs to transcode itself. Generated types therefore + /// rewrite their `any` slots through this helper before handing the value to serde — the + /// field site is also the only place the per-field static fallbacks and the declared kind + /// byte are known. A slot that is already JSON-form is left untouched. + pub fn resolve_any_field_for_json( + ctx: &crate::baboon_runtime::BaboonCodecContext, + expected_kind: u8, + static_domain: Option<&str>, + static_version: Option<&str>, + static_typeid: Option<&str>, + slot: &mut crate::any_opaque::AnyOpaque, + ) -> Result<(), crate::any_opaque::BaboonCodecError> { + if slot.meta().kind != expected_kind { + return Err(crate::any_opaque::BaboonCodecError::encoder_failure(format!( + "any: meta-kind 0x{:02x} does not match field-declared 0x{:02x}", + slot.meta().kind, expected_kind + ))); + } + let converted = match slot { + crate::any_opaque::AnyOpaque::Json(_) => return Ok(()), + crate::any_opaque::AnyOpaque::Ueba(u) => { + let f = ctx.facade().ok_or_else(|| { + crate::any_opaque::BaboonCodecError::encoder_failure( + "Cannot encode AnyOpaque::Ueba into JSON without a facade reference. Construct the codec context via BaboonCodecContext::with_facade(use_indices, facade), or supply AnyOpaque::Json directly.", + ) + })?; + let json = f.ueba_to_json(ctx, &u.meta, &u.bytes, static_domain, static_version, static_typeid)?; + crate::any_opaque::AnyOpaque::Json(crate::any_opaque::AnyOpaqueJson::new(u.meta.clone(), json)) + } + }; + *slot = converted; + Ok(()) + } } impl std::error::Error for BaboonCodecError { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index a78654ff1..533520697 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -289,9 +289,9 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( |struct $jsonCodec; |impl BaboonAnyJsonCodec for $jsonCodec { | fn type_identifier(&self) -> &str { "$typeId" } - | fn encode_json_dyn(&self, _ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn) -> Result { + | fn encode_json_dyn(&self, ctx: &BaboonCodecContext, value: &dyn BaboonGeneratedDyn) -> Result { | let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure("$jsonCodec.encode: wrong type"))?; - | serde_json::to_value(v).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e))) + | v.encode_json(ctx) | } | fn decode_json_dyn(&self, _ctx: &BaboonCodecContext, wire: &serde_json::Value) -> Result, crate::any_opaque::BaboonCodecError> { | let v: $fullPath = serde_json::from_value(wire.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))?; diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index 6c0600e90..01c6013b5 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -2,9 +2,10 @@ package io.septimalmind.baboon.translator.rust import io.septimalmind.baboon.CompilerTarget.RsTarget import io.septimalmind.baboon.parser.model.RawMemberMeta -import io.septimalmind.baboon.translator.rust.RsDefnTranslator.toSnakeCase +import io.septimalmind.baboon.translator.rust.RsDefnTranslator.{escapeRustTypeName, toSnakeCase} import io.septimalmind.baboon.typer.BaboonEnquiries import io.septimalmind.baboon.typer.model.* +import io.septimalmind.baboon.typer.model.TypeRef.AnyVariant import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* @@ -20,9 +21,9 @@ class RsJsonCodecGenerator( // The actual serde attributes are generated by RsDefnTranslator. // Here we only generate helper methods for programmatic encode/decode. defn.defn match { - case _: Typedef.Dto => Some(genJsonHelpers(rsRef)) - case _: Typedef.Enum => Some(genJsonHelpers(rsRef)) - case _: Typedef.Adt => Some(genJsonHelpers(rsRef)) + case _: Typedef.Dto => Some(genJsonHelpers(rsRef, defn)) + case _: Typedef.Enum => Some(genJsonHelpers(rsRef, defn)) + case _: Typedef.Adt => Some(genJsonHelpers(rsRef, defn)) case _: Typedef.Foreign => None case _: Typedef.Contract => None case _: Typedef.Service => None @@ -30,7 +31,7 @@ class RsJsonCodecGenerator( } else None } - private def genJsonHelpers(name: RsValue.RsType): TextTree[RsValue] = { + private def genJsonHelpers(name: RsValue.RsType, defn: DomainMember.User): TextTree[RsValue] = { q"""impl ${name.asName} { | pub fn to_json(&self) -> Result { | serde_json::to_string(self) @@ -51,9 +52,192 @@ class RsJsonCodecGenerator( | pub fn from_json_value(v: serde_json::Value) -> Result { | serde_json::from_value(v) | } + | + | ${ctxEncoder(defn).shift(4).trim} + |}${anyResolver(defn, name)}""".stripMargin + } + + /** Context-carrying JSON encoder, the counterpart of the UEBA side's `encode_ueba(ctx, writer)`. + * + * `serde::Serialize` has no room for a codec context, so neither the derive nor the + * hand-written `Serialize` impls (ADTs, wrapped branches) can reach the facade that an `any` + * field holding a UEBA payload needs to transcode itself to JSON. Rather than re-deriving the + * whole JSON shape by hand — renames, hex bytes, decimal-as-number, timestamps, user map-key + * adapters, ADT wrapping — this resolves the `any` slots to their JSON branch first and then + * lets serde produce the document exactly as it always has. + * + * Types with no `any` anywhere below them delegate straight to `serde_json::to_value`, so + * their wire shape is the derive's by construction and cannot drift. + */ + private def ctxEncoder(defn: DomainMember.User): TextTree[RsValue] = { + val body = if (anyBearing.contains(defn.id)) { + q"""let mut resolved = self.clone(); + |resolved.baboon_resolve_any_for_json(ctx)?; + |serde_json::to_value(&resolved) + | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin + } else { + q"""let _ = ctx; + |serde_json::to_value(self) + | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin + } + + q"""/// JSON encode under a codec context. Unlike `to_json_value`, this can transcode `any` + |/// fields that hold a UEBA payload, provided `ctx` carries a facade. + |pub fn encode_json(&self, ctx: &crate::baboon_runtime::BaboonCodecContext) -> Result { + | ${body.shift(4).trim} |}""".stripMargin } + /** In-place rewrite of every `any` slot reachable from this value into its JSON branch. + * Emitted only for any-bearing types; the field site is the only place the per-field static + * fallbacks and the declared kind byte are known. + */ + private def anyResolver(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { + if (!anyBearing.contains(defn.id)) return q"" + + val body = defn.defn match { + case d: Typedef.Dto => + val steps = d.fields.toList.filter(f => refBearsAny(f.tpe)).map { + f => resolveStep(f.tpe, q"self.${toSnakeCase(f.name.name)}") + } + if (steps.isEmpty) q"Ok(())" else q"""${steps.joinN()} + |Ok(())""".stripMargin + + case a: Typedef.Adt => + val arms = a.dataMembers(domain).toList.map { + mid => + val rsVariantName = escapeRustTypeName(mid.name.name.capitalize) + if (anyBearing.contains(mid)) { + q"""${name.asName}::$rsVariantName(v) => v.baboon_resolve_any_for_json(ctx),""" + } else { + q"""${name.asName}::$rsVariantName(_) => Ok(()),""" + } + } + q"""match self { + | ${arms.joinN().shift(4).trim} + |}""".stripMargin + + case _ => q"Ok(())" + } + + q""" + | + |impl ${name.asName} { + | #[doc(hidden)] + | pub fn baboon_resolve_any_for_json(&mut self, ctx: &crate::baboon_runtime::BaboonCodecContext) -> Result<(), crate::any_opaque::BaboonCodecError> { + | ${body.shift(8).trim} + | } + |}""".stripMargin + } + + /** Walks `tpe` down to its `any` positions, rewriting each in place. Mirrors the shape of + * `RsUEBACodecGenerator.mkEncoder`'s recursion over collection constructors. + */ + private def resolveStep(tpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case a: TypeRef.Any => + val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedHex = "0x%02x".format(expectedKind & 0xFF) + val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) + q"crate::any_opaque::any_field_codec::resolve_any_field_for_json(ctx, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &mut $ref)?;" + + case TypeRef.Scalar(_) => + // A user type that transitively carries `any` — recurse into its own resolver. + q"$ref.baboon_resolve_any_for_json(ctx)?;" + + case c: TypeRef.Constructor => + c.id match { + case TypeId.Builtins.opt => + q"""if let Some(v) = ($ref).as_mut() { + | ${resolveStep(c.args.head, q"(*v)").shift(4).trim} + |}""".stripMargin + case TypeId.Builtins.lst | TypeId.Builtins.set => + q"""for item in ($ref).iter_mut() { + | ${resolveStep(c.args.head, q"(*item)").shift(4).trim} + |}""".stripMargin + case TypeId.Builtins.map => + // Keys are never `any` (the typer rejects it); only values can carry one. + q"""for (_, v) in ($ref).iter_mut() { + | ${resolveStep(c.args.last, q"(*v)").shift(4).trim} + |}""".stripMargin + case o => throw new RuntimeException(s"BUG: Unexpected collection type: $o") + } + } + } + + /** Static fallbacks for the cross-format facade helper (`ueba_to_json`). Identical table to + * `RsUEBACodecGenerator.anyStaticFallbacks` — the wire `meta` may omit components pinned by + * the field's static declaration, and the facade merges what the codec supplies. + */ + private def anyStaticFallbacks(a: TypeRef.Any): (TextTree[RsValue], TextTree[RsValue], TextTree[RsValue]) = { + val none = q"None" + def some(s: String) = q"""Some("$s")""" + val currentDomain: String = domain.id.toString + val currentDomainVer: String = domain.version.v.toString + val typeidStatic = a.underlying match { + case Some(u) => some(u.id.toString) + case None => none + } + val (domainStatic, versionStatic) = a.variant match { + case AnyVariant.Global => (none, none) + case AnyVariant.ThisDom => (some(currentDomain), none) + case AnyVariant.Current => (some(currentDomain), some(currentDomainVer)) + } + (domainStatic, versionStatic, typeidStatic) + } + + /** Does `tpe` reach an `any` position, either directly or through a user type it references? + * Drives both which types get a resolver and which fields that resolver has to walk. + */ + private def refBearsAny(tpe: TypeRef): Boolean = tpe match { + case _: TypeRef.Any => true + case TypeRef.Scalar(u: TypeId.User) => anyBearing.contains(u) + case _: TypeRef.Scalar => false + case c: TypeRef.Constructor => c.args.exists(refBearsAny) + } + + /** Types that carry an `any` somewhere below them, as a least fixpoint over the typespace. + * Iterative rather than recursive because recursive DTOs (`recursive-ok`) make the reference + * graph cyclic. + */ + private lazy val anyBearing: Set[TypeId] = { + val users = domain.defs.meta.nodes.toList.collect { case (id, u: DomainMember.User) => (id, u) } + + def directAny(tpe: TypeRef): Boolean = tpe match { + case _: TypeRef.Any => true + case _: TypeRef.Scalar => false + case c: TypeRef.Constructor => c.args.exists(directAny) + } + + def userRefs(tpe: TypeRef): List[TypeId] = tpe match { + case TypeRef.Scalar(u: TypeId.User) => List(u) + case _: TypeRef.Scalar => Nil + case c: TypeRef.Constructor => c.args.toList.flatMap(userRefs) + case _: TypeRef.Any => Nil // the stored value is an opaque, not the underlying type + } + + val seeds: Set[TypeId] = users.collect { + case (id, DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.exists(f => directAny(f.tpe)) => id + }.toSet + + val edges: Map[TypeId, List[TypeId]] = users.map { + case (id, u) => + val refs = u.defn match { + case d: Typedef.Dto => d.fields.toList.flatMap(f => userRefs(f.tpe)) + case a: Typedef.Adt => a.dataMembers(domain).toList + case _ => Nil + } + (id, refs) + }.toMap + + Iterator + .iterate(seeds) { + current => current ++ edges.collect { case (id, refs) if refs.exists(current.contains) => id }.toSet + } + .sliding(2).collectFirst { case Seq(a, b) if a == b => a } + .getOrElse(seeds) + } + def codecName(name: RsValue.RsType): RsValue.RsType = { RsValue.RsType(name.crate, s"${name.name}_JsonCodec", name.fq) } diff --git a/defects.md b/defects.md index 0ff303b10..20951ae70 100644 --- a/defects.md +++ b/defects.md @@ -26,6 +26,13 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved **Description:** Both declared `ctx: str`. The value is a `BaboonCodecContext` — the private `_bin_type_meta(value, ctx: BaboonCodecContext)` annotates it correctly and reads `ctx.envelope_version` / `ctx.use_indices`, which would raise `AttributeError` on a `str`. Wrong annotation on a public method: type-checkers and IDEs actively mislead callers. **Fix:** Corrected both annotations to `BaboonCodecContext`. +### [FIELD-2026-09-24-D03] Rust `encode_to_json` cannot transcode a UEBA-form `any` payload at all +**Status:** resolved +**Severity:** major +**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala` (`encode_json_dyn`); `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala` +**Description:** Follow-up to D01. Threading a context into the Rust facade was necessary but not sufficient: Rust is the only backend whose JSON codec is not generated — `RsJsonCodecGenerator` emitted serde helpers only, and the dyn adapter read `fn encode_json_dyn(&self, _ctx, value)` then called `serde_json::to_value`. `serde::Serialize::serialize(&self, S)` has no room for a context, so `Serialize for AnyOpaque` could reach neither the facade nor the per-field static fallbacks, and errored on the `Ueba` branch. Reproduced against the `any-ok` fixture: `facade.encode_to_json` failed identically with and without a facade in the context. A scoped thread-local carrying the facade was rejected as a fix — `decode_any_field` yields the WIRE meta, which for variants B/C/D is deliberately incomplete, so an ambient facade would have worked for variant A only and failed the realistic decode-UEBA-then-encode-JSON path for the other five, with no kind validation either. +**Fix:** Emitted a context-carrying inherent `encode_json(ctx)` for every generated type, mirroring the UEBA side's `encode_ueba(ctx, writer)`, and pointed the dyn adapter at it. Any-bearing types first rewrite their `any` slots to the JSON branch via the new `any_field_codec::resolve_any_field_for_json` (kind check plus the same static-fallback table `RsUEBACodecGenerator.anyStaticFallbacks` computes), then hand the value to serde unchanged — so renames, hex bytes, decimal-as-number, timestamps, user map-key adapters and ADT wrapping are still produced by the derive and cannot drift. Types with no `any` below them emit a one-line `serde_json::to_value` passthrough. Containment is a least fixpoint over the typespace, iterative because recursive DTOs make the reference graph cyclic. `to_json`/`to_json_value` stay ctx-less and keep refusing UEBA-form payloads. Regression tests: `test/rs-stub/tests/facade_json_any_ctx_tests.rs` — all six DSL variants plus opt/lst/map-value positions transcoding through the facade, the facade-less error, the kind-mismatch check, and a drift guard asserting `encode_json` is byte-identical to `serde_json::to_value` wherever no transcoding happens. Verified with `mdl --seq :ci` (206/206, cross-language acceptance matrix included). + ### [FIELD-2026-09-24-N01] Facade decode entry points also pin a facade-less context (assessed, no change) **Status:** resolved (note-only; no functional change) **Severity:** nit diff --git a/test/rs-stub/tests/facade_json_any_ctx_tests.rs b/test/rs-stub/tests/facade_json_any_ctx_tests.rs new file mode 100644 index 000000000..57aef652b --- /dev/null +++ b/test/rs-stub/tests/facade_json_any_ctx_tests.rs @@ -0,0 +1,175 @@ +// NOTE: This integration test references generated runtime and DTO symbols which are produced +// into this stub only by the rs-stub codegen path (rsync + codegen into +// target/test-regular/rs-stub/). Run from the codegen'd copy, not the source tree. +// +// `serde::Serialize` has no room for a codec context, so neither the derive nor the hand-written +// `Serialize` impls (ADTs, wrapped branches) can reach the facade that an `any` field holding a +// UEBA payload needs to transcode itself to JSON. The generated `encode_json(ctx)` resolves the +// `any` slots first — with the per-field kind byte and static fallbacks, which only the field +// site knows — and then hands the value to serde unchanged. +// +// Two properties are pinned here: +// 1. `facade.encode_to_json` transcodes UEBA-form `any` payloads when the context carries a +// facade, across all six DSL variants plus the opt/lst/map-value nested positions. Before +// the fix this failed regardless of context, because the dyn adapter discarded `ctx` and +// called `serde_json::to_value` directly. +// 2. For values that carry no UEBA-form `any`, `encode_json` is byte-identical to the derive's +// `serde_json::to_value`. This is the drift guard: the encoder deliberately does NOT +// re-implement the JSON shape (renames, hex bytes, decimal-as-number, timestamps, user +// map-key adapters, ADT wrapping) — serde still produces the document, so any divergence +// here means the resolve step corrupted something it should have left alone. +#![allow(dead_code)] + +use baboon_rs_stub::any_opaque::{AnyMeta, AnyOpaque, AnyOpaqueUeba}; +use baboon_rs_stub::baboon_codecs_facade::BaboonCodecsFacade; +use baboon_rs_stub::baboon_fixture::BaboonRandom; +use baboon_rs_stub::baboon_runtime::{BaboonBinEncode, BaboonCodecContext}; +use baboon_rs_stub::my::ok::domain_myok_facade::DomainMyOkFacade; +use baboon_rs_stub::my::ok::holder::Holder; +use baboon_rs_stub::my::ok::holder_fixture::{random_holder, random_holder_json}; +use baboon_rs_stub::my::ok::inner::Inner; +use baboon_rs_stub::my::ok::inner_fixture::random_inner; +use std::collections::BTreeMap; +use std::sync::Arc; + +const DOMAIN_ID: &str = "my.ok"; +const VERSION_STR: &str = "1.0.0"; +const INNER_TYPE: &str = "my.ok/:#Inner"; + +fn inner_bytes() -> Vec { + let mut b = Vec::new(); + Inner { x: 42 } + .encode_ueba(&BaboonCodecContext::Compact, &mut b) + .expect("encode Inner"); + b +} + +fn ueba(kind: u8, domain: Option<&str>, version: Option<&str>, typeid: Option<&str>) -> AnyOpaque { + let meta = AnyMeta::new( + kind, + domain.map(|s| s.to_string()), + version.map(|s| s.to_string()), + typeid.map(|s| s.to_string()), + ) + .expect("AnyMeta"); + AnyOpaque::Ueba(AnyOpaqueUeba::new(meta, inner_bytes())) +} + +// Every field carries a UEBA payload whose meta resolves to the registered `Inner` codec — the +// untyped variants (A/B/C) carry the typeid on the wire, the D variants rely on the static +// fallbacks the codec generator emits at the field site. +fn holder_with_ueba_any() -> Holder { + let mut map = BTreeMap::new(); + map.insert("k1".to_string(), ueba(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE))); + Holder { + f_any: ueba(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE)), + f_domain_this: ueba(0x03, None, Some(VERSION_STR), Some(INNER_TYPE)), + f_domain_current: ueba(0x01, None, None, Some(INNER_TYPE)), + f_underlying: ueba(0x06, Some(DOMAIN_ID), Some(VERSION_STR), None), + f_this_underlying: ueba(0x02, None, Some(VERSION_STR), None), + f_current_underlying: ueba(0x00, None, None, None), + f_opt: Some(ueba(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE))), + f_lst: vec![ueba(0x06, Some(DOMAIN_ID), Some(VERSION_STR), None)], + f_map_value: map, + } +} + +fn facade_arc() -> Arc { + Arc::new(DomainMyOkFacade::new().facade) +} + +#[test] +fn encode_to_json_with_facade_ctx_transcodes_ueba_any_payloads() { + let dom = DomainMyOkFacade::new(); + let ctx = BaboonCodecContext::with_facade(false, facade_arc()); + + let json = dom + .facade + .encode_to_json(&ctx, &holder_with_ueba_any()) + .expect("encode_to_json must transcode UEBA `any` payloads when the ctx carries a facade"); + + // Every `any` slot must have become a JSON envelope carrying the inner document, not bytes. + let content = json.get("$c").expect("envelope content key"); + for field in [ + "fAny", + "fDomainThis", + "fDomainCurrent", + "fUnderlying", + "fThisUnderlying", + "fCurrentUnderlying", + ] { + let slot = content.get(field).unwrap_or_else(|| panic!("missing {}", field)); + let inner = slot.get("$c").unwrap_or_else(|| panic!("{} has no $c", field)); + assert_eq!(inner.get("x").and_then(|v| v.as_i64()), Some(42), "{} payload", field); + } + // Nested positions: option, list, map value. + assert!(content.get("fOpt").and_then(|v| v.get("$c")).is_some(), "fOpt transcoded"); + assert!(content.get("fLst").and_then(|v| v.get(0)).and_then(|v| v.get("$c")).is_some(), "fLst transcoded"); + assert!(content.get("fMapValue").and_then(|v| v.get("k1")).and_then(|v| v.get("$c")).is_some(), "fMapValue transcoded"); +} + +#[test] +fn encode_to_json_without_facade_ctx_still_reports_the_missing_facade() { + let dom = DomainMyOkFacade::new(); + let err = dom + .facade + .encode_to_json(&BaboonCodecContext::Compact, &holder_with_ueba_any()) + .err() + .expect("a facade-less context cannot transcode a UEBA `any` payload"); + let msg = format!("{}", err); + assert!(msg.contains("without a facade reference"), "got: {}", msg); + assert!(msg.contains("with_facade"), "error must point at the workaround; got: {}", msg); +} + +#[test] +fn encode_json_rejects_a_payload_whose_kind_contradicts_the_field() { + let dom = DomainMyOkFacade::new(); + let ctx = BaboonCodecContext::with_facade(false, facade_arc()); + let mut bad = holder_with_ueba_any(); + // fUnderlying is declared D1 (0x06); hand it a variant-A payload. + bad.f_underlying = ueba(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE)); + + let err = dom.facade.encode_to_json(&ctx, &bad).err().expect("kind mismatch must fail"); + let msg = format!("{}", err); + assert!(msg.contains("meta-kind"), "got: {}", msg); +} + +// ===== drift guard: encode_json must agree with the derive wherever no transcoding happens ===== + +#[test] +fn encode_json_matches_serde_derive_for_json_form_any() { + let ctx = BaboonCodecContext::with_facade(false, facade_arc()); + let mut rnd = BaboonRandom::new(); + for _ in 0..32 { + let value = random_holder_json(&mut rnd); + let via_encoder = value.encode_json(&ctx).expect("encode_json"); + let via_derive = serde_json::to_value(&value).expect("to_value"); + assert_eq!(via_encoder, via_derive, "encode_json must not reshape a JSON-form value"); + } +} + +#[test] +fn encode_json_matches_serde_derive_for_any_free_types() { + let ctx = BaboonCodecContext::Compact; + let mut rnd = BaboonRandom::new(); + for _ in 0..32 { + let value = random_inner(&mut rnd); + assert_eq!( + value.encode_json(&ctx).expect("encode_json"), + serde_json::to_value(&value).expect("to_value"), + "an any-free type must route straight back to the derive", + ); + } +} + +#[test] +fn encode_json_on_a_ueba_form_value_is_the_only_thing_that_needs_a_facade() { + // The raw serde path stays ctx-less and must keep refusing UEBA-form payloads: `to_json_value` + // has no facade to reach, and silently emitting bytes would break cross-language readers. + let mut rnd = BaboonRandom::new(); + let value = random_holder(&mut rnd); + assert!( + serde_json::to_value(&value).is_err(), + "raw serde must still refuse a UEBA-form `any`", + ); +} From 3530405a13d1f65f19b15e480899e13ea7e0b68e Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 13:23:33 +0100 Subject: [PATCH 41/48] refactor(rust): emit an explicit JSON encoder, pinned against the serde derive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of replacing Rust's derive-based JSON codec with generated ones. The encoder is converted here; the decoder, the removal of the derives and the `serde` dependency drop follow. The derives stay in place for now, which is what makes the step verifiable: while both exist, the derive is the specification and the new encoder must agree with it exactly. `RsJsonCodecGenerator` now emits a real field-by-field `encode_json(ctx)` for any-bearing types — DTO fields keyed by wire name, ADT branches as `{"Branch": ...}`, recursion through `opt`/`lst`/`set`/`map`. Plain leaves keep calling `serde_json::to_value`: that is what the derive did, so it is exact by construction, and it names no serde trait, so it survives dropping the direct dependency. Shapes serde expressed through field attributes (hex bytes, decimal-as-number, `tsu`/`tso`) are written by matching `json_tools` helpers. Types with no `any` below them keep a one-line passthrough. Supporting runtime: - `baboon_runtime::json_tools` — leaf conversions with typed errors: object/array/ string/bool accessors, the lenient i64/u64 readers reproducing the `lenient_numeric` attribute, upper-case hex bytes, decimal via `normalize()` + `Number::from_str`, and timestamps through the existing `time_formats` free functions. No serde. - `any_field_codec::any_to_json` replaces the interim `resolve_any_field_for_json`: it writes the `any` envelope directly (kind check, static fallbacks, facade transcode for a UEBA-form payload) instead of rewriting the slot and deferring to serde. The oracle is the point of this commit. `RsCodecTestsTranslator` emits `test__json_encode_matches_derive` for every type with a JSON codec — 145 of them — asserting `encode_json(Compact) == serde_json::to_value(value)` over the existing random fixtures. rs-stub goes 604 -> 749 tests, all green. Verified the oracle is not vacuous: renaming one emitted field key to `fAnyBROKEN` failed exactly `my::ok::holder_tests::test_holder_json_encode_matches_derive` and left the other 144 passing. Verified with `mdl --seq :build :test-gen-regular-adt :test-rust-regular :test-gen-wrapped-adt :test-rust-wrapped`. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon-runtime/rust/any_opaque.rs | 43 +++--- .../baboon-runtime/rust/baboon_runtime.rs | 133 +++++++++++++++++ .../rust/RsCodecTestsTranslator.scala | 32 ++++ .../rust/RsJsonCodecGenerator.scala | 137 +++++++++++------- 4 files changed, 272 insertions(+), 73 deletions(-) diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index 272d90f53..3bf36248d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -203,41 +203,50 @@ pub mod any_field_codec { Ok(crate::any_opaque::AnyOpaqueUeba::new(any_meta, any_blob)) } - /// Resolves an `any` slot into its JSON-form branch ahead of serde. + /// Writes an `any` field as its JSON envelope, transcoding a UEBA-form payload when needed. /// - /// `serde::Serialize` has no room for a codec context, so `Serialize for AnyOpaque` cannot - /// reach the facade a UEBA-form payload needs to transcode itself. Generated types therefore - /// rewrite their `any` slots through this helper before handing the value to serde — the - /// field site is also the only place the per-field static fallbacks and the declared kind - /// byte are known. A slot that is already JSON-form is left untouched. - pub fn resolve_any_field_for_json( + /// The field site is the only place that knows the declared kind byte and the static + /// fallbacks the facade needs to resolve a wire `meta` that deliberately omits what the + /// declaration already pins down, so the generated JSON encoder calls through here rather + /// than relying on `Serialize for AnyOpaque` — which gets no codec context and therefore no + /// facade. + pub fn any_to_json( ctx: &crate::baboon_runtime::BaboonCodecContext, expected_kind: u8, static_domain: Option<&str>, static_version: Option<&str>, static_typeid: Option<&str>, - slot: &mut crate::any_opaque::AnyOpaque, - ) -> Result<(), crate::any_opaque::BaboonCodecError> { - if slot.meta().kind != expected_kind { + value: &crate::any_opaque::AnyOpaque, + ) -> Result { + if value.meta().kind != expected_kind { return Err(crate::any_opaque::BaboonCodecError::encoder_failure(format!( "any: meta-kind 0x{:02x} does not match field-declared 0x{:02x}", - slot.meta().kind, expected_kind + value.meta().kind, expected_kind ))); } - let converted = match slot { - crate::any_opaque::AnyOpaque::Json(_) => return Ok(()), + let inner = match value { + crate::any_opaque::AnyOpaque::Json(j) => j.json.clone(), crate::any_opaque::AnyOpaque::Ueba(u) => { let f = ctx.facade().ok_or_else(|| { crate::any_opaque::BaboonCodecError::encoder_failure( "Cannot encode AnyOpaque::Ueba into JSON without a facade reference. Construct the codec context via BaboonCodecContext::with_facade(use_indices, facade), or supply AnyOpaque::Json directly.", ) })?; - let json = f.ueba_to_json(ctx, &u.meta, &u.bytes, static_domain, static_version, static_typeid)?; - crate::any_opaque::AnyOpaque::Json(crate::any_opaque::AnyOpaqueJson::new(u.meta.clone(), json)) + f.ueba_to_json(ctx, &u.meta, &u.bytes, static_domain, static_version, static_typeid)? } }; - *slot = converted; - Ok(()) + let mut envelope = crate::any_opaque::any_meta_codec::write_json(value.meta()); + match envelope.as_object_mut() { + Some(map) => { + map.insert(crate::any_opaque::any_meta_codec::ANY_CONTENT_KEY.to_string(), inner); + } + None => { + return Err(crate::any_opaque::BaboonCodecError::encoder_failure( + "any: AnyMeta envelope must be a JSON object", + )) + } + } + Ok(envelope) } } diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs index d0691f706..a973b4655 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs @@ -982,3 +982,136 @@ pub mod bin_tools { Ok(dt.with_timezone(&offset)) } } + +// --- Explicit JSON codec support --- +// +// `serde::Serialize` has no room for a codec context, so the generated types carry inherent +// `encode_json(ctx)` / `decode_json(ctx, wire)` methods instead of relying on the derive. +// These helpers hold the leaf conversions those methods need. Plain primitives deliberately +// still route through `serde_json`'s own conversion at the call site, so their JSON form is +// exactly what the derive produced (f32 shortest-repr, full-range i64/u64, bool, string) and +// cannot drift; only the shapes serde expressed through attributes live here. +#[cfg(feature = "json-helpers")] +pub mod json_tools { + use crate::any_opaque::BaboonCodecError; + use serde_json::Value; + + pub fn decoder_failure>(message: S) -> BaboonCodecError { + BaboonCodecError::decoder_failure(message) + } + + pub fn encoder_failure>(message: S) -> BaboonCodecError { + BaboonCodecError::encoder_failure(message) + } + + pub fn expect_object<'a>(wire: &'a Value, what: &str) -> Result<&'a serde_json::Map, BaboonCodecError> { + wire.as_object().ok_or_else(|| decoder_failure(format!("{}: expected a JSON object, got {}", what, kind_of(wire)))) + } + + pub fn expect_array<'a>(wire: &'a Value, what: &str) -> Result<&'a Vec, BaboonCodecError> { + wire.as_array().ok_or_else(|| decoder_failure(format!("{}: expected a JSON array, got {}", what, kind_of(wire)))) + } + + pub fn expect_str<'a>(wire: &'a Value, what: &str) -> Result<&'a str, BaboonCodecError> { + wire.as_str().ok_or_else(|| decoder_failure(format!("{}: expected a JSON string, got {}", what, kind_of(wire)))) + } + + pub fn expect_bool(wire: &Value, what: &str) -> Result { + wire.as_bool().ok_or_else(|| decoder_failure(format!("{}: expected a JSON boolean, got {}", what, kind_of(wire)))) + } + + /// A field the wire is required to carry. Absent and explicit-null are distinguished: an + /// `opt` field decodes null itself, every other field treats it as malformed input. + pub fn field<'a>(obj: &'a serde_json::Map, name: &str, what: &str) -> Result<&'a Value, BaboonCodecError> { + obj.get(name).ok_or_else(|| decoder_failure(format!("{}: missing field '{}'", what, name))) + } + + pub fn kind_of(wire: &Value) -> &'static str { + match wire { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + // Integers. `i64`/`u64` also accept a string on the wire, matching the lenient + // `#[serde(deserialize_with = "lenient_numeric::deserialize")]` the derive carried — + // JavaScript producers cannot represent the full 64-bit range as a JSON number. + pub fn read_i64(wire: &Value, what: &str) -> Result { + match wire { + Value::Number(n) => n.as_i64().ok_or_else(|| decoder_failure(format!("{}: {} is not an i64", what, n))), + Value::String(s) => s.parse::().map_err(|e| decoder_failure(format!("{}: {}", what, e))), + other => Err(decoder_failure(format!("{}: expected a number or string, got {}", what, kind_of(other)))), + } + } + + pub fn read_u64(wire: &Value, what: &str) -> Result { + match wire { + Value::Number(n) => n.as_u64().ok_or_else(|| decoder_failure(format!("{}: {} is not a u64", what, n))), + Value::String(s) => s.parse::().map_err(|e| decoder_failure(format!("{}: {}", what, e))), + other => Err(decoder_failure(format!("{}: expected a number or string, got {}", what, kind_of(other)))), + } + } + + pub fn read_f64(wire: &Value, what: &str) -> Result { + wire.as_f64().ok_or_else(|| decoder_failure(format!("{}: expected a number, got {}", what, kind_of(wire)))) + } + + // Bytes travel as upper-case hex, matching the `hex_bytes` adapter the derive used. + pub fn bytes_to_json(bytes: &[u8]) -> Value { + Value::String(bytes.iter().map(|b| format!("{:02X}", b)).collect::()) + } + + pub fn bytes_from_json(wire: &Value, what: &str) -> Result, BaboonCodecError> { + let s = expect_str(wire, what)?; + super::hex_bytes::hex_decode(s).map_err(|e| decoder_failure(format!("{}: {}", what, e))) + } + + #[cfg(feature = "decimal")] + pub fn decimal_to_json(value: &rust_decimal::Decimal) -> Value { + use std::str::FromStr; + let normalized = value.normalize().to_string(); + match serde_json::Number::from_str(&normalized) { + Ok(n) => Value::Number(n), + Err(_) => Value::Number(serde_json::Number::from(0)), + } + } + + #[cfg(feature = "decimal")] + pub fn decimal_from_json(wire: &Value, what: &str) -> Result { + use std::str::FromStr; + let text = match wire { + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + other => return Err(decoder_failure(format!("{}: expected a number or string, got {}", what, kind_of(other)))), + }; + rust_decimal::Decimal::from_str(&text) + .map(|d| d.normalize()) + .map_err(|e| decoder_failure(format!("{}: {}", what, e))) + } + + #[cfg(feature = "timestamps")] + pub fn tsu_to_json(value: &chrono::DateTime) -> Value { + Value::String(super::time_formats::format_tsu(value)) + } + + #[cfg(feature = "timestamps")] + pub fn tsu_from_json(wire: &Value, what: &str) -> Result, BaboonCodecError> { + let s = expect_str(wire, what)?; + super::time_formats::parse_tsu(s).map_err(|e| decoder_failure(format!("{}: {}", what, e))) + } + + #[cfg(feature = "timestamps")] + pub fn tso_to_json(value: &chrono::DateTime) -> Value { + Value::String(super::time_formats::format_tso(value)) + } + + #[cfg(feature = "timestamps")] + pub fn tso_from_json(wire: &Value, what: &str) -> Result, BaboonCodecError> { + let s = expect_str(wire, what)?; + super::time_formats::parse_tso(s).map_err(|e| decoder_failure(format!("{}: {}", what, e))) + } +} diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala index 818e43fb6..bb7b48d2d 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala @@ -78,6 +78,31 @@ object RsCodecTestsTranslator { |assert_eq!(fixture, decoded);""".stripMargin } + // Conversion oracle: while the serde derive is still in place it defines the JSON + // shape, so the explicit `encode_json` must agree with it value for value. This is + // what lets the derives be removed later without moving the wire. + val encodeMatchesDerive = definition.defn match { + case _: Typedef.Adt => + q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); + |for fixture in super::${fixtureMethodJson}_all(&mut rnd) { + | let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); + | let derived = serde_json::to_value(&fixture).expect("serde encode failed"); + | assert_eq!(explicit, derived); + |}""".stripMargin + case _: Typedef.Enum => + q"""for fixture in $srcRef::all() { + | let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); + | let derived = serde_json::to_value(&fixture).expect("serde encode failed"); + | assert_eq!(explicit, derived); + |}""".stripMargin + case _ => + q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); + |let fixture = super::$fixtureMethodJson(&mut rnd); + |let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); + |let derived = serde_json::to_value(&fixture).expect("serde encode failed"); + |assert_eq!(explicit, derived);""".stripMargin + } + q"""#[test] |fn test_${testFnName}_json_codec() { | crate::cross_language_fixture_path::assert_cross_language_fixture_root_exists(); @@ -101,6 +126,13 @@ object RsCodecTestsTranslator { | let re_encoded = serde_json::to_value(&decoded).expect("Failed to re-encode"); | let re_decoded: $srcRef = serde_json::from_value(re_encoded).expect("Failed to decode re-encoded"); | assert_eq!(decoded, re_decoded); + |} + | + |#[test] + |fn test_${testFnName}_json_encode_matches_derive() { + | for _ in 0..${target.generic.codecTestIterations.toString} { + | ${encodeMatchesDerive.shift(8).trim} + | } |}""".stripMargin }.toList diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index 01c6013b5..b83df2152 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -53,28 +53,23 @@ class RsJsonCodecGenerator( | serde_json::from_value(v) | } | - | ${ctxEncoder(defn).shift(4).trim} - |}${anyResolver(defn, name)}""".stripMargin + | ${ctxEncoder(defn, name).shift(4).trim} + |}""".stripMargin } /** Context-carrying JSON encoder, the counterpart of the UEBA side's `encode_ueba(ctx, writer)`. * * `serde::Serialize` has no room for a codec context, so neither the derive nor the * hand-written `Serialize` impls (ADTs, wrapped branches) can reach the facade that an `any` - * field holding a UEBA payload needs to transcode itself to JSON. Rather than re-deriving the - * whole JSON shape by hand — renames, hex bytes, decimal-as-number, timestamps, user map-key - * adapters, ADT wrapping — this resolves the `any` slots to their JSON branch first and then - * lets serde produce the document exactly as it always has. + * field holding a UEBA payload needs to transcode itself to JSON. * - * Types with no `any` anywhere below them delegate straight to `serde_json::to_value`, so - * their wire shape is the derive's by construction and cannot drift. + * Any-bearing types get an explicit field-by-field encoder. Types with no `any` anywhere + * below them delegate straight to `serde_json::to_value`, so their wire shape is the derive's + * by construction and cannot drift while the two coexist. */ - private def ctxEncoder(defn: DomainMember.User): TextTree[RsValue] = { + private def ctxEncoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { val body = if (anyBearing.contains(defn.id)) { - q"""let mut resolved = self.clone(); - |resolved.baboon_resolve_any_for_json(ctx)?; - |serde_json::to_value(&resolved) - | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin + explicitEncoder(defn, name) } else { q"""let _ = ctx; |serde_json::to_value(self) @@ -88,83 +83,113 @@ class RsJsonCodecGenerator( |}""".stripMargin } - /** In-place rewrite of every `any` slot reachable from this value into its JSON branch. - * Emitted only for any-bearing types; the field site is the only place the per-field static - * fallbacks and the declared kind byte are known. + /** Explicit field-by-field JSON encoder for an any-bearing type. + * + * Plain leaves still go through `serde_json::to_value`, which is exact by construction — it + * is what the derive called — while the shapes serde only expressed through field attributes + * (hex bytes, decimal-as-number, timestamps) are written by the matching `json_tools` helper. */ - private def anyResolver(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { - if (!anyBearing.contains(defn.id)) return q"" - - val body = defn.defn match { + private def explicitEncoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { + defn.defn match { case d: Typedef.Dto => - val steps = d.fields.toList.filter(f => refBearsAny(f.tpe)).map { - f => resolveStep(f.tpe, q"self.${toSnakeCase(f.name.name)}") + val inserts = d.fields.toList.map { + f => + val wireName = f.name.name + q"""obj.insert("$wireName".to_string(), ${fieldEnc(f.tpe, q"self.${toSnakeCase(f.name.name)}")});""" } - if (steps.isEmpty) q"Ok(())" else q"""${steps.joinN()} - |Ok(())""".stripMargin + val build = if (inserts.isEmpty) q"" else inserts.joinN() + q"""let mut obj = serde_json::Map::new(); + |$build + |Ok(serde_json::Value::Object(obj))""".stripMargin case a: Typedef.Adt => val arms = a.dataMembers(domain).toList.map { mid => - val rsVariantName = escapeRustTypeName(mid.name.name.capitalize) - if (anyBearing.contains(mid)) { - q"""${name.asName}::$rsVariantName(v) => v.baboon_resolve_any_for_json(ctx),""" - } else { - q"""${name.asName}::$rsVariantName(_) => Ok(()),""" - } + val wireVariantName = mid.name.name.capitalize + val rsVariantName = escapeRustTypeName(wireVariantName) + q"""${name.asName}::$rsVariantName(v) => { + | obj.insert("$wireVariantName".to_string(), v.encode_json(ctx)?); + |}""".stripMargin } - q"""match self { + q"""let mut obj = serde_json::Map::new(); + |match self { | ${arms.joinN().shift(4).trim} - |}""".stripMargin + |} + |Ok(serde_json::Value::Object(obj))""".stripMargin - case _ => q"Ok(())" + case _ => q"""serde_json::to_value(self) + | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin } - - q""" - | - |impl ${name.asName} { - | #[doc(hidden)] - | pub fn baboon_resolve_any_for_json(&mut self, ctx: &crate::baboon_runtime::BaboonCodecContext) -> Result<(), crate::any_opaque::BaboonCodecError> { - | ${body.shift(8).trim} - | } - |}""".stripMargin } - /** Walks `tpe` down to its `any` positions, rewriting each in place. Mirrors the shape of - * `RsUEBACodecGenerator.mkEncoder`'s recursion over collection constructors. + /** Encoder expression for one field. Recurses through the collection constructors the same way + * `RsUEBACodecGenerator.mkEncoder` does. */ - private def resolveStep(tpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { + private def fieldEnc(tpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { case a: TypeRef.Any => val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) val expectedHex = "0x%02x".format(expectedKind & 0xFF) val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) - q"crate::any_opaque::any_field_codec::resolve_any_field_for_json(ctx, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &mut $ref)?;" + q"crate::any_opaque::any_field_codec::any_to_json(ctx, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &$ref)?" + + case TypeRef.Scalar(TypeId.Builtins.bytes) => + q"crate::baboon_runtime::json_tools::bytes_to_json(&$ref)" + case TypeRef.Scalar(TypeId.Builtins.f128) => + q"crate::baboon_runtime::json_tools::decimal_to_json(&$ref)" + case TypeRef.Scalar(TypeId.Builtins.tsu) => + q"crate::baboon_runtime::json_tools::tsu_to_json(&$ref)" + case TypeRef.Scalar(TypeId.Builtins.tso) => + q"crate::baboon_runtime::json_tools::tso_to_json(&$ref)" - case TypeRef.Scalar(_) => - // A user type that transitively carries `any` — recurse into its own resolver. - q"$ref.baboon_resolve_any_for_json(ctx)?;" + case TypeRef.Scalar(_: TypeId.BuiltinScalar) => + q"""serde_json::to_value(&$ref).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))?""" + + case TypeRef.Scalar(u: TypeId.User) => + domain.defs.meta.nodes.get(u) match { + // Foreign leaves keep the host type's own serde impl — Baboon cannot know their shape. + case Some(DomainMember.User(_, _: Typedef.Foreign, _, _)) => + q"""serde_json::to_value(&$ref).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))?""" + case _ => + q"$ref.encode_json(ctx)?" + } case c: TypeRef.Constructor => c.id match { case TypeId.Builtins.opt => - q"""if let Some(v) = ($ref).as_mut() { - | ${resolveStep(c.args.head, q"(*v)").shift(4).trim} + q"""match &$ref { + | None => serde_json::Value::Null, + | Some(v) => ${fieldEnc(c.args.head, q"(*v)")}, |}""".stripMargin case TypeId.Builtins.lst | TypeId.Builtins.set => - q"""for item in ($ref).iter_mut() { - | ${resolveStep(c.args.head, q"(*item)").shift(4).trim} + q"""{ + | let mut items = Vec::new(); + | for item in ($ref).iter() { + | items.push(${fieldEnc(c.args.head, q"(*item)")}); + | } + | serde_json::Value::Array(items) |}""".stripMargin case TypeId.Builtins.map => - // Keys are never `any` (the typer rejects it); only values can carry one. - q"""for (_, v) in ($ref).iter_mut() { - | ${resolveStep(c.args.last, q"(*v)").shift(4).trim} + q"""{ + | let mut entries = serde_json::Map::new(); + | for (k, v) in ($ref).iter() { + | entries.insert(${mapKeyEnc(c.args.head, q"(*k)")}, ${fieldEnc(c.args.last, q"(*v)")}); + | } + | serde_json::Value::Object(entries) |}""".stripMargin case o => throw new RuntimeException(s"BUG: Unexpected collection type: $o") } } } + /** JSON object keys are strings; serde_json coerces map keys the same way. */ + private def mapKeyEnc(tpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + case _ => q"($ref).to_string()" + } + } + /** Static fallbacks for the cross-format facade helper (`ueba_to_json`). Identical table to * `RsUEBACodecGenerator.anyStaticFallbacks` — the wire `meta` may omit components pinned by * the field's static declaration, and the facade merges what the codec supplies. From 983137d6511e068db0312c366185ede4ec5cc905 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 13:28:34 +0100 Subject: [PATCH 42/48] refactor(rust): reuse RsFieldRepresentation's transitive `any` analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit-encoder commit hand-rolled a second least-fixpoint over the typespace to decide which types carry an `any`. `RsFieldRepresentation.containsAny` already computes exactly that — same iterative fixpoint, cycle-safe, ADT members followed through `dataMembers` — and already drives boxing and the Ord/Eq derive decisions. Dropped the duplicate and took the ctor deps needed to construct the shared helper. No behaviour change: rs-stub stays at 749 passing, the 145 encoder-vs-derive differentials included. Co-Authored-By: Claude Opus 5 (1M context) --- .../rust/RsJsonCodecGenerator.scala | 66 ++++--------------- 1 file changed, 13 insertions(+), 53 deletions(-) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index b83df2152..a69319b7e 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -10,10 +10,22 @@ import izumi.fundamentals.platform.strings.TextTree import izumi.fundamentals.platform.strings.TextTree.* class RsJsonCodecGenerator( + trans: RsTypeTranslator, target: RsTarget, domain: Domain, + evo: BaboonEvolution, + enquiries: BaboonEnquiries, ) extends RsCodecTranslator { + // Reuses the transitive `any` analysis that already drives boxing and Ord derivation, + // rather than recomputing the same fixpoint here. + private val representation = new RsFieldRepresentation(domain, evo, trans, enquiries) + + private def bearsAny(id: TypeId): Boolean = id match { + case u: TypeId.User => representation.containsAny(TypeRef.Scalar(u)) + case _ => false + } + override def translate(defn: DomainMember.User, rsRef: RsValue.RsType, srcRef: RsValue.RsType): Option[TextTree[RsValue]] = { if (isActive(defn.id)) { // For Rust, JSON codecs are handled via serde derive macros on the struct/enum definitions. @@ -68,7 +80,7 @@ class RsJsonCodecGenerator( * by construction and cannot drift while the two coexist. */ private def ctxEncoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { - val body = if (anyBearing.contains(defn.id)) { + val body = if (bearsAny(defn.id)) { explicitEncoder(defn, name) } else { q"""let _ = ctx; @@ -211,58 +223,6 @@ class RsJsonCodecGenerator( (domainStatic, versionStatic, typeidStatic) } - /** Does `tpe` reach an `any` position, either directly or through a user type it references? - * Drives both which types get a resolver and which fields that resolver has to walk. - */ - private def refBearsAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case TypeRef.Scalar(u: TypeId.User) => anyBearing.contains(u) - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(refBearsAny) - } - - /** Types that carry an `any` somewhere below them, as a least fixpoint over the typespace. - * Iterative rather than recursive because recursive DTOs (`recursive-ok`) make the reference - * graph cyclic. - */ - private lazy val anyBearing: Set[TypeId] = { - val users = domain.defs.meta.nodes.toList.collect { case (id, u: DomainMember.User) => (id, u) } - - def directAny(tpe: TypeRef): Boolean = tpe match { - case _: TypeRef.Any => true - case _: TypeRef.Scalar => false - case c: TypeRef.Constructor => c.args.exists(directAny) - } - - def userRefs(tpe: TypeRef): List[TypeId] = tpe match { - case TypeRef.Scalar(u: TypeId.User) => List(u) - case _: TypeRef.Scalar => Nil - case c: TypeRef.Constructor => c.args.toList.flatMap(userRefs) - case _: TypeRef.Any => Nil // the stored value is an opaque, not the underlying type - } - - val seeds: Set[TypeId] = users.collect { - case (id, DomainMember.User(_, d: Typedef.Dto, _, _)) if d.fields.exists(f => directAny(f.tpe)) => id - }.toSet - - val edges: Map[TypeId, List[TypeId]] = users.map { - case (id, u) => - val refs = u.defn match { - case d: Typedef.Dto => d.fields.toList.flatMap(f => userRefs(f.tpe)) - case a: Typedef.Adt => a.dataMembers(domain).toList - case _ => Nil - } - (id, refs) - }.toMap - - Iterator - .iterate(seeds) { - current => current ++ edges.collect { case (id, refs) if refs.exists(current.contains) => id }.toSet - } - .sliding(2).collectFirst { case Seq(a, b) if a == b => a } - .getOrElse(seeds) - } - def codecName(name: RsValue.RsType): RsValue.RsType = { RsValue.RsType(name.crate, s"${name.name}_JsonCodec", name.fq) } From fb57054f9a2ee1a9f9f0cf341fc03a3dab046f8c Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 14:27:13 +0100 Subject: [PATCH 43/48] fix(typescript): mark the generated MCP server's findTool as an override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported during a consumer migration: every generated TypeScript MCP server fails to compile under `noImplicitOverride`, which Deno enables by default. TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'AbstractAsyncBaboonMcpServer'. Both runtime base classes define `findTool` concretely (`BaboonMcpRuntime.ts:241` for the sync server, `:382` for the async one), while the generator emitted the derived member without the modifier. The emitter is shared by both variants, so both were affected. Reproduced by generating the `mcp-stub-ok` server and running `tsc` with `noImplicitOverride`: exactly one TS4114 at the generated `findTool`, in each variant. Not a regression from the recent facade work — the concrete base implementations predate it. `test/ts-stub/tsconfig.json` now sets `noImplicitOverride: true`. Its absence is the reason CI never saw this: the stub compiled with `strict` but not the flag the consumer's toolchain applies, so the generated code type-checked here and failed there. Also records the second report from that migration in the defect ledger as MIGRATION-2026-09-24-D02, status open: i64/u64 have no consistent JSON representation across backends, TypeScript's decoder silently rounds a numeric token beyond Number.MAX_SAFE_INTEGER, and the generated cross-language JSON test cannot detect either because it asserts TypeScript-side self-consistency rather than fidelity to the producer. Fixing the reader alone makes the existing C#-to-TypeScript interop fail loudly rather than quietly, so it waits on a wire-contract decision; the prototype and its test are held outside the tree and referenced from the ledger entry. Verified with `mdl --seq :build :test-gen-ts-mcp :test-ts-mcp :test-gen-regular-adt :test-typescript-regular`. Co-Authored-By: Claude Opus 5 (1M context) --- .../typescript/TsMcpServerGenerator.scala | 2 +- defects.md | 21 +++++++++++++++++++ test/ts-stub/tsconfig.json | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala index ac03f9eda..a7aff22c1 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala @@ -139,7 +139,7 @@ class TsMcpServerGenerator[F[+_, +_]: Error2]( | return this.toolEntries.map(tool => ({ ...tool, method: { ...tool.method }, inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) })); | } | - | protected findTool(name: string): McpToolEntry | undefined { + | protected override findTool(name: string): McpToolEntry | undefined { | return this.toolsByName.get(name); | } | diff --git a/defects.md b/defects.md index 20951ae70..7679ae6d5 100644 --- a/defects.md +++ b/defects.md @@ -10,6 +10,27 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved --- +## MIGRATION-2026-09-24 (user migration reports, `bugs.md`) + +### [MIGRATION-2026-09-24-D01] Generated TypeScript MCP servers fail strict compilation (TS4114) +**Status:** resolved +**Severity:** major +**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala:142` +**Description:** The emitted MCP server declares `protected findTool(name: string): McpToolEntry | undefined`, but both runtime base classes define it concretely — `AbstractBaboonMcpServer` and `AbstractAsyncBaboonMcpServer` at `baboon-runtime/typescript/BaboonMcpRuntime.ts:241,382`. Under `noImplicitOverride` (which Deno enables by default) TypeScript rejects the derived member with TS4114, so every generated MCP server fails to compile at the consumer. Reproduced by generating the `mcp-stub-ok` MCP server and running `tsc` with `noImplicitOverride`: exactly one TS4114 at the generated `findTool`. The emitter is shared by the sync and async variants, so both are affected. Not a regression from the facade work — the concrete base implementations predate it. +**Fix:** Emit `protected override findTool(...)`. Verified the diagnostic count goes 1 -> 0 for both variants. `test/ts-stub/tsconfig.json` gained `noImplicitOverride: true` so the TypeScript lanes enforce what the consumer's toolchain enforces — its absence is why CI never saw this. + +### [MIGRATION-2026-09-24-D02] i64/u64 JSON representation disagrees across backends; TypeScript silently rounds +**Status:** open (blocked on a wire-contract decision) +**Severity:** major +**Location:** `CSScalarCodecEmitter.scala:44` (catch-all numeric `JValue`); `TsScalarCodecOps.scala:16,38`; `docs/json-codecs.md:19,23` +**Description:** `docs/json-codecs.md` specifies signed integers as JSON numbers and `u64` as a decimal string (explicitly to avoid the JavaScript safe-integer limit). Measured against that spec the backends disagree in both directions: C#/Java/Kotlin/Rust emit `i64` as a number (per spec) while TypeScript/Dart/Swift emit it as a decimal string (contrary to spec); C# emits `u64` as a number (contrary to spec) while TypeScript emits a string (per spec). The spec itself is inconsistent — `i64` has exactly the JavaScript precision problem it acknowledges for `u64`. +Every decoder except TypeScript's is lenient about the two forms. TypeScript's assumed a string and passed whatever it received to `BigInt(...)`; when the producer sent a number, `JSON.parse` had already rounded it, so `Int64.MaxValue` written by C# as `{"appPortalId":9223372036854775807}` decoded to `9223372036854775808n`. The original value is unrecoverable at that layer. +A third, enabling defect: the generated cross-language JSON test only checks TypeScript-side self-consistency — it decodes the producer's fixture, re-encodes, decodes again and compares the two TypeScript values (`Renamed.test.ts` `test_cs_json`). A value corrupted by the first decode is equally corrupted in both operands, so the assertion holds. The UEBA cross-language path compares real bytes; JSON does not. That is why this survived CI under both v0.0.195 and the current compiler, matching the reporter's observation that binary interchange passes the same maximum-value case. +**Evidence:** A prototype lenient TypeScript reader that refuses numeric tokens beyond `Number.MAX_SAFE_INTEGER` turns the silent corruption into 8 failing `test_cs_json` cases across the shared model. The rejected values (`843449402620578600`, `-5314845832925431000`, `5219970098605879000`) all carry trailing zeros: they are the rounded doubles, not the integers C# wrote. Patch held at `scratchpad/i64-decoder-fix.patch` with its test; not landed, because correcting only the reader makes the existing C#-to-TypeScript interop fail loudly instead of quietly, which is right but is not a decision the compiler should take on its own. +**Fix:** Pending. Options: (i) represent `i64` and `u64` as decimal strings in every backend, readers staying lenient for compatibility — lossless and consistent, and already what TypeScript/Dart/Swift do for `i64`; or (ii) keep numeric tokens and require lossless JSON ingress everywhere, which plain `JSON.parse` cannot provide. Either way C#'s `u64` must stop contradicting the spec, the documentation must be aligned, and the cross-language JSON test must assert fidelity to the producer rather than self-consistency. + +--- + ## FIELD-2026-09-24 (user report + facade review, all backends) ### [FIELD-2026-09-24-D01] Facade JSON encode entry points take no codec context, so `any` fields holding the other wire form cannot be encoded diff --git a/test/ts-stub/tsconfig.json b/test/ts-stub/tsconfig.json index f979bc75b..f6ed1ac45 100644 --- a/test/ts-stub/tsconfig.json +++ b/test/ts-stub/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "noImplicitOverride": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, From c9c7203657e2cf84ef0bae292a1daf778cbef31c Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 14:39:05 +0100 Subject: [PATCH 44/48] refactor(rust): emit an explicit JSON decoder, pinned against the serde derive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of step 1. `decode_json(ctx, wire)` now exists alongside `encode_json(ctx)` for every generated type, and both are held to the derive across the whole fixture corpus. The derives are still in place — removing them is the next step, and these oracles are what makes that safe. Any-bearing types get an explicit walk: - DTO: `expect_object`, then per-field binds. `opt` is read straight off the map, because serde treats an ABSENT `Option` field as `None` rather than an error, so absent and explicit-null must behave alike. Recursive fields are rebuilt with `Box::new`. - ADT: single-key envelope, arity-checked, dispatched by branch name with an explicit unknown-branch error. - Leaves: attribute-driven shapes (`bytes`, `f128`, `tsu`/`tso`) go through `json_tools`; `i64`/`u64` use the lenient readers that reproduce the `lenient_numeric` attribute; everything else calls `serde_json::from_value`, which is what the derive did and is therefore exact. Foreign leaves keep the host type's own impl, since Baboon cannot know their shape. - Collections: `Vec` / `BTreeSet` / `BTreeMap`, map keys parsed back via `FromStr`. `any_field_codec::any_from_json` reads an `any` envelope into the JSON branch and checks the declared kind byte, which only the field site knows. Oracles. The per-type `test_*_json_decode_matches_derive` compares the explicit decoder against serde ON THE SAME WIRE rather than merely round-tripping the value: a shared misreading cancels itself out under a round-trip, which is precisely how the TypeScript i64 corruption stayed invisible (see MIGRATION-2026-09-24-D02). rs-stub goes 749 -> 894. Seven behaviours escape any corpus oracle, because it can only feed back documents the encoder produced, so `test/rs-stub/tests/explicit_json_codec_tests.rs` covers them directly: absent optional -> None, explicit null -> None, unknown keys ignored, absent required -> error naming the field, non-object document -> error, `any` kind mismatch rejected, and decoded `any` slots arriving as the JSON branch. Each one also asserts the explicit and derived decoders agree, so the expectations record the derive's actual behaviour rather than an assumption about it. Verified with `mdl --seq :build :test-gen-regular-adt :test-rust-regular :test-gen-wrapped-adt :test-rust-wrapped`. Co-Authored-By: Claude Opus 5 (1M context) --- .../baboon-runtime/rust/any_opaque.rs | 26 +++ .../rust/RsCodecTestsTranslator.scala | 41 ++++- .../rust/RsJsonCodecGenerator.scala | 159 ++++++++++++++++++ .../tests/explicit_json_codec_tests.rs | 139 +++++++++++++++ 4 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 test/rs-stub/tests/explicit_json_codec_tests.rs diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index 3bf36248d..bf078f34d 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -203,6 +203,32 @@ pub mod any_field_codec { Ok(crate::any_opaque::AnyOpaqueUeba::new(any_meta, any_blob)) } + /// Reads an `any` field from its JSON envelope. Always yields the JSON branch — a JSON + /// payload is already in JSON form, and no backend's `any` decoder needs a facade. The + /// declared kind byte is checked here because only the field site knows it. + pub fn any_from_json( + wire: &serde_json::Value, + expected_kind: u8, + ) -> Result { + let meta = crate::any_opaque::any_meta_codec::read_json(wire)?; + if meta.kind != expected_kind { + return Err(crate::any_opaque::BaboonCodecError::decoder_failure(format!( + "any: wire kind 0x{:02x} does not match field-declared 0x{:02x}", + meta.kind, expected_kind + ))); + } + let content = wire + .as_object() + .and_then(|o| o.get(crate::any_opaque::any_meta_codec::ANY_CONTENT_KEY)) + .ok_or_else(|| { + crate::any_opaque::BaboonCodecError::decoder_failure(format!( + "any: JSON envelope missing '{}' content key", + crate::any_opaque::any_meta_codec::ANY_CONTENT_KEY + )) + })?; + Ok(crate::any_opaque::AnyOpaqueJson::new(meta, content.clone())) + } + /// Writes an `any` field as its JSON envelope, transcoding a UEBA-form payload when needed. /// /// The field site is the only place that knows the declared kind byte and the static diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala index bb7b48d2d..5a3a504f0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala @@ -79,8 +79,10 @@ object RsCodecTestsTranslator { } // Conversion oracle: while the serde derive is still in place it defines the JSON - // shape, so the explicit `encode_json` must agree with it value for value. This is - // what lets the derives be removed later without moving the wire. + // shape, so the explicit codecs must agree with it in both directions. This is what + // lets the derives be removed later without moving the wire. The decode side is + // compared against serde on the SAME wire rather than merely round-tripped, so a + // shared misreading cannot cancel itself out. val encodeMatchesDerive = definition.defn match { case _: Typedef.Adt => q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); @@ -103,6 +105,34 @@ object RsCodecTestsTranslator { |assert_eq!(explicit, derived);""".stripMargin } + val decodeMatchesDerive = definition.defn match { + case _: Typedef.Adt => + q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); + |for fixture in super::${fixtureMethodJson}_all(&mut rnd) { + | let wire = serde_json::to_value(&fixture).expect("serde encode failed"); + | let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); + | let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); + | assert_eq!(explicit, derived); + | assert_eq!(explicit, fixture); + |}""".stripMargin + case _: Typedef.Enum => + q"""for fixture in $srcRef::all() { + | let wire = serde_json::to_value(&fixture).expect("serde encode failed"); + | let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); + | let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); + | assert_eq!(explicit, derived); + | assert_eq!(explicit, fixture); + |}""".stripMargin + case _ => + q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); + |let fixture = super::$fixtureMethodJson(&mut rnd); + |let wire = serde_json::to_value(&fixture).expect("serde encode failed"); + |let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); + |let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); + |assert_eq!(explicit, derived); + |assert_eq!(explicit, fixture);""".stripMargin + } + q"""#[test] |fn test_${testFnName}_json_codec() { | crate::cross_language_fixture_path::assert_cross_language_fixture_root_exists(); @@ -133,6 +163,13 @@ object RsCodecTestsTranslator { | for _ in 0..${target.generic.codecTestIterations.toString} { | ${encodeMatchesDerive.shift(8).trim} | } + |} + | + |#[test] + |fn test_${testFnName}_json_decode_matches_derive() { + | for _ in 0..${target.generic.codecTestIterations.toString} { + | ${decodeMatchesDerive.shift(8).trim} + | } |}""".stripMargin }.toList diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index a69319b7e..65c002c1b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -66,6 +66,8 @@ class RsJsonCodecGenerator( | } | | ${ctxEncoder(defn, name).shift(4).trim} + | + | ${ctxDecoder(defn, name).shift(4).trim} |}""".stripMargin } @@ -95,6 +97,163 @@ class RsJsonCodecGenerator( |}""".stripMargin } + /** Context-carrying JSON decoder, the counterpart of `encode_json`. + * + * Decoding never needs the facade — the `any` decoders take no context in any backend, and a + * JSON payload always yields the JSON branch — but the method takes one so the two directions + * stay symmetric and so a future context-sensitive read has somewhere to go. + * + * Any-bearing types get an explicit walk; everything else delegates to `serde_json::from_value`, + * which is what the derive did, so their accepted wire shape cannot drift while both exist. + */ + private def ctxDecoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { + val body = if (bearsAny(defn.id)) { + explicitDecoder(defn, name) + } else { + q"""let _ = ctx; + |serde_json::from_value(wire.clone()) + | .map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))""".stripMargin + } + + q"""/// JSON decode under a codec context, the counterpart of `encode_json`. + |pub fn decode_json(ctx: &crate::baboon_runtime::BaboonCodecContext, wire: &serde_json::Value) -> Result { + | ${body.shift(4).trim} + |}""".stripMargin + } + + private def explicitDecoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { + val what = name.name + defn.defn match { + case d: Typedef.Dto => + val binds = d.fields.toList.map { + f => + val rsName = toSnakeCase(f.name.name) + val boxed = representation.needsBox(f.tpe) + val decoded = fieldDec(f.tpe, f.name.name, what) + if (boxed) q"$rsName: Box::new($decoded)," else q"$rsName: $decoded," + } + val ctor = if (binds.isEmpty) q"Ok(Self {})" else q"""Ok(Self { + | ${binds.joinN().shift(4).trim} + |})""".stripMargin + q"""let obj = crate::baboon_runtime::json_tools::expect_object(wire, "$what")?; + |$ctor""".stripMargin + + case a: Typedef.Adt => + val arms = a.dataMembers(domain).toList.map { + mid => + val wireVariantName = mid.name.name.capitalize + val rsVariantName = escapeRustTypeName(wireVariantName) + val branchType = trans.asRsRef(TypeRef.Scalar(mid), domain, evo) + q""""$wireVariantName" => Ok(${name.asName}::$rsVariantName($branchType::decode_json(ctx, branch)?)),""" + } + q"""let obj = crate::baboon_runtime::json_tools::expect_object(wire, "$what")?; + |let (tag, branch) = obj.iter().next().ok_or_else(|| { + | crate::baboon_runtime::json_tools::decoder_failure("$what: expected a single-key ADT envelope, got an empty object") + |})?; + |if obj.len() != 1 { + | return Err(crate::baboon_runtime::json_tools::decoder_failure(format!("$what: expected a single-key ADT envelope, got {} keys", obj.len()))); + |} + |match tag.as_str() { + | ${arms.joinN().shift(4).trim} + | other => Err(crate::baboon_runtime::json_tools::decoder_failure(format!("$what: unknown ADT branch '{}'", other))), + |}""".stripMargin + + case _ => q"""serde_json::from_value(wire.clone()) + | .map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))""".stripMargin + } + } + + /** One field. `opt` is read from the map directly: serde treats an absent `Option` field as + * `None`, not as an error, so absent and explicit-null must behave alike. + */ + private def fieldDec(tpe: TypeRef, wireName: String, what: String): TextTree[RsValue] = { + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case c: TypeRef.Constructor if c.id == TypeId.Builtins.opt => + q"""match obj.get("$wireName") { + | None | Some(serde_json::Value::Null) => None, + | Some(v) => Some(${valueDec(c.args.head, q"v", what)}), + |}""".stripMargin + case _ => + valueDec(tpe, q"""crate::baboon_runtime::json_tools::field(obj, "$wireName", "$what")?""", what) + } + } + + private def valueDec(tpe: TypeRef, ref: TextTree[RsValue], what: String): TextTree[RsValue] = { + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case a: TypeRef.Any => + val expectedKind = AnyVariant.metaKindByte(a.variant, a.underlying.isDefined) + val expectedHex = "0x%02x".format(expectedKind & 0xFF) + q"crate::any_opaque::AnyOpaque::Json(crate::any_opaque::any_field_codec::any_from_json($ref, ${expectedHex}u8)?)" + + case TypeRef.Scalar(TypeId.Builtins.bytes) => q"""crate::baboon_runtime::json_tools::bytes_from_json($ref, "$what")?""" + case TypeRef.Scalar(TypeId.Builtins.f128) => q"""crate::baboon_runtime::json_tools::decimal_from_json($ref, "$what")?""" + case TypeRef.Scalar(TypeId.Builtins.tsu) => q"""crate::baboon_runtime::json_tools::tsu_from_json($ref, "$what")?""" + case TypeRef.Scalar(TypeId.Builtins.tso) => q"""crate::baboon_runtime::json_tools::tso_from_json($ref, "$what")?""" + // Lenient like the `lenient_numeric` attribute the derive carried: 64-bit values may + // legitimately arrive as decimal strings from producers that cannot hold them in a number. + case TypeRef.Scalar(TypeId.Builtins.i64) => q"""crate::baboon_runtime::json_tools::read_i64($ref, "$what")?""" + case TypeRef.Scalar(TypeId.Builtins.u64) => q"""crate::baboon_runtime::json_tools::read_u64($ref, "$what")?""" + + case TypeRef.Scalar(_: TypeId.BuiltinScalar) => + val rsT = trans.asRsRef(tpe, domain, evo) + q"""serde_json::from_value::<$rsT>($ref.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: {}", e)))?""" + + case TypeRef.Scalar(u: TypeId.User) => + domain.defs.meta.nodes.get(u) match { + case Some(DomainMember.User(_, _: Typedef.Foreign, _, _)) => + val rsT = trans.asRsRef(tpe, domain, evo) + q"""serde_json::from_value::<$rsT>($ref.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: {}", e)))?""" + case _ => + val rsT = trans.asRsRef(tpe, domain, evo) + q"$rsT::decode_json(ctx, $ref)?" + } + + case c: TypeRef.Constructor => + c.id match { + case TypeId.Builtins.opt => + q"""match $ref { + | serde_json::Value::Null => None, + | v => Some(${valueDec(c.args.head, q"v", what)}), + |}""".stripMargin + case TypeId.Builtins.lst => + q"""{ + | let mut items = Vec::new(); + | for item in crate::baboon_runtime::json_tools::expect_array($ref, "$what")?.iter() { + | items.push(${valueDec(c.args.head, q"item", what)}); + | } + | items + |}""".stripMargin + case TypeId.Builtins.set => + q"""{ + | let mut items = std::collections::BTreeSet::new(); + | for item in crate::baboon_runtime::json_tools::expect_array($ref, "$what")?.iter() { + | items.insert(${valueDec(c.args.head, q"item", what)}); + | } + | items + |}""".stripMargin + case TypeId.Builtins.map => + q"""{ + | let mut entries = std::collections::BTreeMap::new(); + | for (k, v) in crate::baboon_runtime::json_tools::expect_object($ref, "$what")?.iter() { + | entries.insert(${mapKeyDec(c.args.head, q"k", what)}, ${valueDec(c.args.last, q"v", what)}); + | } + | entries + |}""".stripMargin + case o => throw new RuntimeException(s"BUG: Unexpected collection type: $o") + } + } + } + + /** JSON object keys arrive as strings; mirror `mapKeyEnc`. */ + private def mapKeyDec(tpe: TypeRef, ref: TextTree[RsValue], what: String): TextTree[RsValue] = { + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + case _ => + val rsT = trans.asRsRef(tpe, domain, evo) + q"""($ref).parse::<$rsT>().map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + } + } + /** Explicit field-by-field JSON encoder for an any-bearing type. * * Plain leaves still go through `serde_json::to_value`, which is exact by construction — it diff --git a/test/rs-stub/tests/explicit_json_codec_tests.rs b/test/rs-stub/tests/explicit_json_codec_tests.rs new file mode 100644 index 000000000..4fbbe6eef --- /dev/null +++ b/test/rs-stub/tests/explicit_json_codec_tests.rs @@ -0,0 +1,139 @@ +// NOTE: references generated symbols produced into this stub only by the rs-stub codegen path. +// Run from the codegen'd copy, not the source tree. +// +// The generated per-type `test_*_json_{encode,decode}_matches_derive` oracles pin the explicit +// codecs against the serde derive across the whole fixture corpus, but they can only exercise +// wire documents that the encoder itself produced. Three behaviours therefore escape them +// entirely, and all three are ones the derive has and the explicit decoder must match: +// +// * an ABSENT optional field decodes to None — serde special-cases `Option`, so this is not +// the same thing as an explicit null, and getting it wrong would reject valid documents +// from producers that omit empty fields; +// * unknown keys are ignored, which is what makes forward-compatible readers possible; +// * an absent REQUIRED field is an error rather than a default. +// +// `Holder` is used because it is any-bearing, which is precisely the case that takes the +// explicit path; an any-free type would delegate to serde and prove nothing. +#![allow(dead_code)] + +use baboon_rs_stub::any_opaque::{AnyMeta, AnyOpaque, AnyOpaqueJson}; +use baboon_rs_stub::baboon_runtime::BaboonCodecContext; +use baboon_rs_stub::my::ok::holder::Holder; + +const DOMAIN_ID: &str = "my.ok"; +const VERSION_STR: &str = "1.0.0"; +const INNER_TYPE: &str = "my.ok/:#Inner"; + +fn any_json(kind: u8, domain: Option<&str>, version: Option<&str>, typeid: Option<&str>) -> serde_json::Value { + let mut env = serde_json::Map::new(); + env.insert("$ak".to_string(), serde_json::Value::from(kind)); + if let Some(d) = domain { + env.insert("$ad".to_string(), serde_json::Value::from(d)); + } + if let Some(v) = version { + env.insert("$av".to_string(), serde_json::Value::from(v)); + } + if let Some(t) = typeid { + env.insert("$at".to_string(), serde_json::Value::from(t)); + } + env.insert("$c".to_string(), serde_json::json!({"x": 42})); + serde_json::Value::Object(env) +} + +/// A complete Holder document with every required `any` slot populated. +fn holder_wire() -> serde_json::Map { + let mut m = serde_json::Map::new(); + m.insert("fAny".to_string(), any_json(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE))); + m.insert("fDomainThis".to_string(), any_json(0x03, None, Some(VERSION_STR), Some(INNER_TYPE))); + m.insert("fDomainCurrent".to_string(), any_json(0x01, None, None, Some(INNER_TYPE))); + m.insert("fUnderlying".to_string(), any_json(0x06, Some(DOMAIN_ID), Some(VERSION_STR), None)); + m.insert("fThisUnderlying".to_string(), any_json(0x02, None, Some(VERSION_STR), None)); + m.insert("fCurrentUnderlying".to_string(), any_json(0x00, None, None, None)); + m.insert("fOpt".to_string(), serde_json::Value::Null); + m.insert("fLst".to_string(), serde_json::Value::Array(vec![])); + m.insert("fMapValue".to_string(), serde_json::Value::Object(serde_json::Map::new())); + m +} + +fn decode(wire: serde_json::Value) -> Result { + Holder::decode_json(&BaboonCodecContext::Compact, &wire).map_err(|e| format!("{}", e)) +} + +/// Each case is also checked against serde, so the assertions record the derive's behaviour +/// rather than my reading of it. +fn assert_agrees_with_derive(wire: &serde_json::Value) { + let explicit = decode(wire.clone()); + let derived: Result = serde_json::from_value(wire.clone()).map_err(|e| format!("{}", e)); + match (&explicit, &derived) { + (Ok(a), Ok(b)) => assert_eq!(a, b, "explicit and derived decode disagree"), + (Err(_), Err(_)) => {} + _ => panic!("explicit and derived disagree on acceptance: explicit={:?} derived={:?}", explicit.is_ok(), derived.is_ok()), + } +} + +#[test] +fn absent_optional_field_decodes_to_none() { + let mut wire = holder_wire(); + wire.remove("fOpt"); + let wire = serde_json::Value::Object(wire); + + let decoded = decode(wire.clone()).expect("an absent optional field must decode, not fail"); + assert!(decoded.f_opt.is_none()); + assert_agrees_with_derive(&wire); +} + +#[test] +fn explicit_null_optional_field_decodes_to_none() { + let wire = serde_json::Value::Object(holder_wire()); + let decoded = decode(wire.clone()).expect("an explicit null optional must decode"); + assert!(decoded.f_opt.is_none()); + assert_agrees_with_derive(&wire); +} + +#[test] +fn unknown_keys_are_ignored() { + let mut wire = holder_wire(); + wire.insert("somethingFromANewerVersion".to_string(), serde_json::json!({"nested": [1, 2, 3]})); + let wire = serde_json::Value::Object(wire); + + decode(wire.clone()).expect("unknown keys must be ignored, not rejected"); + assert_agrees_with_derive(&wire); +} + +#[test] +fn absent_required_field_is_an_error() { + let mut wire = holder_wire(); + wire.remove("fAny"); + let wire = serde_json::Value::Object(wire); + + let err = decode(wire.clone()).expect_err("an absent required field must fail"); + assert!(err.contains("fAny"), "the error must name the missing field; got: {}", err); + assert_agrees_with_derive(&wire); +} + +#[test] +fn a_non_object_document_is_an_error() { + let err = decode(serde_json::json!([1, 2, 3])).expect_err("an array is not a Holder"); + assert!(err.contains("expected a JSON object"), "got: {}", err); +} + +#[test] +fn an_any_slot_whose_kind_contradicts_the_field_is_rejected() { + let mut wire = holder_wire(); + // fUnderlying is declared D1 (0x06); hand it a variant-A envelope. + wire.insert("fUnderlying".to_string(), any_json(0x07, Some(DOMAIN_ID), Some(VERSION_STR), Some(INNER_TYPE))); + let err = decode(serde_json::Value::Object(wire)).expect_err("kind mismatch must fail"); + assert!(err.contains("does not match field-declared"), "got: {}", err); +} + +#[test] +fn decoded_any_slots_come_back_as_the_json_branch() { + let decoded = decode(serde_json::Value::Object(holder_wire())).expect("decode"); + match &decoded.f_any { + AnyOpaque::Json(AnyOpaqueJson { meta, json }) => { + assert_eq!(meta, &AnyMeta::new(0x07, Some(DOMAIN_ID.to_string()), Some(VERSION_STR.to_string()), Some(INNER_TYPE.to_string())).unwrap()); + assert_eq!(json, &serde_json::json!({"x": 42})); + } + other => panic!("a JSON payload must decode to the JSON branch, got {:?}", other), + } +} From 940b47e600b6ada1b4d8cc34431d7f964cacb3bb Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 14:50:26 +0100 Subject: [PATCH 45/48] fix(rust): route user-typed map keys through their adapter in the explicit JSON codecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect introduced by the explicit decoder: map keys were read with `(k).parse::()`, which compiles for exactly one of the three eligible user key kinds. id type Display yes, FromStr no -- its reader is the free `parse_repr` enum Display yes, FromStr yes wrapper Display no, FromStr no -- the adapter peels it to the inner scalar A DTO that is both any-bearing and carries an id- or wrapper-keyed map therefore produced Rust that does not compile. No fixture combines those two, so all 894 corpus tests stayed green; the gap is recorded as RSJSON-2026-09-24-N01. Such fields now delegate to the `_as_map_key` adapter module the definition translator already emits, which is the only thing that knows how each kind converts. The eligibility lookup moved out of `RsDefnTranslator` into a shared `RsMapKeyAdapter` so both emitters read the same answer — the same duplication trap as the earlier `containsAny` fixpoint. Enum and builtin keys keep the native path, where serde handles them directly. Found by building the combination as a purpose-made model and compiling the generated crate rather than reading the output. That also caught a second mistake in the first attempt: the value was wrapped in `IntoDeserializer`, but `serde_json::Value` is itself a `Deserializer`. Verified with `mdl --seq :build :test-gen-regular-adt :test-rust-regular :test-gen-wrapped-adt :test-rust-wrapped`, plus the probe crate compiling clean with id-keyed and wrapper-keyed maps on adapters and enum-keyed maps on the native path. Co-Authored-By: Claude Opus 5 (1M context) --- .../translator/rust/RsDefnTranslator.scala | 71 ++------------- .../rust/RsJsonCodecGenerator.scala | 16 ++++ .../translator/rust/RsMapKeyAdapter.scala | 86 +++++++++++++++++++ defects.md | 11 +++ 4 files changed, 118 insertions(+), 66 deletions(-) create mode 100644 baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index 41a0b563f..f315d9e87 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -1079,73 +1079,12 @@ object RsDefnTranslator { attrs.toList } - /** PR-61 (M19.3): if `f` is a top-level `map[K, V]` whose K is an eligible user - * key, return the FQ Rust path of the adapter module sibling to K's struct. - * Eligibility mirrors PR-59's validator (`isEligibleKey`): id types are eligible - * unconditionally; non-id `data` requires single-primitive-field shape with no - * contracts. We assume the validator has already rejected ineligible cases — - * here we only decide whether to attach the `#[serde(with = ...)]` attribute. - * - * Limited to the top-level `map[K, V]` shape (no `Option>` or - * nested `BTreeMap>` — adapter modules are field-scoped - * and serde's `with` attribute is applied to the field's full type). - */ - private def userMapKeyAdapterPath(tpe: TypeRef): Option[String] = tpe match { - case TypeRef.Constructor(TypeId.Builtins.map, args) => - args.head match { - case TypeRef.Scalar(uid: TypeId.User) => - domain.defs.meta.nodes.get(uid) match { - case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) if isUserMapKeyEligibleDto(dto) => - val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) - val modName = s"${toSnakeCase(rsT.name)}_as_map_key" - Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) - // PR-I.3 (M24 Phase 3.3): direct foreign map key — route through - // the foreign's emitted `_as_map_key` adapter (Custom only; - // BaboonRef-aliased foreigns reuse the aliased type's serde path). - case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => - f.bindings.get(BaboonLang.Rust) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) - val modName = s"${toSnakeCase(rsT.name)}_as_map_key" - Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) - case _ => None - } - case _ => None - } - case _ => None - } - case _ => None - } + // Map-key adapter lookup lives in RsMapKeyAdapter so the explicit JSON codecs can reuse it. + private val mapKeyAdapter = new RsMapKeyAdapter(domain, domainTypes) - /** Mirrors `BaboonValidator.isEligibleKey` — DTO, Enum, and Foreign branches. - * id types are always eligible (Q-M19-6); single-primitive-field non-contract DTOs are - * eligible if the inner field is a primitive scalar, an enum, a foreign type, or a - * recursively-eligible nested wrapper (no opt/collection, no float wrappers per Q-M19-2). - */ - private def isUserMapKeyEligibleDto(dto: Typedef.Dto): Boolean = { - if (dto.isIdentifier) true - else if (dto.contracts.nonEmpty) false - else if (dto.fields.size != 1) false - else - dto.fields.head.tpe match { - case _: TypeRef.Constructor => false - case TypeRef.Scalar(b: TypeId.BuiltinScalar) => - b match { - case TypeId.Builtins.f32 | TypeId.Builtins.f64 | TypeId.Builtins.f128 => false - case _ => true - } - case TypeRef.Scalar(u: TypeId.User) => - domain.defs.meta.nodes.get(u) match { - case Some(DomainMember.User(_, nested: Typedef.Dto, _, _)) => isUserMapKeyEligibleDto(nested) - // Q-M19-7: enums round-trip via Display / parse; foreign types assume the host - // provides compatible Display / FromStr (PR-I will route through a dedicated KeyCodec). - case Some(DomainMember.User(_, _: Typedef.Enum, _, _)) => true - case Some(DomainMember.User(_, _: Typedef.Foreign, _, _)) => true - case _ => false - } - case _ => false - } - } + private def userMapKeyAdapterPath(tpe: TypeRef): Option[String] = mapKeyAdapter.userMapKeyAdapterPath(tpe) + + private def isUserMapKeyEligibleDto(dto: Typedef.Dto): Boolean = mapKeyAdapter.isUserMapKeyEligibleDto(dto) /** For a non-identifier single-field wrapper DTO, peel through nested * single-field wrappers to find the leaf builtin scalar TypeRef and the diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index 65c002c1b..f85a36170 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -11,12 +11,19 @@ import izumi.fundamentals.platform.strings.TextTree.* class RsJsonCodecGenerator( trans: RsTypeTranslator, + domainTypes: RsDomainTypes, target: RsTarget, domain: Domain, evo: BaboonEvolution, enquiries: BaboonEnquiries, ) extends RsCodecTranslator { + // A `map[K, V]` with a user-typed key gets its string keys from the adapter module emitted + // beside K. Only the adapter knows how each eligible kind converts — an `id` type has + // `Display` but no `FromStr`, a single-primitive wrapper has neither — so the explicit codecs + // delegate the whole field to it rather than guessing `to_string()` / `parse::()`. + private val mapKeyAdapter = new RsMapKeyAdapter(domain, domainTypes) + // Reuses the transitive `any` analysis that already drives boxing and Ord derivation, // rather than recomputing the same fixpoint here. private val representation = new RsFieldRepresentation(domain, evo, trans, enquiries) @@ -208,6 +215,11 @@ class RsJsonCodecGenerator( q"$rsT::decode_json(ctx, $ref)?" } + case _ if mapKeyAdapter.userMapKeyAdapterPath(tpe).isDefined => + val path = mapKeyAdapter.userMapKeyAdapterPath(tpe).get + // `serde_json::Value` is itself a Deserializer, so the adapter can read straight from it. + q"""$path::deserialize($ref.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: {}", e)))?""" + case c: TypeRef.Constructor => c.id match { case TypeId.Builtins.opt => @@ -325,6 +337,10 @@ class RsJsonCodecGenerator( q"$ref.encode_json(ctx)?" } + case _ if mapKeyAdapter.userMapKeyAdapterPath(tpe).isDefined => + val path = mapKeyAdapter.userMapKeyAdapterPath(tpe).get + q"""$path::serialize(&$ref, serde_json::value::Serializer).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))?""" + case c: TypeRef.Constructor => c.id match { case TypeId.Builtins.opt => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala new file mode 100644 index 000000000..274d5cf1c --- /dev/null +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala @@ -0,0 +1,86 @@ +package io.septimalmind.baboon.translator.rust + +import io.septimalmind.baboon.translator.rust.RsDefnTranslator.toSnakeCase +import io.septimalmind.baboon.typer.model.* + +/** Where a `map[K, V]` with a user-typed key gets its JSON string keys from. + * + * serde_json rejects non-string-shaped map keys, so the definition translator emits a + * per-key-type adapter module beside K's struct and points the field at it with + * `#[serde(with = ...)]`. The adapter is the only thing that knows how each eligible key kind + * converts: an `id` type has `Display` but deliberately no `FromStr` (its reader is the free + * `parse_repr`), a single-primitive wrapper has neither and is peeled to its inner scalar, and + * an enum has both. + * + * The explicit JSON codecs need the same answer, so the lookup lives here rather than being + * duplicated — guessing `to_string()` / `parse::()` compiles for enums and for nothing else. + */ +final class RsMapKeyAdapter(domain: Domain, domainTypes: RsDomainTypes) { + /** PR-61 (M19.3): if `f` is a top-level `map[K, V]` whose K is an eligible user + * key, return the FQ Rust path of the adapter module sibling to K's struct. + * Eligibility mirrors PR-59's validator (`isEligibleKey`): id types are eligible + * unconditionally; non-id `data` requires single-primitive-field shape with no + * contracts. We assume the validator has already rejected ineligible cases — + * here we only decide whether to attach the `#[serde(with = ...)]` attribute. + * + * Limited to the top-level `map[K, V]` shape (no `Option>` or + * nested `BTreeMap>` — adapter modules are field-scoped + * and serde's `with` attribute is applied to the field's full type). + */ + def userMapKeyAdapterPath(tpe: TypeRef): Option[String] = tpe match { + case TypeRef.Constructor(TypeId.Builtins.map, args) => + args.head match { + case TypeRef.Scalar(uid: TypeId.User) => + domain.defs.meta.nodes.get(uid) match { + case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) if isUserMapKeyEligibleDto(dto) => + val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) + val modName = s"${toSnakeCase(rsT.name)}_as_map_key" + Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) + // PR-I.3 (M24 Phase 3.3): direct foreign map key — route through + // the foreign's emitted `_as_map_key` adapter (Custom only; + // BaboonRef-aliased foreigns reuse the aliased type's serde path). + case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => + f.bindings.get(BaboonLang.Rust) match { + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => + val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) + val modName = s"${toSnakeCase(rsT.name)}_as_map_key" + Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) + case _ => None + } + case _ => None + } + case _ => None + } + case _ => None + } + + /** Mirrors `BaboonValidator.isEligibleKey` — DTO, Enum, and Foreign branches. + * id types are always eligible (Q-M19-6); single-primitive-field non-contract DTOs are + * eligible if the inner field is a primitive scalar, an enum, a foreign type, or a + * recursively-eligible nested wrapper (no opt/collection, no float wrappers per Q-M19-2). + */ + def isUserMapKeyEligibleDto(dto: Typedef.Dto): Boolean = { + if (dto.isIdentifier) true + else if (dto.contracts.nonEmpty) false + else if (dto.fields.size != 1) false + else + dto.fields.head.tpe match { + case _: TypeRef.Constructor => false + case TypeRef.Scalar(b: TypeId.BuiltinScalar) => + b match { + case TypeId.Builtins.f32 | TypeId.Builtins.f64 | TypeId.Builtins.f128 => false + case _ => true + } + case TypeRef.Scalar(u: TypeId.User) => + domain.defs.meta.nodes.get(u) match { + case Some(DomainMember.User(_, nested: Typedef.Dto, _, _)) => isUserMapKeyEligibleDto(nested) + // Q-M19-7: enums round-trip via Display / parse; foreign types assume the host + // provides compatible Display / FromStr (PR-I will route through a dedicated KeyCodec). + case Some(DomainMember.User(_, _: Typedef.Enum, _, _)) => true + case Some(DomainMember.User(_, _: Typedef.Foreign, _, _)) => true + case _ => false + } + case _ => false + } + } +} diff --git a/defects.md b/defects.md index 7679ae6d5..8bf7c244f 100644 --- a/defects.md +++ b/defects.md @@ -10,6 +10,17 @@ Status: `[ ]` open · `[~]` under fix · `[x]` resolved --- +## RSJSON-2026-09-24 (Rust explicit JSON codec conversion) + +### [RSJSON-2026-09-24-N01] No fixture combines an `any` field with a user-typed map key +**Status:** open (coverage gap; the defect it hid is fixed) +**Severity:** minor +**Location:** `baboon-compiler/src/test/resources/baboon/` (shared model corpus) +**Description:** The explicit JSON codecs take the field-by-field path only for any-bearing types; everything else delegates to serde. A DTO that is BOTH any-bearing and carries a `map[K, V]` with a user-typed key therefore exercises a combination no fixture contains, and the first cut of the decoder emitted `(k).parse::()` for it. That compiles only for enums: an `id` type has `Display` but deliberately no `FromStr` (its reader is the free `parse_repr`), and a single-primitive wrapper has neither — the adapter peels it to the inner scalar. Generated Rust for such a model would not compile, while all 894 corpus tests stayed green. Found by constructing the combination by hand and compiling the output as a throwaway crate; fixed by routing those fields through the emitted adapter module (see the shared `RsMapKeyAdapter`). +**Fix:** Pending — add a fixture carrying `any` alongside id-keyed, enum-keyed and wrapper-keyed maps. Deliberately not added with the fix: it belongs in the shared model dir, so all nine backends would have to handle the combination at once, which is likely to surface further gaps and is its own piece of work. Until then the combination is correct but only covered by an ad-hoc probe. + +--- + ## MIGRATION-2026-09-24 (user migration reports, `bugs.md`) ### [MIGRATION-2026-09-24-D01] Generated TypeScript MCP servers fail strict compilation (TS4114) From 9df09e0ba23f512790dbec43ee83070269667762 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 15:08:27 +0100 Subject: [PATCH 46/48] refactor(rust): route JSON through the explicit codecs, and give map-key adapters a serializer-independent entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two steps towards removing the derives, both verifiable while they are still in place. The convenience helpers and the dyn decode adapter now run the explicit codecs: `to_json`, `to_json_pretty`, `from_json`, `to_json_value`, `from_json_value` and `decode_json_dyn` all go through `encode_json` / `decode_json` under a facade-less context, which is the behaviour they always had — no facade means an `any` field holding a UEBA payload is refused rather than silently mis-encoded. Their error type becomes `BaboonCodecError`, since `serde_json::Error` cannot survive the conversion. The map-key adapters were the real blocker. They exist as whole-map serde functions only because `#[serde(with = ...)]` attaches to a field, and their `deserialize` is bounded on `V: Deserialize` — which stops compiling the moment the derives go. The explicit codecs build the map themselves and only ever needed the key, so the adapter now also exposes pub fn key_to_string(k: &K) -> String pub fn key_from_string(s: &str) -> Result reusing the per-key expressions that were already being computed inside the serde functions: `Display` / `parse_repr` for an id type, peel-and-rewrap for a single-primitive wrapper, and the host-registered key codec for a Custom foreign. The codecs call these per key instead of delegating the whole field, so nothing in the JSON path is bounded on serde traits any more. The whole-map serde functions are now dead and go out with the derives. `RsMapKeyAdapter` gained `keyAdapterPathFor`, the same lookup against a bare key type. Verified with the corpus at 901 passing and a purpose-built probe model (an any-bearing DTO carrying id-keyed, enum-keyed and wrapper-keyed maps) compiling as a standalone crate. Co-Authored-By: Claude Opus 5 (1M context) --- .../translator/rust/RsBaboonTranslator.scala | 4 +- .../translator/rust/RsDefnTranslator.scala | 21 ++++++ .../rust/RsJsonCodecGenerator.scala | 67 +++++++++++-------- .../translator/rust/RsMapKeyAdapter.scala | 36 +++++----- 4 files changed, 82 insertions(+), 46 deletions(-) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index 533520697..fcb94c6e3 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -293,8 +293,8 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( | let v = value.as_any().downcast_ref::<$fullPath>().ok_or_else(|| crate::any_opaque::BaboonCodecError::encoder_failure("$jsonCodec.encode: wrong type"))?; | v.encode_json(ctx) | } - | fn decode_json_dyn(&self, _ctx: &BaboonCodecContext, wire: &serde_json::Value) -> Result, crate::any_opaque::BaboonCodecError> { - | let v: $fullPath = serde_json::from_value(wire.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))?; + | fn decode_json_dyn(&self, ctx: &BaboonCodecContext, wire: &serde_json::Value) -> Result, crate::any_opaque::BaboonCodecError> { + | let v = <$fullPath>::decode_json(ctx, wire)?; | Ok(Box::new(v)) | } |}""".stripMargin diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index f315d9e87..1a0a38a80 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -310,6 +310,16 @@ object RsDefnTranslator { | use super::*; | use std::collections::BTreeMap; | + | /// Per-key conversion, independent of any serializer — see the DTO adapter. + | /// Routes through the host-registered key codec so the encoding stays pluggable. + | pub fn key_to_string(k: &${derefedTpe.asName}) -> String { + | super::$getterName().encode_key(k) + | } + | + | pub fn key_from_string(s: &str) -> Result<${derefedTpe.asName}, String> { + | super::$getterName().decode_key(s).map_err(|e| format!("{}", e)) + | } + | | pub fn serialize(map: &BTreeMap<${derefedTpe.asName}, V>, serializer: S) -> Result | where | S: serde::Serializer, @@ -903,6 +913,17 @@ object RsDefnTranslator { | use serde::Deserialize as _BaboonDeserialize; | use std::collections::BTreeMap; | + | /// Per-key conversion, independent of any serializer. The explicit JSON codecs build + | /// the map themselves and only need the key, so they call these rather than the + | /// whole-map serde adapter below. + | pub fn key_to_string(k: &${name.asName}) -> String { + | $encodeKey + | } + | + | pub fn key_from_string(s: &str) -> Result<${name.asName}, String> { + | $decodeKey.map_err(|e| format!("{}", e)) + | } + | | pub fn serialize(map: &BTreeMap<${name.asName}, V>, serializer: S) -> Result | where | S: serde::Serializer, diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index f85a36170..edf548e3b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -51,25 +51,33 @@ class RsJsonCodecGenerator( } private def genJsonHelpers(name: RsValue.RsType, defn: DomainMember.User): TextTree[RsValue] = { + // The convenience helpers run the explicit codecs under a facade-less context, which is the + // behaviour they always had: no facade means an `any` field holding a UEBA payload cannot be + // transcoded and is refused rather than silently mis-encoded. Callers that need it reach for + // `encode_json(ctx)` with `BaboonCodecContext::with_facade`. q"""impl ${name.asName} { - | pub fn to_json(&self) -> Result { - | serde_json::to_string(self) + | pub fn to_json(&self) -> Result { + | Ok(self.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact)?.to_string()) | } | - | pub fn to_json_pretty(&self) -> Result { - | serde_json::to_string_pretty(self) + | pub fn to_json_pretty(&self) -> Result { + | let value = self.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact)?; + | serde_json::to_string_pretty(&value) + | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e))) | } | - | pub fn from_json(s: &str) -> Result { - | serde_json::from_str(s) + | pub fn from_json(s: &str) -> Result { + | let value: serde_json::Value = serde_json::from_str(s) + | .map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))?; + | Self::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &value) | } | - | pub fn to_json_value(&self) -> Result { - | serde_json::to_value(self) + | pub fn to_json_value(&self) -> Result { + | self.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact) | } | - | pub fn from_json_value(v: serde_json::Value) -> Result { - | serde_json::from_value(v) + | pub fn from_json_value(v: serde_json::Value) -> Result { + | Self::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &v) | } | | ${ctxEncoder(defn, name).shift(4).trim} @@ -215,11 +223,6 @@ class RsJsonCodecGenerator( q"$rsT::decode_json(ctx, $ref)?" } - case _ if mapKeyAdapter.userMapKeyAdapterPath(tpe).isDefined => - val path = mapKeyAdapter.userMapKeyAdapterPath(tpe).get - // `serde_json::Value` is itself a Deserializer, so the adapter can read straight from it. - q"""$path::deserialize($ref.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: {}", e)))?""" - case c: TypeRef.Constructor => c.id match { case TypeId.Builtins.opt => @@ -258,11 +261,16 @@ class RsJsonCodecGenerator( /** JSON object keys arrive as strings; mirror `mapKeyEnc`. */ private def mapKeyDec(tpe: TypeRef, ref: TextTree[RsValue], what: String): TextTree[RsValue] = { - BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { - case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" - case _ => - val rsT = trans.asRsRef(tpe, domain, evo) - q"""($ref).parse::<$rsT>().map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + mapKeyAdapter.keyAdapterPathFor(tpe) match { + case Some(path) => + q"""$path::key_from_string($ref).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + case None => + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + case _ => + val rsT = trans.asRsRef(tpe, domain, evo) + q"""($ref).parse::<$rsT>().map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + } } } @@ -337,10 +345,6 @@ class RsJsonCodecGenerator( q"$ref.encode_json(ctx)?" } - case _ if mapKeyAdapter.userMapKeyAdapterPath(tpe).isDefined => - val path = mapKeyAdapter.userMapKeyAdapterPath(tpe).get - q"""$path::serialize(&$ref, serde_json::value::Serializer).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))?""" - case c: TypeRef.Constructor => c.id match { case TypeId.Builtins.opt => @@ -369,11 +373,18 @@ class RsJsonCodecGenerator( } } - /** JSON object keys are strings; serde_json coerces map keys the same way. */ + /** JSON object keys are strings. A user-typed key converts through the adapter module emitted + * beside it — only that knows how each eligible kind maps, since an `id` type has no `FromStr` + * and a single-primitive wrapper has neither `Display` nor `FromStr`. + */ private def mapKeyEnc(tpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { - BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { - case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" - case _ => q"($ref).to_string()" + mapKeyAdapter.keyAdapterPathFor(tpe) match { + case Some(path) => q"$path::key_to_string(&$ref)" + case None => + BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { + case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + case _ => q"($ref).to_string()" + } } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala index 274d5cf1c..04137cc2b 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsMapKeyAdapter.scala @@ -28,25 +28,29 @@ final class RsMapKeyAdapter(domain: Domain, domainTypes: RsDomainTypes) { * and serde's `with` attribute is applied to the field's full type). */ def userMapKeyAdapterPath(tpe: TypeRef): Option[String] = tpe match { - case TypeRef.Constructor(TypeId.Builtins.map, args) => - args.head match { - case TypeRef.Scalar(uid: TypeId.User) => - domain.defs.meta.nodes.get(uid) match { - case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) if isUserMapKeyEligibleDto(dto) => + case TypeRef.Constructor(TypeId.Builtins.map, args) => keyAdapterPathFor(args.head) + case _ => None + } + + /** Same lookup against a bare key type, for callers that build the map themselves and need + * only the key conversion. + */ + def keyAdapterPathFor(keyTpe: TypeRef): Option[String] = keyTpe match { + case TypeRef.Scalar(uid: TypeId.User) => + domain.defs.meta.nodes.get(uid) match { + case Some(DomainMember.User(_, dto: Typedef.Dto, _, _)) if isUserMapKeyEligibleDto(dto) => + val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) + val modName = s"${toSnakeCase(rsT.name)}_as_map_key" + Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) + // PR-I.3 (M24 Phase 3.3): direct foreign map key — route through + // the foreign's emitted `_as_map_key` adapter (Custom only; + // BaboonRef-aliased foreigns reuse the aliased type's serde path). + case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => + f.bindings.get(BaboonLang.Rust) match { + case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) val modName = s"${toSnakeCase(rsT.name)}_as_map_key" Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) - // PR-I.3 (M24 Phase 3.3): direct foreign map key — route through - // the foreign's emitted `_as_map_key` adapter (Custom only; - // BaboonRef-aliased foreigns reuse the aliased type's serde path). - case Some(DomainMember.User(_, f: Typedef.Foreign, _, _)) => - f.bindings.get(BaboonLang.Rust) match { - case Some(Typedef.ForeignEntry(_, Typedef.ForeignMapping.Custom(_, _))) => - val rsT = domainTypes.toRsTypeRefKeepForeigns(uid) - val modName = s"${toSnakeCase(rsT.name)}_as_map_key" - Some((rsT.crate.parts.toSeq :+ modName).mkString("::")) - case _ => None - } case _ => None } case _ => None From 5a87a4ee43f643e3439367f3c92692f389d0408f Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 20:05:07 +0100 Subject: [PATCH 47/48] feat(json): explicit Rust JSON codecs, and 64-bit integers as decimal strings everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that both come from the same place — a JSON wire that no single mechanism owned. Rust: replace the serde derives with explicit codecs ---------------------------------------------------- `serde::Serialize::serialize(&self, S)` has no parameter a `BaboonCodecContext` could travel in, so no derive-produced encoder could reach the facade that an `any` field holding a UEBA payload needs in order to transcode itself to JSON. That was the Rust half of the facade-context defect, and unfixable while the derive owned the wire. Every generated DTO, enum and ADT now carries `encode_json(ctx)` / `decode_json(ctx, wire)`, mirroring the UEBA side. `serde_json::Value` stays as the JSON DOM, so builtin scalars and foreign leaves still go through `to_value`/`from_value` — Baboon cannot know a foreign type's shape, and a std scalar's serde form is exact by construction. Before the derives were deleted, the generated per-type oracles compared the explicit codecs against the derive across the whole fixture corpus: 290 differential tests, all passing. That comparison cannot survive the removal of its own subject, so it is recorded here rather than kept. Consequences worth knowing: * `encode_json`/`decode_json` are emitted for every generated type, not only the ones JSON codecs are active for, and the generated `Cargo.toml` enables `json-helpers` unconditionally. They are the structural round-trip the conversions copy unrelated-but-identical types through; that availability used to come from the unconditional derive. * `preserve_order` on `serde_json` is now load-bearing: the encoders build a `serde_json::Map` per object, and without the feature that Map is a BTreeMap and every document comes out alphabetised. Two harness crates were missing it (the derive never needed it). * `Serialize`/`Deserialize for AnyOpaque` and the dead serde adapter modules go with the derives. A second, context-less encoding path beside the explicit codecs is exactly the drift hazard this removes. * Timestamp map keys go through `time_formats::format_ts{u,o}`, not chrono's `Display`, which renders `2026-05-02 12:00:00.123 +05:30` — a form no other backend writes or reads. * The map-key decode diagnostic keeps its `malformed key: ` prefix, which is the cross-language contract the per-backend tests assert. 64-bit integers: decimal strings on the wire --------------------------------------------- A JSON number beyond 2^53 has already been rounded by `JSON.parse` before any codec runs, so `Int64.MaxValue` written by C# as a bare number reached a TypeScript reader as 9223372036854775808 — a different, wrong, perfectly valid-looking integer. The backends also disagreed about the representation in both directions, and the spec contradicted itself. Every backend now writes `i64` and `u64` as decimal strings. Backward compatibility is carried by the readers, all of which accept both forms, so documents written by an older compiler still decode. The one case that cannot be made compatible is a numeric token above 2^53 read by TypeScript, where the value is already gone: `BaboonInt64.read` refuses it with a diagnostic naming the producer requirement instead of returning a rounded integer. Found along the way: Kotlin wrote `u64` as `JsonPrimitive(value.toLong())`, so values above `Long.MAX_VALUE` went on the wire as negative numbers. It now writes the unsigned form, and its reader still accepts the old signed one. Scala's reader needed no change — measured, not assumed: circe's `decodeBigInt` already accepts a JSON string. `docs/json-codecs.md` gains a "64-bit integers" section, and the OpenAPI/MCP schema projections describe `i64`/`u64` as strings, the treatment `f128` already had. Verified with `mdl --seq :ci` (206 actions, green), which includes the cross-language serialization acceptance matrix. `defects.md` is removed: it is an artifact of a superseded process. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + .../tests/McpInputSchemaEmissionTest.scala | 3 +- .../baboon/tests/ScalarCodecEmitterTest.scala | 22 +- ...SchemaProjectionCharacterizationTest.scala | 4 +- .../python/baboon_runtime_shared.py | 12 +- .../baboon-runtime/rust/any_opaque.rs | 128 ---- .../baboon-runtime/rust/baboon_runtime.rs | 246 +------- .../typescript/BaboonSharedRuntime.ts | 39 ++ .../csharp/CSScalarCodecEmitter.scala | 4 + .../java/JvScalarCodecEmitter.scala | 4 +- .../kotlin/KtScalarCodecEmitter.scala | 12 +- .../openapi/OasTypeTranslator.scala | 6 +- .../translator/python/PyTypeTranslator.scala | 3 +- .../baboon/translator/python/PyTypes.scala | 4 + .../translator/rust/RsBaboonTranslator.scala | 14 +- .../rust/RsCodecTestsTranslator.scala | 89 +-- .../rust/RsConversionTranslator.scala | 36 +- .../translator/rust/RsDefnTranslator.scala | 281 +-------- .../rust/RsJsonCodecGenerator.scala | 129 ++-- .../rust/RsServiceWiringTranslator.scala | 36 +- .../translator/scl/ScScalarCodecEmitter.scala | 6 +- .../typescript/TsScalarCodecOps.scala | 4 +- .../translator/typescript/TsTypes.scala | 1 + .../baboon/typer/BaboonRuntimeCodec.scala | 30 +- defects.md | 561 ------------------ docs/json-codecs.md | 36 +- test/conv-test-rs/Cargo.toml | 2 +- test/conv-test-rs/src/main.rs | 4 +- test/conv-test-rs/tests/compat_tests.rs | 10 +- test/cs-stub-mcp-overlay/McpTests/McpTests.cs | 2 +- .../test/mcp/mcp_tests.dart | 4 +- .../src/test/java/mcp/McpTests.java | 4 +- .../src/test/kotlin/mcp/McpTests.kt | 4 +- .../BaboonTests/mcp/test_mcp.py | 4 +- .../tests/wiring_tests.rs | 20 +- .../tests/wiring_tests.rs | 12 +- .../tests/wiring_tests.rs | 12 +- test/rs-stub/tests/any_meta_codec_tests.rs | 86 ++- test/rs-stub/tests/any_round_trip_tests.rs | 36 +- .../baboon_codecs_facade_latest_tests.rs | 4 +- .../tests/explicit_json_codec_tests.rs | 32 +- .../tests/facade_json_any_ctx_tests.rs | 43 +- .../tests/foreign_map_key_round_trip.rs | 14 +- .../rs-stub/tests/key_codec_host_last_wins.rs | 4 +- test/rs-stub/tests/map_key_malformed_test.rs | 18 +- .../nested_wrapper_map_key_round_trip.rs | 12 +- test/rust-stub-mcp-overlay/tests/mcp_tests.rs | 2 +- .../src/test/scala/McpTests.scala | 2 +- test/services/rs/Cargo.toml | 2 +- .../Tests/McpTests/McpTests.swift | 4 +- test/ts-stub-mcp-overlay/src/mcp.test.ts | 2 +- .../src/runtime-tests/Int64Precision.test.ts | 49 ++ 52 files changed, 568 insertions(+), 1533 deletions(-) delete mode 100644 defects.md create mode 100644 test/ts-stub/src/runtime-tests/Int64Precision.test.ts diff --git a/.gitignore b/.gitignore index bf46310f2..c3d142fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,6 @@ pubspec.lock # hand-written documentation exempt from the ledger-backend ignore above !docs/cli-reference.md + +# local-only working notes (not part of any ledger) +notes.local.md diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala index 81aaaf585..02b732d6a 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/McpInputSchemaEmissionTest.scala @@ -267,7 +267,8 @@ abstract class McpInputSchemaEmissionTestBase[F[+_, +_]: Error2: TagKK: BaboonTe assertWellFormed("listCollections", schema) val instance = Json.obj( "tags" -> Json.arr(Json.fromString("a"), Json.fromString("b")), - "uniqueIds" -> Json.arr(Json.fromLong(1L), Json.fromLong(2L)), + // set[i64]: 64-bit integers are carried as decimal strings (docs/json-codecs.md). + "uniqueIds" -> Json.arr(Json.fromString("1"), Json.fromString("2")), "labels" -> Json.obj("k" -> Json.fromString("v")), "byColor" -> Json.obj("Green" -> Json.fromString("ok")), ) diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala index 41455d1e9..6a013deb7 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/ScalarCodecEmitterTest.scala @@ -28,6 +28,11 @@ class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { render( CSScalarCodecEmitter.jsonEncode(bit, q"value", CSScalarCodecEmitter.JsonBooleanFormat.LowercaseString) ) shouldBe "new JValue(value.ToString().ToLowerInvariant())" + // i64/u64 travel as decimal strings; `JValue.Value()` reads both forms back. + render(CSScalarCodecEmitter.jsonEncode(i64, q"value", CSScalarCodecEmitter.JsonBooleanFormat.BooleanValue)) shouldBe + "new JValue(value.ToString(CultureInfo.InvariantCulture))" + render(CSScalarCodecEmitter.jsonEncode(u64, q"value", CSScalarCodecEmitter.JsonBooleanFormat.BooleanValue)) shouldBe + "new JValue(value.ToString(CultureInfo.InvariantCulture))" render(CSScalarCodecEmitter.uebaDecode(uid, q"wire")) shouldBe "new Guid(wire.ReadBytes(16))" render(CSScalarCodecEmitter.uebaEncode(uid, q"writer", q"value")) shouldBe "writer.Write(value.ToByteArray())" } @@ -39,6 +44,7 @@ class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { } render(JvScalarCodecEmitter.jsonDecode(i64, q"wire")) shouldBe "(wire.isTextual() ? Long.parseLong(wire.textValue()) : wire.longValue())" render(JvScalarCodecEmitter.jsonDecode(u64, q"wire")) shouldBe "(wire.isTextual() ? Long.parseUnsignedLong(wire.textValue()) : wire.longValue())" + render(JvScalarCodecEmitter.jsonEncode(i64, q"value")) shouldBe "new TextNode(Long.toString(value))" render(JvScalarCodecEmitter.jsonEncode(u64, q"value")) shouldBe "new TextNode(Long.toUnsignedString(value))" render(JvScalarCodecEmitter.uebaDecode(u32, q"wire")) shouldBe "(wire.readInt() & 0xFFFFFFFFL)" render(JvScalarCodecEmitter.uebaEncode(u32, q"writer", q"value")) shouldBe "writer.writeInt((int) (value & 0xFFFFFFFFL));" @@ -49,11 +55,12 @@ class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { (i08, "value.toInt()", "int.toByte()"), (i16, "value.toInt()", "int.toShort()"), (i32, "value", "int"), - (i64, "value", "long"), + // i64/u64 travel as decimal strings; the u64 reader still accepts the older signed number. + (i64, "value.toString()", "long"), (u08, "value.toInt()", "int.toUByte()"), (u16, "value.toInt()", "int.toUShort()"), (u32, "value.toLong()", "long.toUInt()"), - (u64, "value.toLong()", "long.toULong()"), + (u64, "value.toString()", null), (f32, "value", "float"), (f64, "value", "double"), (str, "value", "content"), @@ -66,8 +73,12 @@ class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { kotlinJsonCases.foreach { case (id, value, accessor) => kotlin(emitter.jsonEncode(id, q"value")) shouldBe s"JsonPrimitive($value)" - kotlin(emitter.jsonDecode(id, q"wire")) shouldBe s"wire.jsonPrimitive.$accessor" + if (accessor != null) kotlin(emitter.jsonDecode(id, q"wire")) shouldBe s"wire.jsonPrimitive.$accessor" } + // u64's reader does not fit the `wire.jsonPrimitive.` shape: the canonical form + // is the unsigned decimal string, and the fallback keeps the older signed number readable. + kotlin(emitter.jsonDecode(u64, q"wire")) shouldBe + "(wire.jsonPrimitive.content.toULongOrNull() ?: wire.jsonPrimitive.long.toULong())" kotlin(emitter.jsonEncode(bytes, q"value")) shouldBe "JsonPrimitive(value.toHexString())" kotlin(emitter.jsonDecode(bytes, q"wire")) shouldBe "ByteString.fromHexString(wire.jsonPrimitive.content)" kotlin(emitter.jsonEncode(uid, q"value")) shouldBe "JsonPrimitive(value.toString())" @@ -137,11 +148,12 @@ class ScalarCodecEmitterTest extends AnyFlatSpec with Matchers { (i08, "fromInt(value.toInt)", "decodeByte"), (i16, "fromInt(value.toInt)", "decodeShort"), (i32, "fromInt(value)", "decodeInt"), - (i64, "fromLong(value)", "decodeLong"), + // i64/u64 travel as decimal strings; `decodeLong` accepts both forms. + (i64, "fromString(value.toString)", "decodeLong"), (u08, "fromInt(java.lang.Byte.toUnsignedInt(value))", "decodeByte"), (u16, "fromInt(java.lang.Short.toUnsignedInt(value))", "decodeShort"), (u32, "fromLong(java.lang.Integer.toUnsignedLong(value))", "decodeInt"), - (u64, "fromBigInt(BaboonBinTools.toUnsignedBigInt(value))", "decodeLong"), + (u64, "fromString(BaboonBinTools.toUnsignedBigInt(value).toString)", "decodeLong"), (f32, "fromFloat(value).get", "decodeFloat"), (f64, "fromDouble(value).get", "decodeDouble"), (f128, "fromBigDecimal(value)", "decodeBigDecimalLenient"), diff --git a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala index e84a2996f..e940e3959 100644 --- a/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala +++ b/baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/translator/openapi/SchemaProjectionCharacterizationTest.scala @@ -16,7 +16,9 @@ class SchemaProjectionCharacterizationTest extends AnyWordSpec { "Schema projections" should { "preserve scalar formats and nullable collection structure" in { - assert(parse(oas.scalarSchemaJson(TypeId.Builtins.u64)) == parse("""{"type":"integer","format":"int64","minimum":0}""")) + // 64-bit integers are carried as decimal strings on the wire (docs/json-codecs.md). + assert(parse(oas.scalarSchemaJson(TypeId.Builtins.i64)) == parse("""{"type":"string","format":"int64"}""")) + assert(parse(oas.scalarSchemaJson(TypeId.Builtins.u64)) == parse("""{"type":"string","format":"uint64"}""")) val ref = TypeRef.Constructor(TypeId.Builtins.opt, NEList(TypeRef.Constructor(TypeId.Builtins.set, NEList(TypeRef.Scalar(TypeId.Builtins.str))))) assert(parse(oas.typeRefSchema(ref)) == parse("""{"oneOf":[{"type":"array","items":{"type":"string"},"uniqueItems":true},{"type":"null"}]}""")) assert(gql.fieldTypeStr(ref) == "[String!]") diff --git a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py index 71ca7ed4d..b9492761c 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py +++ b/baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py @@ -13,15 +13,23 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal from functools import wraps -from typing import TypeVar, Generic, Callable, Optional, Any, ClassVar +from typing import Annotated, TypeVar, Generic, Callable, Optional, Any, ClassVar from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, PlainSerializer from .baboon_exceptions import BaboonCodecException T = TypeVar("T") +# 64-bit integers go on the wire as decimal strings in every backend: a JSON number beyond 2**53 +# is already rounded by the time a JavaScript reader sees it (docs/json-codecs.md, "64-bit +# integers"). Only the JSON direction is affected — `model_dump()` without `mode="json"`, and the +# UEBA codecs, still see a plain `int`. Decoding needs no counterpart: pydantic's lax `int` +# validator already accepts a decimal string, which is what keeps older numeric payloads readable. +BaboonI64 = Annotated[int, PlainSerializer(str, return_type=str, when_used="json")] +BaboonU64 = Annotated[int, PlainSerializer(str, return_type=str, when_used="json")] + class BaboonGenerated(ABC): @property diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs index bf078f34d..2430bcd7a 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/any_opaque.rs @@ -1,7 +1,4 @@ use crate::baboon_runtime::bin_tools; -use serde::de::{self, MapAccess, Visitor}; -use serde::ser::{SerializeMap, Serializer}; -use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; use std::fmt; use std::io::{Read, Write}; @@ -396,131 +393,6 @@ impl AnyOpaqueJson { } } -// --- Custom serde Serialize/Deserialize for AnyOpaque --- -// -// Rust's serde derive would emit `{"Ueba": {...}}` / `{"Json": {...}}` — NOT the locked -// `{"$ak", "$ad"?, "$av"?, "$at"?, "$c"}` envelope. Custom impls below produce/consume -// the envelope shape required by the cross-language wire spec. -// -// Behavior: -// Serialize AnyOpaque::Json(j) → emits the envelope with $c = j.json. -// Serialize AnyOpaque::Ueba(_) → returns an error. The Ueba branch's bytes can only -// be cross-converted to JSON via the `BaboonCodecsFacade` -// (which has codec access). Consistent with PR 3.1/3.2/3.3 -// fail-fast: codec generators route Ueba → JSON via -// `facade.ueba_to_json(...)` *before* serde, so this -// branch should not be reached in generator-emitted code. -// Deserialize → AnyOpaque::Json(j) always (matches Scala/C# decoder, which always -// returns the native branch from the input format). - -impl Serialize for AnyOpaque { - fn serialize(&self, serializer: S) -> Result { - match self { - AnyOpaque::Ueba(_) => Err(serde::ser::Error::custom( - "AnyOpaque::Ueba cannot be JSON-serialized directly; use BaboonCodecsFacade::ueba_to_json to convert first", - )), - AnyOpaque::Json(j) => { - let meta = &j.meta; - let mut count = 2usize; // $ak, $c - if meta.domain.is_some() { - count += 1; - } - if meta.version.is_some() { - count += 1; - } - if meta.typeid.is_some() { - count += 1; - } - let mut map = serializer.serialize_map(Some(count))?; - map.serialize_entry(any_meta_codec::ANY_KIND_KEY, &(meta.kind as u32))?; - if let Some(d) = &meta.domain { - map.serialize_entry(any_meta_codec::ANY_DOMAIN_KEY, d)?; - } - if let Some(v) = &meta.version { - map.serialize_entry(any_meta_codec::ANY_VERSION_KEY, v)?; - } - if let Some(t) = &meta.typeid { - map.serialize_entry(any_meta_codec::ANY_TYPEID_KEY, t)?; - } - map.serialize_entry(any_meta_codec::ANY_CONTENT_KEY, &j.json)?; - map.end() - } - } - } -} - -impl<'de> Deserialize<'de> for AnyOpaque { - fn deserialize>(deserializer: D) -> Result { - struct AnyOpaqueVisitor; - - impl<'de> Visitor<'de> for AnyOpaqueVisitor { - type Value = AnyOpaque; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "an AnyOpaque envelope object with $ak/$c keys") - } - - fn visit_map>(self, mut map: M) -> Result { - let mut kind: Option = None; - let mut domain: Option = None; - let mut version: Option = None; - let mut typeid: Option = None; - let mut content: Option = None; - - while let Some(key) = map.next_key::()? { - match key.as_str() { - k if k == any_meta_codec::ANY_KIND_KEY => { - let v: u32 = map.next_value()?; - if v > 0xFF { - return Err(de::Error::custom(format!( - "AnyOpaque deserialize: '$ak' value out of u8 range: {}", - v - ))); - } - kind = Some(v as u8); - } - k if k == any_meta_codec::ANY_DOMAIN_KEY => { - domain = Some(map.next_value()?); - } - k if k == any_meta_codec::ANY_VERSION_KEY => { - version = Some(map.next_value()?); - } - k if k == any_meta_codec::ANY_TYPEID_KEY => { - typeid = Some(map.next_value()?); - } - k if k == any_meta_codec::ANY_CONTENT_KEY => { - content = Some(map.next_value()?); - } - _ => { - // forward-compat: ignore unknown keys - let _: serde::de::IgnoredAny = map.next_value()?; - } - } - } - - let kind = kind.ok_or_else(|| { - de::Error::custom(format!( - "AnyOpaque deserialize: missing '{}' field", - any_meta_codec::ANY_KIND_KEY - )) - })?; - let content = content.ok_or_else(|| { - de::Error::custom(format!( - "AnyOpaque deserialize: missing '{}' field", - any_meta_codec::ANY_CONTENT_KEY - )) - })?; - - let meta = AnyMeta::new(kind, domain, version, typeid) - .map_err(|e| de::Error::custom(format!("AnyOpaque deserialize: {}", e)))?; - Ok(AnyOpaque::Json(AnyOpaqueJson::new(meta, content))) - } - } - - deserializer.deserialize_map(AnyOpaqueVisitor) - } -} - // --- AnyMetaCodec helpers --- pub mod any_meta_codec { diff --git a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs index a973b4655..60c46e5ce 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs +++ b/baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_runtime.rs @@ -366,21 +366,9 @@ pub trait AbstractBaboonConversions { fn version_to(&self) -> &str; } -// --- Serde helpers for bytes (hex encoding) --- +// --- Hex encoding for bytes --- pub mod hex_bytes { - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(bytes: &[u8], serializer: S) -> Result { - let hex_string: String = bytes.iter().map(|b| format!("{:02X}", b)).collect(); - serializer.serialize_str(&hex_string) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let s = String::deserialize(deserializer)?; - hex_decode(&s).map_err(serde::de::Error::custom) - } - pub fn hex_decode(s: &str) -> Result, String> { if s.len() % 2 != 0 { return Err(format!("Hex string has odd length: {}", s.len())); @@ -395,238 +383,6 @@ pub mod hex_bytes { } } -pub mod opt_hex_bytes { - use serde::{Deserializer, Serializer}; - - pub fn serialize( - value: &Option>, - serializer: S, - ) -> Result { - match value { - Some(bytes) => super::hex_bytes::serialize(bytes, serializer), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result>, D::Error> { - use serde::Deserialize; - let opt: Option = Option::deserialize(deserializer)?; - match opt { - Some(s) => { - let bytes = super::hex_bytes::hex_decode(&s).map_err(serde::de::Error::custom)?; - Ok(Some(bytes)) - } - None => Ok(None), - } - } -} - -// --- Serde helpers for Decimal --- - -#[cfg(all(feature = "decimal", feature = "json-helpers"))] -pub mod decimal_as_number { - use rust_decimal::Decimal; - use serde::{Deserialize, Deserializer, Serializer}; - use std::str::FromStr; - - pub fn serialize(value: &Decimal, serializer: S) -> Result { - use serde::Serialize; - let s = value.normalize().to_string(); - let n: serde_json::Number = - serde_json::Number::from_str(&s).unwrap_or_else(|_| serde_json::Number::from(0)); - n.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let value = ::deserialize(deserializer)?; - match &value { - serde_json::Value::Number(n) => { - Decimal::from_str(&n.to_string()).map(|d| d.normalize()).map_err(serde::de::Error::custom) - } - serde_json::Value::String(s) => { - Decimal::from_str(s).map(|d| d.normalize()).map_err(serde::de::Error::custom) - } - _ => Err(serde::de::Error::custom(format!( - "Expected number or string for Decimal, got: {}", - value - ))), - } - } -} - -#[cfg(all(feature = "decimal", feature = "json-helpers"))] -pub mod opt_decimal_as_number { - use rust_decimal::Decimal; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize( - value: &Option, - serializer: S, - ) -> Result { - match value { - Some(d) => super::decimal_as_number::serialize(d, serializer), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - use std::str::FromStr; - let opt: Option = Option::deserialize(deserializer)?; - match opt { - Some(serde_json::Value::Null) => Ok(None), - Some(serde_json::Value::Number(n)) => { - let d = rust_decimal::Decimal::from_str(&n.to_string()) - .map(|d| d.normalize()) - .map_err(serde::de::Error::custom)?; - Ok(Some(d)) - } - Some(serde_json::Value::String(s)) => { - let d = rust_decimal::Decimal::from_str(&s) - .map(|d| d.normalize()) - .map_err(serde::de::Error::custom)?; - Ok(Some(d)) - } - Some(other) => Err(serde::de::Error::custom(format!( - "Expected number, string, or null for Option, got: {}", - other - ))), - None => Ok(None), - } - } -} - -// --- Serde helper for lenient numeric deserialization (accepts both JSON numbers and strings) --- - -#[cfg(feature = "json-helpers")] -pub mod lenient_numeric { - use serde::Deserializer; - - /// Deserializes an i64 that may be encoded as either a JSON number or a JSON string. - pub mod i64 { - pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { - struct I64Visitor; - impl<'de> serde::de::Visitor<'de> for I64Visitor { - type Value = i64; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "an i64 as number or string") - } - fn visit_i64(self, v: i64) -> Result { Ok(v) } - fn visit_u64(self, v: u64) -> Result { - i64::try_from(v).map_err(E::custom) - } - fn visit_str(self, v: &str) -> Result { - v.parse::().map_err(E::custom) - } - } - deserializer.deserialize_any(I64Visitor) - } - } - - /// Deserializes a u64 that may be encoded as either a JSON number or a JSON string. - pub mod u64 { - pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { - struct U64Visitor; - impl<'de> serde::de::Visitor<'de> for U64Visitor { - type Value = u64; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "a u64 as number or string") - } - fn visit_i64(self, v: i64) -> Result { - u64::try_from(v).map_err(E::custom) - } - fn visit_u64(self, v: u64) -> Result { Ok(v) } - fn visit_str(self, v: &str) -> Result { - v.parse::().map_err(E::custom) - } - } - deserializer.deserialize_any(U64Visitor) - } - } - - /// Generic deserialize that handles containers with i64/u64 values encoded as strings. - /// Uses serde_json::Value for recursive transformation when dealing with complex types. - pub fn deserialize<'de, T, D>(deserializer: D) -> Result - where - T: serde::de::DeserializeOwned, - D: Deserializer<'de>, - { - fn transform_strings_to_numbers(value: serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::String(s) => { - if let Ok(n) = s.parse::() { - serde_json::Value::Number(n.into()) - } else if let Ok(n) = s.parse::() { - serde_json::Value::Number(n.into()) - } else { - serde_json::Value::String(s) - } - } - serde_json::Value::Array(arr) => { - serde_json::Value::Array(arr.into_iter().map(transform_strings_to_numbers).collect()) - } - serde_json::Value::Object(map) => { - serde_json::Value::Object( - map.into_iter() - .map(|(k, v)| (k, transform_strings_to_numbers(v))) - .collect(), - ) - } - other => other, - } - } - let value = ::deserialize(deserializer)?; - let transformed = transform_strings_to_numbers(value); - T::deserialize(transformed).map_err(serde::de::Error::custom) - } -} - -// --- Serde helpers for timestamps --- - -#[cfg(feature = "timestamps")] -pub mod tsu_serde { - use chrono::{DateTime, Utc}; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize( - value: &DateTime, - serializer: S, - ) -> Result { - let s = super::time_formats::format_tsu(value); - serializer.serialize_str(&s) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - let s = String::deserialize(deserializer)?; - super::time_formats::parse_tsu(&s).map_err(serde::de::Error::custom) - } -} - -#[cfg(feature = "timestamps")] -pub mod tso_serde { - use chrono::{DateTime, FixedOffset}; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize( - value: &DateTime, - serializer: S, - ) -> Result { - let s = super::time_formats::format_tso(value); - serializer.serialize_str(&s) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - let s = String::deserialize(deserializer)?; - super::time_formats::parse_tso(&s).map_err(serde::de::Error::custom) - } -} // --- Time format helpers --- diff --git a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts index 3d95ba0f7..6ba379cb1 100644 --- a/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts +++ b/baboon-compiler/src/main/resources/baboon-runtime/typescript/BaboonSharedRuntime.ts @@ -430,6 +430,45 @@ const DOTNET_EPOCH_OFFSET_MS = 62135596800000n; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +/// 64-bit integer reading from JSON. +/// +/// Backends disagree about how i64/u64 reach the wire: C#/Java/Kotlin/Rust write JSON numbers, +/// TypeScript/Dart/Swift write decimal strings (docs/json-codecs.md). Every other backend's +/// decoder accepts both forms; TypeScript's assumed a string and handed whatever it got to +/// `BigInt(...)`. +/// +/// That was silently lossy: `JSON.parse` has already rounded a numeric token beyond +/// `Number.MAX_SAFE_INTEGER` by the time a codec sees it, so `BigInt(9223372036854775807)` +/// yields `9223372036854775808n`. The original value is unrecoverable at this layer, so the +/// only honest options are to accept the string form exactly or to refuse a number that cannot +/// be represented — never to return a quietly corrupted integer. +export class BaboonInt64 { + static read(wire: unknown, what: string): bigint { + if (typeof wire === "bigint") return wire; + if (typeof wire === "string") { + try { + return BigInt(wire); + } catch (e) { + throw new BaboonDecoderFailure(`${what}: '${wire}' is not a 64-bit integer`, { cause: e }); + } + } + if (typeof wire === "number") { + if (!Number.isInteger(wire)) { + throw new BaboonDecoderFailure(`${what}: ${wire} is not an integer`); + } + if (!Number.isSafeInteger(wire)) { + // JSON.parse already rounded this token; the source value cannot be recovered. + throw new BaboonDecoderFailure( + `${what}: ${wire} exceeds Number.MAX_SAFE_INTEGER, so JSON.parse has already lost precision. ` + + `The producer must emit 64-bit integers as decimal strings.`, + ); + } + return BigInt(wire); + } + throw new BaboonDecoderFailure(`${what}: expected a decimal string or a number, got ${typeof wire}`); + } +} + export class BinTools { static consumeIndex(reader: BaboonBinReader, expectedElements: number): number { const header = reader.readByte(); diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala index ddd6cbcc6..dd56d0076 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSScalarCodecEmitter.scala @@ -36,6 +36,10 @@ object CSScalarCodecEmitter { } def jsonEncode(b: TypeId.BuiltinScalar, value: TextTree[CSValue], booleanFormat: JsonBooleanFormat): TextTree[CSValue] = b match { + // 64-bit integers go on the wire as decimal strings in every backend: a JSON number beyond + // 2^53 is already rounded by the time a JavaScript reader sees it (docs/json-codecs.md, + // "64-bit integers"). Readers stay lenient about the older numeric form. + case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"new $nsJValue($value.ToString($csInvariantCulture.InvariantCulture))" case TypeId.Builtins.bytes => q"new $nsJValue($value.Encode())" case TypeId.Builtins.uid => q"new $nsJValue($value.ToString())" case TypeId.Builtins.tsu => q"new $nsJValue($baboonTimeFormats.TsuToString($value))" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala index d25b6fbf9..5529dc2f0 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/java/JvScalarCodecEmitter.scala @@ -36,7 +36,9 @@ object JvScalarCodecEmitter { case TypeId.Builtins.i08 => q"$shortNode.valueOf((short) $value)" case TypeId.Builtins.i16 => q"$shortNode.valueOf($value)" case TypeId.Builtins.i32 => q"$intNode.valueOf($value)" - case TypeId.Builtins.i64 => q"$longNode.valueOf($value)" + // 64-bit integers go on the wire as decimal strings in every backend — see the C# emitter + // and docs/json-codecs.md, "64-bit integers". The decoder stays lenient about numbers. + case TypeId.Builtins.i64 => q"new $textNode(Long.toString($value))" case TypeId.Builtins.u08 => q"$shortNode.valueOf($value)" case TypeId.Builtins.u16 => q"$intNode.valueOf($value)" case TypeId.Builtins.u32 => q"$longNode.valueOf($value)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala index 26a4d6084..b3b232ba6 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/kotlin/KtScalarCodecEmitter.scala @@ -16,7 +16,9 @@ final class KtScalarCodecEmitter(ktTypes: KtTypes) { case TypeId.Builtins.u08 => q"$wire.jsonPrimitive.int.toUByte()" case TypeId.Builtins.u16 => q"$wire.jsonPrimitive.int.toUShort()" case TypeId.Builtins.u32 => q"$wire.jsonPrimitive.long.toUInt()" - case TypeId.Builtins.u64 => q"$wire.jsonPrimitive.long.toULong()" + // Lenient: the canonical form is the unsigned decimal string; the older numeric form is a + // signed two's-complement Long, which `toULong()` maps back to the same value. + case TypeId.Builtins.u64 => q"($wire.jsonPrimitive.content.toULongOrNull() ?: $wire.jsonPrimitive.long.toULong())" case TypeId.Builtins.f32 => q"$wire.jsonPrimitive.float" case TypeId.Builtins.f64 => q"$wire.jsonPrimitive.double" case TypeId.Builtins.f128 => @@ -40,11 +42,15 @@ final class KtScalarCodecEmitter(ktTypes: KtTypes) { case TypeId.Builtins.i08 => q"$jsonPrimitive($value.toInt())" case TypeId.Builtins.i16 => q"$jsonPrimitive($value.toInt())" case TypeId.Builtins.i32 => q"$jsonPrimitive($value)" - case TypeId.Builtins.i64 => q"$jsonPrimitive($value)" + // 64-bit integers go on the wire as decimal strings in every backend — see the C# emitter + // and docs/json-codecs.md, "64-bit integers". The decoders stay lenient about numbers. + case TypeId.Builtins.i64 => q"$jsonPrimitive($value.toString())" case TypeId.Builtins.u08 => q"$jsonPrimitive($value.toInt())" case TypeId.Builtins.u16 => q"$jsonPrimitive($value.toInt())" case TypeId.Builtins.u32 => q"$jsonPrimitive($value.toLong())" - case TypeId.Builtins.u64 => q"$jsonPrimitive($value.toLong())" + // `ULong.toString()` is unsigned; the previous `toLong()` wrote u64 values above + // Long.MAX_VALUE as negative numbers, which no other backend reads back as the same value. + case TypeId.Builtins.u64 => q"$jsonPrimitive($value.toString())" case TypeId.Builtins.f32 => q"$jsonPrimitive($value)" case TypeId.Builtins.f64 => q"$jsonPrimitive($value)" case TypeId.Builtins.f128 => diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala index e8baeb449..f0ea422ea 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/openapi/OasTypeTranslator.scala @@ -41,11 +41,13 @@ class OasTypeTranslator { case TypeId.Builtins.i08 => ("integer", Some("int32"), Map.empty) case TypeId.Builtins.i16 => ("integer", Some("int32"), Map.empty) case TypeId.Builtins.i32 => ("integer", Some("int32"), Map.empty) - case TypeId.Builtins.i64 => ("integer", Some("int64"), Map.empty) + // 64-bit integers are carried as decimal strings on the wire (docs/json-codecs.md, + // "64-bit integers"), the same treatment `f128` already gets here. + case TypeId.Builtins.i64 => ("string", Some("int64"), Map.empty) case TypeId.Builtins.u08 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) case TypeId.Builtins.u16 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) case TypeId.Builtins.u32 => ("integer", Some("int32"), Map("minimum" -> Json.fromInt(0))) - case TypeId.Builtins.u64 => ("integer", Some("int64"), Map("minimum" -> Json.fromInt(0))) + case TypeId.Builtins.u64 => ("string", Some("uint64"), Map.empty) case TypeId.Builtins.f32 => ("number", Some("float"), Map.empty) case TypeId.Builtins.f64 => ("number", Some("double"), Map.empty) case TypeId.Builtins.f128 => ("string", Some("decimal"), Map.empty) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypeTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypeTranslator.scala index c335c5886..c64f3fcf4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypeTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypeTranslator.scala @@ -53,7 +53,8 @@ final class PyTypeTranslator { case TypeId.Builtins.i08 | TypeId.Builtins.u08 => pyInt case TypeId.Builtins.i16 | TypeId.Builtins.u16 => pyInt case TypeId.Builtins.i32 | TypeId.Builtins.u32 => pyInt - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => pyInt + case TypeId.Builtins.i64 => pyI64 + case TypeId.Builtins.u64 => pyU64 case TypeId.Builtins.f32 => pyFloat case TypeId.Builtins.f64 => pyFloat case TypeId.Builtins.f128 => pyDecimal diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypes.scala index 772dd03b5..90483036a 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/python/PyTypes.scala @@ -112,6 +112,10 @@ object PyTypes { // builtins val pyInt = PyType(pyBuiltins, "int") + // `Annotated[int, PlainSerializer(str, ..., when_used="json")]` — see the aliases in + // baboon_runtime_shared.py and docs/json-codecs.md, "64-bit integers". + val pyI64 = PyType(pyBaboonSharedRuntimeModule, "BaboonI64") + val pyU64 = PyType(pyBaboonSharedRuntimeModule, "BaboonU64") val pyFloat = PyType(pyBuiltins, "float") val pyDecimal = PyType(pyDecimalModule, "Decimal") val pyBool = PyType(pyBuiltins, "bool") diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala index fcb94c6e3..fb09cb53c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala @@ -715,15 +715,12 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( } val hasUuids = allTypes.contains(TypeId.Builtins.uid) val hasDecimals = allTypes.contains(TypeId.Builtins.f128) - val hasJsonCodecs = target.language.generateJsonCodecs - // lenient_numeric (requires serde_json) is used whenever any field contains i64/u64 - val hasLenientNumeric = allTypes.exists { - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => true - case _ => false - } val deps = scala.collection.mutable.ListBuffer.empty[String] deps += """serde = { version = "1", features = ["derive"] }""" + // `preserve_order` is load-bearing, not a nicety: the explicit JSON encoders build a + // `serde_json::Map` per object, and without the feature that Map is a BTreeMap, so every + // generated document would come out with its keys alphabetised instead of in field order. deps += """serde_json = { version = "1", features = ["preserve_order"], optional = true }""" deps += """rust_decimal = { version = "1", features = ["serde-with-str"], optional = true }""" deps += """chrono = { version = "0.4", features = ["serde"], optional = true }""" @@ -731,7 +728,10 @@ class RsBaboonTranslator[F[+_, +_]: Error2]( val defaultFeatures = scala.collection.mutable.ListBuffer.empty[String] if (hasDecimals) defaultFeatures += """"decimal"""" - if (hasJsonCodecs || hasDecimals || hasLenientNumeric) defaultFeatures += """"json-helpers"""" + // Unconditional: `encode_json`/`decode_json` are emitted for every generated type — they are + // also the structural round-trip the conversions copy identical types through — and they are + // written in terms of `baboon_runtime::json_tools`, which this feature gates. + defaultFeatures += """"json-helpers"""" if (hasTimestamps) defaultFeatures += """"timestamps"""" if (hasUuids) defaultFeatures += """"uuids"""" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala index 5a3a504f0..1c09d0f66 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsCodecTestsTranslator.scala @@ -60,79 +60,24 @@ object RsCodecTestsTranslator { q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); |let fixtures = super::${fixtureMethodJson}_all(&mut rnd); |for fixture in fixtures { - | let json = serde_json::to_value(&fixture).expect("JSON encode failed"); - | let decoded: $srcRef = serde_json::from_value(json.clone()).expect("JSON decode failed"); + | let json = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("JSON encode failed"); + | let decoded: $srcRef = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &json).expect("JSON decode failed"); | assert_eq!(fixture, decoded); |}""".stripMargin case _: Typedef.Enum => q"""for fixture in $srcRef::all() { - | let json = serde_json::to_value(&fixture).expect("JSON encode failed"); - | let decoded: $srcRef = serde_json::from_value(json.clone()).expect("JSON decode failed"); + | let json = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("JSON encode failed"); + | let decoded: $srcRef = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &json).expect("JSON decode failed"); | assert_eq!(fixture, decoded); |}""".stripMargin case _ => q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); |let fixture = super::$fixtureMethodJson(&mut rnd); - |let json = serde_json::to_value(&fixture).expect("JSON encode failed"); - |let decoded: $srcRef = serde_json::from_value(json.clone()).expect("JSON decode failed"); + |let json = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("JSON encode failed"); + |let decoded: $srcRef = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &json).expect("JSON decode failed"); |assert_eq!(fixture, decoded);""".stripMargin } - // Conversion oracle: while the serde derive is still in place it defines the JSON - // shape, so the explicit codecs must agree with it in both directions. This is what - // lets the derives be removed later without moving the wire. The decode side is - // compared against serde on the SAME wire rather than merely round-tripped, so a - // shared misreading cannot cancel itself out. - val encodeMatchesDerive = definition.defn match { - case _: Typedef.Adt => - q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); - |for fixture in super::${fixtureMethodJson}_all(&mut rnd) { - | let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); - | let derived = serde_json::to_value(&fixture).expect("serde encode failed"); - | assert_eq!(explicit, derived); - |}""".stripMargin - case _: Typedef.Enum => - q"""for fixture in $srcRef::all() { - | let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); - | let derived = serde_json::to_value(&fixture).expect("serde encode failed"); - | assert_eq!(explicit, derived); - |}""".stripMargin - case _ => - q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); - |let fixture = super::$fixtureMethodJson(&mut rnd); - |let explicit = fixture.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("encode_json failed"); - |let derived = serde_json::to_value(&fixture).expect("serde encode failed"); - |assert_eq!(explicit, derived);""".stripMargin - } - - val decodeMatchesDerive = definition.defn match { - case _: Typedef.Adt => - q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); - |for fixture in super::${fixtureMethodJson}_all(&mut rnd) { - | let wire = serde_json::to_value(&fixture).expect("serde encode failed"); - | let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); - | let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); - | assert_eq!(explicit, derived); - | assert_eq!(explicit, fixture); - |}""".stripMargin - case _: Typedef.Enum => - q"""for fixture in $srcRef::all() { - | let wire = serde_json::to_value(&fixture).expect("serde encode failed"); - | let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); - | let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); - | assert_eq!(explicit, derived); - | assert_eq!(explicit, fixture); - |}""".stripMargin - case _ => - q"""let mut rnd = crate::baboon_fixture::BaboonRandom::new(); - |let fixture = super::$fixtureMethodJson(&mut rnd); - |let wire = serde_json::to_value(&fixture).expect("serde encode failed"); - |let explicit = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &wire).expect("decode_json failed"); - |let derived: $srcRef = serde_json::from_value(wire).expect("serde decode failed"); - |assert_eq!(explicit, derived); - |assert_eq!(explicit, fixture);""".stripMargin - } - q"""#[test] |fn test_${testFnName}_json_codec() { | crate::cross_language_fixture_path::assert_cross_language_fixture_root_exists(); @@ -152,25 +97,13 @@ object RsCodecTestsTranslator { | return; | } | let data = std::fs::read_to_string(path).expect("Failed to read JSON file"); - | let decoded: $srcRef = serde_json::from_str(&data).expect("Failed to decode cross-language JSON"); - | let re_encoded = serde_json::to_value(&decoded).expect("Failed to re-encode"); - | let re_decoded: $srcRef = serde_json::from_value(re_encoded).expect("Failed to decode re-encoded"); + | let parsed: serde_json::Value = serde_json::from_str(&data).expect("Failed to parse cross-language JSON"); + | let decoded: $srcRef = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &parsed).expect("Failed to decode cross-language JSON"); + | let re_encoded = decoded.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact).expect("Failed to re-encode"); + | let re_decoded: $srcRef = $srcRef::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &re_encoded).expect("Failed to decode re-encoded"); | assert_eq!(decoded, re_decoded); |} - | - |#[test] - |fn test_${testFnName}_json_encode_matches_derive() { - | for _ in 0..${target.generic.codecTestIterations.toString} { - | ${encodeMatchesDerive.shift(8).trim} - | } - |} - | - |#[test] - |fn test_${testFnName}_json_decode_matches_derive() { - | for _ in 0..${target.generic.codecTestIterations.toString} { - | ${decodeMatchesDerive.shift(8).trim} - | } - |}""".stripMargin +""".stripMargin }.toList val uebaTests = codecs diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala index 40ce54c0c..fea38ae85 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsConversionTranslator.scala @@ -41,8 +41,30 @@ class RsConversionTranslator[F[+_, +_]: Error2]( private val srcVer = srcDom.version type Out[T] = F[NEList[BaboonIssue], T] - private def serdeConvert(expr: TextTree[RsValue]): TextTree[RsValue] = { - q"serde_json::from_value(serde_json::to_value(&$expr).unwrap()).unwrap()" + private val codecCtx = q"&crate::baboon_runtime::BaboonCodecContext::Compact" + + /** A pair of types with no targeted conversion is copied structurally, through JSON. A + * generated type carries `encode_json`/`decode_json`; a foreign type carries neither — + * Baboon does not know its shape — so it keeps the serde impl its host provides, and so do + * the builtins. + */ + private def structuralConvert(oldTpe: TypeRef, newTpe: TypeRef, ref: TextTree[RsValue]): TextTree[RsValue] = { + (oldTpe, newTpe) match { + case (TypeRef.Scalar(oldId), TypeRef.Scalar(newId)) if hasJsonCodec(oldId, srcDom) && hasJsonCodec(newId, domain) => + val target = trans.asRsRef(newTpe, domain, evo) + q"$target::decode_json($codecCtx, &($ref).encode_json($codecCtx).unwrap()).unwrap()" + case _ => + q"serde_json::from_value(serde_json::to_value(&$ref).unwrap()).unwrap()" + } + } + + private def hasJsonCodec(id: TypeId, dom: Domain): Boolean = id match { + case u: TypeId.User => + dom.defs.meta.nodes.get(u) match { + case Some(DomainMember.User(_, _: Typedef.Dto | _: Typedef.Enum | _: Typedef.Adt, _, _)) => true + case _ => false + } + case _ => false } private val sourceRepresentation = new RsFieldRepresentation(srcDom, evo, trans, enquiries) @@ -76,7 +98,7 @@ class RsConversionTranslator[F[+_, +_]: Error2]( rules.conversions.collectFirst { case c: TargetedConversion if c.sourceTpe == oldId && c.targetTpe == newId && !c.isInstanceOf[Conversion.CustomConversionRequired] => q"${crate.parts.mkString("::")}::${conversionFile(c)}::${conversionName(c)}($ref)" - }.getOrElse(serdeConvert(ref)) + }.getOrElse(structuralConvert(oldTpe, newTpe, ref)) case (TypeRef.Scalar(oldId: TypeId.BuiltinScalar), TypeRef.Scalar(newId: TypeId.BuiltinScalar)) if oldId != newId => val integers = Set( TypeId.Builtins.i08, @@ -89,14 +111,14 @@ class RsConversionTranslator[F[+_, +_]: Error2]( TypeId.Builtins.u64, ) if (integers.contains(oldId) && integers.contains(newId)) q"(*($ref)) as ${trans.asRsRef(newTpe, domain, evo)}" - else serdeConvert(ref) + else structuralConvert(oldTpe, newTpe, ref) case (old: TypeRef.Scalar, TypeRef.Constructor(newId, args)) => val inner = transfer(old, args.head, ref) newId match { case TypeId.Builtins.opt => q"Some($inner)" case TypeId.Builtins.lst => q"vec![$inner]" case TypeId.Builtins.set => q"std::collections::BTreeSet::from([$inner])" - case _ => serdeConvert(ref) + case _ => structuralConvert(oldTpe, newTpe, ref) } case (TypeRef.Constructor(oldId, oldArgs), TypeRef.Constructor(newId, newArgs)) => (oldId, newId) match { @@ -110,10 +132,10 @@ class RsConversionTranslator[F[+_, +_]: Error2]( case (_, TypeId.Builtins.lst | TypeId.Builtins.set) => val inner = transfer(oldArgs.head, newArgs.head, q"e") q"($ref).iter().map(|e| $inner).collect()" - case _ => serdeConvert(ref) + case _ => structuralConvert(oldTpe, newTpe, ref) } case _ if oldTpe == newTpe => q"(*($ref)).clone()" - case _ => serdeConvert(ref) + case _ => structuralConvert(oldTpe, newTpe, ref) } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala index 1a0a38a80..61d0b4fb2 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsDefnTranslator.scala @@ -213,10 +213,10 @@ object RsDefnTranslator { } /** PR-I.3 (M24 Phase 3.3) — emit a `_KeyCodec` extension hook - * + sibling `_as_map_key` serde adapter module for every + * + sibling `_as_map_key` adapter module for every * Custom-mapped Rust foreign declaration. The host registers an - * implementation at boot via `register__keycodec`; serde - * routes map keys through it via the adapter module. + * implementation at boot via `register__keycodec`; the JSON + * codecs route map keys through it via the adapter module. * * Stringy customs ({"String", "&str", "std::string::String"}) get a * default identity impl so the common case works out of the box. @@ -258,9 +258,9 @@ object RsDefnTranslator { |}""".stripMargin } else { // Non-stringy DefaultImpl: encode_key panics with FQN-bearing message - // (serde::Serializer cannot consume Result, and a String stub would + // (a map key is a `String` with no room for a Result, and a String stub would // produce malformed wire data). decode_key returns Err with the same - // message so deserializers surface a clear "register me" diagnostic. + // message so decoders surface a clear "register me" diagnostic. // The panic-on-encode is intentional: silently emitting bogus keys // is worse than a crash that points the host operator at the fix. val msg = s"$hostFqn() is not registered; call $registerName(impl) at app boot." @@ -308,7 +308,6 @@ object RsDefnTranslator { | |pub mod $adapterMod { | use super::*; - | use std::collections::BTreeMap; | | /// Per-key conversion, independent of any serializer — see the DTO adapter. | /// Routes through the host-registered key codec so the encoding stays pluggable. @@ -320,35 +319,6 @@ object RsDefnTranslator { | super::$getterName().decode_key(s).map_err(|e| format!("{}", e)) | } | - | pub fn serialize(map: &BTreeMap<${derefedTpe.asName}, V>, serializer: S) -> Result - | where - | S: serde::Serializer, - | V: serde::Serialize, - | { - | use serde::ser::SerializeMap; - | let mut m = serializer.serialize_map(Some(map.len()))?; - | for (k, v) in map.iter() { - | let s: String = super::$getterName().encode_key(k); - | m.serialize_entry(&s, v)?; - | } - | m.end() - | } - | - | pub fn deserialize<'de, __De, V>(deserializer: __De) -> Result, __De::Error> - | where - | __De: serde::Deserializer<'de>, - | V: serde::Deserialize<'de>, - | { - | use serde::Deserialize as _BaboonDeserialize; - | let raw: BTreeMap = BTreeMap::::deserialize(deserializer)?; - | let mut out: BTreeMap<${derefedTpe.asName}, V> = BTreeMap::new(); - | for (s, v) in raw.into_iter() { - | let key = super::$getterName().decode_key(&s) - | .map_err(|e| serde::de::Error::custom(format!("malformed key: {}", e)))?; - | out.insert(key, v); - | } - | Ok(out) - | } |}""".stripMargin } } @@ -628,10 +598,7 @@ object RsDefnTranslator { val fields = dto.fields.map { f => val t = representation.field(f.tpe).stored - val serdeAttrs = fieldSerdeAttributes(f) - val attrLine = if (serdeAttrs.nonEmpty) serdeAttrs.joinN() else q"" - val fieldEx = q"""$attrLine - |pub ${toSnakeCase(f.name.name)}: $t,""".stripMargin.trim + val fieldEx = q"""pub ${toSnakeCase(f.name.name)}: $t,""" prependDocs(f.docs, fieldEx) } val fieldsList = if (fields.nonEmpty) fields.joinN() else q"" @@ -639,49 +606,10 @@ object RsDefnTranslator { val derives = dtoDerives(dto) val ordImpls = dtoOrdImpls(dto, name) - val customSerialize = if (isWrappedAdtBranch(dto)) { - val branchName = dto.id.name.name - val hasFields = dto.fields.nonEmpty - - val innerFields = dto.fields.map { - f => - val t = representation.field(f.tpe).stored - val serdeAttrs = fieldSerdeAttributes(f) - val attrLine = if (serdeAttrs.nonEmpty) serdeAttrs.joinN() else q"" - q"""$attrLine - |${toSnakeCase(f.name.name)}: &'a $t,""".stripMargin.trim - } - val innerFieldsList = if (innerFields.nonEmpty) innerFields.joinN() else q"" - - val fieldAssignments = dto.fields.map { - f => - val fld = toSnakeCase(f.name.name) - q"$fld: &self.$fld," - } - val fieldAssignmentsList = if (fieldAssignments.nonEmpty) fieldAssignments.joinN() else q"" - - val fieldsStructDecl = if (hasFields) q"struct Fields<'a>" else q"struct Fields" - - q""" - | - |impl serde::Serialize for ${name.asName} { - | fn serialize(&self, serializer: S) -> Result { - | use serde::ser::SerializeMap; - | #[derive(serde::Serialize)] - | $fieldsStructDecl { - | ${innerFieldsList.shift(12).trim} - | } - | let fields = Fields { - | ${fieldAssignmentsList.shift(12).trim} - | }; - | let mut map = serializer.serialize_map(Some(1))?; - | map.serialize_entry("$branchName", &fields)?; - | map.end() - | } - |}""".stripMargin - } else { - q"" - } + // The wrapped-ADT-branch envelope used to need a hand-written `Serialize` (the derive + // cannot express `{"Branch": {...}}`). `encode_json` emits that shape directly now, so + // no serde impl is emitted for branches either. + val customSerialize: TextTree[RsValue] = q"" val structDef = if (dto.fields.isEmpty) { q"""$derives @@ -707,8 +635,8 @@ object RsDefnTranslator { |$parseRepr""".stripMargin } else q"" - // PR-61 (M19.3): per-key-type serde adapter module so this DTO can appear - // as a JSON map key. See userMapKeyAdapterPath / fieldSerdeAttributes. + // PR-61 (M19.3): per-key-type adapter module so this DTO can appear + // as a JSON map key. See RsMapKeyAdapter. val mapKeyAdapter: TextTree[RsValue] = if (isUserMapKeyEligibleDto(dto)) renderUserMapKeyAdapter(dto, name) else q"" @@ -798,17 +726,16 @@ object RsDefnTranslator { if (impls.nonEmpty) impls.joinNN() else q"" } - /** PR-61 (M19.3) — emit a per-key-type serde adapter module as a sibling of - * the struct, generic over the map value type V. Used by fields of shape - * `BTreeMap` via `#[serde(with = "")]`. + /** PR-61 (M19.3) — emit a per-key-type adapter module as a sibling of the struct. Used by + * the explicit JSON codecs for fields of shape `BTreeMap`. * * String form on the wire: * - id types → `format!("{}", k)` (Display from PR-57c) ↔ parse_repr * - single-primitive → inner primitive's Display ↔ inner primitive parse * - * Approach (b) per PR-61 brief: the wrapper's value-position Serialize is - * NOT overridden — wrappers/ids continue to JSON-serialize as objects when - * they appear in value position. Only the map-key path is rerouted. + * Approach (b) per PR-61 brief: the wrapper's value-position encoding is NOT overridden — + * wrappers/ids continue to JSON-encode as objects when they appear in value position. Only + * the map-key path is rerouted. */ private def renderUserMapKeyAdapter(dto: Typedef.Dto, name: RsType): TextTree[RsValue] = { val modName = s"${toSnakeCase(name.name)}_as_map_key" @@ -910,12 +837,9 @@ object RsDefnTranslator { q"""pub mod $modName { | use super::*; - | use serde::Deserialize as _BaboonDeserialize; - | use std::collections::BTreeMap; | | /// Per-key conversion, independent of any serializer. The explicit JSON codecs build - | /// the map themselves and only need the key, so they call these rather than the - | /// whole-map serde adapter below. + | /// the map themselves and only need the key. | pub fn key_to_string(k: &${name.asName}) -> String { | $encodeKey | } @@ -924,34 +848,6 @@ object RsDefnTranslator { | $decodeKey.map_err(|e| format!("{}", e)) | } | - | pub fn serialize(map: &BTreeMap<${name.asName}, V>, serializer: S) -> Result - | where - | S: serde::Serializer, - | V: serde::Serialize, - | { - | use serde::ser::SerializeMap; - | let mut m = serializer.serialize_map(Some(map.len()))?; - | for (k, v) in map.iter() { - | let key_str: String = $encodeKey; - | m.serialize_entry(&key_str, v)?; - | } - | m.end() - | } - | - | pub fn deserialize<'de, __De, V>(deserializer: __De) -> Result, __De::Error> - | where - | __De: serde::Deserializer<'de>, - | V: serde::Deserialize<'de>, - | { - | let raw: BTreeMap = BTreeMap::::deserialize(deserializer)?; - | let mut out: BTreeMap<${name.asName}, V> = BTreeMap::new(); - | for (s, v) in raw.into_iter() { - | let key = $decodeKey - | .map_err(|e| serde::de::Error::custom(format!("malformed key: {}", e)))?; - | out.insert(key, v); - | } - | Ok(out) - | } |}""".stripMargin } @@ -980,18 +876,19 @@ object RsDefnTranslator { // - hasUnorderable (any): PartialEq is derived (AnyOpaque has PartialEq); no Eq/Ord because // serde_json::Value has no total ordering and JSON-source bytes can be byte-different but // semantically equal — Eq is misleading. - // - wrappedBranch: encoded as an inline ADT branch — no Serialize derive (handled by ADT). - val serdeDerives = - if (wrappedBranch) "serde::Deserialize" - else "serde::Serialize, serde::Deserialize" + // JSON is produced by the generated `encode_json` / `decode_json` pair, so no serde + // derive is emitted: `serde::Serialize` has no room for a codec context and cannot reach + // the facade an `any` field needs. + val _ = wrappedBranch val cmpDerives = - if (hasUnorderable) "PartialEq, " - else if (hasWrappedFlt) "PartialEq, PartialOrd, " - else if (hasBareFloat) "" // manual PartialEq/Eq/PartialOrd/Ord via dtoOrdImpls - else "PartialEq, Eq, PartialOrd, Ord, " + if (hasUnorderable) "PartialEq" + else if (hasWrappedFlt) "PartialEq, PartialOrd" + else if (hasBareFloat) "Clone2Marker" // manual PartialEq/Eq/PartialOrd/Ord via dtoOrdImpls + else "PartialEq, Eq, PartialOrd, Ord" - q"#[derive(Clone, Debug, $cmpDerives$serdeDerives)]" + if (cmpDerives == "Clone2Marker") q"#[derive(Clone, Debug)]" + else q"#[derive(Clone, Debug, $cmpDerives)]" } private def dtoOrdImpls(dto: Typedef.Dto, name: RsType): TextTree[RsValue] = { @@ -1052,54 +949,6 @@ object RsDefnTranslator { private def needsBox(tpe: TypeRef): Boolean = representation.needsBox(tpe) - private def fieldSerdeAttributes(f: Field): List[TextTree[RsValue]] = { - val attrs = scala.collection.mutable.ListBuffer.empty[TextTree[RsValue]] - // Rename field to original name if we snake_cased it - val originalName = f.name.name - val rustName = toSnakeCase(originalName) - if (rustName != originalName) { - attrs += q"""#[serde(rename = "$originalName")]""" - } - - // Special serde for bytes (hex encoding) - trans.needsHexSerde(f.tpe) match { - case Some(RsTypeTranslator.HexSerdeKind.Direct) => - attrs += q"""#[serde(with = "crate::baboon_runtime::hex_bytes")]""" - case Some(RsTypeTranslator.HexSerdeKind.Optional) => - attrs += q"""#[serde(with = "crate::baboon_runtime::opt_hex_bytes")]""" - case None => - } - // Special serde for Decimal (as number) - if (trans.needsDecimalSerde(f.tpe)) { - attrs += q"""#[serde(with = "crate::baboon_runtime::decimal_as_number")]""" - } - // Special serde for timestamps - f.tpe match { - case TypeRef.Scalar(TypeId.Builtins.tsu) => - attrs += q"""#[serde(with = "crate::baboon_runtime::tsu_serde")]""" - case TypeRef.Scalar(TypeId.Builtins.tso) => - attrs += q"""#[serde(with = "crate::baboon_runtime::tso_serde")]""" - case _ => - } - // Lenient deserialization for fields containing i64/u64 (accepts both numbers and strings) - if (trans.needsLenientSerde(f.tpe)) { - attrs += q"""#[serde(deserialize_with = "crate::baboon_runtime::lenient_numeric::deserialize")]""" - } - // PR-61 (M19.3 / PR-60-D06): user-typed map keys. - // serde_json's default Serialize for `BTreeMap` rejects non-string-shaped K - // with `key must be a string`. For `map[K, V]` where K is a user `id` (M18) or - // single-primitive-field `data` wrapper (M19), redirect through a per-K adapter - // module that converts K↔String via the existing Display / parse_repr machinery - // (id types) or by peeling to the inner primitive (single-field wrappers). The - // adapter module is emitted next to K's struct (see `userMapKeyAdapter` below). - userMapKeyAdapterPath(f.tpe).foreach { - path => - attrs += q"""#[serde(with = "$path")]""" - } - - attrs.toList - } - // Map-key adapter lookup lives in RsMapKeyAdapter so the explicit JSON codecs can reuse it. private val mapKeyAdapter = new RsMapKeyAdapter(domain, domainTypes) @@ -1168,7 +1017,7 @@ object RsDefnTranslator { q"${name.asName}::${EnumWireStyle.wireName(m.name)}," }.toList - q"""#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] + q"""#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] |pub enum ${name.asName} { | ${variants.joinN().shift(4).trim} |} @@ -1235,7 +1084,7 @@ object RsDefnTranslator { // Generate the ADT enum val variants = dataMembers.map { mid => - // wireVariantName: original model name (capitalized) used in serde string literals. + // wireVariantName: original model name (capitalized) used in the wire string literals. // rsVariantName: keyword-escaped Rust identifier used in source code. val wireVariantName = mid.name.name.capitalize val rsVariantName = escapeRustTypeName(wireVariantName) @@ -1243,52 +1092,7 @@ object RsDefnTranslator { q"$rsVariantName(${branchType.asName})," } - // Custom serde for ADT: serialize as {"BranchName": { ... }} - val serImpl = if (target.language.wrappedAdtBranchCodecs) { - val serBranches = dataMembers.map { - mid => - val rsVariantName = escapeRustTypeName(mid.name.name.capitalize) - q"""${name.asName}::$rsVariantName(v) => v.serialize(serializer),""" - } - q"""impl serde::Serialize for ${name.asName} { - | fn serialize(&self, serializer: S) -> Result { - | match self { - | ${serBranches.toList.joinN().shift(12).trim} - | } - | } - |}""".stripMargin - } else { - val serBranches = dataMembers.map { - mid => - val wireVariantName = mid.name.name.capitalize - val rsVariantName = escapeRustTypeName(wireVariantName) - q"""${name.asName}::$rsVariantName(v) => { - | map.serialize_entry("$wireVariantName", v)?; - |}""".stripMargin - } - q"""impl serde::Serialize for ${name.asName} { - | fn serialize(&self, serializer: S) -> Result { - | use serde::ser::SerializeMap; - | let mut map = serializer.serialize_map(Some(1))?; - | match self { - | ${serBranches.toList.joinN().shift(12).trim} - | } - | map.end() - | } - |}""".stripMargin - } - - val deBranches = dataMembers.map { - mid => - val wireVariantName = mid.name.name.capitalize - val rsVariantName = escapeRustTypeName(wireVariantName) - q""""$wireVariantName" => Ok(${name.asName}::$rsVariantName(map.next_value()?)),""" - } - - // branchNames for serde error messages use wire names (original model names). - val branchNames = dataMembers.map(_.name.name.capitalize) - val branchNamesLit = branchNames.map(n => s""""$n"""").mkString(", ") - + // The ADT wire shape is {"BranchName": { ... }}; `Display` mirrors the branch names. val displayBranches = dataMembers.map { mid => val wireVariantName = mid.name.name.capitalize @@ -1322,29 +1126,6 @@ object RsDefnTranslator { | ${variants.toList.joinN().shift(4).trim} |} | - |$serImpl - | - |impl<'de> serde::Deserialize<'de> for ${name.asName} { - | fn deserialize<__De: serde::Deserializer<'de>>(deserializer: __De) -> Result { - | struct AdtVisitor; - | impl<'de> serde::de::Visitor<'de> for AdtVisitor { - | type Value = ${name.asName}; - | fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - | write!(f, "a single-key map representing ${name.name}") - | } - | fn visit_map<__M: serde::de::MapAccess<'de>>(self, mut map: __M) -> Result { - | let key: String = map.next_key()? - | .ok_or_else(|| serde::de::Error::custom("expected single-key map for ADT"))?; - | match key.as_str() { - | ${deBranches.toList.joinN().shift(20).trim} - | _ => Err(serde::de::Error::unknown_variant(&key, &[$branchNamesLit])), - | } - | } - | } - | deserializer.deserialize_map(AdtVisitor) - | } - |} - | |impl std::fmt::Display for ${name.asName} { | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | match self { diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala index edf548e3b..619c6e0e8 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala @@ -33,21 +33,22 @@ class RsJsonCodecGenerator( case _ => false } + /** `encode_json`/`decode_json` are emitted for every generated type, not only for the types + * the JSON codecs are active for: they are the structural JSON round-trip that + * `RsConversionTranslator` copies unrelated-but-identical types through. That round-trip + * used to ride on an unconditional `serde::Serialize` derive; the derive is gone, so the + * availability it provided has to come from here. `isActive` still governs codec + * registration and the generated codec tests. + */ override def translate(defn: DomainMember.User, rsRef: RsValue.RsType, srcRef: RsValue.RsType): Option[TextTree[RsValue]] = { - if (isActive(defn.id)) { - // For Rust, JSON codecs are handled via serde derive macros on the struct/enum definitions. - // We don't need separate codec objects like in Scala/C#. - // The actual serde attributes are generated by RsDefnTranslator. - // Here we only generate helper methods for programmatic encode/decode. - defn.defn match { - case _: Typedef.Dto => Some(genJsonHelpers(rsRef, defn)) - case _: Typedef.Enum => Some(genJsonHelpers(rsRef, defn)) - case _: Typedef.Adt => Some(genJsonHelpers(rsRef, defn)) - case _: Typedef.Foreign => None - case _: Typedef.Contract => None - case _: Typedef.Service => None - } - } else None + defn.defn match { + case _: Typedef.Dto => Some(genJsonHelpers(rsRef, defn)) + case _: Typedef.Enum => Some(genJsonHelpers(rsRef, defn)) + case _: Typedef.Adt => Some(genJsonHelpers(rsRef, defn)) + case _: Typedef.Foreign => None + case _: Typedef.Contract => None + case _: Typedef.Service => None + } } private def genJsonHelpers(name: RsValue.RsType, defn: DomainMember.User): TextTree[RsValue] = { @@ -92,46 +93,45 @@ class RsJsonCodecGenerator( * hand-written `Serialize` impls (ADTs, wrapped branches) can reach the facade that an `any` * field holding a UEBA payload needs to transcode itself to JSON. * - * Any-bearing types get an explicit field-by-field encoder. Types with no `any` anywhere - * below them delegate straight to `serde_json::to_value`, so their wire shape is the derive's - * by construction and cannot drift while the two coexist. + * Every type gets an explicit field-by-field encoder. Builtin leaves still go through + * `serde_json::to_value`, which is exact for a std type; foreign leaves keep the serde impl + * their host provides, because Baboon cannot know their shape. */ private def ctxEncoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { - val body = if (bearsAny(defn.id)) { - explicitEncoder(defn, name) - } else { - q"""let _ = ctx; - |serde_json::to_value(self) - | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin - } + val body = explicitEncoder(defn, name) q"""/// JSON encode under a codec context. Unlike `to_json_value`, this can transcode `any` |/// fields that hold a UEBA payload, provided `ctx` carries a facade. - |pub fn encode_json(&self, ctx: &crate::baboon_runtime::BaboonCodecContext) -> Result { + |pub fn encode_json(&self, ${ctxParam(body)}: &crate::baboon_runtime::BaboonCodecContext) -> Result { | ${body.shift(4).trim} |}""".stripMargin } + /** A body that reaches no nested codec and no `any` field never mentions `ctx`, and an unused + * parameter is an error in a crate built with `-D warnings`. The name is read off the rendered + * body rather than derived from the type's shape, because every use of `ctx` is literal text. + */ + private def ctxParam(body: TextTree[RsValue]): String = { + val literals = body.mapRender { + case _: RsValue.RsType => "" + case _: RsValue.RsTypeName => "" + } + if (literals.contains("ctx")) "ctx" else "_ctx" + } + /** Context-carrying JSON decoder, the counterpart of `encode_json`. * * Decoding never needs the facade — the `any` decoders take no context in any backend, and a * JSON payload always yields the JSON branch — but the method takes one so the two directions * stay symmetric and so a future context-sensitive read has somewhere to go. * - * Any-bearing types get an explicit walk; everything else delegates to `serde_json::from_value`, - * which is what the derive did, so their accepted wire shape cannot drift while both exist. + * Every type gets an explicit walk, mirroring `encode_json` leaf for leaf. */ private def ctxDecoder(defn: DomainMember.User, name: RsValue.RsType): TextTree[RsValue] = { - val body = if (bearsAny(defn.id)) { - explicitDecoder(defn, name) - } else { - q"""let _ = ctx; - |serde_json::from_value(wire.clone()) - | .map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))""".stripMargin - } + val body = explicitDecoder(defn, name) q"""/// JSON decode under a codec context, the counterpart of `encode_json`. - |pub fn decode_json(ctx: &crate::baboon_runtime::BaboonCodecContext, wire: &serde_json::Value) -> Result { + |pub fn decode_json(${ctxParam(body)}: &crate::baboon_runtime::BaboonCodecContext, wire: &serde_json::Value) -> Result { | ${body.shift(4).trim} |}""".stripMargin } @@ -150,7 +150,10 @@ class RsJsonCodecGenerator( val ctor = if (binds.isEmpty) q"Ok(Self {})" else q"""Ok(Self { | ${binds.joinN().shift(4).trim} |})""".stripMargin - q"""let obj = crate::baboon_runtime::json_tools::expect_object(wire, "$what")?; + // A field-less DTO still has to reject a non-object document, but nothing reads the + // object afterwards — and an unused binding is an error under `-D warnings`. + val bind = if (binds.isEmpty) q"let _" else q"let obj" + q"""$bind = crate::baboon_runtime::json_tools::expect_object(wire, "$what")?; |$ctor""".stripMargin case a: Typedef.Adt => @@ -173,8 +176,11 @@ class RsJsonCodecGenerator( | other => Err(crate::baboon_runtime::json_tools::decoder_failure(format!("$what: unknown ADT branch '{}'", other))), |}""".stripMargin - case _ => q"""serde_json::from_value(wire.clone()) - | .map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))""".stripMargin + case _: Typedef.Enum => + q"""let s = crate::baboon_runtime::json_tools::expect_str(wire, "$what")?; + |Self::parse(s).map_err(|e| crate::baboon_runtime::json_tools::decoder_failure(format!("$what: {}", e)))""".stripMargin + + case other => throw new RuntimeException(s"BUG: no JSON decoder for $other") } } @@ -259,17 +265,26 @@ class RsJsonCodecGenerator( } } - /** JSON object keys arrive as strings; mirror `mapKeyEnc`. */ + /** JSON object keys arrive as strings; mirror `mapKeyEnc`. + * + * The `malformed key: ` prefix is the cross-language contract asserted by the M24/PR-F + * per-backend `MapKeyMalformed` tests; it carried over verbatim from the serde + * deserialiser's `serde::de::Error::custom(format!("malformed key: {}", e))`. + */ private def mapKeyDec(tpe: TypeRef, ref: TextTree[RsValue], what: String): TextTree[RsValue] = { mapKeyAdapter.keyAdapterPathFor(tpe) match { case Some(path) => - q"""$path::key_from_string($ref).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + q"""$path::key_from_string($ref).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("malformed key: {}", e)))?""" case None => BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + case TypeRef.Scalar(TypeId.Builtins.tsu) => + q"""crate::baboon_runtime::time_formats::parse_tsu($ref).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("malformed key: {}", e)))?""" + case TypeRef.Scalar(TypeId.Builtins.tso) => + q"""crate::baboon_runtime::time_formats::parse_tso($ref).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("malformed key: {}", e)))?""" case _ => val rsT = trans.asRsRef(tpe, domain, evo) - q"""($ref).parse::<$rsT>().map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("$what: bad map key '{}': {}", $ref, e)))?""" + q"""($ref).parse::<$rsT>().map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("malformed key: {}", e)))?""" } } } @@ -286,10 +301,18 @@ class RsJsonCodecGenerator( val inserts = d.fields.toList.map { f => val wireName = f.name.name - q"""obj.insert("$wireName".to_string(), ${fieldEnc(f.tpe, q"self.${toSnakeCase(f.name.name)}")});""" + // A recursive field is stored behind a `Box`; deref so the walk sees the value's + // own shape rather than the box. The decoder's `Box::new` is the mirror of this. + val access = + if (representation.needsBox(f.tpe)) q"(*self.${toSnakeCase(f.name.name)})" + else q"self.${toSnakeCase(f.name.name)}" + q"""obj.insert("$wireName".to_string(), ${fieldEnc(f.tpe, access)});""" } - val build = if (inserts.isEmpty) q"" else inserts.joinN() - q"""let mut obj = serde_json::Map::new(); + // A field-less DTO produces an empty object, and `mut` on a binding nothing writes to + // is an error under the `-D warnings` the generated crates build with. + val binding = if (inserts.isEmpty) q"let obj" else q"let mut obj" + val build = if (inserts.isEmpty) q"" else inserts.joinN() + q"""$binding = serde_json::Map::new(); |$build |Ok(serde_json::Value::Object(obj))""".stripMargin @@ -308,8 +331,10 @@ class RsJsonCodecGenerator( |} |Ok(serde_json::Value::Object(obj))""".stripMargin - case _ => q"""serde_json::to_value(self) - | .map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""".stripMargin + case _: Typedef.Enum => + q"Ok(serde_json::Value::String(self.to_string()))" + + case other => throw new RuntimeException(s"BUG: no JSON encoder for $other") } } @@ -324,6 +349,12 @@ class RsJsonCodecGenerator( val (staticDom, staticVer, staticTid) = anyStaticFallbacks(a) q"crate::any_opaque::any_field_codec::any_to_json(ctx, ${expectedHex}u8, $staticDom, $staticVer, $staticTid, &$ref)?" + // 64-bit integers go on the wire as decimal strings in every backend — see the C# emitter + // and docs/json-codecs.md, "64-bit integers". `read_i64`/`read_u64` stay lenient about the + // older numeric form. + case TypeRef.Scalar(TypeId.Builtins.i64 | TypeId.Builtins.u64) => + q"serde_json::Value::String(($ref).to_string())" + case TypeRef.Scalar(TypeId.Builtins.bytes) => q"crate::baboon_runtime::json_tools::bytes_to_json(&$ref)" case TypeRef.Scalar(TypeId.Builtins.f128) => @@ -383,6 +414,12 @@ class RsJsonCodecGenerator( case None => BaboonEnquiries.resolveBaboonRef(tpe, domain, BaboonLang.Rust) match { case TypeRef.Scalar(TypeId.Builtins.str) => q"($ref).clone()" + // A timestamp key is the cross-language format, not chrono's `Display` — `Display` + // renders `2026-05-02 12:00:00.123 +05:30`, which no other backend writes or reads. + // serde's chrono impl (which the derive used here) emits RFC 3339; `format_ts*` is + // the same shape and is what the value position already uses. + case TypeRef.Scalar(TypeId.Builtins.tsu) => q"crate::baboon_runtime::time_formats::format_tsu(&$ref)" + case TypeRef.Scalar(TypeId.Builtins.tso) => q"crate::baboon_runtime::time_formats::format_tso(&$ref)" case _ => q"($ref).to_string()" } } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala index c1afd4fb5..906560e6c 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsServiceWiringTranslator.scala @@ -218,13 +218,15 @@ object RsServiceWiringTranslator { val jsonMethod = if (hasJson) { val decodeResult = m.out match { case Some(_) => - q"""let decoded: $outFq = serde_json::from_str(&resp)?; + val dec = m.out.map(o => jsonDecodeExpr(o.id, outFq, q"parsed")).getOrElse(q"Ok(())") + q"""let parsed: serde_json::Value = serde_json::from_str(&resp)?; + |let decoded: $outFq = $dec?; |Ok(decoded)""".stripMargin case None => q"Ok(())" } Some( q"""pub ${asyncKw}fn ${toSnakeCase(m.name.name)}_json(&self, ${ctxParamDecl}arg: $inFq) -> Result<$outFq, Box> { - | let encoded = serde_json::to_string(&arg)?; + | let encoded = ${jsonEncodeExpr(m.sig.id, q"arg")}?.to_string(); | let resp = (self.transport_json)($transportCtxArg"$svcName", "${m.name.name}", &encoded)$awaitSuffix$transportErrMap?; | ${decodeResult.shift(4).trim} |}""".stripMargin @@ -651,6 +653,23 @@ object RsServiceWiringTranslator { case t: RsValue.RsTypeName => t.name } + /** A method's in/out type is either a generated type, which carries `encode_json`/ + * `decode_json`, or a builtin scalar, which does not — a builtin is a std type and keeps + * serde's own impl, so it needs no generated codec and never had a derive to lose. + */ + private def isGeneratedType(id: TypeId): Boolean = id match { + case _: TypeId.User => true + case _ => false + } + + private def jsonEncodeExpr(id: TypeId, ref: TextTree[RsValue]): TextTree[RsValue] = + if (isGeneratedType(id)) q"$ref.encode_json(&crate::baboon_runtime::BaboonCodecContext::Compact)" + else q"""serde_json::to_value(&$ref).map_err(|e| crate::any_opaque::BaboonCodecError::encoder_failure(format!("{}", e)))""" + + private def jsonDecodeExpr(id: TypeId, tpe: String, ref: TextTree[RsValue]): TextTree[RsValue] = + if (isGeneratedType(id)) q"<$tpe>::decode_json(&crate::baboon_runtime::BaboonCodecContext::Compact, &$ref)" + else q"""serde_json::from_value::<$tpe>($ref.clone()).map_err(|e| crate::any_opaque::BaboonCodecError::decoder_failure(format!("{}", e)))""" + private def inTypeFq(m: Typedef.MethodDef): String = { val ref = domainTypes.asRsType(m.sig.id) renderFq(q"$ref") @@ -685,14 +704,15 @@ object RsServiceWiringTranslator { val encodeAndReturn = m.out match { case Some(_) => q"""let result = impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}decoded)$awaitSuffix; - |serde_json::to_string(&result).map_err(|e| $baboonWiringError::EncoderFailed(method.clone(), Box::new(e)))""".stripMargin + |${jsonEncodeExpr(m.out.get.id, q"result")}.map(|v| v.to_string()).map_err(|e| $baboonWiringError::EncoderFailed(method.clone(), Box::new(e)))""".stripMargin case None => q"""impl_.${toSnakeCase(m.name.name)}(${ctxArgPass}decoded)$awaitSuffix; |Ok("null".to_string())""".stripMargin } q""""${m.name.name}" => { - | let decoded: $inFq = serde_json::from_str(data).map_err(|e| $baboonWiringError::DecoderFailed(method.clone(), Box::new(e)))?; + | let parsed: serde_json::Value = serde_json::from_str(data).map_err(|e| $baboonWiringError::DecoderFailed(method.clone(), Box::new(e)))?; + | let decoded: $inFq = ${jsonDecodeExpr(m.sig.id, inFq, q"parsed")}.map_err(|e| $baboonWiringError::DecoderFailed(method.clone(), Box::new(e)))?; | ${encodeAndReturn.shift(4).trim} |}""".stripMargin }.join("\n") @@ -823,9 +843,11 @@ object RsServiceWiringTranslator { val inFq = inTypeFq(m) val decodeStep = - q"""let input: ${ct(bweFq, inFq)} = match serde_json::from_str::<$inFq>(data) { + q"""let input: ${ct(bweFq, inFq)} = match serde_json::from_str::(data) + | .map_err(|e| Box::new(e) as Box) + | .and_then(|parsed| ${jsonDecodeExpr(m.sig.id, inFq, q"parsed")}.map_err(|e| Box::new(e) as Box)) { | Ok(v) => rt.pure$pureHint(v), - | Err(e) => rt.fail($bweFq::DecoderFailed(method.clone(), Box::new(e))), + | Err(e) => rt.fail($bweFq::DecoderFailed(method.clone(), e)), |};""".stripMargin val callAndEncodeStep = m.out match { @@ -836,7 +858,7 @@ object RsServiceWiringTranslator { | ${callBody.shift(4).trim} |$combinatorClosureClose)$combinatorAwait; |rt.flat_map$flatMapHint(output, |v| $combinatorClosureOpen - | match serde_json::to_string(&v) { + | match ${jsonEncodeExpr(m.out.get.id, q"v")}.map(|j| j.to_string()) { | Ok(s) => rt.pure$pureHint(s), | Err(e) => rt.fail($bweFq::EncoderFailed(method.clone(), Box::new(e))), | } diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala index 8b95a1d2e..9fcbb2795 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/scl/ScScalarCodecEmitter.scala @@ -36,11 +36,13 @@ object ScScalarCodecEmitter { case TypeId.Builtins.i08 => q"$circeJson.fromInt($value.toInt)" case TypeId.Builtins.i16 => q"$circeJson.fromInt($value.toInt)" case TypeId.Builtins.i32 => q"$circeJson.fromInt($value)" - case TypeId.Builtins.i64 => q"$circeJson.fromLong($value)" + // 64-bit integers go on the wire as decimal strings in every backend — see the C# emitter + // and docs/json-codecs.md, "64-bit integers". `decodeLong` stays lenient about numbers. + case TypeId.Builtins.i64 => q"$circeJson.fromString($value.toString)" case TypeId.Builtins.u08 => q"$circeJson.fromInt(java.lang.Byte.toUnsignedInt($value))" case TypeId.Builtins.u16 => q"$circeJson.fromInt(java.lang.Short.toUnsignedInt($value))" case TypeId.Builtins.u32 => q"$circeJson.fromLong(java.lang.Integer.toUnsignedLong($value))" - case TypeId.Builtins.u64 => q"$circeJson.fromBigInt($baboonBinTools.toUnsignedBigInt($value))" + case TypeId.Builtins.u64 => q"$circeJson.fromString($baboonBinTools.toUnsignedBigInt($value).toString)" case TypeId.Builtins.f32 => q"$circeJson.fromFloat($value).get" case TypeId.Builtins.f64 => q"$circeJson.fromDouble($value).get" case TypeId.Builtins.f128 => q"$circeJson.fromBigDecimal($value)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala index a6b0f621d..bfc170bd4 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsScalarCodecOps.scala @@ -13,7 +13,9 @@ final class TsScalarCodecOps(target: TsTarget) { case TypeId.Builtins.i08 | TypeId.Builtins.i16 | TypeId.Builtins.i32 | TypeId.Builtins.u08 | TypeId.Builtins.u16 | TypeId.Builtins.u32 | TypeId.Builtins.f32 | TypeId.Builtins.f64 => q"$wire as number" - case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"BigInt($wire as string)" + // Lenient like every other backend's decoder, and loud rather than lossy when the + // producer sent a number too large for JSON.parse to have preserved. See BaboonInt64. + case TypeId.Builtins.i64 | TypeId.Builtins.u64 => q"""$tsBaboonInt64.read($wire, "i64/u64")""" case TypeId.Builtins.f128 => q"$tsBaboonDecimal.fromString($wire as string)" case TypeId.Builtins.str | TypeId.Builtins.uid => q"$wire as string" case TypeId.Builtins.bytes => q"$tsBinTools.hexDecode($wire as string)" diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala index ff22bd8aa..fa051cf1f 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsTypes.scala @@ -52,6 +52,7 @@ object TsTypes { val tsBaboonBinWriter: TsType = TsType(tsBaboonRuntimeShared, "BaboonBinWriter") val tsBaboonBinReader: TsType = TsType(tsBaboonRuntimeShared, "BaboonBinReader") val tsBinTools: TsType = TsType(tsBaboonRuntimeShared, "BinTools") + val tsBaboonInt64: TsType = TsType(tsBaboonRuntimeShared, "BaboonInt64") val tsBaboonGenerated: TsType = TsType(tsBaboonRuntimeShared, "BaboonGenerated", typeOnly = true) val tsBaboonGeneratedLatest: TsType = TsType(tsBaboonRuntimeShared, "BaboonGeneratedLatest", typeOnly = true) val tsBaboonAdtMemberMeta: TsType = TsType(tsBaboonRuntimeShared, "BaboonAdtMemberMeta", typeOnly = true) diff --git a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRuntimeCodec.scala b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRuntimeCodec.scala index 3ab1f125b..2d2f5aa42 100644 --- a/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRuntimeCodec.scala +++ b/baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonRuntimeCodec.scala @@ -336,7 +336,7 @@ object BaboonRuntimeCodec { } case TypeId.Builtins.i64 => - json.asNumber.flatMap(_.toLong) match { + readWire64(json) match { case Some(value) => writer.writeLong(value) F.unit @@ -368,7 +368,7 @@ object BaboonRuntimeCodec { } case TypeId.Builtins.u64 => - json.asNumber.flatMap(_.toLong) match { + readWire64(json) match { case Some(value) => writer.writeLong(value) F.unit @@ -442,26 +442,34 @@ object BaboonRuntimeCodec { } } + /** 64-bit integers arrive as a decimal string, and as a JSON number from a writer older than + * the string form (docs/json-codecs.md, "64-bit integers"). `longValue` narrows an unsigned + * u64 to the two's-complement Long the UEBA writer expects. + */ + private def readWire64(json: Json): Option[Long] = { + json.asNumber + .flatMap(_.toBigInt) + .orElse(json.asString.flatMap(str => scala.util.Try(BigInt(str)).toOption)) + .map(_.longValue) + } + private def decodeBuiltinScalar(id: TypeId.BuiltinScalar, reader: LEDataInputStream): F[BaboonIssue, Json] = { id match { case TypeId.Builtins.bit => F.pure(Json.fromBoolean(reader.readBoolean())) case TypeId.Builtins.i08 => F.pure(Json.fromInt(reader.readByte().toInt)) case TypeId.Builtins.i16 => F.pure(Json.fromInt(reader.readShort().toInt)) case TypeId.Builtins.i32 => F.pure(Json.fromInt(reader.readInt())) - case TypeId.Builtins.i64 => F.pure(Json.fromLong(reader.readLong())) + // 64-bit integers go on the wire as decimal strings, matching every backend + // (docs/json-codecs.md, "64-bit integers"). + case TypeId.Builtins.i64 => F.pure(Json.fromString(reader.readLong().toString)) case TypeId.Builtins.u08 => F.pure(Json.fromInt(reader.readByte() & 0xFF)) case TypeId.Builtins.u16 => F.pure(Json.fromInt(reader.readShort() & 0xFFFF)) case TypeId.Builtins.u32 => F.pure(Json.fromLong(reader.readInt() & 0xFFFFFFFFL)) case TypeId.Builtins.u64 => val value = reader.readLong() - F.pure( - if (value < 0) { - // Convert to unsigned BigInt - Json.fromBigInt(BigInt(value & Long.MaxValue) + BigInt(Long.MaxValue) + 1) - } else { - Json.fromLong(value) - } - ) + // Unsigned decimal string: the upper half of the u64 range has no signed 64-bit + // rendering any other backend would read back as the same value. + F.pure(Json.fromString((if (value < 0) BigInt(value) + (BigInt(1) << 64) else BigInt(value)).toString)) case TypeId.Builtins.f32 => F.pure(Json.fromFloatOrString(reader.readFloat())) case TypeId.Builtins.f64 => F.pure(Json.fromDoubleOrString(reader.readDouble())) case TypeId.Builtins.f128 => F.pure(Json.fromBigDecimal(BaboonBinTools.readBigDecimal(reader))) diff --git a/defects.md b/defects.md deleted file mode 100644 index 8bf7c244f..000000000 --- a/defects.md +++ /dev/null @@ -1,561 +0,0 @@ -# Baboon — Defect Ledger - -> **Predecessor ledgers (frozen, chained):** -> - `docs/archive/20260505-m31-close-ledgers/defects.md` — PR-29P.x .. PR-31.x defects (M29 generics, M30 docstrings, M31 upstream defects). -> - `docs/archive/20260503-bab-any-anyopaque-ledgers/defects.md` — PR-01..PR-28.x defects (`any`/AnyOpaque, identifiers, ADT inheritance, codecs, map-key encoding). -> -> Reuse historical entries when investigating regressions in those areas. - -Status: `[ ]` open · `[~]` under fix · `[x]` resolved - ---- - -## RSJSON-2026-09-24 (Rust explicit JSON codec conversion) - -### [RSJSON-2026-09-24-N01] No fixture combines an `any` field with a user-typed map key -**Status:** open (coverage gap; the defect it hid is fixed) -**Severity:** minor -**Location:** `baboon-compiler/src/test/resources/baboon/` (shared model corpus) -**Description:** The explicit JSON codecs take the field-by-field path only for any-bearing types; everything else delegates to serde. A DTO that is BOTH any-bearing and carries a `map[K, V]` with a user-typed key therefore exercises a combination no fixture contains, and the first cut of the decoder emitted `(k).parse::()` for it. That compiles only for enums: an `id` type has `Display` but deliberately no `FromStr` (its reader is the free `parse_repr`), and a single-primitive wrapper has neither — the adapter peels it to the inner scalar. Generated Rust for such a model would not compile, while all 894 corpus tests stayed green. Found by constructing the combination by hand and compiling the output as a throwaway crate; fixed by routing those fields through the emitted adapter module (see the shared `RsMapKeyAdapter`). -**Fix:** Pending — add a fixture carrying `any` alongside id-keyed, enum-keyed and wrapper-keyed maps. Deliberately not added with the fix: it belongs in the shared model dir, so all nine backends would have to handle the combination at once, which is likely to surface further gaps and is its own piece of work. Until then the combination is correct but only covered by an ad-hoc probe. - ---- - -## MIGRATION-2026-09-24 (user migration reports, `bugs.md`) - -### [MIGRATION-2026-09-24-D01] Generated TypeScript MCP servers fail strict compilation (TS4114) -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/typescript/TsMcpServerGenerator.scala:142` -**Description:** The emitted MCP server declares `protected findTool(name: string): McpToolEntry | undefined`, but both runtime base classes define it concretely — `AbstractBaboonMcpServer` and `AbstractAsyncBaboonMcpServer` at `baboon-runtime/typescript/BaboonMcpRuntime.ts:241,382`. Under `noImplicitOverride` (which Deno enables by default) TypeScript rejects the derived member with TS4114, so every generated MCP server fails to compile at the consumer. Reproduced by generating the `mcp-stub-ok` MCP server and running `tsc` with `noImplicitOverride`: exactly one TS4114 at the generated `findTool`. The emitter is shared by the sync and async variants, so both are affected. Not a regression from the facade work — the concrete base implementations predate it. -**Fix:** Emit `protected override findTool(...)`. Verified the diagnostic count goes 1 -> 0 for both variants. `test/ts-stub/tsconfig.json` gained `noImplicitOverride: true` so the TypeScript lanes enforce what the consumer's toolchain enforces — its absence is why CI never saw this. - -### [MIGRATION-2026-09-24-D02] i64/u64 JSON representation disagrees across backends; TypeScript silently rounds -**Status:** open (blocked on a wire-contract decision) -**Severity:** major -**Location:** `CSScalarCodecEmitter.scala:44` (catch-all numeric `JValue`); `TsScalarCodecOps.scala:16,38`; `docs/json-codecs.md:19,23` -**Description:** `docs/json-codecs.md` specifies signed integers as JSON numbers and `u64` as a decimal string (explicitly to avoid the JavaScript safe-integer limit). Measured against that spec the backends disagree in both directions: C#/Java/Kotlin/Rust emit `i64` as a number (per spec) while TypeScript/Dart/Swift emit it as a decimal string (contrary to spec); C# emits `u64` as a number (contrary to spec) while TypeScript emits a string (per spec). The spec itself is inconsistent — `i64` has exactly the JavaScript precision problem it acknowledges for `u64`. -Every decoder except TypeScript's is lenient about the two forms. TypeScript's assumed a string and passed whatever it received to `BigInt(...)`; when the producer sent a number, `JSON.parse` had already rounded it, so `Int64.MaxValue` written by C# as `{"appPortalId":9223372036854775807}` decoded to `9223372036854775808n`. The original value is unrecoverable at that layer. -A third, enabling defect: the generated cross-language JSON test only checks TypeScript-side self-consistency — it decodes the producer's fixture, re-encodes, decodes again and compares the two TypeScript values (`Renamed.test.ts` `test_cs_json`). A value corrupted by the first decode is equally corrupted in both operands, so the assertion holds. The UEBA cross-language path compares real bytes; JSON does not. That is why this survived CI under both v0.0.195 and the current compiler, matching the reporter's observation that binary interchange passes the same maximum-value case. -**Evidence:** A prototype lenient TypeScript reader that refuses numeric tokens beyond `Number.MAX_SAFE_INTEGER` turns the silent corruption into 8 failing `test_cs_json` cases across the shared model. The rejected values (`843449402620578600`, `-5314845832925431000`, `5219970098605879000`) all carry trailing zeros: they are the rounded doubles, not the integers C# wrote. Patch held at `scratchpad/i64-decoder-fix.patch` with its test; not landed, because correcting only the reader makes the existing C#-to-TypeScript interop fail loudly instead of quietly, which is right but is not a decision the compiler should take on its own. -**Fix:** Pending. Options: (i) represent `i64` and `u64` as decimal strings in every backend, readers staying lenient for compatibility — lossless and consistent, and already what TypeScript/Dart/Swift do for `i64`; or (ii) keep numeric tokens and require lossless JSON ingress everywhere, which plain `JSON.parse` cannot provide. Either way C#'s `u64` must stop contradicting the spec, the documentation must be aligned, and the cross-language JSON test must assert fidelity to the producer rather than self-consistency. - ---- - -## FIELD-2026-09-24 (user report + facade review, all backends) - -### [FIELD-2026-09-24-D01] Facade JSON encode entry points take no codec context, so `any` fields holding the other wire form cannot be encoded -**Status:** resolved -**Severity:** major -**Location:** `encodeToJson` in `baboon-compiler/src/main/resources/baboon-runtime/{cs,scala,java,python,rust,typescript,dart,swift}/BaboonCodecsFacade.*`; `jsonToUebaBytes`/`uebaToJson` in all ten runtimes (the two Kotlin ones included) -**Description:** `BaboonCodecContext` carries `UseIndices`, `ForwardWritePolicy`, `EnvelopeVersion` and `Facade`. The first three are UEBA-only for encoding (`BaboonTypeMeta.From` computes `$rv` unconditionally, so JSON needs no policy input); `Facade` is not — `BaboonAnyJsonCodec.Encode` requires it whenever an `any` field holds an `AnyOpaqueUeba`, because the payload has to be transcoded through `facade.uebaToJson`. `encodeToJson` hardcoded a facade-less context (`Compact` / `default()`), so encoding such a value failed with `EncoderFailure: Cannot encode AnyOpaqueUeba into JSON without a facade reference. Pass BaboonCodecContext.WithFacade(useIndices, facade) into Encode()` — advice the signature made impossible to follow, while the sibling `encodeToBin(ctx, …)` in the same class was configurable. Kotlin and Kotlin-KMP alone already took a context on `encodeToJson`; the other eight were inconsistent with both Kotlin and their own binary path. The cross-format helpers `jsonToUebaBytes`/`uebaToJson` had the same hole in all ten runtimes (hardcoded `Compact` on both legs), so a payload whose content contains a nested `any` in the opposite wire form failed one level down, and `jsonToUebaBytes` could never emit indexed UEBA. Reproduced in the C# stub against the `any-ok` fixture: `facade.EncodeToJson(holderWithUebaAny)` returned `Left(EncoderFailure)` while the generated codec called directly with `WithFacade` succeeded — which is why the existing `AnyRoundTrip*` cross-format suites (they call `Holder_JsonCodec.Instance.Encode(ctxWithFacade, …)`, never the facade) never caught it. -**Fix:** Threaded a codec context through `encodeToJson` (and Scala's `encodeToJsonString`, Rust's `encode_to_json_with_override`/`_with_declared_trait`) and through `jsonToUebaBytes`/`uebaToJson` in all ten runtimes, using it in place of the hardcoded constant; the `any` runtime codecs now pass their own `ctx` into the facade call, so nested transcoding inherits the facade. Breaking API change, deliberately: the context is a required leading parameter, matching `encodeToBin` and Kotlin's existing `encodeToJson`, with no ctx-less overload left to fall into. Rust's JSON side still refuses `AnyOpaque::Ueba` outright (serde-side limitation predating this change) and is untouched. Regression tests: `EncodeToJson_WithFacadeContext_TranscodesUebaAnyPayloads` and `EncodeToJson_WithoutFacadeContext_StillReportsTheMissingFacade` in `test/cs-stub/BaboonTests/AnyRoundTripTests.cs`. - -### [FIELD-2026-09-24-D02] Python facade annotates the codec context as `str` -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/python/baboon_codecs_facade.py` (`encode_to_bin`, `_encode_to_bin_stream`) -**Description:** Both declared `ctx: str`. The value is a `BaboonCodecContext` — the private `_bin_type_meta(value, ctx: BaboonCodecContext)` annotates it correctly and reads `ctx.envelope_version` / `ctx.use_indices`, which would raise `AttributeError` on a `str`. Wrong annotation on a public method: type-checkers and IDEs actively mislead callers. -**Fix:** Corrected both annotations to `BaboonCodecContext`. - -### [FIELD-2026-09-24-D03] Rust `encode_to_json` cannot transcode a UEBA-form `any` payload at all -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsBaboonTranslator.scala` (`encode_json_dyn`); `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/rust/RsJsonCodecGenerator.scala` -**Description:** Follow-up to D01. Threading a context into the Rust facade was necessary but not sufficient: Rust is the only backend whose JSON codec is not generated — `RsJsonCodecGenerator` emitted serde helpers only, and the dyn adapter read `fn encode_json_dyn(&self, _ctx, value)` then called `serde_json::to_value`. `serde::Serialize::serialize(&self, S)` has no room for a context, so `Serialize for AnyOpaque` could reach neither the facade nor the per-field static fallbacks, and errored on the `Ueba` branch. Reproduced against the `any-ok` fixture: `facade.encode_to_json` failed identically with and without a facade in the context. A scoped thread-local carrying the facade was rejected as a fix — `decode_any_field` yields the WIRE meta, which for variants B/C/D is deliberately incomplete, so an ambient facade would have worked for variant A only and failed the realistic decode-UEBA-then-encode-JSON path for the other five, with no kind validation either. -**Fix:** Emitted a context-carrying inherent `encode_json(ctx)` for every generated type, mirroring the UEBA side's `encode_ueba(ctx, writer)`, and pointed the dyn adapter at it. Any-bearing types first rewrite their `any` slots to the JSON branch via the new `any_field_codec::resolve_any_field_for_json` (kind check plus the same static-fallback table `RsUEBACodecGenerator.anyStaticFallbacks` computes), then hand the value to serde unchanged — so renames, hex bytes, decimal-as-number, timestamps, user map-key adapters and ADT wrapping are still produced by the derive and cannot drift. Types with no `any` below them emit a one-line `serde_json::to_value` passthrough. Containment is a least fixpoint over the typespace, iterative because recursive DTOs make the reference graph cyclic. `to_json`/`to_json_value` stay ctx-less and keep refusing UEBA-form payloads. Regression tests: `test/rs-stub/tests/facade_json_any_ctx_tests.rs` — all six DSL variants plus opt/lst/map-value positions transcoding through the facade, the facade-less error, the kind-mismatch check, and a drift guard asserting `encode_json` is byte-identical to `serde_json::to_value` wherever no transcoding happens. Verified with `mdl --seq :ci` (206/206, cross-language acceptance matrix included). - -### [FIELD-2026-09-24-N01] Facade decode entry points also pin a facade-less context (assessed, no change) -**Status:** resolved (note-only; no functional change) -**Severity:** nit -**Location:** `decodeFromBin`, `decodeFromJson`, `decodeAny` and their `*Latest` variants, all runtimes -**Description:** These hardcode `Compact` too, but the read path does not need the context: the UEBA reader takes indexing from the wire header byte (`ReadIndexEntries` reads `header & 1`), `IndexElementsCount` is a generated constant, and the `any` decoders take no context at all (`Decode(expectedKind, wire)`), so no facade is needed to decode. The only effect is that the decoder's `if (ctx.UseIndices)` index-count assertion never fires on a facade decode — a validation the caller cannot switch on. -**Fix:** None. Recorded so a future reader does not re-derive the analysis; revisit only if a decode-side context knob is introduced. - ---- - -## FIELD-2026-09-23 (user reports, C# backend) - -### [FIELD-2026-09-23-D01] C# service signature of an obsolete version names deduplicated-away method I/O types (CS0234) -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSDefnTranslator.scala:513-521` (service case); `baboon-compiler/src/main/scala/io/septimalmind/baboon/translator/csharp/CSServiceWiringTranslator.scala:741-745` (`renderFq`) -**Description:** With `deduplicate` on (the CLI default), a type whose schema is unchanged across versions is emitted once in the latest namespace; references from older namespaces are rewritten to the surviving twin by `CSBaboonTranslator.renderType` -> `CSTypeTranslator.isUpgradeable`, which runs in the final `mapRender` over the emitted tree. Services are never deduplicated (`CSTypeInfo.canBeUpgradedTo` returns `None` for `Typedef.Service`), so an unchanged service is re-emitted per version. Its method signature mixed the two worlds: the parameter stayed a `TextTree` node and was rewritten correctly, but the return type was flattened to a fully-qualified string by `csFqName`/`renderFq` before the renderer ran, so it kept the obsolete version's namespace. The obsolete interface declared `...v1_0_0.Svc.M.Out` (and `.Err`), a type no file declares, and the C# build failed with CS0234. The same flattening affected the obsolete `SvcWiring` result declarations. The trigger is the METHOD I/O types being unchanged, not the service: reproduced both with an identical service and with a service that gains a method while `m`'s inline types stay identical. C#-only, since `deduplicate` has no consumer outside `CSTypeInfo`. -**Fix:** Added `ResolvedServiceResult.renderReturnTypeTree` / `expandPattern` (`ServiceResultResolver.scala`), which splice the `$error`/`$success` pattern around the argument trees instead of around rendered strings. `CSDefnTranslator` and `CSServiceWiringTranslator.ctTree` now build the container from trees (`.fullyQualified`, so the rendered shape is unchanged for every non-deduplicated type) and the upgrade rewrite applies to the return type as it always did to the parameter. The seven non-C# backends keep the string-based `renderReturnType` — none of them rewrite type nodes at render time. Verified byte-identical output for the whole shared model-dir matrix in both default and errors/async configurations. Regression test: `CSharpDedupServiceSignatureTest` over the new isolated fixture `baboon-compiler/src/test/resources/dedup-service-ok/`. - -### [FIELD-2026-09-23-D02] `BaboonCodecsFacade.IsDeprecated` reports live types as deprecated (walk never advances the type) -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonCodecsFacade.cs:971-1010` -**Description:** For every newer registered version the walk looked for a conversion whose `TypeFrom` is the ORIGINAL value's type; it never moved to the type each step produces. From a 1.0.0 value in a three-version lineage, the 2.0.0 step matches and the 3.0.0 step does not (the 3.0.0 registry holds `v2_0_0.X -> X`), so the method returned `true` while `Convert` succeeded — `ConvertClassified` then reported `Deprecated` for a perfectly convertible value. Two registry shapes were affected: a conversion at every step (DTO and ADT alike), and a conversion that reaches the latest CLASS several versions early, which is what deduplicated codegen emits (`Convert__T1_D2__From__1_0_0` in `testpkg.pkg0` is registered under 2.0.0 yet targets the 3.0.0 class, and 3.0.0 registers nothing for that type — a conversion's `VersionTo()` is the registry's version, not the produced class's, so advancing the version alone does not fix this shape). C#-only: no other backend runtime has `IsDeprecated`. -**Fix:** Rewrote the walk as reachability over the registered conversion graph, following each conversion's `TypeTo`, with a visited set; a type that implements `IBaboonGeneratedLatest`, or one that a conversion of the newest registered version accepts, completes the path. Converters are still never executed. Added the type-keyed `AbstractBaboonConversions.FindConversions(Type)` overload for callers that have no instance of the intermediate types. Regression test: `test/cs-stub/BaboonTests/FacadeDeprecationTests.cs` — real generated `fwde2e.chain` codegen for the every-step shape, plus synthetic registries (whose converters throw, pinning the "does not execute converters" half of the contract) for the reach-latest-early, ADT-member and dead-end shapes. - ---- - -## MFACADE-PR-3 - -### [MFACADE-PR-3-D01] C# reader throws OverflowException on out-of-byte numeric `$mv` -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/cs/BaboonTypeMeta.cs:203-208` -**Description:** When `$mv` is `JTokenType.Integer`, `mvToken.Value()` throws `System.OverflowException` for values outside byte range (e.g. `$mv: 300`, `$mv: -1`). All other backends gracefully return null/None on out-of-range. C# propagates the exception out of `ReadMeta` — contradicts the documented "absent or wrong-version → null" contract and breaks per-backend parity. -**Fix:** Replaced `mv = mvToken.Value();` with `long n = mvToken.Value(); if (n < 0 || n > 255) return null; mv = (byte) n;`. Regression tests for `$mv: 300` and `$mv: -1` added in `BaboonTypeMetaCodec_ReadMetaJson_ReturnsNull_NumericMvOutOfRange` (parameterised) at `test/cs-stub/BaboonTests/AnyMetaCodecTests.cs`. - -### [MFACADE-PR-3-D02] Swift reader accepts fractional `$mv: 1.5` via NSNumber.intValue truncation -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/swift/baboon_runtime.swift:1361-1365` -**Description:** Cast chain `mv as? Int` → `mv as? Double` (with truncatingRemainder check) → `mv as? NSNumber` → `mv as? String`. JSONSerialization wraps decimals as NSNumber bridged to Double. The Int branch fails for 1.5; the Double branch's where-clause filters out the truncating-remainder case (so mvInt stays nil); but then the NSNumber branch matches the SAME bridged value and `n.intValue` truncates 1.5 → 1, accepting the envelope. All other backends reject 1.5. -**Fix:** NSNumber branch now extracts `doubleValue` and guards on `truncatingRemainder(dividingBy: 1) == 0` before reading `intValue`. Regression test `testReadMetaJson_rejectsFractionalMv` added in `test/sw-stub/Tests/RuntimeTests/AnyMetaCodecTests.swift`. - -### [MFACADE-PR-3-D03] Python reader accepts whitespace-padded string `$mv` via int() -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/python/baboon_runtime_shared.py:613-628` -**Description:** `int(mv_node)` accepts leading/trailing whitespace (" 1 " → 1). Most other backends reject whitespace; C# also accepts (drift). Cross-backend asymmetry — a producer could emit a wire form one consumer accepts and another rejects. -**Fix:** Replaced single `int(mv_node)` with explicit branch dispatch — `bool` rejected, `int` used directly, `str` gated on `re.fullmatch(r'-?\d+', mv_node)` before parse, all other types return None. `import re` added. Regression test `test_rejects_whitespace_padded_string_mv` added in `test/py-stub/BaboonTests/RuntimeTests/test_baboon_type_meta_codec.py`. - -### [MFACADE-PR-3-D04] No test asserts `writeJson` emits `$mv` as a JSON number -**Status:** resolved -**Severity:** major -**Location:** `test/{cs,sw,dt,ts,jv,py,sc,rs}-stub` test suites -**Description:** The 4 flipped fixtures only verify the reader accepts numeric `$mv`. No test asserts `BaboonTypeMetaCodec.writeJson(meta)["$mv"]` is a JSON-number-typed token. A regression that re-introduces string `$mv` or omits `$mv` would only be caught by the round-trip test, which still passes since the reader tolerates both forms. The whole point of option β (always-write numeric) has zero direct test coverage in any of the 8 modified backends. -**Fix:** Writer-numeric-emission test added per backend: `WriteJson_EmitsMvAsNumber` (cs), `testBaboonTypeMeta_writeJson_emitsMvAsNumber` (sw), `writeJson emits $mv as a JSON number equal to 1` (ts), `writeJson emits $mv as a JSON int equal to 1` (dt), `typeMetaWriteJson_emitsMvAsNumeric1` (jv), `BaboonTypeMetaCodec.writeJson emits $mv as a JSON number equal to 1` (sc), `type_meta_write_json_emits_mv_as_numeric_1` (rs), `test_write_json_emits_mv_as_int` (py). - -### [MFACADE-PR-3-D05] No reader-form coverage in sc, rs, jv, py stubs (acceptsNumericMv tests missing) -**Status:** resolved -**Severity:** minor -**Location:** `test/sc-stub`, `test/rs-stub`, `test/jv-stub`, `test/py-stub` -**Description:** sc/rs/py stubs have NO `BaboonTypeMeta` JSON-form readMeta tests at all. jv-stub has only "absent / `"1"` / `"2"`" tests — no numeric-form. So 4 of 8 modified backends have no direct test that the reader accepts numeric `$mv`. -**Fix:** New test files / cases added: `test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala` (`accepts numeric $mv = 1`), `test/rs-stub/tests/baboon_type_meta_codec_tests.rs` (`type_meta_read_json_accepts_numeric_mv_1`), `test/jv-stub/.../AnyMetaCodecTest.java` (`typeMetaReadJson_acceptsNumericMv1`), `test/py-stub/.../test_baboon_type_meta_codec.py` (`test_read_meta_json_accepts_numeric_mv`). - -### [MFACADE-PR-3-D06] Edge-case test coverage missing across flipped fixtures -**Status:** resolved -**Severity:** minor -**Location:** cs/sw/dt/ts/py stubs -**Description:** None of the four flipped tests covers: numeric `$mv: 2` (forward-compat for numeric form); numeric `$mv: 1.5` (would catch D02); `$mv: true` (would catch a bool-not-rejected bug in Python given `bool` is `int` subclass); `$mv: 300` / `$mv: -1` (would catch D01 in C#); `$mv: []` / `$mv: {}` (non-scalar). The flip-tests are the natural opportunity to close the matrix. -**Fix:** Edge-case rejection matrix added per backend (cs/sw/dt/ts/py): `$mv: 1.5` (fractional), `$mv: true` (boolean), `$mv: 300` (out of byte range), `$mv: -1` (negative), `$mv: []` (array), `$mv: {}` (object/map), plus version-mismatch (`$mv: 2`) for cs, plus whitespace-padded string for py. - -### [MFACADE-PR-3-D07] Java byte-range bound is signed (-128..127), not unsigned (0..255) -**Status:** resolved (PR-E 2026-05-07) -**Severity:** nit -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java:155-176` -**Description:** `if (n < Byte.MIN_VALUE || n > Byte.MAX_VALUE) return null;` uses signed byte range. Rust's reader accepts unsigned u8 range (0..255). Wire format defines the byte as unsigned. Currently immaterial (META_VERSION_1 == 1) but a future bump to a value in 128..255 would be accepted by Rust and rejected by Java. -**Fix:** Switched bound to unsigned `0..255` for both numeric and string `$mv` paths. Numeric branch checks `n < 0 || n > 255` and casts via `(byte) n` (Java cast preserves low 8 bits, so 200 reads back as `(byte) -56` which compares bit-for-bit equal to a 200-encoded `META_VERSION_1`). String branch swaps `Byte.parseByte` for `Integer.parseInt` then bounds-checks 0..255 before casting. META_VERSION_1 = 1 is unchanged; the broader range is forward-compat with future bumps. Comment added explaining the bit-preservation invariant. - -### [MFACADE-PR-3-D08] Rust writer uses literal `"$mv"` instead of `META_VERSION_KEY` constant -**Status:** resolved (incidentally closed by PR-E's D13 extraction) -**Severity:** nit -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs` (`mod baboon_type_meta_codec` `write_meta_json`) -**Description:** Reader used `obj.get(META_VERSION_KEY)`; the original facade-side writer hardcoded `"$mv"` because the constants were scoped to `mod baboon_type_meta_codec` and not visible at facade scope. Other envelope-key literals (`$d`, `$v`, `$t`) shared the same pattern. -**Fix:** PR-E extracted the writer body into `pub fn write_meta_json` *inside* `mod baboon_type_meta_codec` (closing D13), which moved the literals into a scope where the constants are visible. The new writer uses `META_VERSION_KEY`, `DOMAIN_IDENTIFIER_KEY`, `DOMAIN_VERSION_KEY`, `TYPE_IDENTIFIER_KEY`, and `DOMAIN_VERSION_MIN_COMPAT_KEY` directly (lines 344-363). The original "wontfix" justification (scope-of-constants) is no longer applicable. Closed as a free side-effect of PR-E. - -### [MFACADE-PR-3-D09] Stale superseded-PR comment in Java reader contradicts PR-3 contract -**Status:** resolved -**Severity:** major (documentation) -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/java/BaboonTypeMeta.java:148-149` -**Description:** Comment read `// Mirror Scala/C# (PR-08-D01): if $mv is present and not "1", reject; if absent, fall through to v1 read.` — that is the **string-only** semantics PR-3 explicitly replaces. Every other backend's reader comment was updated to reference MFACADE-PR-3 / proposal §10.6 (a). Documentation drift caught by adversarial review round 2. -**Fix:** Replaced with the standard PR-3 phrasing that matches cs/sw/dt/ts/sc/rs/py: "MFACADE-PR-3: accept `$mv` as either a JSON number or a string (back-compat with M28-vintage fixtures); both must equal META_VERSION_1. Absent `$mv` falls through." - -### [MFACADE-PR-3-D10] D06 edge-case rejection matrix missing in sc, rs, jv stubs -**Status:** resolved (PR-E follow-up: symmetric coverage added 2026-05-07) -**Severity:** minor -**Location:** `test/sc-stub/src/test/scala/runtime/BaboonTypeMetaCodecSpec.scala`, `test/rs-stub/tests/baboon_type_meta_codec_tests.rs`, `test/jv-stub/src/test/java/runtime/AnyMetaCodecTest.java` -**Description:** D06 was scoped to "cs/sw/dt/ts/py stubs". sc/rs/jv received D04 (writer-numeric) and D05 (acceptsNumericMv) only — no rejection matrix for `1.5`/`true`/`300`/`-1`/`[]`/`{}`. The cross-language acceptance harness (`mdl :test-acceptance`) covers round-trips but not malformed inputs. -**Fix:** Per-stub rejection matrices added 2026-05-07. Sc: parameterised `readMeta rejects malformed $mv (...)` test with all 6 cases (note: whole-valued doubles like `1.0` are still accepted because circe normalises before our reader sees them — documented in spec § 4 sc/ts limitation, separate concern). Rs: `type_meta_read_json_rejects_malformed_mv` table-driven test exercising the public top-level `read_meta_json` (rs preserves Number-Float vs Number-Int via `serde_json::Number::is_u64()` so `1.0` is rejected). Jv: 6 individual `typeMetaReadJson_rejects*` tests using a shared `badEnvelope(JsonNode)` helper. Closes the original deferred work item. - -### [MFACADE-PR-3-D11] Cross-backend asymmetry on explicit JSON `null` for `$mv` -**Status:** resolved (PR-7 follow-up: tightened Dart and Python to reject explicit null) -**Severity:** minor -**Location:** all 8 readers -**Description:** With explicit `{"$mv": null, ...}`: Dart (`json[r'$mv']` returns null both for absent and explicit-null) and Python (`dict.get()` likewise) **accept** as canonical (treat as absent). C#/Java/Scala/Swift/TS/Rust **reject** (token is non-Number/non-String). No writer in scope emits null `$mv`, so the divergence is hypothetical for round-trips, but a hand-edited fixture would parse on 2 backends and fail on 6. -**Fix:** Tightened Dart `BaboonTypeMeta.readMetaJson` to use `Map.containsKey(r'$mv')` + explicit `null` check (`baboon-runtime/dart/baboon_runtime.dart:1098-1102`). Tightened Python `BaboonTypeMetaCodec.read_meta_json` similarly via `in` + `is None` check (`baboon-runtime/python/baboon_runtime_shared.py:622-628`). Per-backend tests added: dt `rejects explicit \$mv=null`, py `test_rejects_explicit_null_mv`. User-authorised reject-everywhere on 2026-05-07. - -### [MFACADE-PR-3-D12] Cross-backend asymmetry on Double `1.0`-style numeric `$mv` -**Status:** resolved (PR-7 follow-up: tightened Swift; documented sc/ts limitation in spec) -**Severity:** minor -**Location:** Dart vs Swift vs Scala vs TS readers -**Description:** When `$mv` is the JSON number `1.0` (whole-valued double): Swift accepts (Double/NSNumber branches set `mvInt = Int(n)`). Scala accepts (`circe`'s `asNumber.toByte` succeeds for whole-valued doubles). TypeScript accepts (`Number.isInteger(1.0) === true` in JS). C#/TS/Rust/Java reject (Float / non-integer types). Dart rejects (`mv is int` excludes Dart `double`, even whole-valued). -**Fix:** Tightened Swift `readMetaJson` NSNumber branch to inspect `objCType` and reject `d` (Double) and `f` (Float) types — JSONSerialization preserves the source literal's type in the bridged NSNumber, so even `1.0` is rejected when the source token wasn't integer-typed (`baboon-runtime/swift/baboon_runtime.swift:1364-1374`). Per-backend test: sw `testReadMetaJson_rejectsDoubleTypedMv_wholeValued`. **Limitation documented in `docs/spec/codec-envelope.md` § 4** for Scala (circe normalises) and TypeScript (`JSON.parse` collapses `1.0` to `1` losing source-type info) — those backends accept whole-valued doubles silently; the asymmetry is hypothetical (no writer emits floats) and bounded by the spec note. User-authorised reject-everywhere-where-possible on 2026-05-07. - -### [MFACADE-PR-3-D13] Rust writer-numeric test exercises facade path only, not module-level codec -**Status:** resolved (PR-E 2026-05-07: extraction landed) -**Severity:** nit -**Location:** `baboon-compiler/src/main/resources/baboon-runtime/rust/baboon_codecs_facade.rs` (envelope writer extracted into `mod baboon_type_meta_codec`); `test/rs-stub/tests/baboon_type_meta_codec_tests.rs` (new tripwire test exercises the public top-level writer) -**Description:** `mod baboon_type_meta_codec` exposed `read_meta_json` but not a public `write_meta_json`; the envelope-write code lived inline in `BaboonCodecsFacade::encode_to_json`. Lower fidelity than peer backends where the writer is a public top-level function (cs `BaboonTypeMetaCodec.WriteJson`, jv/sc/sw/ts/dt/py equivalents). -**Fix:** Extracted the envelope-emit code from `BaboonCodecsFacade::encode_to_json_with_declared_trait` into a new public top-level `pub fn write_meta_json(meta: &BaboonTypeMeta) -> serde_json::Map` inside `mod baboon_type_meta_codec`. `mod` widened from default to `pub` so external callers (and integration tests) can reach it. The facade now calls `baboon_type_meta_codec::write_meta_json(effective)` and appends `$c` (content). Symmetric to the existing `read_meta_json` reader and to peer backends' top-level writers. New tripwire test `type_meta_write_meta_json_emits_mv_as_numeric_1_at_module_level` exercises the public surface directly (matches the cs/sw/dt/ts/py writer-numeric tests). User-authorised "deliver clean solution through extraction" 2026-05-07. - ---- - -## PR-33.9 - -### [PR-33.9-D01] Scope creep: working tree includes MFACADE planning artefacts unrelated to PR-33.9 -**Status:** resolved -**Severity:** minor (process) -**Location:** `docs/drafts/20260505-1830-questions-multi-version-facade-upstream.md` (Q10-Q14 user answers); `docs/drafts/20260506-0000-mfacade-and-m32-plan.md` (new untracked); `tasks.md` (Active brief switched from "none" to "MFACADE"; PR-33.9 entry only loosely coupled) -**Description:** PR-33.9's stated scope is provenance-aware narrowing of `[PR-33.3-D01]`. The working tree carries MFACADE planning artefacts the orchestrator wrote BEFORE invoking the review-loop — those edits are unrelated to PR-33.9 and should not ride its commit. The executor's report enumerated 5 modified source files + defects.md; `git diff --stat HEAD` shows 8 modified + 1 new untracked. -**Fix:** Pre-PR-33.9 planning commit `0ed9f974` ("MFACADE: planning — synthesize Q1-Q14 answers into the multi-version-codec-facade upstream plan") landed BEFORE PR-33.9. It carries only the two planning artefacts (Q10-Q14 answers + new `docs/drafts/20260506-0000-mfacade-and-m32-plan.md`). PR-33.9's commit will carry strictly the provenance-narrowing files plus the matching tasks.md/defects.md updates. - -### [PR-33.9-D02] Two enumerated edge cases not covered by new fixtures -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala` -**Description:** Executor enumerated four edge cases. Two were tested (row 11 idempotent; new positive control for contract-diamond). Two are not: - (a) `+ MyGen[i32]; + MyGen[str]` — two template-arm origins, different arg types. Implementation fires `NonUniqueFields` (templateArmCounts["v"] = 2). No fixture pins this. - (b) `+ ConcreteBase; + MyGen[i32]` mixed origin (one ParentDef-no-args, one TemplateArmFieldDef) producing same-name field. Implementation correctly stays silent (templateArmCounts["v"] = 1). No fixture pins this either. -A future refactor that flips the rule from "≥2 by lowercased name" to "≥2 with structurally-identical Field" would slip both cases without test signal. -**Fix:** Added two fixtures in `M29ValidatorTest.scala`: `m33_bad_13_duplicate_template_arm_different_args` (asserts `NonUniqueFields` for `+ MyGen[i32]; + MyGen[str]`); `m33_ok_concrete_plus_template_dedup_silent` (asserts `outcome.isRight` AND exactly one `v: i32` field for `+ ConcreteBase; + MyGen[i32]`). M29ValidatorTest 24 → 26 tests. - -### [PR-33.9-D03] Behavioural change: `+ Tmpl[i32]; + Tmpl[i32]; - Tmpl[i32]` now fires NonUniqueFields where the prior suite would dedupe-and-remove silently -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:337-349` -**Description:** New check runs against `converted` BEFORE `removed` is subtracted. For input `+ MyGen[i32]; + MyGen[i32]; - MyGen[i32]` the converted list contains two TemplateArmFieldDef("v") origins → templateArmCounts["v"] = 2 → fires NonUniqueFields. Pre-PR-33.9, the redundant duplicate would have been silently absorbed by `.distinct` and then removed entirely by `removed`, yielding an empty net field set. The new failure is arguably correct (duplicate `+` arms are likely a user mistake), but undocumented and template-arm-asymmetric vs concrete-type analogue (`+ A; + A; - A` continues to silently dedupe). -**Fix:** Appended 5 lines to the existing scaladoc on the new check in `BaboonTranslator.scala:318-332`. Documents: check fires BEFORE `removed` is processed; `+ Tmpl[i32]; + Tmpl[i32]; - Tmpl[i32]` fires `NonUniqueFields` even though `-` would cancel — intentional fail-fast on duplicate intent. Concrete-type analogue (`+ A; + A; - A`) remains silent because origins are not `TemplateArmFieldDef`. - -### [PR-33.9-D04] No fixture covers `^ Outer[T]` recursive over inner `+ Inner[U]` -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:588-605, 619-645` -**Description:** Caret arm's `checkFlatOrFail` strips inner-recursion `TemplateArmFieldDef` back to `FieldDef` before bundling into `IntersectionFields(Seq[FieldDef])`. PR-33.9's `checkFlatOrFail` widening was added; the recursive Caret-over-Plus path is now exercised but not pinned. Behaviour is "Caret with template-arm provenance is dropped" — consistent with intent (`^` is a filter, not an add) but implicit in the carrier type. -**Fix:** Added fixture `caretOverRecursiveTemplateFixture` in `M33StructuralTemplateInstantiationTest.scala`: `data Inner[T] { v: T }; data Outer[U] { + Inner[U] }; data Wide { v: i32; extra: str }; root data X { + Wide; ^ Outer[i32] }`. Test asserts `outcome.isRight`, `x.fields.size == 1`, and that `v: i32` is the sole field. The `+ Wide` parent (a non-template `+ ConcreteRef`) is needed because `^` is a filter — without something to intersect against, the result is empty. Surprise: original suggested fixture `root data X { ^ Outer[i32] }` (no `+` parent) yields zero fields — contradicts the assertion. Fixture corrected. M33StructuralTemplateInstantiationTest 17 → 18 tests. - -### [PR-33.9-D05] tasks.md M33 entry says "eight PRs" but ledger has nine entries (PR-33.1 .. PR-33.9) -**Status:** resolved -**Severity:** nit -**Location:** `tasks.md` (M33 high-level milestone entry) -**Description:** tasks.md declares M33 closed AND lists PR-33.9 as `[~]` under the M33 PR ledger. Reader sees "eight PRs landed (PR-33.1 .. PR-33.8)" but ledger has nine entries. Internal-consistency drift introduced by adding PR-33.9 to the M33 list. -**Fix:** Updated the M33 high-level entry in `tasks.md` to "Nine PRs landed (PR-33.1 .. PR-33.9 inc. PR-33.8 hot-fix + PR-33.9 provenance follow-up)" with a closed-2026-05-06 follow-up note. Orchestrator-side at PR-33.9 close-out commit. - ---- - -## PR-33.7 - -### [PR-33.7-D03] Submodule-chain script's precondition allowlist hard-codes orchestrator-private files -**Status:** resolved -**Severity:** minor -**Location:** `scripts/m33-submodule-chain-commit.sh:66` -**Description:** The precondition check excludes `^ M editors/baboon-zed$` and `^?? proposal.md$` from the parent repo's expected dirty state. `proposal.md` is orchestrator-private (not in the project's git or `.gitignore`); another developer running this script encounters a hard failure with no carve-out for their own untracked files. The script should require a clean parent tree (modulo the submodule pointer) and let users stash/commit other state themselves. -**Fix:** `scripts/m33-submodule-chain-commit.sh` — dropped `^?? proposal.md$` carve-out; precondition now expects ONLY the submodule pointer (`^ M editors/baboon-zed$`); added opt-in `M33_CHAIN_ALLOW_DIRTY=path1,path2,…` env var that extends the allowlist per comma-separated path. Header comment updated to document the requirement and the opt-in env var. - -### [PR-33.7-D04] `checkNoDuplicateConvertedFields` silently broadens `NonUniqueFields` to contract-diamond cases -**Status:** resolved (PR-33.8 — broad check reverted entirely; CI red on `pkg03.baboon` confirmed the broadening was incompatible with project's existing dedup behavior) -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:223-237` (new method) + invocation in `convertDto` -**Description:** The new pre-`.distinct` check operates on `converted`, which (`BaboonTranslator.scala:249-256`) absorbs ContractRef-flattened fields too. A DTO with `+ ContractA + ContractB` where both contracts share a field name will now fire `NonUniqueFields` where `.distinct` at L341 previously absorbed the diamond silently. 61/61 typer + 200/200 cross-language acceptance suggests no existing fixture exercises this, but the behavior change is wider than "duplicate template arm" — the original D01 was specifically about template-arm dedup. The broadening is arguably an improvement (silent diamond dedup masks user errors) but undocumented and unverified. -**Fix:** PR-33.8 hot-fix. Reverted `checkNoDuplicateConvertedFields` and its invocation in `BaboonTranslator.scala`. CI surfaced the predicted regression on `pkg03.baboon` line 153/162 — `testpkg.pkg0/[testpkg.pkg0/:#T4_A1]#B1` legitimately uses contract diamonds producing two `f2: #i32` that `.distinct` was silently absorbing. The broadening was incompatible with established project semantics; reopened `[PR-33.3-D01]` with the provenance-aware narrowing path documented as the future fix. - -### [PR-33.6-D01] Close-log + tasks.md falsely claim the inner-submodule corpus file is "staged but not committed" -**Status:** resolved -**Severity:** minor -**Location:** `tasks.md:67` (PR-33.6 entry); `docs/logs/20260505-2005-m33-close-log.md:215-216` -**Description:** `git -C editors/baboon-zed/grammars/baboon status` shows `test/corpus/m33-template-arms.txt` under "Untracked files" — NOT staged. The outer `editors/baboon-zed` repo also has no staged changes (only `grammars/baboon (untracked content)` reported as modified). Both write-ups are factually wrong; an orchestrator following them would expect pre-staged content. -**Fix:** Reworded `tasks.md:67` and `docs/logs/20260505-2005-m33-close-log.md:215-216` to "Files present in the inner submodule's working tree as untracked entries; the orchestrator stages and commits them at close-out." Verified `git -C editors/baboon-zed/grammars/baboon status` shows the corpus file under "Untracked files" — matches the corrected wording. - -### [PR-33.6-D02] M33 closed in tasks.md but `mdl :build :test` close-out gate was deferred -**Status:** resolved -**Severity:** minor (process) -**Location:** `docs/logs/20260505-2005-m33-close-log.md:284-291` (verification matrix rows 4 + 6) -**Description:** Both row 4 (`sbt baboonJVM/test`) and row 6 (`mdl --simple-log :build :test`) marked "deferred / not run in this PR's executor pass". CLAUDE.md is explicit: "Run mdl with the appropriate target before every commit and push: `mdl :build :test`". Closing M33 without running the documented pre-commit gate is a process violation. Combined with D01, the close posture is uncomfortably thin. -**Fix:** Ran `mdl --simple-log :build :test` (parallel) — failed on documented Kotlin daemon OOM (CLAUDE.md "<16GB RAM use --seq" workaround). Re-ran `mdl --simple-log --seq :build :test` — green. Total wall time 2132.3s (~36 min); all actions completed successfully ("Execution completed successfully", `success: true`). M33's `[x]` flip is now confirmed by the gate. Verification matrix row 6 backfilled in the close log. - -### [PR-33.6-D03] Hover-test type-param assertion is vacuously true (`markdown.contains("T")` matches "Template") -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/features/LspFeaturesTest.scala:514` -**Description:** Test asserts `markdown.contains("T")` with comment "Hover should list type-param 'T'". Since the rendered markdown always contains "Template" (asserted by previous line), `contains("T")` cannot fail — asserts nothing about the type-param's actual presence. A regression dropping `[T]` from `renderTemplateInfo`'s output would not break this test. -**Fix:** `LspFeaturesTest.scala:516` — replaced `markdown.contains("T")` with `markdown.contains("[T]")`. Now pins the type-param-list rendering; would catch a regression that drops `[T]` from `renderTemplateInfo`'s output. - -### [PR-33.6-D04] LSP-test docstring lies about fixture line numbering and DTO shape -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/lsp/features/LspFeaturesTest.scala:491-493` -**Description:** Comment claims "15: data MyGen[T] { v: T }" and "17: data Holder { + MyGen[i32] }". Actual fixture has `data MyGen[T] { v: T }` on (1-indexed) line 10 and the `+ MyGen[i32]` arm on line 13 — split across three lines, not the single-line `data Holder { + MyGen[i32] }` form. Test logic uses `lines.indexWhere` so it's correct; only the comment is wrong. -**Fix:** `LspFeaturesTest.scala:491-493` — comment corrected to actual 1-indexed layout (line 10 for `data MyGen[T] { v: T }`, lines 12-14 for the multi-line `root data Holder { …\n + MyGen[i32]\n}` shape). - -### [PR-33.6-D05] Spec §9.1 claims legacy non-templated arms "behave exactly as before" — false for `+ MyGen` (head IS a template) -**Status:** resolved -**Severity:** minor -**Location:** `docs/spec/generics.md:837-838` (§9.1 closing sentence) -**Description:** §9.1 says legacy non-templated forms (`+ Page`, `- Stats`, `^ Page`) "continue to parse and behave exactly as before — the new clause is opt-in". This is FALSE when the head names a registered template: §9.6 ("No bare-template heads") and `validateNoBareTemplateRefs` actively reject `+ MyGen` with `TemplateNotInstantiated`. M33 is a behavioural change for the bare-template-head case, not pure opt-in. §9.1 contradicts §9.6. -**Fix:** `docs/spec/generics.md` §9.1 — appended qualifier "provided the head is not itself a registered template (see §9.6 'No bare-template heads')" to the closing sentence. Resolves the contradiction with §9.6. - -### [PR-33.6-D06] Spec §2.6 supersession note is grammatically tangled -**Status:** resolved -**Severity:** nit -**Location:** `docs/spec/generics.md:320-324` (M33-update blockquote in §2.6) -**Description:** "`adt`-arm instantiation remains scoped only to *DTO/contract* bodies, not ADT bodies" — paradoxical at the surface ("adt-arm instantiation scoped to DTO/contract bodies" reads contradictorily). Intent: "template instantiation in structural-composition arms remains scoped to DTO/contract bodies; ADT inheritance arms are not widened in M33". -**Fix:** `docs/spec/generics.md` §2.6 — replaced the tangled "`adt`-arm instantiation remains scoped only to *DTO/contract* bodies, not ADT bodies" with: "Template instantiation in structural-composition arms (`+`/`-`/`^`) is widened only inside `data` and `contract` bodies; ADT inheritance arms remain restricted as in §2.6 above (see §9.6)." - -### [PR-33.6-D07] Spec §9.7 attributes "Cycle / recursive-substitution guard" entirely to PR-33.4; bulk landed in PR-33.2 -**Status:** resolved -**Severity:** nit -**Location:** `docs/spec/generics.md:976-977` (§9.7 PR-33.4 row) -**Description:** tasks.md's PR-33.4 entry: "Bulk of the planned work landed in PR-33.2 (recursion guard, cycle-set, depth-limit, self/mutual recursion tests) and PR-33.3 (negative-path pins). Residual: closed the empty-intersection edge case." Spec §9.7 collapses this: "PR-33.4 — Cycle / recursive-substitution guard; empty-body sentinel for `^`; cycle-key canonicalisation hardening." Overstates PR-33.4, understates PR-33.2. -**Fix:** `docs/spec/generics.md` §9.7 — split the single PR-33.4 row into two: PR-33.2 owns recursion-depth limit + cycle-set guard + self/mutual-recursion tests; PR-33.4 owns empty-`^`-body sentinel + cycle-key canonicalisation (`render` not `toString`) + regression-guard pins for `+ Empty[i32]` / `- Empty[i32]`. Attribution now matches `tasks.md`'s Completed entries. - -### [PR-33.6-D08] Pre-existing stale hover string survives M33 close-out — `renderTemplateInfo` advertises only the alias instantiation form -**Status:** resolved (note-only — pre-existing, deferred) -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/lsp/features/HoverProvider.scala:165` -**Description:** Hover output reads `*Template — instantiate via `type Alias = $canonicalName[…]`*`. With M33 landed, `+ MyGen[i32]` / `- MyGen[i32]` / `^ MyGen[i32]` are also valid instantiation sites. PR-33.6 added an LSP smoke test exercising hover on `MyGen` inside a `+ MyGen[i32]` arm — but kept the rendered text suggesting only the alias form. Pre-existing (PR-29.8 vintage). -**Fix:** Note-only — pre-existing PR-29.8-vintage wording. M33 close-out scope is documentation + tests, not LSP UX upgrades. Deferred as a future LSP-polish PR. - ---- - -## PR-33.5 - -### [PR-33.5-D01] m33-ok exercises only the `+` arm; `-` and `^` template-arm lowering paths are codegen-unverified at byte level -**Status:** resolved -**Severity:** minor (cross-language gate coverage gap) -**Location:** `test/conv-test/m33.baboon:36-39` -**Description:** Plan §5 explicitly recommended `data PageOnly { + Page[str]; - Stats[str] }` (`-` operator) and a `^` intersection example. The shipped fixture only contains `+ Page[i32]; + Stats[i32]` — both arms are `+`. In `TemplateInstantiator.scala:540-595`, ArmKind.Plus / Minus / Caret produce *different* intermediate `RawDtoMember` shapes (`FieldDef`, `UnfieldDef`, `IntersectionFields`) that are differently consumed by `BaboonTranslator.convertDto`. The cross-language byte-canonical gate, which the plan claims pins "the codegen surface", thus only covers the `+` path. A regression in the `-`/`^` lowering reduction in `convertDto` could ship undetected. -**Fix:** Extended both `test/conv-test/m33.baboon` and `baboon-compiler/src/test/resources/baboon/m33-ok/m33.baboon` with two additional consumer DTOs: `PageMinusStats { + Page[i32]; + Stats[i32]; - Stats[i32] }` (lowered shape `items, total`) and `PageOnly { + Page[i32]; + Stats[i32]; ^ Page[i32] }` (lowered shape `items, total`). Both added to `M33OkHolder` root. All 10 `CompatMain.*` updated with `CreateM33OkSample` (+ per-language equivalents) producing the new fields. `:test-acceptance` re-run: 200/200 passed; both `-` and `^` paths now exercised across all 10 backends and both wire formats. - -### [PR-33.5-D02] Sample-value collision — `total = nObservations = 3 = items.length` masks swapped-field bugs -**Status:** resolved -**Severity:** minor -**Location:** every `CompatMain.*` (e.g. `test/conv-test-cs/ConvTest/CompatMain.cs:382-385`) -**Description:** Sample data is `items=[10,20,30], total=3, sum=60, nObservations=3` across all 10 backends. Both u32 fields (total, nObservations) carry the literal value 3, and items has 3 elements. A codegen defect that swapped the encoder positions of `total` and `nObservations`, or that read `items.size()` instead of the literal `total` field, would round-trip cleanly because the values are pairwise indistinguishable. -**Fix:** All 10 `CompatMain.*` updated with `pageWithStats: items=[10,20,30] (len 3), total=42, sum=60, nObservations=7`. Five distinct values (3, 5 from items.length+sum coincidence is benign; 42, 60, 7 + length 3 are pairwise distinct). New consumers got their own distinct values: `pageMinusStats: items=[100,200], total=99`; `pageOnlyIntersect: items=[1..5], total=5`. Cross-language byte-canonical agreement preserved (md5 `f340dd76…` across 9 backends; Swift diverges by sorted-key shape, consistent with m29 history). - -### [PR-33.5-D03] No assertion that decoded value matches the canonical sample (round-trip-only check) -**Status:** resolved (note-only — project-wide convention, not introduced by PR-33.5) -**Severity:** nit -**Location:** every `readAndVerifyM33Ok` across 10 backends -**Description:** Each backend's `readAndVerifyM33Ok` decodes the blob, re-encodes, re-decodes, and compares `data == reDecoded` — a self-consistency check. There is no `assertEquals(data, expectedSample)` against the canonical sample. A backend that decodes any well-formed m33-ok blob into `M33OkHolder(IntPageWithStats([], 0, 0, 0))` and re-encodes into the same shape would PASS the round-trip but mask data loss. m29's path is identical; this is a project-wide pattern. -**Fix:** Note-only. PR-33.5 follows the existing m29 convention. A project-wide upgrade to value-content assertions is out of scope for this PR; documented as residual risk. - -### [PR-33.5-D04] No mixed-arm-kind, nested template-arg, or cross-namespace coverage in the conv-test fixture -**Status:** resolved (note-only — typer-only coverage in M29ValidatorTest is the documented home) -**Severity:** minor -**Location:** `test/conv-test/m33.baboon` -**Description:** Plan §5 / §3.c / §3.d called out: mixed `+ ConcreteRef; + Template[Args]` arms; nested template-arg `+ Foo[Bar[i32]]`; cross-namespace structural-arm template (`+ ns.NsT[i32]`). None appear in the conv-test fixture. Cross-namespace IS covered by `m33ok5CrossNsStructuralArmPlus` in `M29ValidatorTest.scala` (typer-only), and nested template-arg by `M33StructuralTemplateInheritanceParserTest`. The risk that codegen-side handling of namespace-prefixed identity formation regresses without surfacing in cross-language byte gate is low — codegen sees a flat post-lowered DTO regardless of source-side prefixes. -**Fix:** Note-only. Cross-language coverage is restricted to `+ Template[Concrete]` at top-level; cross-ns / nested / mixed shapes are covered by typer-only tests. Documented in the PR's Completed entry. - -### [PR-33.5-D05] No `bit`-typed, foreign-typed, ADT-typed, or `str`-typed concrete instantiation -**Status:** resolved (note-only — m29 covers str/non-template-arg variety; m33 is `+`-arm-shape canonical) -**Severity:** nit -**Location:** `test/conv-test/m33.baboon` -**Description:** Both templates instantiate at `i32` only. Different concrete type categories (`bit`, `str`, `f64`, foreign, ADT, enum) routed through `T` are not exercised. m29 already covers `IntPage`/`StrPage`/`ItemPage` with non-`i32` args; m33's role is to pin the structural-arm operator surface, not re-cover M29's substitution variety. -**Fix:** Note-only. Different concrete arg types are M29's coverage; M33 focuses on the new structural-arm operator paths. - -### [PR-33.5-D06] Plan §5 enumerated four shapes; executor shipped one (scope-shrink note) -**Status:** resolved (cascaded from D01 fix) -**Severity:** minor (process) -**Location:** `docs/drafts/20260505-1500-m33-generic-structural-inheritance-plan.md` §5 vs `test/conv-test/m33.baboon` -**Description:** Plan §5 explicitly listed: (1) `+ Page[i32] + Stats[i32]`, (2) `-` example, (3) `^` intersection, (4) holder. Executor shipped only (1) and (4). Coverage component of the cross-language gate (everything the plan said the fixture should exercise) was reduced. -**Fix:** Resolved when D01's fixture extension landed. All four shapes now present. - ---- - -## PR-33.4 - -### [PR-33.4-D02] Empty-body test asserts diagnostic class only; does not pin sentinel `kind="caret"` / `offendingMemberKind="empty body"` -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInstantiationTest.scala:594-604` -**Description:** The new `^ Empty[i32]` test asserts only `assertProducesTyperIssue[TyperIssue.TemplateBodyNotFlatForRemoval](outcome)` — class-only. It does not check `kind == "caret"` nor `offendingMemberKind == "empty body"`. The whole point of the PR-33.4 fix is the new sentinel-driven branch. The assertion is satisfied by ANY `TemplateBodyNotFlatForRemoval` surfacing through this fixture — including a hypothetical regression that fires the diagnostic for the wrong arm. -**Fix:** `M33StructuralTemplateInstantiationTest.scala:628-649` — extracts `TemplateBodyNotFlatForRemoval` issues from `outcome` (mirrors `selfRefStructural` pattern at L517-525) and asserts `kind == "caret"` AND `offendingMemberKind == "empty body"`. Class-level coverage retained. - -### [PR-33.4-D03] Printer message for empty-body sentinel is paradoxical: "contains a non-field member (empty body)" -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/TyperIssue.scala:738-748` (printer); sentinel produced at `TemplateInstantiator.scala:568` -**Description:** The printer template reads "the substituted body contains a non-field member (${issue.offendingMemberKind})". For preexisting call-sites `offendingMemberKind` is a `getClass.getSimpleName` (e.g. `"ParentDef"`) and the sentence reads correctly. PR-33.4 introduces sentinel `"empty body"`, making the rendered message "the substituted body contains a non-field member (empty body)" — paradoxical (an empty body contains no member of any kind). The in-code comment at `TemplateInstantiator.scala:558` claiming the message is "informative enough" is wrong on inspection of actual printer output. -**Fix:** `TyperIssue.scala:746-751` — printer for `TemplateBodyNotFlatForRemoval` now branches on the `"empty body"` sentinel, emitting "the substituted body is empty (intersection over an empty field set would be a silent no-op; caught at lowering time…)" instead of the paradoxical "contains a non-field member (empty body)". `TemplateInstantiator.scala:553-558` — comment retracted; replaced with a pointer to the sentinel-aware printer branch. - -### [PR-33.4-D04] Sentinel `"empty body"` violates the documented `offendingMemberKind` contract -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/TyperIssue.scala:269-277` (case-class scaladoc); `TemplateInstantiator.scala:568` -**Description:** The case-class scaladoc defines `offendingMemberKind` as "the simple-class-name of the offending RawDtoMember subtype". The new use injects `"empty body"` — NOT a simple-class-name. Contract is silently broken at one call-site; the in-code comment at `TemplateInstantiator.scala:556-558` is the only place describing the convention break. -**Fix:** `TyperIssue.scala:269` — scaladoc on `offendingMemberKind` now reads "...the simple-class-name of the offending RawDtoMember subtype, OR the sentinel `\"empty body\"` when the substituted body is empty (PR-33.4)". - -### [PR-33.4-D05] No negative-control test for `+ Empty[i32]` and no regression-pin for `- Empty[i32]` (deferred D01) -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInstantiationTest.scala` -**Description:** The PR adds one positive-fail test for `^ Empty[i32]`. Without a sibling positive-pass `+ Empty[i32]` (must compile, idempotent no-op) and a regression-pin for `- Empty[i32]` (currently silent no-op, deferred per [PR-33.4-D01]), nothing in the suite distinguishes between the diagnostic firing for the right operator versus a future refactor that accidentally swaps outcomes. Per the PR-33.3-D08 lesson: pin even defective behaviour as a regression guard. -**Fix:** `M33StructuralTemplateInstantiationTest.scala` — added `emptyBodyPlusFixture` (`Receiver + Empty[i32]` → zero fields) and `emptyBodyMinusFixture` (`Receiver + Foo[i32] - Empty[i32]` → `v: i32` survives), each with its own test arm. `- Empty[i32]` test carries the regression-guard comment naming `[PR-33.4-D01]`. - -### [PR-33.4-D06] Cycle-key uses `RawTypeRef.toString` rather than the explicit `render` canonical form -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:449` -**Description:** The cycle-key tuple uses `argList.map(_.toString).mkString(",")`. `RawTypeRef` declares an explicit `render: String` method (`RawTypeRef.scala:6-17`) intended as the canonical display form. Auto-derived case-class toString is structurally equivalent for the current shape, but it is implicit equivalence rather than declared. If a non-canonical field (e.g. a parse-time hint) is later added to `RawTypeRef.{Simple,Constructor,AnyRef}`, auto-toString would silently bake it into cycle-keys, while `render` would not. Audit conclusion was correct *now* but fragile. -**Fix:** `TemplateInstantiator.scala:449` — `argList.map(_.toString).mkString(",")` changed to `argList.map(_.render).mkString(",")`. Same behaviour today; canonicalisation contract now explicit. - -### [PR-33.4-D01] Empty template body under `-` is a silent no-op (analogous to the `^` gap fixed in this PR) -**Status:** resolved (option (b) — silent no-op confirmed as correct semantics; user-decided 2026-05-07) -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala` (`convertLoweredArm` Minus case); `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:311-316` (`removedSet` / `withoutRemoved`) -**Description:** PR-33.4 fixed the empty-body-under-`^` gap (empty `IntersectionFields` → `if (intersectionSet.isEmpty)` short-circuit was a no-op). The analogous `^`-fix was: fail with `TemplateBodyNotFlatForRemoval(offendingMemberKind="empty body")` at lowering time. The `-` operator with an empty body is semantically different: removing an empty set of fields is idempotent (no fields are removed). Whether this should be a hard error or a silent no-op is a product decision. Current behaviour: silently accepted. This is not a silent corruption (unlike the `^` case which masked a "pass-through" masquerading as "intersection"), but it may indicate a user mistake. Deferred for a future product decision. -**Fix:** Option (b) accepted by user 2026-05-07: silent no-op. Removing the empty set of fields is the identity — semantically correct. Promoting to a hard error would block legitimate refactor flows mid-edit; promoting to a warning would require introducing a warning facility (Baboon has none today) which is out of scope. The asymmetry with the `^` fix is principled: `^ Empty[T]` was a footgun (intersection with empty set should be empty, but legacy code treated it as pass-through); `- Empty[T]` is mathematically idempotent and harmless. - ---- - -## PR-33.3 - -### [PR-33.3-D02] Row 4 fixture exercises matrix #1 (template-in-body) instead of plan-mandated matrix #2 (forbidden type-arg) -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala:251-271` (`m33bad4ForbiddenTypeArg` fixture + test); underlying gap at `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:342-499` (`lowerOneArm`) -**Description:** Plan §4 row 4 specifies "`+ MyGeneric[T]` where `T` is a forbidden type per existing template-arg rules" — i.e. the type ARGUMENT itself is forbidden (matrix #2: nested template instantiation in arg position). The executor's fixture instead places `Other[T]` in MyGen's body field (`data MyGen[T] { v: Other[T] }`). The fired diagnostic IS `TemplateInstantiationInForbiddenPosition`, but it fires from `substituteTypeRef`'s matrix #1 check at `TemplateInstantiator.scala:1006-1014` — the same code path that would fire for an alias-RHS instantiation. The structural-arm pre-validation `lowerOneArm` does NOT check whether `args` themselves are template constructors — there is no equivalent of the matrix-#2 `args.collectFirst { … forbiddenInnerTemplate … }` block found in `processMember` at lines 686-708. Concretely, `+ MyGen[OtherTemplate[i32]]` over a benign `data MyGen[T] { v: T }` would silently pass — the actual plan §4 row 4 case is NOT covered, AND there is a real underlying validation gap. -**Fix:** Confirmed real validation gap (failing-first test produced `UnexpectedNonBuiltin`, not `TemplateInstantiationInForbiddenPosition`). Replaced fixture with genuine matrix-#2 shape `data Other[T]; data MyGen[T] { v: T }; root data Receiver { + MyGen[Other[i32]] }`. Extended `lowerOneArm` at `TemplateInstantiator.scala:422-441` with a matrix-#2 walk mirroring `processMember`'s alias-RHS check at L686-708: walks each `arg` and rejects any `RawTypeRef.Constructor` whose head names a registered template, emitting `TyperIssue.TemplateInstantiationInForbiddenPosition`. Reuses existing TyperIssue case — no 3-site exhaustive-match update. Test asserts `containingTemplateName="MyGen", instantiatedName="Other"`. - -### [PR-33.3-D03] Row 6 fixture is single-Pkg cross-namespace, not cross-package — name misleads -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala:273-291` (`m33bad6CrossPkgLike` and the test name "m33_bad_6: …cross-pkg.baboon") -**Description:** The fixture has a single `model m33.bad6` declaration with `OtherTemplate` declared at top level, then a `+ otherpkg.OtherTemplate[i32]` reference. Resolves to prefix-derived `Owner.Ns(["otherpkg"])` and misses the registry — but this is cross-namespace within ONE Pkg, NOT cross-package. The test name suggests "cross-pkg" coverage; future readers debugging an actual cross-Pkg regression will not find what the name advertises. Plan §4 row 6 wording was "cross-package template via `+`" with explicit reference to spec §6 item 11 (deferred). -**Fix:** Renamed val `m33bad6CrossPkgLike` → `m33bad6NamespacePrefixMiss`, test name no longer mentions "cross-pkg", docstring now says "namespace-prefix miss". No multi-Pkg fixture added (deferred per spec §6 item 11). - -### [PR-33.3-D04] Plan §4 row 2 (`TemplateNotInstantiated` for `+ MyGen` no brackets) is uncovered -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala` (no test added for row 2) -**Description:** Plan §4 row 2 lists `+ MyGeneric` (no brackets, head IS a registered template) → `TyperIssue.TemplateNotInstantiated` as required negative-path coverage. The executor's per-row mapping (rows 1, 3, 4, 6, 9, 11, 12) silently omits row 2. Wired-up code path exists: `TemplateInstantiator.validateNoBareTemplateRefs` (PR-33.2 D05 fix) emits the diagnostic from line 188. -**Fix:** Added test arm `m33_bad_2_template_not_instantiated` in `M29ValidatorTest.scala`. Fixture: `data MyGen[T] { v: T }; root data X { + MyGen }`. Asserts `TyperIssue.TemplateNotInstantiated(templateName="MyGen", aliasName="X")`. Pins the diagnostic at validator-level entry; complements PR-33.2's M33StructuralTemplateInstantiationTest coverage of the same case (no duplication). - -### [PR-33.3-D05] Plan §3.d-mandated positive `+ ns.NsTemplate[i32]` cross-namespace structural-arm test missing -**Status:** resolved -**Severity:** minor -**Location:** `M29ValidatorTest.scala` (no test added) — plan §3.d / §4 row 5 -**Description:** Plan §3.d states: "PR-33.3's negative tests must include a positive `+ ns.NsTemplate[i32]` case to verify cross-ns instantiation works in structural-arm position". Without it, a future bug breaking cross-ns lookup in `lowerOneArm`'s prefix→Owner.Ns conversion (line 386-390) would slip through (negative tests cannot isolate it). -**Fix:** Added test arm `m33_ok_cross_ns_structural_arm_plus` in `M29ValidatorTest.scala`. Fixture: `ns foo { data NsT[T] { v: T } } root data Receiver { + foo.NsT[i32] }`. Asserts `outcome.isRight` AND `Receiver` carries `v: i32` (TypeRef.Scalar `i32`). Used the file's existing positive-test scaffolding pattern (`domain.defs.meta.nodes` traversal). - -### [PR-33.3-D06] Plan §4 row 7 (template self-instantiation via structural arm) is uncovered -**Status:** resolved -**Severity:** minor -**Location:** `M29ValidatorTest.scala` (no test added) — plan §4 row 7 -**Description:** Plan §4 row 7 specifies `template X[T] { data X { + X[T] } }` as the close cousin of M29 matrix #5 — self-instantiation through a structural arm. Per §3.f should fire `CircularInheritance` via the cycle-detection set. Row 9 covers MUTUAL recursion (two templates) but not direct self-recursion (one template, single arm) — different cycleSet code paths. -**Fix:** Added test arm `m33_bad_7_template_self_instantiation` in `M29ValidatorTest.scala`. Fixture: `data X[T] { + X[T] }; root type Y = X[i32]`. Asserts `TyperIssue.CircularInheritance` fires AND the matrix is non-empty AND mentions `X` (verifying the PR-33.2 D01 synthetic-edge fix). - -### [PR-33.3-D07] [PR-33.3-D01] deferral framing implies broader work than required; `.distinct` is preexisting -**Status:** resolved -**Severity:** nit -**Location:** `defects.md` `## [PR-33.3-D01]` entry; underlying preexisting code at `BaboonTranslator.scala:314-316` -**Description:** [PR-33.3-D01]'s entry attributes the silent-dedup behaviour to `.distinct` at `BaboonTranslator.scala:316`. This `.distinct` predates M33 (it is old translator code), so the issue is preexisting — not a regression introduced by PR-33.x. The "Fix (suggested)" mentions "careful audit of callers" implying broader scope than is actually required: a small local fix is feasible (compute `convertedDuplicateNames = converted.groupBy(_.name).filter(_._2.size > 1)`, fail with `NonUniqueFields` if non-empty BEFORE the `.distinct` line, leaving ADT contract dedup untouched). -**Fix:** Updated the `[PR-33.3-D01]` entry: added "Preexisting; predates M33" note to Description; replaced Fix-suggested with the scoped `convertedDuplicateNames = converted.groupBy(_.name).filter(_._2.size > 1)` approach leaving `.distinct` intact; updated Test reference to the new test name. - -### [PR-33.3-D08] m33_bad_11 test docstring says "DEFERRED" but the test runs and asserts a passing outcome -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala:649-660` -**Description:** Test name string starts with "DEFERRED [PR-33.3-D01]" but the test body is fully active — asserts `outcome.isRight` and runs in normal suite. Reader scanning suite output sees "DEFERRED" line that actually executes and pins the (defective) idempotent-dedup behaviour. If [PR-33.3-D01] is later fixed, this test will FAIL and a maintainer reading "DEFERRED" may mistakenly mark it ignored rather than updating the assertion. -**Fix:** Renamed test to `m33_bad_11_duplicate_arm_silently_deduplicated_REGRESSION_GUARD`. Dropped "DEFERRED" from the name; updated docstring to "regression guard pinning current (defective) idempotent-dedup behaviour; will need updating when [PR-33.3-D01] is resolved". Assertion (`outcome.isRight`) unchanged — still pins current behaviour. - -### [PR-33.3-D09] Row 1 fixture conflates user type-name with diagnostic case-name -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M29ValidatorTest.scala:225-233` (`m33bad1NotATemplate` fixture) -**Description:** The fixture declares `data NotATemplate { v: str }` and asserts `TyperIssue.NotATemplate`. User type and diagnostic case share the exact name. The assertion `issue.head == "NotATemplate"` is checking the field-value of a case named NotATemplate where "NotATemplate" is the head name. If a future regression fires NotATemplate for the WRONG reason (e.g. prefix walk fails entirely), the head-string assertion is uninformative due to the naming collision. -**Fix:** Renamed `data NotATemplate { v: str }` → `data PlainDto { v: str }` in the fixture; `+ NotATemplate[i32]` → `+ PlainDto[i32]`; assertion updated to `issue.head == "PlainDto"`. Diagnostic case `TyperIssue.NotATemplate` unchanged. - -### [PR-33.3-D01] Duplicate template-arm `+ MyGen[T]; + MyGen[T]` is silently deduplicated rather than rejected -**Status:** resolved (PR-33.9) -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:314-316` (`convertDto` — `withoutRemoved` computation applies `.distinct` before the `toUniqueMap` uniqueness check) -**Description:** The plan §4 row 11 expected `NonUniqueFields` to fire when the same template is included twice with the same type args (e.g. `data X { + MyGen[i32]; + MyGen[i32] }`). At runtime, both arms inline `v: i32`; the `.distinct` call at `BaboonTranslator.scala:316` deduplicates identical `Field` instances before `toUniqueMap` runs — so only one `v: i32` survives and the DTO compiles without error. `NonUniqueFields` fires only when two fields share the same name but DIFFER in type (e.g. `v: i32` vs `v: str`). The idempotent-duplicate case is silently accepted. **Preexisting; predates M33** — the `.distinct` call at `BaboonTranslator.scala:316` is older translator code; M33 only exposed the case via inlined fields. -**Fix:** PR-33.9 — provenance-aware narrowing via a typer-internal AST variant. Added `RawDtoMember.TemplateArmFieldDef(field, meta)` (sibling of `FieldDef`; sealed-trait extension) at `baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/RawDtoMember.scala:9-25`. `TemplateInstantiator.convertLoweredArm` Plus arm at `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:539-552` maps every lowered `FieldDef` to `TemplateArmFieldDef` (inner-recursion's already-tagged carriers pass through). `BaboonTranslator.convertDto` at `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:316-345` walks `dto.members` to count template-arm-origin field names; when ≥2 entries with the same lowercased name are `TemplateArmFieldDef` and the converted-list contains ≥2 same-name Fields, fires `NonUniqueFields(id, dupes, dto.meta)` BEFORE `.distinct` collapses them. Contract-diamond duplicates (e.g. pkg03 `T4_A1#B1` two `f2: #i32` from `is S2`/`is S1` ContractRef paths) carry zero template-arm origins → silent (`.distinct` absorption preserved). New positive control test `m33_ok_contract_diamond_duplicate_silent` pins the contract-diamond path. JVM 556/556 pass; JS cross-build clean. -**Test:** `M29ValidatorTest.scala` row 11 (`m33_bad_11: produce NonUniqueFields for '+ MyGen[i32]; + MyGen[i32]'`) and the new positive-control `m33_ok_contract_diamond_duplicate_silent`. - ---- - -## PR-33.2 - -### [PR-33.2-D01] Synthetic `CircularInheritance` from recursion guard prints with empty payload -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:267-273, 326-331`; printer at `baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/model/issues/TyperIssue.scala:528-542` -**Description:** Both depth-limit and cycle-set `CircularInheritance` instances pass `done = Seq.empty` and `matrix = AdjacencyList(Map.empty)`. The printer matches `ToposortError.UnexpectedLoop(_, matrix)` and prints `matrix.links.map(...).niceList()`; on an empty matrix, `niceList()` produces an empty string. The user sees the literal text "Circular inheritance have been found:" with NO trailing context — no template name, no cycle path. Operationally indistinguishable from a bug. -**Fix:** `TemplateInstantiator.scala:295-302` — the synthetic `ToposortError.UnexpectedLoop` matrix now carries one edge `(receivingTypeId → templateTypeId)` constructed from `TypeId.User(pkg, Owner.Toplevel, TypeName(...))` at both the depth-limit and cycle-set sites. `niceList()` renders a meaningful `Holder => Self`-shaped diagnostic. Reused existing `CircularInheritance` per §3.f — no new TyperIssue case for the recursion guard. The existing self-ref test extended with assertions on the rendered matrix text. - -### [PR-33.2-D02] `- Template[Args]` and `^ Template[Args]` silently drop non-FieldDef members from the substituted body -**Status:** resolved -**Severity:** major -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:411-422` (`convertLoweredArm` Minus / Caret cases) -**Description:** For `- Template[Args]`, the comment claims parity with `- ParentDto`, but `- ParentDto` resolves the parent and uses `dtoParentToDefs` → `defn.fields`, which is the parent's FINAL flattened field list (including transitively-inherited fields from the parent's own `+` chain). The inline-substituted form for `-` collects ONLY `case f: RawDtoMember.FieldDef => UnfieldDef(...)` and drops every other member. So if the template body contains `+ ConcreteBase` (a concrete parent ref with `args = None`), `ConcreteBase`'s contributed fields are NOT removed by `- MyTemplate[T]`. Equivalent semantic gap for `^ Template[Args]` (line 419-422). No test exercises a template whose body contains a concrete `+ Base` arm; the gap is invisible today. -**Fix:** Added new TyperIssue case `TemplateBodyNotFlatForRemoval(templateName, receivingName, kind: "minus"|"caret", offendingMemberKind, meta)` at `TyperIssue.scala:730-742` with printer. Validation helper `checkFlatOrFail` in `TemplateInstantiator.scala:444-475`; `convertLoweredArm` (`TemplateInstantiator.scala:415-441`) now fails for `-`/`^` arms whose substituted body contains any non-FieldDef member, instead of silently dropping them. `+` arm preserves the full member list (concrete `+ Base` arms continue to compose). M29 3-site exhaustive-match update bundled per playbook: `lsp/features/DiagnosticsProvider.scala:130-133`, `lsp/state/WorkspaceState.scala:163, 245-247`, `.js/src/main/scala/io/septimalmind/baboon/BaboonJS.scala:1545`. Regression tests: D02-positive (concrete `+ Base` preserved through `+ Template[T]`), D02-negative-minus, D02-negative-caret in `M33StructuralTemplateInstantiationTest.scala`. - -### [PR-33.2-D03] Test "template absent from Domain.defs" assertion is weak (would pass under buggy lowering) -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInstantiationTest.scala:236-243, 253, 268, 285, 311-312` -**Description:** All "template not in Domain.defs" assertions are `!names.contains("MyGen")`. But `MyGen` (the template name) is NEVER added to `Domain.defs` regardless of PR-33.2 — `templateRegistryBuilder.build` extracts templates and removes them from the non-template member list before any `DomainMember` is produced (`BaboonTyper.scala:417-418`). Assertion passes trivially even for a buggy lowering that synthesises `MyGen_i32` as a real `DomainMember`. -**Fix:** Replaced 5 sites of `!names.contains(...)` with strict-set assertions reflecting the post-`@root`-pruning domain (e.g. `names == Set("Holder")`). Surfaced an unexpected behaviour: with strict counts, the initial `Set("Wide", "Narrow")` etc. failed because `Wide` is consumed by `+ Wide` (its fields inlined) and is no longer transitively reachable from any root. Assertions corrected to the actual post-pruning set. - -### [PR-33.2-D04] Depth-limit branch of recursion guard is untested -**Status:** resolved (natural-fixture approach; constructor-param attempt incompatible with distage) -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:263-274` -**Description:** The `selfRefStructuralFixture` (`template Self[T] { + Self[T] }`) fires the cycle-set branch at depth=1 — its cycle key collides on the second iteration. The depth-limit branch (`depth >= structuralArmRecursionLimit`) is unreachable from any test. A non-cyclic linearly-deepening shape (e.g. chain of >32 templates each instantiating the next at a distinct argument that keeps the cycle key fresh) would exercise it. Dead-code-untested; refactor that breaks the depth check would not regress any test. -**Fix:** Added `depthLimitFixture` (`template Deep[T] { + Deep[lst[T]] }; type X = Deep[i32]`). Each recursion step deepens the argument by one `lst[…]` so the cycle key never repeats and termination must come from the depth-limit branch (depth=32). Did NOT add a constructor parameter to `Impl` — initial attempt produced 33 test failures because distage cannot fill an unbound `Int` from a Scala default; reverted to the hard-coded `structuralArmRecursionLimit` and a self-deepening fixture. Documented the surprise in the surprises section of this PR's Completed entry. - -### [PR-33.2-D05] `+ MyGen` (bare template name, no brackets) in DTO body produces a confusing diagnostic -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:222-241` (`lowerStructuralArmsInMembers`) -**Description:** When a user writes `+ MyGen` (no brackets) where MyGen IS a registered template, parsing produces `RawDtoMember.ParentDef(parent=ScopedRef(MyGen), args=None)`. `lowerStructuralArmsInMembers`'s pattern guards on `args.isDefined` so the bare-name case falls through unchanged. The non-template scope tree does not contain MyGen (extracted to the registry), so resolution fails with a generic ref-resolution error. User sees a confusing diagnostic when the right answer is "you forgot the type arguments". -**Fix:** Added `validateNoBareTemplateRefs` to the `TemplateInstantiator` trait and called it from `BaboonTyper.scala:419-425` BEFORE the toposort. The validator walks every DTO/Identifier/Contract/ADT-branch body and emits `TemplateNotInstantiated` for any `+/-/^ Foo` arm whose head names a registered template AND `args = None`. Architectural insight (surprise): cannot be fixed inside `lowerStructuralArmsInMembers` because that pass runs AFTER `BaboonTyper.order`, but `hardDepsOfRawDefn` already reports bare `+ MyGen` as a hard dep, so `resolveScopedRef` would fail with `NameNotFound` before lowering even sees the member list. New test cases (3) cover `+ MyGen`, `- MyGen`, `^ MyGen` bare-name forms. - -### [PR-33.2-D06] `IntersectionFields` carrier loses per-field meta (uses arm-site armMeta for every field) -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:419-422` (`convertLoweredArm` Caret); `BaboonTranslator.scala:272-276` -**Description:** `IntersectionFields(rawFields, armMeta)` takes a single `meta = armMeta`. The translator maps each `rf` via `dtoFieldToDefs(rf, f.meta)` — passing the SAME `armMeta` to every field. Per-field docs/positions captured during template-body parsing (each `FieldDef` carried its own `RawNodeMeta`) are discarded. For diagnostics that anchor at the field's source position, every intersected field now points at the `^` operator's position, not the original field's declaration site in the template body. For `+` and `-` arms this is fine. The asymmetry is unique to `^`. -**Fix:** Changed `RawDtoMember.IntersectionFields(fields: Seq[RawField], …)` to `IntersectionFields(fields: Seq[RawDtoMember.FieldDef], …)` at `RawDtoMember.scala:38`. `convertLoweredArm` (Caret) collects FieldDef instances directly. `BaboonTranslator.scala:272-277` iterates `(fd.field, fd.meta)` so per-field meta is preserved. Verified `BaboonFamilyManager`'s `IntersectionFields(_, meta)` pattern (binds fields as `_`) still works — the change is in the type of the inner sequence, not the case-class arity. - -### [PR-33.2-D07] tasks.md edit included in PR-33.2 working tree -**Status:** resolved (note-only; no functional change) -**Severity:** nit -**Location:** `tasks.md` (1 line — flipping PR-33.2 from `[ ]` to `[~]`) -**Description:** `tasks.md` was modified as part of starting PR-33.2 (per loop discipline — flip task to `[~]`). Per CLAUDE.md "Surgical Changes" guideline, every changed line should trace directly to the user's request. Tracking-file updates are acceptable — they are the loop's audit trail. Not a defect of the implementation itself. -**Fix:** Note in commit message that tasks.md is the M33 ledger, not a PR-33.2 source change. No code change required. - ---- - -## PR-33.1 - -### [PR-33.1-D01] `[]` rejection test passes for the wrong reason; in-test gloss is misleading -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInheritanceParserTest.scala:158-160` -**Description:** The negative test for `+ Foo[]` passes, but for a different reason than the test comment asserts. `parentDef` is `("+" ~ nonGenericTypeRef ~ typeParams.?)`. When `typeParams` fails on `[]` (`rep(min=1)`), the surrounding `.?` swallows the failure and `parentDef` succeeds with `(Foo, None)`, leaving `[]` unconsumed. The outer `dtoEnclosed` then fails because `]` cannot start a new `dtoMember`. So the rejection is real, but it is enforced by "leftover bracket inside DTO body cannot start a new dtoMember", not by the NEList min=1 contract. A future refactor of `dtoMember.rep` could regress the test silently. -**Fix:** Rewrote the existing test's comment to accurately state "empty-bracket form is not accepted by the DTO body grammar" (not the misleading min=1 gloss). Added a sibling micro-test "parentDef alone rejects + Foo[]" that calls `c.defDto.parentDef(_)` directly on `+ Foo[]` and asserts `args.isEmpty && idx < input.length` — making the min=1 contract observable at the `parentDef` level so it is now a tripwire under future `dtoMember.rep` refactors. - -### [PR-33.1-D02] Mixed-operator test exercises only `+` arms; `^` and `-` not represented -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInheritanceParserTest.scala:123-153` -**Description:** The mixed-body test exercises three `+` arms (`+ Foo; + Bar[i32]; + Baz`). Plan §PR-33.1 considers this satisfied — but no test places `+`, `-`, and `^` arms (with and without `[…]`) in the same body. The grammar admits this, and a future regression in `dtoMember`'s alternation order (`P(parentDef) | P(unparentDef) | P(intersectionDef)`) would not be caught. -**Fix:** Appended new test `parse mixed + Foo[i32]; - Bar[str]; ^ Baz[i32]` that parses the three-operator body and asserts the three members come back as `RawDtoMember.ParentDef`/`UnparentDef`/`IntersectionDef`, each with `args = Some(NEList(...))`. Now exercises the alternation order across all three operators in a single body. - -### [PR-33.1-D03] No test for nested template argument `+ Foo[Bar[i32]]` -**Status:** resolved -**Severity:** nit -**Location:** `baboon-compiler/.jvm/src/test/scala/io/septimalmind/baboon/tests/M33StructuralTemplateInheritanceParserTest.scala` -**Description:** Plan §PR-33.1 acceptance does not explicitly require a nested-arg test, but `typeParams` recurses through `typeRef`, and `+ Foo[Bar[i32]]` should parse to `ParentDef(Foo, Some([Constructor(Bar, [Simple(i32)], Nil)]))`. PR-33.2 lowering must handle this; a parser regression would be discovered late. -**Fix:** Appended new test `parse + Foo[Bar[i32]] as ParentDef with nested Constructor arg` asserting `args == Some(NEList(RawTypeRef.Constructor(RawTypeName("Bar"), NEList(Simple(i32, Nil)), Nil)))`. The recursion through `typeParams` → `typeRef` → `typeParams` is now exercised at the structural-arm position. - -### [PR-33.1-D04] Cross-line whitespace binding: `[…]` on a new line silently binds as args, no anchoring -**Status:** resolved (option (b) — documented + tripwire test; semantic tightening deferred) -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/parser/defns/DefDto.scala:116-129` -**Description:** `parentDef`/`unparentDef`/`intersectionDef` compose `nonGenericTypeRef ~ typeParams.?` under `BaboonWhitespace`, which consumes newlines. Source like `data X { + Foo\n [i32]\n }` silently binds `[i32]` as `Foo`'s args, even though the user wrote them on a separate line. Today this is harmless because no other DTO-member alternative starts with `[`, so there is no ambiguity. But it is the same silent-binding hazard PR-30.2-D01 addressed for `//!` doc-suffix capture: a future grammar extension that introduces any `[…]`-starting construct inside a DTO body will regress this composition silently. -**Fix:** Applied option (b). Appended cross-line tripwire test `parse + Foo with [i32] on the next line` that pins the current cross-line binding behaviour: any future tightening will fail the test and force a deliberate decision. Added a 4-line comment above `parentDef` in `DefDto.scala:116` flagging the BaboonWhitespace cross-line composition contingency. Semantic tightening (option (a)) deferred — out of scope for parser-only PR-33.1. - -### [PR-33.1-D05] `TemplateInstantiator.substituteDtoMember` catch-all comment is stale; will mislead PR-33.2 -**Status:** resolved -**Severity:** minor -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/TemplateInstantiator.scala:384-386` -**Description:** The catch-all in `substituteDtoMember` matches `ParentDef`, `UnparentDef`, `IntersectionDef`, `ContractRef` and returns them unchanged with the comment "no RawTypeRef to walk." After PR-33.1, those three cases now carry `args: Option[NEList[RawTypeRef]]` — which IS a list of `RawTypeRef`s that any code recursing through a member's `RawTypeRef`s must walk. PR-33.1 is parser-only and PR-33.2 explicitly owns the lowering, so this is not a functional defect of PR-33.1. But the stale comment will mislead PR-33.2's author into thinking the catch-all is correct as-is. -**Fix:** Updated the comment at `TemplateInstantiator.scala:385` to flag that `ParentDef.args`/`UnparentDef.args`/`IntersectionDef.args` (introduced in PR-33.1) carry `RawTypeRef`s that PR-33.2 must substitute through. The catch-all body itself is unchanged — PR-33.1 is parser-only and the substitution belongs in PR-33.2. - -### [PR-33.1-D06] PR-33.2 hand-off comment is local to TemplateInstantiator; two other sites silently drop `args` -**Status:** resolved (Approach A — single-source-of-truth) -**Severity:** nit -**Location:** `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonTranslator.scala:243-258`; `baboon-compiler/src/main/scala/io/septimalmind/baboon/typer/BaboonEnquiries.scala:223-228` -**Description:** PR-33.1 added `args` to `RawDtoMember.ParentDef`/`UnparentDef`/`IntersectionDef`. Two non-test sites destructure these cases by type pattern (`p: RawDtoMember.ParentDef`) and read only `p.parent`, silently dropping `args`: `BaboonTranslator.dtoParentToDefs` callers (parent/unparent/intersection arms) and `BaboonEnquiries.hardDepsOfRawDefn` (`Seq(d.parent)`). Today this is harmless — the typer rejects template references at parent positions outside instantiation — but PR-33.2's lowering must walk those `args` for hard-dep resolution and substitution. D05's fix flagged only the `TemplateInstantiator.substituteDtoMember` site; an executor of PR-33.2 reading just that comment may produce a partial implementation. -**Fix:** Approach A. Extended the catch-all comment at `TemplateInstantiator.scala:388-395` into a three-site index naming `BaboonTranslator.scala` parent/unparent/intersection arms and `BaboonEnquiries.hardDepsOfRawDefn` ParentDef/UnparentDef/IntersectionDef sites. Added pointer comments at the six drop-sites: `BaboonTranslator.scala:~244,~253,~259` and `BaboonEnquiries.scala:~224,~227,~230`. Each pointer comment names "PR-33.2" verbatim so a future grep finds all related sites. Comment-only; zero behaviour change. diff --git a/docs/json-codecs.md b/docs/json-codecs.md index 042d001c8..1a3353fc4 100644 --- a/docs/json-codecs.md +++ b/docs/json-codecs.md @@ -11,22 +11,47 @@ Baboon's generated JSON codecs aim for predictable, deterministic shapes that ro - Scala: `None` becomes `null`; `Some` encodes the wrapped value. - C#: `null` reference is encoded as JSON `null`; value types use `WriteOptionVal` and produce `null` or the wrapped value. - ADT branches are represented as their branch object; when `wrappedAdtBranchCodecs` is enabled for a target, branches are wrapped as `{ "": { ...payload... } }`. -- Primitives use native JSON numbers/booleans/strings; 64‑bit unsigned become decimal strings to avoid loss of precision in JavaScript. +- Primitives use native JSON numbers/booleans/strings; both 64‑bit integer types become decimal strings to avoid loss of precision in JavaScript (see "64‑bit integers"). ## Primitive mappings - `bit` → JSON boolean. -- Signed ints/floats → JSON numbers. +- `i08`/`i16`/`i32` and the floats → JSON numbers. +- `i64`/`u64` → JSON **string**, in every backend. See "64‑bit integers" below. - Unsigned ints: - `u08`/`u16` → JSON numbers. - - `u32` → JSON number in Scala, number in C# (fits 53 bits). - - `u64` → JSON string (Scala uses `toUnsignedBigInt`, C# uses `BigInteger`), because it may exceed JS safe integer range. + - `u32` → JSON number (fits 53 bits). - `f128` → JSON number via `BigDecimal`/`decimal`. - `str` → JSON string (UTF‑8). - `bytes` → Base64 string in C# (`ByteString.Encode()`), and `.Parse` on read. - `uid` → JSON string (canonical GUID string). - `tsu`/`tso` → JSON string using `BaboonTimeFormats` (`formatTsu`/`formatTso` on Scala, `ToString` on C#). +## 64‑bit integers + +`i64` and `u64` are written as decimal strings by every backend, and every backend's reader +accepts **both** the string form and the older JSON‑number form. + +The asymmetry is JavaScript's. A JSON number beyond `Number.MAX_SAFE_INTEGER` (2^53 − 1) has +already been rounded by `JSON.parse` before any codec runs, so a `i64` value such as +`9223372036854775807` written as a bare number reaches a TypeScript reader as +`9223372036854775808` — a different, wrong, perfectly valid‑looking integer, and the original is +unrecoverable at that layer. The same holds for any JavaScript consumer of Baboon JSON, Baboon‑ +generated or not. Writing the decimal string is the only representation all ten backends can both +produce and consume without loss. + +`u64` additionally needs the string form because its upper half does not fit a signed 64‑bit +number at all; the string always carries the canonical **unsigned** decimal form +(`18446744073709551615` for `u64` max), never a signed two's‑complement rendering. + +Reader leniency is what makes the change backward compatible: a JSON document produced by an +older compiler, with `i64`/`u64` as numbers, still decodes everywhere. The one case that cannot +be made compatible is a numeric token above 2^53 read by TypeScript — `JSON.parse` destroyed the +value before the codec was reached, so `BaboonInt64.read` refuses it with a diagnostic naming the +producer requirement rather than returning a rounded integer. + +Map keys are unaffected: JSON object keys are strings in all cases (see "Map keys"). + ## Map keys Map keys are always strings in JSON. The string form depends on the key type: @@ -222,4 +247,5 @@ Value: } ``` -`u64` keys and values are stringified when needed to preserve precision. +`u64` keys are strings because every JSON object key is. `u64` and `i64` *values* are strings too +— see "64-bit integers". diff --git a/test/conv-test-rs/Cargo.toml b/test/conv-test-rs/Cargo.toml index 85599db4c..e724739fd 100644 --- a/test/conv-test-rs/Cargo.toml +++ b/test/conv-test-rs/Cargo.toml @@ -6,7 +6,7 @@ rust-version = "1.75" [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["arbitrary_precision"] } +serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } rust_decimal = { version = "1", features = ["serde-with-str"] } diff --git a/test/conv-test-rs/src/main.rs b/test/conv-test-rs/src/main.rs index b362ac5e2..e87c12822 100644 --- a/test/conv-test-rs/src/main.rs +++ b/test/conv-test-rs/src/main.rs @@ -164,7 +164,7 @@ fn expected_inner_payloads() -> Vec { // cover cross-format conversion within Rust). fn create_sample_any_showcase_json() -> AnyShowcase { let payloads = expected_inner_payloads(); - let to_json = |p: &InnerPayload| serde_json::to_value(p).expect("InnerPayload to_json failed"); + let to_json = |p: &InnerPayload| p.to_json_value().expect("InnerPayload to_json failed"); let meta_a = AnyMeta::new(0x07, Some(DOMAIN_ID.to_string()), Some(DOMAIN_VER.to_string()), Some(INNER_TYPE_ID.to_string())).expect("metaA"); let meta_b = AnyMeta::new(0x03, None, Some(DOMAIN_VER.to_string()), Some(INNER_TYPE_ID.to_string())).expect("metaB"); @@ -501,7 +501,7 @@ fn decode_inner(o: &AnyOpaque) -> InnerPayload { .unwrap_or_else(|e| panic!("InnerPayload UEBA decode failed: {}", e)) } AnyOpaque::Json(j) => { - serde_json::from_value(j.json.clone()) + InnerPayload::from_json_value(j.json.clone()) .unwrap_or_else(|e| panic!("InnerPayload JSON decode failed: {}", e)) } } diff --git a/test/conv-test-rs/tests/compat_tests.rs b/test/conv-test-rs/tests/compat_tests.rs index 096124582..85752d0fe 100644 --- a/test/conv-test-rs/tests/compat_tests.rs +++ b/test/conv-test-rs/tests/compat_tests.rs @@ -14,7 +14,7 @@ fn read_json_file(source: &str) -> AllBasicTypes { let path = base_dir().join(format!("{}-json/all-basic-types.json", source)); let json_str = fs::read_to_string(&path) .unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e)); - serde_json::from_str(&json_str) + AllBasicTypes::from_json(&json_str) .unwrap_or_else(|e| panic!("Failed to parse JSON from {:?}: {}", path, e)) } @@ -221,7 +221,7 @@ fn read_any_showcase_json(source: &str) -> AnyShowcase { let path = base_dir().join(format!("{}-json/any-showcase.json", source)); let json_str = fs::read_to_string(&path) .unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e)); - serde_json::from_str(&json_str) + AnyShowcase::from_json(&json_str) .unwrap_or_else(|e| panic!("Failed to parse AnyShowcase JSON from {:?}: {}", path, e)) } @@ -242,7 +242,7 @@ fn decode_inner(o: &AnyOpaque) -> InnerPayload { InnerPayload::decode_ueba(&BaboonCodecContext::Compact, &mut cursor) .unwrap_or_else(|e| panic!("InnerPayload UEBA decode failed: {}", e)) } - AnyOpaque::Json(j) => serde_json::from_value(j.json.clone()) + AnyOpaque::Json(j) => InnerPayload::from_json_value(j.json.clone()) .unwrap_or_else(|e| panic!("InnerPayload JSON decode failed: {}", e)), } } @@ -422,7 +422,7 @@ fn test_pr_i3_foreign_keycodec_roundtrip() { assert!(path.exists(), "Rust m24-foreign-keycodec fixture not found: {:?}", path); let json_str = fs::read_to_string(&path) .unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e)); - let decoded: ForeignKeyHolder = serde_json::from_str(&json_str) + let decoded: ForeignKeyHolder = ForeignKeyHolder::from_json(&json_str) .unwrap_or_else(|e| panic!("Failed to decode m24-foreign-keycodec JSON: {}", e)); let mut expected_map = BTreeMap::new(); expected_map.insert(ItemKey { v: "alpha".to_string() }, "v1".to_string()); @@ -437,7 +437,7 @@ fn test_pr_i3_foreign_keycodec_canonical_wire_form() { m.insert(ItemKey { v: "alpha".to_string() }, "v1".to_string()); m.insert(ItemKey { v: "beta".to_string() }, "v2".to_string()); let sample = ForeignKeyHolder { m }; - let encoded = serde_json::to_string(&sample).expect("ForeignKeyHolder encode"); + let encoded = sample.to_json().expect("ForeignKeyHolder encode"); let expected = r#"{"m":{"alpha":"v1","beta":"v2"}}"#; assert_eq!(encoded, expected, "FStr_KeyCodec wire form diverged"); } diff --git a/test/cs-stub-mcp-overlay/McpTests/McpTests.cs b/test/cs-stub-mcp-overlay/McpTests/McpTests.cs index bbce7115f..c569c131d 100644 --- a/test/cs-stub-mcp-overlay/McpTests/McpTests.cs +++ b/test/cs-stub-mcp-overlay/McpTests/McpTests.cs @@ -350,7 +350,7 @@ public async Task Sec2_NJsonSchema_Tool0_ListCollections_ConformingInstance() var schema = await JsonSchema.FromJsonAsync(schemaJson); // D6/T30: byColor is map[Color,str] — a string-keyed object with enum wire-name // keys (matches the wire the codecs emit and the reconciled inputSchema). - var errors = schema.Validate("{\"tags\":[\"a\",\"b\"],\"uniqueIds\":[1,2],\"labels\":{\"k\":\"v\"},\"byColor\":{\"Green\":\"ok\",\"Red\":\"stop\"}}"); + var errors = schema.Validate("{\"tags\":[\"a\",\"b\"],\"uniqueIds\":[\"1\",\"2\"],\"labels\":{\"k\":\"v\"},\"byColor\":{\"Green\":\"ok\",\"Red\":\"stop\"}}"); Assert.AreEqual(0, errors.Count, $"McpTools_listCollections conforming instance must be valid; errors: {FormatErrors(errors)}"); } diff --git a/test/dart-stub-mcp-overlay/test/mcp/mcp_tests.dart b/test/dart-stub-mcp-overlay/test/mcp/mcp_tests.dart index 2e415b679..0419c1f44 100644 --- a/test/dart-stub-mcp-overlay/test/mcp/mcp_tests.dart +++ b/test/dart-stub-mcp-overlay/test/mcp/mcp_tests.dart @@ -60,7 +60,7 @@ import '../../lib/mcp/stub/mcptools/describepricing/out.dart' as describepricing // --------------------------------------------------------------------------- // Tool 1: McpTools_listCollections — list/set/map[str]/map[enum-key] -final _refListCollections = jsonDecode(r'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"uniqueIds":{"type":"array","items":{"type":"integer","format":"int64"},"uniqueItems":true},"labels":{"type":"object","additionalProperties":{"type":"string"}},"byColor":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","enum":["Red","Green","Blue"]}}},"required":["tags","uniqueIds","labels","byColor"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]}}}') as Map; +final _refListCollections = jsonDecode(r'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"uniqueIds":{"type":"array","items":{"type":"string","format":"int64"},"uniqueItems":true},"labels":{"type":"object","additionalProperties":{"type":"string"}},"byColor":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","enum":["Red","Green","Blue"]}}},"required":["tags","uniqueIds","labels","byColor"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]}}}') as Map; // Tool 2: McpTools_submitComposite — nested DTO + opt[DTO] + enum + foreign-string final _refSubmitComposite = jsonDecode(r'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"nested":{"$ref":"#/$defs/mcp_stub_Nested"},"maybePoint":{"oneOf":[{"$ref":"#/$defs/mcp_stub_Point"},{"type":"null"}]},"color":{"$ref":"#/$defs/mcp_stub_Color"},"fancy":{"type":"string"}},"required":["nested","color","fancy"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]},"mcp_stub_Nested":{"type":"object","properties":{"point":{"$ref":"#/$defs/mcp_stub_Point"},"color":{"$ref":"#/$defs/mcp_stub_Color"},"label":{"oneOf":[{"type":"string"},{"type":"null"}]}},"required":["point","color"]},"mcp_stub_Point":{"type":"object","properties":{"x":{"type":"integer","format":"int32"},"y":{"type":"integer","format":"int32"}},"required":["x","y"]}}}') as Map; @@ -469,7 +469,7 @@ void main() { // object conforming to the inputSchema (exercises the enum key-codec path). final resp = _send(server, session, stub, JsonRpcRequest( 4, 'tools/call', - jsonDecode('{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":[1,2],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}'), + jsonDecode('{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":["1","2"],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}'), )); expect(resp.error, isNull, reason: 'Unexpected error on listCollections call'); diff --git a/test/java-stub-mcp-overlay/src/test/java/mcp/McpTests.java b/test/java-stub-mcp-overlay/src/test/java/mcp/McpTests.java index 2a7ac66e1..28d8f8716 100644 --- a/test/java-stub-mcp-overlay/src/test/java/mcp/McpTests.java +++ b/test/java-stub-mcp-overlay/src/test/java/mcp/McpTests.java @@ -90,7 +90,7 @@ private static JsonNode ref(String json) { "\"type\":\"object\"," + "\"properties\":{" + "\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}," + - "\"uniqueIds\":{\"type\":\"array\",\"items\":{\"type\":\"integer\",\"format\":\"int64\"},\"uniqueItems\":true}," + + "\"uniqueIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"int64\"},\"uniqueItems\":true}," + "\"labels\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}}," + "\"byColor\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}," + "\"propertyNames\":{\"type\":\"string\",\"enum\":[\"Red\",\"Green\",\"Blue\"]}}" + @@ -652,7 +652,7 @@ public void sec3_listCollections_returnsOkTrue() throws Exception { // D6/T30: byColor is map[Color,str]; Java encodes/decodes it as a string-keyed // object with enum wire-name keys. Send a NON-EMPTY object conforming to the // inputSchema (exercises the enum key-codec path). - MAPPER.readTree("{\"name\":\"McpTools_listCollections\",\"arguments\":{\"tags\":[\"a\",\"b\"],\"uniqueIds\":[1,2],\"labels\":{\"k\":\"v\"},\"byColor\":{\"Green\":\"ok\",\"Red\":\"stop\"}}}") + MAPPER.readTree("{\"name\":\"McpTools_listCollections\",\"arguments\":{\"tags\":[\"a\",\"b\"],\"uniqueIds\":[\"1\",\"2\"],\"labels\":{\"k\":\"v\"},\"byColor\":{\"Green\":\"ok\",\"Red\":\"stop\"}}}") )); assertEquals(4, resp.id.intValue()); diff --git a/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpTests.kt b/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpTests.kt index 6a316a539..63e19d604 100644 --- a/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpTests.kt +++ b/test/kotlin-stub-mcp-overlay/src/test/kotlin/mcp/McpTests.kt @@ -81,7 +81,7 @@ private val REF_LIST_COLLECTIONS = Json.parseToJsonElement( "\"type\":\"object\"," + "\"properties\":{" + "\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}," + - "\"uniqueIds\":{\"type\":\"array\",\"items\":{\"type\":\"integer\",\"format\":\"int64\"},\"uniqueItems\":true}," + + "\"uniqueIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"int64\"},\"uniqueItems\":true}," + "\"labels\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}}," + "\"byColor\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}," + "\"propertyNames\":{\"type\":\"string\",\"enum\":[\"Red\",\"Green\",\"Blue\"]}}" + @@ -612,7 +612,7 @@ class McpTests { // object with enum-name keys (e.g. {"Green":"ok"}). Send a NON-EMPTY object // — it conforms to the inputSchema (string-keyed object, propertyNames // constrained to the enum wire values) and exercises the enum key-codec path. - Json.parseToJsonElement("""{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":[1,2],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}"""), + Json.parseToJsonElement("""{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":["1","2"],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}"""), ), ) diff --git a/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py index fcd884dec..68a73442f 100644 --- a/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py +++ b/test/py-stub-mcp-overlay/BaboonTests/mcp/test_mcp.py @@ -56,7 +56,7 @@ '"type":"object",' '"properties":{' '"tags":{"type":"array","items":{"type":"string"}},' - '"uniqueIds":{"type":"array","items":{"type":"integer","format":"int64"},"uniqueItems":true},' + '"uniqueIds":{"type":"array","items":{"type":"string","format":"int64"},"uniqueItems":true},' '"labels":{"type":"object","additionalProperties":{"type":"string"}},' '"byColor":{"type":"object","additionalProperties":{"type":"string"},' '"propertyNames":{"type":"string","enum":["Red","Green","Blue"]}}' @@ -609,7 +609,7 @@ def test_listCollections_returns_ok_true(self): "name": "McpTools_listCollections", "arguments": { "tags": ["a", "b"], - "uniqueIds": [1, 2], + "uniqueIds": ["1", "2"], "labels": {"k": "v"}, "byColor": byColor, }, diff --git a/test/rs-stub-either-overlay/tests/wiring_tests.rs b/test/rs-stub-either-overlay/tests/wiring_tests.rs index 40c162599..043c43a1b 100644 --- a/test/rs-stub-either-overlay/tests/wiring_tests.rs +++ b/test/rs-stub-either-overlay/tests/wiring_tests.rs @@ -42,19 +42,19 @@ impl I2 for MockI2 { #[test] fn i1_json_test_call_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = BaboonServiceRtDefault; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); assert!(result.is_ok()); - let decoded: I1_testCall_out = serde_json::from_str(&result.unwrap()).unwrap(); + let decoded: I1_testCall_out = I1_testCall_out::from_json(&result.unwrap()).unwrap(); assert_eq!(decoded.i00, 42); } #[test] fn i1_json_test_call2_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall2".to_string() }; - let input_json = serde_json::to_string(&T7_Empty {}).unwrap(); + let input_json = (T7_Empty {}).to_json().unwrap(); let rt = BaboonServiceRtDefault; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); @@ -64,7 +64,7 @@ fn i1_json_test_call2_success() { #[test] fn i1_json_domain_error() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = BaboonServiceRtDefault; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &FailingI1, &rt, &ctx); @@ -115,12 +115,12 @@ fn i1_ueba_unknown_method() { #[test] fn i2_json_no_err_call_success() { let method = BaboonMethodId { service_name: "I2".to_string(), method_name: "noErrCall".to_string() }; - let input_json = serde_json::to_string(&I2_noErrCall_in { value: 123 }).unwrap(); + let input_json = (I2_noErrCall_in { value: 123 }).to_json().unwrap(); let rt = BaboonServiceRtDefault; let ctx = BaboonCodecContext::Default; let result = invoke_json_i2(&method, &input_json, &MockI2, &rt, &ctx); assert!(result.is_ok()); - let decoded: I2_noErrCall_out = serde_json::from_str(&result.unwrap()).unwrap(); + let decoded: I2_noErrCall_out = I2_noErrCall_out::from_json(&result.unwrap()).unwrap(); assert_eq!(decoded.result, "result_123"); } @@ -162,9 +162,9 @@ fn new_ueba_muxer() -> UebaMuxer { fn json_muxer_routes_to_i1() { let ctx = BaboonCodecContext::Default; let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let routed = new_json_muxer().invoke(&method, &input_json, &ctx).expect("routing"); - let decoded: I1_testCall_out = serde_json::from_str(&routed.unwrap()).unwrap(); + let decoded: I1_testCall_out = I1_testCall_out::from_json(&routed.unwrap()).unwrap(); assert_eq!(decoded.i00, 42); } @@ -172,9 +172,9 @@ fn json_muxer_routes_to_i1() { fn json_muxer_routes_to_i2() { let ctx = BaboonCodecContext::Default; let method = BaboonMethodId { service_name: "I2".to_string(), method_name: "noErrCall".to_string() }; - let input_json = serde_json::to_string(&I2_noErrCall_in { value: 123 }).unwrap(); + let input_json = (I2_noErrCall_in { value: 123 }).to_json().unwrap(); let routed = new_json_muxer().invoke(&method, &input_json, &ctx).expect("routing"); - let decoded: I2_noErrCall_out = serde_json::from_str(&routed.unwrap()).unwrap(); + let decoded: I2_noErrCall_out = I2_noErrCall_out::from_json(&routed.unwrap()).unwrap(); assert_eq!(decoded.result, "result_123"); } diff --git a/test/rs-stub-outcome-overlay/tests/wiring_tests.rs b/test/rs-stub-outcome-overlay/tests/wiring_tests.rs index 53270dcf8..0094f6ed6 100644 --- a/test/rs-stub-outcome-overlay/tests/wiring_tests.rs +++ b/test/rs-stub-outcome-overlay/tests/wiring_tests.rs @@ -41,13 +41,13 @@ impl I2 for MockI2 { #[test] fn i1_json_test_call_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = OutcomeServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); match result { Outcome::Success(json_str) => { - let decoded: I1_testCall_out = serde_json::from_str(&json_str).unwrap(); + let decoded: I1_testCall_out = I1_testCall_out::from_json(&json_str).unwrap(); assert_eq!(decoded.i00, 42); } Outcome::Failure(err) => panic!("Expected Success, got Failure({:?})", err.downcast_ref::()), @@ -57,7 +57,7 @@ fn i1_json_test_call_success() { #[test] fn i1_json_test_call2_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall2".to_string() }; - let input_json = serde_json::to_string(&T7_Empty {}).unwrap(); + let input_json = (T7_Empty {}).to_json().unwrap(); let rt = OutcomeServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); @@ -67,7 +67,7 @@ fn i1_json_test_call2_success() { #[test] fn i1_json_domain_error() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = OutcomeServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &FailingI1, &rt, &ctx); @@ -137,13 +137,13 @@ fn i1_ueba_unknown_method() { #[test] fn i2_json_no_err_call_success() { let method = BaboonMethodId { service_name: "I2".to_string(), method_name: "noErrCall".to_string() }; - let input_json = serde_json::to_string(&I2_noErrCall_in { value: 123 }).unwrap(); + let input_json = (I2_noErrCall_in { value: 123 }).to_json().unwrap(); let rt = OutcomeServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i2(&method, &input_json, &MockI2, &rt, &ctx); match result { Outcome::Success(json_str) => { - let decoded: I2_noErrCall_out = serde_json::from_str(&json_str).unwrap(); + let decoded: I2_noErrCall_out = I2_noErrCall_out::from_json(&json_str).unwrap(); assert_eq!(decoded.result, "result_123"); } Outcome::Failure(err) => panic!("Expected Success, got Failure({:?})", err.downcast_ref::()), diff --git a/test/rs-stub-result-overlay/tests/wiring_tests.rs b/test/rs-stub-result-overlay/tests/wiring_tests.rs index 40fc7a456..c0e106241 100644 --- a/test/rs-stub-result-overlay/tests/wiring_tests.rs +++ b/test/rs-stub-result-overlay/tests/wiring_tests.rs @@ -42,13 +42,13 @@ impl I2 for MockI2 { #[test] fn i1_json_test_call_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = ResultServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); match result { MyResult::Success(json_str) => { - let decoded: I1_testCall_out = serde_json::from_str(&json_str).unwrap(); + let decoded: I1_testCall_out = I1_testCall_out::from_json(&json_str).unwrap(); assert_eq!(decoded.i00, 42); } MyResult::Failure(err) => panic!("Expected Success, got Failure({:?})", err), @@ -58,7 +58,7 @@ fn i1_json_test_call_success() { #[test] fn i1_json_test_call2_success() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall2".to_string() }; - let input_json = serde_json::to_string(&T7_Empty {}).unwrap(); + let input_json = (T7_Empty {}).to_json().unwrap(); let rt = ResultServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &MockI1, &rt, &ctx); @@ -68,7 +68,7 @@ fn i1_json_test_call2_success() { #[test] fn i1_json_domain_error() { let method = BaboonMethodId { service_name: "I1".to_string(), method_name: "testCall".to_string() }; - let input_json = serde_json::to_string(&I1_testCall_in {}).unwrap(); + let input_json = (I1_testCall_in {}).to_json().unwrap(); let rt = ResultServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i1(&method, &input_json, &FailingI1, &rt, &ctx); @@ -135,13 +135,13 @@ fn i1_ueba_unknown_method() { #[test] fn i2_json_no_err_call_success() { let method = BaboonMethodId { service_name: "I2".to_string(), method_name: "noErrCall".to_string() }; - let input_json = serde_json::to_string(&I2_noErrCall_in { value: 123 }).unwrap(); + let input_json = (I2_noErrCall_in { value: 123 }).to_json().unwrap(); let rt = ResultServiceRt; let ctx = BaboonCodecContext::Default; let result = invoke_json_i2(&method, &input_json, &MockI2, &rt, &ctx); match result { MyResult::Success(json_str) => { - let decoded: I2_noErrCall_out = serde_json::from_str(&json_str).unwrap(); + let decoded: I2_noErrCall_out = I2_noErrCall_out::from_json(&json_str).unwrap(); assert_eq!(decoded.result, "result_123"); } MyResult::Failure(err) => panic!("Expected Success, got Failure({:?})", err), diff --git a/test/rs-stub/tests/any_meta_codec_tests.rs b/test/rs-stub/tests/any_meta_codec_tests.rs index f4d55c60c..9e65f8c86 100644 --- a/test/rs-stub/tests/any_meta_codec_tests.rs +++ b/test/rs-stub/tests/any_meta_codec_tests.rs @@ -213,16 +213,31 @@ fn any_opaque_json_content_equality() { assert_ne!(a, c); } -// ===== Custom Serialize/Deserialize for AnyOpaque ===== +// ===== any_field_codec: the JSON envelope for an `any` field ===== +// +// These used to exercise `Serialize`/`Deserialize for AnyOpaque`. Those impls are gone: a serde +// impl gets no codec context, so it could never transcode a UEBA-form payload, and having a +// second encoding path beside the explicit codecs was exactly the drift hazard this work +// removed. The envelope contract itself is unchanged and now lives in `any_field_codec`. + +use baboon_rs_stub::any_opaque::any_field_codec; #[test] -fn any_opaque_serialize_json_branch_emits_envelope() { +fn any_to_json_json_branch_emits_envelope() { let payload = serde_json::json!({"foo": 42}); let opaque = AnyOpaque::Json(AnyOpaqueJson::new( mk(0x07, Some("d"), Some("v"), Some("t")), payload.clone(), )); - let serialized = serde_json::to_value(&opaque).expect("serialize"); + let serialized = any_field_codec::any_to_json( + &BaboonCodecContext::Compact, + 0x07, + None, + None, + None, + &opaque, + ) + .expect("encode"); let obj = serialized.as_object().expect("object"); assert_eq!(obj.get("$ak"), Some(&serde_json::json!(7))); assert_eq!(obj.get("$ad"), Some(&serde_json::json!("d"))); @@ -232,16 +247,23 @@ fn any_opaque_serialize_json_branch_emits_envelope() { } #[test] -fn any_opaque_serialize_ueba_branch_returns_error() { - // The Ueba branch's bytes can't be JSON-serialized without facade access. The custom - // Serialize impl errors instead of silently producing wrong wire bytes. +fn any_to_json_ueba_branch_without_a_facade_returns_error() { + // The Ueba branch's bytes can't become JSON without facade access. The encoder errors + // instead of silently producing wrong wire bytes. let opaque = AnyOpaque::Ueba(AnyOpaqueUeba::new(mk(0x00, None, None, None), vec![1, 2, 3])); - let result = serde_json::to_value(&opaque); - assert!(result.is_err(), "Ueba branch must not JSON-serialize directly"); + let result = any_field_codec::any_to_json( + &BaboonCodecContext::Compact, + 0x00, + None, + None, + None, + &opaque, + ); + assert!(result.is_err(), "the Ueba branch must not JSON-encode without a facade"); } #[test] -fn any_opaque_deserialize_returns_json_branch() { +fn any_from_json_returns_the_json_branch() { let envelope = serde_json::json!({ "$ak": 0x07, "$ad": "d", @@ -249,38 +271,41 @@ fn any_opaque_deserialize_returns_json_branch() { "$at": "t", "$c": {"payload": "value"}, }); - let parsed: AnyOpaque = serde_json::from_value(envelope.clone()).expect("deserialize"); - match parsed { - AnyOpaque::Json(j) => { - assert_eq!(j.meta, mk(0x07, Some("d"), Some("v"), Some("t"))); - assert_eq!(j.json, serde_json::json!({"payload": "value"})); - } - AnyOpaque::Ueba(_) => panic!("Deserialize must always return Json branch"), - } + let parsed = any_field_codec::any_from_json(&envelope, 0x07).expect("decode"); + assert_eq!(parsed.meta, mk(0x07, Some("d"), Some("v"), Some("t"))); + assert_eq!(parsed.json, serde_json::json!({"payload": "value"})); } #[test] -fn any_opaque_deserialize_round_trip_json_branch() { +fn any_json_envelope_round_trips() { let payload = serde_json::json!({"nested": {"x": 1}}); let original = AnyOpaque::Json(AnyOpaqueJson::new( mk(0x07, Some("d"), Some("v"), Some("t")), payload.clone(), )); - let serialized = serde_json::to_value(&original).expect("serialize"); - let round: AnyOpaque = serde_json::from_value(serialized).expect("deserialize"); - assert_eq!(round, original); + let serialized = any_field_codec::any_to_json( + &BaboonCodecContext::Compact, + 0x07, + None, + None, + None, + &original, + ) + .expect("encode"); + let round = any_field_codec::any_from_json(&serialized, 0x07).expect("decode"); + assert_eq!(AnyOpaque::Json(round), original); } #[test] -fn any_opaque_deserialize_left_on_missing_kind() { +fn any_from_json_fails_on_missing_kind() { let envelope = serde_json::json!({"$c": {}}); - assert!(serde_json::from_value::(envelope).is_err()); + assert!(any_field_codec::any_from_json(&envelope, 0x00).is_err()); } #[test] -fn any_opaque_deserialize_left_on_missing_content() { +fn any_from_json_fails_on_missing_content() { let envelope = serde_json::json!({"$ak": 0x00}); - assert!(serde_json::from_value::(envelope).is_err()); + assert!(any_field_codec::any_from_json(&envelope, 0x00).is_err()); } // ===== BaboonCodecContext + facade plumbing ===== @@ -872,7 +897,16 @@ fn pr11_d05_any_opaque_json_preserves_key_insertion_order() { AnyMeta::new(0x07, Some("d".into()), Some("v".into()), Some("t".into())).unwrap(), payload, )); - let serialized = serde_json::to_string(&opaque).expect("serialize"); + let serialized = any_field_codec::any_to_json( + &BaboonCodecContext::Compact, + 0x07, + None, + None, + None, + &opaque, + ) + .expect("encode") + .to_string(); // With preserve_order: $ak, $ad, $av, $at, $c (insertion order). // Without (BTreeMap default): $ad, $ak, $at, $av, $c (lexical). let ak_pos = serialized.find("\"$ak\"").expect("$ak in output"); diff --git a/test/rs-stub/tests/any_round_trip_tests.rs b/test/rs-stub/tests/any_round_trip_tests.rs index 30a2cae36..b6cc9ab2b 100644 --- a/test/rs-stub/tests/any_round_trip_tests.rs +++ b/test/rs-stub/tests/any_round_trip_tests.rs @@ -33,7 +33,7 @@ const INNER_TYPE: &str = "my.ok/:#Inner"; // The Rust generator does not (yet) emit per-domain `BaboonCodecsJson`/`BaboonCodecsUeba` // adapters analogous to C#'s `My.Ok.BaboonCodecsJson.Instance`. The cross-format helpers // look up codecs by `(domain, version, typeid)` through the facade, so for the round-trip -// tests we wrap `Inner`'s `BaboonBinEncode`/`BaboonBinDecode` and serde derives in +// tests we wrap `Inner`'s `BaboonBinEncode`/`BaboonBinDecode` and JSON codecs in // trait-object adapters and register them under `INNER_TYPE`. #[derive(Debug, Clone, PartialEq)] @@ -93,7 +93,7 @@ impl BaboonAnyJsonCodec for InnerJsonCodec { let v = value.as_any().downcast_ref::().ok_or_else(|| { BaboonCodecError::encoder_failure("InnerJsonCodec.encode: value is not InnerDyn") })?; - serde_json::to_value(&v.0) + v.0.to_json_value() .map_err(|e| BaboonCodecError::encoder_failure(format!("InnerJsonCodec.encode: {}", e))) } @@ -102,7 +102,7 @@ impl BaboonAnyJsonCodec for InnerJsonCodec { _ctx: &BaboonCodecContext, wire: &serde_json::Value, ) -> Result, BaboonCodecError> { - let inner: Inner = serde_json::from_value(wire.clone()).map_err(|e| { + let inner: Inner = Inner::from_json_value(wire.clone()).map_err(|e| { BaboonCodecError::decoder_failure(format!("InnerJsonCodec.decode: {}", e)) })?; Ok(Box::new(InnerDyn(inner))) @@ -153,7 +153,7 @@ fn inner_to_ueba_bytes(inner: &Inner) -> Vec { } fn inner_to_json(inner: &Inner) -> serde_json::Value { - serde_json::to_value(inner).expect("encode Inner json") + inner.to_json_value().expect("encode Inner json") } fn sample_inner() -> Inner { @@ -269,16 +269,16 @@ fn ueba_decode_yields_any_opaque_ueba_with_matching_kind_bytes() { #[test] fn json_round_trip_all_six_variants_plus_nested_positions_preserve_content() { let original = build_json_native_holder(); - let json = serde_json::to_value(&original).expect("encode JSON"); - let decoded: Holder = serde_json::from_value(json).expect("decode JSON"); + let json = original.to_json_value().expect("encode JSON"); + let decoded: Holder = Holder::from_json_value(json).expect("decode JSON"); assert_eq!(decoded, original); } #[test] fn json_decode_yields_any_opaque_json_with_matching_kind_bytes() { let original = build_json_native_holder(); - let json = serde_json::to_value(&original).expect("encode JSON"); - let decoded: Holder = serde_json::from_value(json).expect("decode JSON"); + let json = original.to_json_value().expect("encode JSON"); + let decoded: Holder = Holder::from_json_value(json).expect("decode JSON"); assert!(matches!(decoded.f_any, AnyOpaque::Json(_)), "JSON decode must yield AnyOpaque::Json"); assert_eq!(decoded.f_any.meta().kind, 0x07); assert_eq!(decoded.f_domain_this.meta().kind, 0x03); @@ -445,14 +445,14 @@ fn encode_json_any_into_ueba_without_facade_fails_fast() { #[test] fn encode_ueba_any_into_json_without_facade_fails_fast() { - // The serde-derive JSON encoder routes through the custom `Serialize` impl which errors - // on the Ueba branch. Bytes can only become JSON via facade cross-convert. + // The facade-less JSON encoder refuses the Ueba branch. Bytes can only become JSON via + // facade cross-convert. let mut mixed = build_json_native_holder(); mixed.f_any = AnyOpaque::Ueba(AnyOpaqueUeba::new(meta_a(), vec![1, 2])); - let res = serde_json::to_value(&mixed); - assert!(res.is_err(), "JSON serialize of AnyOpaque::Ueba must fail"); + let res = mixed.to_json_value(); + assert!(res.is_err(), "JSON encode of AnyOpaque::Ueba must fail"); let msg = format!("{}", res.err().unwrap()); - // Custom Serialize impl includes the workaround pointer. + // The refusal includes the workaround pointer. assert!( msg.contains("ueba_to_json") || msg.contains("AnyOpaque::Ueba"), "error must point to the cross-convert path; got: {}", @@ -464,11 +464,11 @@ fn encode_ueba_any_into_json_without_facade_fails_fast() { #[test] fn json_envelope_carries_ak_and_optional_ad_av_at_and_content_key() { - // Sanity: the JSON envelope produced by the serde Serialize embeds the AnyMeta keys ($ak, - // $ad?, $av?, $at?) alongside the $c content key. Any change to the envelope that drops - // one of these would break cross-language interop. + // Sanity: the JSON envelope embeds the AnyMeta keys ($ak, $ad?, $av?, $at?) alongside the + // $c content key. Any change to the envelope that drops one of these would break + // cross-language interop. let original = build_json_native_holder(); - let token = serde_json::to_value(&original).expect("encode JSON"); + let token = original.to_json_value().expect("encode JSON"); let obj = token.as_object().expect("Holder serialises to object"); // f_any → variant A → all four meta keys + $c present. @@ -504,7 +504,7 @@ fn json_envelope_key_order_is_ak_first() { // PR-11-D05 (preserve_order): keys must serialise in insertion order. $ak is inserted first // — assert it appears first in the serialised string. let original = build_json_native_holder(); - let s = serde_json::to_string(&original).expect("serialize"); + let s = original.to_json().expect("serialize"); // Find the start of fAny's object value, then assert "$ak" appears before "$ad"/"$c". let any_idx = s.find("\"fAny\":").expect("fAny key present"); let suffix = &s[any_idx..]; diff --git a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs index 7a501d034..de6e5fa9b 100644 --- a/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs +++ b/test/rs-stub/tests/baboon_codecs_facade_latest_tests.rs @@ -96,7 +96,7 @@ impl BaboonAnyJsonCodec for InnerJsonCodec { let v = value.as_any().downcast_ref::().ok_or_else(|| { BaboonCodecError::encoder_failure("InnerJsonCodec.encode: value is not InnerDyn") })?; - serde_json::to_value(&v.0) + v.0.to_json_value() .map_err(|e| BaboonCodecError::encoder_failure(format!("InnerJsonCodec.encode: {}", e))) } @@ -105,7 +105,7 @@ impl BaboonAnyJsonCodec for InnerJsonCodec { _ctx: &BaboonCodecContext, wire: &serde_json::Value, ) -> Result, BaboonCodecError> { - let inner: Inner = serde_json::from_value(wire.clone()).map_err(|e| { + let inner: Inner = Inner::from_json_value(wire.clone()).map_err(|e| { BaboonCodecError::decoder_failure(format!("InnerJsonCodec.decode: {}", e)) })?; Ok(Box::new(InnerDyn(inner))) diff --git a/test/rs-stub/tests/explicit_json_codec_tests.rs b/test/rs-stub/tests/explicit_json_codec_tests.rs index 4fbbe6eef..a37fa0f82 100644 --- a/test/rs-stub/tests/explicit_json_codec_tests.rs +++ b/test/rs-stub/tests/explicit_json_codec_tests.rs @@ -1,19 +1,17 @@ // NOTE: references generated symbols produced into this stub only by the rs-stub codegen path. // Run from the codegen'd copy, not the source tree. // -// The generated per-type `test_*_json_{encode,decode}_matches_derive` oracles pin the explicit -// codecs against the serde derive across the whole fixture corpus, but they can only exercise -// wire documents that the encoder itself produced. Three behaviours therefore escape them -// entirely, and all three are ones the derive has and the explicit decoder must match: +// The generated per-type round-trip tests can only exercise wire documents that the encoder +// itself produced, so three reader behaviours escape them entirely. All three were the serde +// derive's behaviour before the explicit codecs replaced it, and the explicit decoder has to +// keep them: // -// * an ABSENT optional field decodes to None — serde special-cases `Option`, so this is not -// the same thing as an explicit null, and getting it wrong would reject valid documents -// from producers that omit empty fields; +// * an ABSENT optional field decodes to None — not the same thing as an explicit null, and +// getting it wrong would reject valid documents from producers that omit empty fields; // * unknown keys are ignored, which is what makes forward-compatible readers possible; // * an absent REQUIRED field is an error rather than a default. // -// `Holder` is used because it is any-bearing, which is precisely the case that takes the -// explicit path; an any-free type would delegate to serde and prove nothing. +// `Holder` is used because it is any-bearing, which is the most intricate decode path. #![allow(dead_code)] use baboon_rs_stub::any_opaque::{AnyMeta, AnyOpaque, AnyOpaqueJson}; @@ -59,18 +57,6 @@ fn decode(wire: serde_json::Value) -> Result { Holder::decode_json(&BaboonCodecContext::Compact, &wire).map_err(|e| format!("{}", e)) } -/// Each case is also checked against serde, so the assertions record the derive's behaviour -/// rather than my reading of it. -fn assert_agrees_with_derive(wire: &serde_json::Value) { - let explicit = decode(wire.clone()); - let derived: Result = serde_json::from_value(wire.clone()).map_err(|e| format!("{}", e)); - match (&explicit, &derived) { - (Ok(a), Ok(b)) => assert_eq!(a, b, "explicit and derived decode disagree"), - (Err(_), Err(_)) => {} - _ => panic!("explicit and derived disagree on acceptance: explicit={:?} derived={:?}", explicit.is_ok(), derived.is_ok()), - } -} - #[test] fn absent_optional_field_decodes_to_none() { let mut wire = holder_wire(); @@ -79,7 +65,6 @@ fn absent_optional_field_decodes_to_none() { let decoded = decode(wire.clone()).expect("an absent optional field must decode, not fail"); assert!(decoded.f_opt.is_none()); - assert_agrees_with_derive(&wire); } #[test] @@ -87,7 +72,6 @@ fn explicit_null_optional_field_decodes_to_none() { let wire = serde_json::Value::Object(holder_wire()); let decoded = decode(wire.clone()).expect("an explicit null optional must decode"); assert!(decoded.f_opt.is_none()); - assert_agrees_with_derive(&wire); } #[test] @@ -97,7 +81,6 @@ fn unknown_keys_are_ignored() { let wire = serde_json::Value::Object(wire); decode(wire.clone()).expect("unknown keys must be ignored, not rejected"); - assert_agrees_with_derive(&wire); } #[test] @@ -108,7 +91,6 @@ fn absent_required_field_is_an_error() { let err = decode(wire.clone()).expect_err("an absent required field must fail"); assert!(err.contains("fAny"), "the error must name the missing field; got: {}", err); - assert_agrees_with_derive(&wire); } #[test] diff --git a/test/rs-stub/tests/facade_json_any_ctx_tests.rs b/test/rs-stub/tests/facade_json_any_ctx_tests.rs index 57aef652b..3d45598d5 100644 --- a/test/rs-stub/tests/facade_json_any_ctx_tests.rs +++ b/test/rs-stub/tests/facade_json_any_ctx_tests.rs @@ -2,22 +2,18 @@ // into this stub only by the rs-stub codegen path (rsync + codegen into // target/test-regular/rs-stub/). Run from the codegen'd copy, not the source tree. // -// `serde::Serialize` has no room for a codec context, so neither the derive nor the hand-written -// `Serialize` impls (ADTs, wrapped branches) can reach the facade that an `any` field holding a -// UEBA payload needs to transcode itself to JSON. The generated `encode_json(ctx)` resolves the -// `any` slots first — with the per-field kind byte and static fallbacks, which only the field -// site knows — and then hands the value to serde unchanged. +// `serde::Serialize` has no room for a codec context, which is why the derive it replaced could +// never reach the facade that an `any` field holding a UEBA payload needs to transcode itself to +// JSON. `encode_json(ctx)` carries the context down to every field, including the per-field kind +// byte and static fallbacks that only the field site knows. // // Two properties are pinned here: // 1. `facade.encode_to_json` transcodes UEBA-form `any` payloads when the context carries a // facade, across all six DSL variants plus the opt/lst/map-value nested positions. Before // the fix this failed regardless of context, because the dyn adapter discarded `ctx` and // called `serde_json::to_value` directly. -// 2. For values that carry no UEBA-form `any`, `encode_json` is byte-identical to the derive's -// `serde_json::to_value`. This is the drift guard: the encoder deliberately does NOT -// re-implement the JSON shape (renames, hex bytes, decimal-as-number, timestamps, user -// map-key adapters, ADT wrapping) — serde still produces the document, so any divergence -// here means the resolve step corrupted something it should have left alone. +// 2. Whatever `encode_json` writes, `decode_json` reads back unchanged — for a JSON-form +// any-bearing value and for an any-free type alike. #![allow(dead_code)] use baboon_rs_stub::any_opaque::{AnyMeta, AnyOpaque, AnyOpaqueUeba}; @@ -134,42 +130,39 @@ fn encode_json_rejects_a_payload_whose_kind_contradicts_the_field() { assert!(msg.contains("meta-kind"), "got: {}", msg); } -// ===== drift guard: encode_json must agree with the derive wherever no transcoding happens ===== +// ===== round-trip guard: what encode_json writes, decode_json must read back ================= #[test] -fn encode_json_matches_serde_derive_for_json_form_any() { +fn encode_json_round_trips_a_json_form_any_bearing_value() { let ctx = BaboonCodecContext::with_facade(false, facade_arc()); let mut rnd = BaboonRandom::new(); for _ in 0..32 { let value = random_holder_json(&mut rnd); - let via_encoder = value.encode_json(&ctx).expect("encode_json"); - let via_derive = serde_json::to_value(&value).expect("to_value"); - assert_eq!(via_encoder, via_derive, "encode_json must not reshape a JSON-form value"); + let wire = value.encode_json(&ctx).expect("encode_json"); + let back = Holder::decode_json(&ctx, &wire).expect("decode_json"); + assert_eq!(back, value, "encode_json must not reshape a JSON-form value"); } } #[test] -fn encode_json_matches_serde_derive_for_any_free_types() { +fn encode_json_round_trips_an_any_free_type() { let ctx = BaboonCodecContext::Compact; let mut rnd = BaboonRandom::new(); for _ in 0..32 { let value = random_inner(&mut rnd); - assert_eq!( - value.encode_json(&ctx).expect("encode_json"), - serde_json::to_value(&value).expect("to_value"), - "an any-free type must route straight back to the derive", - ); + let wire = value.encode_json(&ctx).expect("encode_json"); + assert_eq!(Inner::decode_json(&ctx, &wire).expect("decode_json"), value); } } #[test] fn encode_json_on_a_ueba_form_value_is_the_only_thing_that_needs_a_facade() { - // The raw serde path stays ctx-less and must keep refusing UEBA-form payloads: `to_json_value` - // has no facade to reach, and silently emitting bytes would break cross-language readers. + // The facade-less path must keep refusing UEBA-form payloads: `to_json_value` has no facade + // to reach, and silently emitting bytes would break cross-language readers. let mut rnd = BaboonRandom::new(); let value = random_holder(&mut rnd); assert!( - serde_json::to_value(&value).is_err(), - "raw serde must still refuse a UEBA-form `any`", + value.to_json_value().is_err(), + "the facade-less path must still refuse a UEBA-form `any`", ); } diff --git a/test/rs-stub/tests/foreign_map_key_round_trip.rs b/test/rs-stub/tests/foreign_map_key_round_trip.rs index 773f62141..a27472aa1 100644 --- a/test/rs-stub/tests/foreign_map_key_round_trip.rs +++ b/test/rs-stub/tests/foreign_map_key_round_trip.rs @@ -6,7 +6,7 @@ // test suite from the codegen'd copy. // // Round-trip test for Custom-foreign map keys (PR-A / PR-65-D01 fix). -// Verifies that serde_json encode → decode round-trips cleanly when the +// Verifies that the JSON encode → decode round-trips cleanly when the // map key is a wrapper DTO (ItemKey) whose single field is a Custom-foreign type (FStr) // that maps to std::string::String in Rust. @@ -21,11 +21,9 @@ fn holder_foreign_map_key_json_round_trip() { m.insert(ItemKey { v: "beta".to_string() }, "2".to_string()); let original = Holder { m }; - let json = serde_json::to_string(&original) - .expect("serde_json::to_string should succeed"); + let json = original.to_json().expect("to_json should succeed"); - let decoded: Holder = serde_json::from_str(&json) - .expect("serde_json::from_str should succeed"); + let decoded: Holder = Holder::from_json(&json).expect("from_json should succeed"); assert_eq!(decoded, original, "round-trip diverged: {:?} vs {:?}", decoded, original); } @@ -34,11 +32,9 @@ fn holder_foreign_map_key_json_round_trip() { fn holder_foreign_map_key_json_round_trip_empty() { let original = Holder { m: BTreeMap::new() }; - let json = serde_json::to_string(&original) - .expect("serde_json::to_string should succeed"); + let json = original.to_json().expect("to_json should succeed"); - let decoded: Holder = serde_json::from_str(&json) - .expect("serde_json::from_str should succeed"); + let decoded: Holder = Holder::from_json(&json).expect("from_json should succeed"); assert_eq!(decoded, original); } diff --git a/test/rs-stub/tests/key_codec_host_last_wins.rs b/test/rs-stub/tests/key_codec_host_last_wins.rs index 3a56efd52..826e5063d 100644 --- a/test/rs-stub/tests/key_codec_host_last_wins.rs +++ b/test/rs-stub/tests/key_codec_host_last_wins.rs @@ -78,7 +78,7 @@ fn key_codec_host_last_wins() { // Register impl A and observe it in the encode wire form. register_f_str_keycodec(Arc::new(PrefixCodec("A"))); - let encoded_a = serde_json::to_string(&sample).expect("encode under A"); + let encoded_a = sample.to_json().expect("encode under A"); assert!( encoded_a.contains("A:k"), "expected A: prefix in encoded wire form, got {}", @@ -88,7 +88,7 @@ fn key_codec_host_last_wins() { // Register impl B and observe it (NOT A) in the new encode wire form. // Pre-fix: this re-register silently no-ops; encoded_b would still contain "A:k". register_f_str_keycodec(Arc::new(PrefixCodec("B"))); - let encoded_b = serde_json::to_string(&sample).expect("encode under B"); + let encoded_b = sample.to_json().expect("encode under B"); assert!( encoded_b.contains("B:k"), "PR-26.2 last-wins regression: expected B: prefix after re-register, got {}. \ diff --git a/test/rs-stub/tests/map_key_malformed_test.rs b/test/rs-stub/tests/map_key_malformed_test.rs index 094ae78b1..43b268486 100644 --- a/test/rs-stub/tests/map_key_malformed_test.rs +++ b/test/rs-stub/tests/map_key_malformed_test.rs @@ -1,9 +1,9 @@ // PR-F (M24) — cross-language malformed map-key error consistency. // -// Verifies that serde_json::from_str surfaces a "malformed key: ..." error when the -// JSON map key cannot be parsed back into the id type. The Rust deserializer routes -// the parse error through `serde::de::Error::custom(format!("malformed key: {}", e))`, -// so the resulting serde_json::Error message contains "malformed key". +// Verifies that `Holder::from_json` surfaces a "malformed key: ..." error when the +// JSON map key cannot be parsed back into the id type. The explicit JSON decoder routes +// the parse error through `BaboonCodecError::decoder_failure(format!("malformed key: {}", e))`, +// so the resulting error message starts with "malformed key". // // Uses the my.ok.m19.singleid fixture (id ItemId { v: uid }; root data Holder { m: map[ItemId, str] }). // Generated symbols are produced by mdl :test-gen-regular-adt under target/test-regular/rs-stub/. @@ -13,11 +13,11 @@ use baboon_rs_stub::my::ok::m19::singleid::holder::Holder; #[test] fn holder_json_decode_returns_malformed_key_for_bad_map_key() { let bad_json = r#"{"m":{"not_a_valid_id":"v"}}"#; - let result: Result = serde_json::from_str(bad_json); - let err = result.expect_err("expected serde_json error for malformed map key"); - assert!(matches!(err.classify(), serde_json::error::Category::Data), - "expected Data error category but got: {:?}", err.classify()); - let msg = err.to_string(); + let err = Holder::from_json(bad_json).expect_err("expected a decode error for malformed map key"); + let msg = match &err { + baboon_rs_stub::any_opaque::BaboonCodecError::DecoderFailure { message, .. } => message.clone(), + other => panic!("expected a DecoderFailure but got: {:?}", other), + }; assert!(msg.starts_with("malformed key: "), "expected message to start with 'malformed key: ' but got: {msg}"); } diff --git a/test/rs-stub/tests/nested_wrapper_map_key_round_trip.rs b/test/rs-stub/tests/nested_wrapper_map_key_round_trip.rs index a1236d2aa..f95750cbd 100644 --- a/test/rs-stub/tests/nested_wrapper_map_key_round_trip.rs +++ b/test/rs-stub/tests/nested_wrapper_map_key_round_trip.rs @@ -40,11 +40,9 @@ fn holder_nested_wrapper_map_key_json_round_trip() { ); let original = Holder { m }; - let json = serde_json::to_string(&original) - .expect("serde_json::to_string should succeed"); + let json = original.to_json().expect("to_json should succeed"); - let decoded: Holder = serde_json::from_str(&json) - .expect("serde_json::from_str should succeed"); + let decoded: Holder = Holder::from_json(&json).expect("from_json should succeed"); assert_eq!(decoded, original, "round-trip diverged: {:?} vs {:?}", decoded, original); } @@ -53,11 +51,9 @@ fn holder_nested_wrapper_map_key_json_round_trip() { fn holder_nested_wrapper_map_key_json_round_trip_empty() { let original = Holder { m: BTreeMap::new() }; - let json = serde_json::to_string(&original) - .expect("serde_json::to_string should succeed"); + let json = original.to_json().expect("to_json should succeed"); - let decoded: Holder = serde_json::from_str(&json) - .expect("serde_json::from_str should succeed"); + let decoded: Holder = Holder::from_json(&json).expect("from_json should succeed"); assert_eq!(decoded, original, "round-trip diverged: {:?} vs {:?}", decoded, original); } diff --git a/test/rust-stub-mcp-overlay/tests/mcp_tests.rs b/test/rust-stub-mcp-overlay/tests/mcp_tests.rs index c58206f4b..aae4ee737 100644 --- a/test/rust-stub-mcp-overlay/tests/mcp_tests.rs +++ b/test/rust-stub-mcp-overlay/tests/mcp_tests.rs @@ -302,7 +302,7 @@ fn ref_schema_list_collections() -> serde_json::Value { "type": "object", "properties": { "tags": { "type": "array", "items": { "type": "string" } }, - "uniqueIds": { "type": "array", "items": { "type": "integer", "format": "int64" }, "uniqueItems": true }, + "uniqueIds": { "type": "array", "items": { "type": "string", "format": "int64" }, "uniqueItems": true }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, "byColor": { "type": "object", diff --git a/test/scala-stub-mcp-overlay/src/test/scala/McpTests.scala b/test/scala-stub-mcp-overlay/src/test/scala/McpTests.scala index 727804c3d..c96b8fa04 100644 --- a/test/scala-stub-mcp-overlay/src/test/scala/McpTests.scala +++ b/test/scala-stub-mcp-overlay/src/test/scala/McpTests.scala @@ -298,7 +298,7 @@ class McpTests extends AnyFlatSpec with Matchers { | "type": "object", | "properties": { | "tags": { "type": "array", "items": { "type": "string" } }, - | "uniqueIds": { "type": "array", "items": { "type": "integer", "format": "int64" }, "uniqueItems": true }, + | "uniqueIds": { "type": "array", "items": { "type": "string", "format": "int64" }, "uniqueItems": true }, | "labels": { "type": "object", "additionalProperties": { "type": "string" } }, | "byColor": { | "type": "object", diff --git a/test/services/rs/Cargo.toml b/test/services/rs/Cargo.toml index 86e977875..c9930083a 100644 --- a/test/services/rs/Cargo.toml +++ b/test/services/rs/Cargo.toml @@ -6,7 +6,7 @@ rust-version = "1.75" [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["arbitrary_precision"] } +serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } rust_decimal = { version = "1", features = ["serde-with-str"] } diff --git a/test/swift-stub-mcp-overlay/Tests/McpTests/McpTests.swift b/test/swift-stub-mcp-overlay/Tests/McpTests/McpTests.swift index 7cb0d49de..1eda7af0a 100644 --- a/test/swift-stub-mcp-overlay/Tests/McpTests/McpTests.swift +++ b/test/swift-stub-mcp-overlay/Tests/McpTests/McpTests.swift @@ -54,7 +54,7 @@ final class McpTests: XCTestCase { // [String: Any] shape the server returns, then deep-compared. // ----------------------------------------------------------------------- - private let refListCollections = ##"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"uniqueIds":{"type":"array","items":{"type":"integer","format":"int64"},"uniqueItems":true},"labels":{"type":"object","additionalProperties":{"type":"string"}},"byColor":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","enum":["Red","Green","Blue"]}}},"required":["tags","uniqueIds","labels","byColor"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]}}}"## + private let refListCollections = ##"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"uniqueIds":{"type":"array","items":{"type":"string","format":"int64"},"uniqueItems":true},"labels":{"type":"object","additionalProperties":{"type":"string"}},"byColor":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","enum":["Red","Green","Blue"]}}},"required":["tags","uniqueIds","labels","byColor"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]}}}"## private let refSubmitComposite = ##"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"nested":{"$ref":"#/$defs/mcp_stub_Nested"},"maybePoint":{"oneOf":[{"$ref":"#/$defs/mcp_stub_Point"},{"type":"null"}]},"color":{"$ref":"#/$defs/mcp_stub_Color"},"fancy":{"type":"string"}},"required":["nested","color","fancy"],"$defs":{"mcp_stub_Color":{"type":"string","enum":["Red","Green","Blue"]},"mcp_stub_Nested":{"type":"object","properties":{"point":{"$ref":"#/$defs/mcp_stub_Point"},"color":{"$ref":"#/$defs/mcp_stub_Color"},"label":{"oneOf":[{"type":"string"},{"type":"null"}]}},"required":["point","color"]},"mcp_stub_Point":{"type":"object","properties":{"x":{"type":"integer","format":"int32"},"y":{"type":"integer","format":"int32"}},"required":["x","y"]}}}"## @@ -383,7 +383,7 @@ final class McpTests: XCTestCase { // object with enum wire-name keys. Send a NON-EMPTY object conforming to the // inputSchema (exercises the enum key-codec path). let resp = send(server, session, stub, JsonRpcRequest(4, "tools/call", - parseAny(#"{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":[1,2],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}"#))) + parseAny(#"{"name":"McpTools_listCollections","arguments":{"tags":["a","b"],"uniqueIds":["1","2"],"labels":{"k":"v"},"byColor":{"Green":"ok","Red":"stop"}}}"#))) XCTAssertNil(resp.error, "Unexpected error on listCollections call") let result = resp.result as! [String: Any] diff --git a/test/ts-stub-mcp-overlay/src/mcp.test.ts b/test/ts-stub-mcp-overlay/src/mcp.test.ts index 9669ad5fd..4f089f6a8 100644 --- a/test/ts-stub-mcp-overlay/src/mcp.test.ts +++ b/test/ts-stub-mcp-overlay/src/mcp.test.ts @@ -358,7 +358,7 @@ describe("MCP §2: tools/list and AJV inputSchema validation", () => { // keys (matches the wire the codecs emit and the reconciled inputSchema). const valid = validate({ tags: ["a", "b"], - uniqueIds: [1, 2], + uniqueIds: ["1", "2"], labels: { k: "v" }, byColor: { Green: "ok", Red: "stop" }, }); diff --git a/test/ts-stub/src/runtime-tests/Int64Precision.test.ts b/test/ts-stub/src/runtime-tests/Int64Precision.test.ts new file mode 100644 index 000000000..cd5842f81 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/Int64Precision.test.ts @@ -0,0 +1,49 @@ +// NOTE: references generated symbols under src/generated/** which are produced into this stub +// only by `mdl :build :test-gen-regular-adt`. Run from the codegen'd copy. +// +// 64-bit integers are the one place where JSON's number type cannot carry Baboon's value range. +// Backends disagree about the wire form — C#/Java/Kotlin/Rust write numbers, TypeScript/Dart/ +// Swift write decimal strings (docs/json-codecs.md) — and every other backend's decoder accepts +// both. TypeScript's assumed a string and passed whatever it received to `BigInt(...)`. +// +// The failure that mattered: by the time a codec runs, `JSON.parse` has already rounded a numeric +// token beyond Number.MAX_SAFE_INTEGER, so Int64.MaxValue written by C# as +// `{"appPortalId":9223372036854775807}` arrived as 9223372036854775808n — a different, wrong, +// perfectly valid-looking integer. The original is unrecoverable at this layer, so the decoder +// must either read the exact string form or refuse; it must never round silently. +import { describe, expect, test } from 'vitest'; +import { BaboonInt64 } from '../generated/BaboonSharedRuntime'; + +describe('BaboonInt64.read', () => { + test('reads the exact value from the decimal-string form', () => { + expect(BaboonInt64.read('9223372036854775807', 'x')).toBe(9223372036854775807n); + expect(BaboonInt64.read('-9223372036854775808', 'x')).toBe(-9223372036854775808n); + expect(BaboonInt64.read('18446744073709551615', 'x')).toBe(18446744073709551615n); + }); + + test('accepts numeric tokens that JSON.parse can carry losslessly', () => { + expect(BaboonInt64.read(42, 'x')).toBe(42n); + expect(BaboonInt64.read(-42, 'x')).toBe(-42n); + expect(BaboonInt64.read(Number.MAX_SAFE_INTEGER, 'x')).toBe(BigInt(Number.MAX_SAFE_INTEGER)); + }); + + test('refuses a numeric token that JSON.parse has already rounded', () => { + // This is what a C#-written Int64.MaxValue looks like after JSON.parse. + const rounded = JSON.parse('{"v":9223372036854775807}').v; + expect(rounded).toBe(9223372036854775808); // precision is gone before we are called + expect(() => BaboonInt64.read(rounded, 'v')).toThrow(/exceeds Number.MAX_SAFE_INTEGER/); + }); + + test('refuses non-integral and non-numeric input', () => { + expect(() => BaboonInt64.read(1.5, 'x')).toThrow(/not an integer/); + expect(() => BaboonInt64.read('nope', 'x')).toThrow(/not a 64-bit integer/); + expect(() => BaboonInt64.read(null, 'x')).toThrow(/expected a decimal string or a number/); + expect(() => BaboonInt64.read({}, 'x')).toThrow(/expected a decimal string or a number/); + }); + + test('round-trips the string form this backend emits', () => { + const original = 9223372036854775807n; + const wire = JSON.parse(JSON.stringify({ v: original.toString() })).v; + expect(BaboonInt64.read(wire, 'v')).toBe(original); + }); +}); From a11da1fe02f34cb57553dc025593b64bb7148544 Mon Sep 17 00:00:00 2001 From: Pavel Shirshov Date: Thu, 24 Sep 2026 22:23:33 +0100 Subject: [PATCH 48/48] test(json): pin the 64-bit reader leniency that backward compatibility rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `i64`/`u64` now go on the wire as decimal strings, and documents written before that change stay readable only because every reader still accepts a JSON number. Nothing writes numbers any more, so the number arm of all ten decoders was dead as far as the suite was concerned — one reader-side simplification away from silently dropping backward compatibility. One test per backend, decoding the `identifier.ok` fixture (`id LongId { x: i64 }`, `id UInts { …, d: u64 }`) from both wire forms. Kotlin carries a fifth case: an older Kotlin writer emitted `u64` as `JsonPrimitive(value.toLong())`, so values above `Long.MAX_VALUE` went out as negative numbers, and that form has to keep reading back as the same value. Confirmed the tests actually run rather than passing by absence — 42 cases across C# (4), Scala (4), Python (4), Rust (4), TypeScript (4), Kotlin (5), Kotlin-KMP (5), Java (4), Dart (4), Swift (4) — and that they fail for the right reason: deleting the `Value::Number` arm from Rust's `read_i64`/`read_u64`, and the `typeof wire === "number"` branch from TypeScript's `BaboonInt64.read`, fails exactly the two legacy-numeric cases in each while the string cases stay green. Verified with `mdl --seq :ci` (green). Co-Authored-By: Claude Opus 5 (1M context) --- .../BaboonTests/LegacyInt64WireTests.cs | 57 +++++++++++++++++++ .../test/runtime/legacy_int64_wire_test.dart | 46 +++++++++++++++ .../java/runtime/LegacyInt64WireTest.java | 48 ++++++++++++++++ .../kotlin/runtime/LegacyInt64WireTest.kt | 56 ++++++++++++++++++ .../kotlin/runtime/LegacyInt64WireTest.kt | 56 ++++++++++++++++++ .../RuntimeTests/test_legacy_int64_wire.py | 39 +++++++++++++ test/rs-stub/tests/legacy_int64_wire_test.rs | 36 ++++++++++++ .../scala/runtime/LegacyInt64WireSpec.scala | 41 +++++++++++++ .../RuntimeTests/LegacyInt64WireTests.swift | 43 ++++++++++++++ .../src/runtime-tests/LegacyInt64Wire.test.ts | 41 +++++++++++++ 10 files changed, 463 insertions(+) create mode 100644 test/cs-stub/BaboonTests/LegacyInt64WireTests.cs create mode 100644 test/dt-stub/test/runtime/legacy_int64_wire_test.dart create mode 100644 test/jv-stub/src/test/java/runtime/LegacyInt64WireTest.java create mode 100644 test/kt-stub-kmp/src/test/kotlin/runtime/LegacyInt64WireTest.kt create mode 100644 test/kt-stub/src/test/kotlin/runtime/LegacyInt64WireTest.kt create mode 100644 test/py-stub/BaboonTests/RuntimeTests/test_legacy_int64_wire.py create mode 100644 test/rs-stub/tests/legacy_int64_wire_test.rs create mode 100644 test/sc-stub/src/test/scala/runtime/LegacyInt64WireSpec.scala create mode 100644 test/sw-stub/Tests/RuntimeTests/LegacyInt64WireTests.swift create mode 100644 test/ts-stub/src/runtime-tests/LegacyInt64Wire.test.ts diff --git a/test/cs-stub/BaboonTests/LegacyInt64WireTests.cs b/test/cs-stub/BaboonTests/LegacyInt64WireTests.cs new file mode 100644 index 000000000..4947394c5 --- /dev/null +++ b/test/cs-stub/BaboonTests/LegacyInt64WireTests.cs @@ -0,0 +1,57 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. It is the only thing standing between a reader-side +// simplification and silently dropping backward compatibility. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt under +// target/test-regular/cs-stub/. +#nullable enable + +using Baboon.Runtime.Shared; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace ConversionsTest +{ + [TestFixture] + public class LegacyInt64WireTests + { + private readonly BaboonCodecContext _ctx = BaboonCodecContext.Compact; + + [Test] + public void I64DecodesFromTheLegacyNumericForm() + { + var wire = JToken.Parse("{\"x\":-9007199254740991}"); + var decoded = Identifier.Ok.LongId_JsonCodec.Instance.Decode(_ctx, wire); + Assert.That(decoded.X, Is.EqualTo(-9007199254740991L)); + } + + [Test] + public void I64DecodesFromTheStringForm() + { + var wire = JToken.Parse("{\"x\":\"-9223372036854775808\"}"); + var decoded = Identifier.Ok.LongId_JsonCodec.Instance.Decode(_ctx, wire); + Assert.That(decoded.X, Is.EqualTo(long.MinValue)); + } + + [Test] + public void U64DecodesFromTheLegacyNumericForm() + { + var wire = JToken.Parse("{\"a\":1,\"b\":2,\"c\":3,\"d\":42}"); + var decoded = Identifier.Ok.UInts_JsonCodec.Instance.Decode(_ctx, wire); + Assert.That(decoded.D, Is.EqualTo(42UL)); + } + + [Test] + public void U64DecodesFromTheStringForm() + { + var wire = JToken.Parse("{\"a\":1,\"b\":2,\"c\":3,\"d\":\"42\"}"); + var decoded = Identifier.Ok.UInts_JsonCodec.Instance.Decode(_ctx, wire); + Assert.That(decoded.D, Is.EqualTo(42UL)); + } + } +} diff --git a/test/dt-stub/test/runtime/legacy_int64_wire_test.dart b/test/dt-stub/test/runtime/legacy_int64_wire_test.dart new file mode 100644 index 000000000..de8faf9b5 --- /dev/null +++ b/test/dt-stub/test/runtime/legacy_int64_wire_test.dart @@ -0,0 +1,46 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. + +import 'dart:convert'; + +import 'package:baboon_runtime/baboon_runtime.dart'; +import 'package:dt_stub/identifier/ok/long_id.dart'; +import 'package:dt_stub/identifier/ok/u_ints.dart'; +import 'package:test/test.dart'; + +void main() { + group('64-bit integers decode from both wire forms', () { + final ctx = BaboonCodecContext.compact; + + test('i64 decodes from the legacy numeric form', () { + final decoded = LongId_JsonCodec.instance + .decode(ctx, jsonDecode('{"x":-9007199254740991}')); + expect(decoded.x, equals(-9007199254740991)); + }); + + test('i64 decodes from the string form', () { + final decoded = LongId_JsonCodec.instance + .decode(ctx, jsonDecode('{"x":"-9223372036854775808"}')); + expect(decoded.x, equals(-9223372036854775808)); + }); + + test('u64 decodes from the legacy numeric form', () { + final decoded = UInts_JsonCodec.instance + .decode(ctx, jsonDecode('{"a":1,"b":2,"c":3,"d":42}')); + expect(decoded.d, equals(42)); + }); + + test('u64 decodes from the string form', () { + final decoded = UInts_JsonCodec.instance + .decode(ctx, jsonDecode('{"a":1,"b":2,"c":3,"d":"42"}')); + expect(decoded.d, equals(42)); + }); + }); +} diff --git a/test/jv-stub/src/test/java/runtime/LegacyInt64WireTest.java b/test/jv-stub/src/test/java/runtime/LegacyInt64WireTest.java new file mode 100644 index 000000000..68331b37c --- /dev/null +++ b/test/jv-stub/src/test/java/runtime/LegacyInt64WireTest.java @@ -0,0 +1,48 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. +package runtime; + +import baboon.runtime.shared.BaboonCodecContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import identifier.ok.LongId_JsonCodec; +import identifier.ok.UInts_JsonCodec; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class LegacyInt64WireTest { + + private final BaboonCodecContext ctx = BaboonCodecContext.Compact; + private final ObjectMapper om = new ObjectMapper(); + + @Test + void i64DecodesFromTheLegacyNumericForm() throws Exception { + var decoded = LongId_JsonCodec.INSTANCE.decode(ctx, om.readTree("{\"x\":-9007199254740991}")); + assertEquals(-9007199254740991L, decoded.x()); + } + + @Test + void i64DecodesFromTheStringForm() throws Exception { + var decoded = LongId_JsonCodec.INSTANCE.decode(ctx, om.readTree("{\"x\":\"-9223372036854775808\"}")); + assertEquals(Long.MIN_VALUE, decoded.x()); + } + + @Test + void u64DecodesFromTheLegacyNumericForm() throws Exception { + var decoded = UInts_JsonCodec.INSTANCE.decode(ctx, om.readTree("{\"a\":1,\"b\":2,\"c\":3,\"d\":42}")); + assertEquals(42L, decoded.d()); + } + + @Test + void u64DecodesFromTheStringForm() throws Exception { + var decoded = UInts_JsonCodec.INSTANCE.decode(ctx, om.readTree("{\"a\":1,\"b\":2,\"c\":3,\"d\":\"42\"}")); + assertEquals(42L, decoded.d()); + } +} diff --git a/test/kt-stub-kmp/src/test/kotlin/runtime/LegacyInt64WireTest.kt b/test/kt-stub-kmp/src/test/kotlin/runtime/LegacyInt64WireTest.kt new file mode 100644 index 000000000..ce11537ba --- /dev/null +++ b/test/kt-stub-kmp/src/test/kotlin/runtime/LegacyInt64WireTest.kt @@ -0,0 +1,56 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. +package runtime + +import baboon.runtime.shared.BaboonCodecContext +import identifier.ok.LongId_JsonCodec +import identifier.ok.UInts_JsonCodec +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LegacyInt64WireTest { + + private val ctx = BaboonCodecContext.Compact + + @Test + fun i64DecodesFromTheLegacyNumericForm() { + val decoded = LongId_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"x":-9007199254740991}""")) + assertEquals(-9007199254740991L, decoded.x) + } + + @Test + fun i64DecodesFromTheStringForm() { + val decoded = LongId_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"x":"-9223372036854775808"}""")) + assertEquals(Long.MIN_VALUE, decoded.x) + } + + @Test + fun u64DecodesFromTheLegacyNumericForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":42}""")) + assertTrue(decoded.d == 42uL, "expected 42, got ${decoded.d}") + } + + @Test + fun u64DecodesFromTheStringForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":"42"}""")) + assertTrue(decoded.d == 42uL, "expected 42, got ${decoded.d}") + } + + // An older Kotlin writer emitted u64 as `JsonPrimitive(value.toLong())`, so anything above + // Long.MAX_VALUE went out as a negative number. That form has to keep reading back as the + // same value. + @Test + fun u64DecodesFromTheLegacySignedNumericForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":-1}""")) + assertTrue(decoded.d == ULong.MAX_VALUE, "expected ULong.MAX_VALUE, got ${decoded.d}") + } +} diff --git a/test/kt-stub/src/test/kotlin/runtime/LegacyInt64WireTest.kt b/test/kt-stub/src/test/kotlin/runtime/LegacyInt64WireTest.kt new file mode 100644 index 000000000..ce11537ba --- /dev/null +++ b/test/kt-stub/src/test/kotlin/runtime/LegacyInt64WireTest.kt @@ -0,0 +1,56 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. +package runtime + +import baboon.runtime.shared.BaboonCodecContext +import identifier.ok.LongId_JsonCodec +import identifier.ok.UInts_JsonCodec +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LegacyInt64WireTest { + + private val ctx = BaboonCodecContext.Compact + + @Test + fun i64DecodesFromTheLegacyNumericForm() { + val decoded = LongId_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"x":-9007199254740991}""")) + assertEquals(-9007199254740991L, decoded.x) + } + + @Test + fun i64DecodesFromTheStringForm() { + val decoded = LongId_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"x":"-9223372036854775808"}""")) + assertEquals(Long.MIN_VALUE, decoded.x) + } + + @Test + fun u64DecodesFromTheLegacyNumericForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":42}""")) + assertTrue(decoded.d == 42uL, "expected 42, got ${decoded.d}") + } + + @Test + fun u64DecodesFromTheStringForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":"42"}""")) + assertTrue(decoded.d == 42uL, "expected 42, got ${decoded.d}") + } + + // An older Kotlin writer emitted u64 as `JsonPrimitive(value.toLong())`, so anything above + // Long.MAX_VALUE went out as a negative number. That form has to keep reading back as the + // same value. + @Test + fun u64DecodesFromTheLegacySignedNumericForm() { + val decoded = UInts_JsonCodec.decode(ctx, Json.parseToJsonElement("""{"a":1,"b":2,"c":3,"d":-1}""")) + assertTrue(decoded.d == ULong.MAX_VALUE, "expected ULong.MAX_VALUE, got ${decoded.d}") + } +} diff --git a/test/py-stub/BaboonTests/RuntimeTests/test_legacy_int64_wire.py b/test/py-stub/BaboonTests/RuntimeTests/test_legacy_int64_wire.py new file mode 100644 index 000000000..5f501abf0 --- /dev/null +++ b/test/py-stub/BaboonTests/RuntimeTests/test_legacy_int64_wire.py @@ -0,0 +1,39 @@ +# 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +# Readers stay lenient about the JSON-number form an older compiler produced, and that +# leniency is what keeps documents written before the change readable. +# +# Nothing writes numbers any more, so without this test the number arm of every decoder is +# dead as far as the suite is concerned. +# +# Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +# d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. + +import unittest + +from BaboonDefinitions.Generated.baboon_codecs import BaboonCodecContext +from BaboonDefinitions.Generated.identifier.ok.LongId import LongId_JsonCodec +from BaboonDefinitions.Generated.identifier.ok.UInts import UInts_JsonCodec + + +class LegacyInt64WireTest(unittest.TestCase): + ctx = BaboonCodecContext.Compact + + def test_i64_decodes_from_the_legacy_numeric_form(self): + decoded = LongId_JsonCodec.instance().decode(self.ctx, '{"x":-9007199254740991}') + self.assertEqual(-9007199254740991, decoded.x) + + def test_i64_decodes_from_the_string_form(self): + decoded = LongId_JsonCodec.instance().decode(self.ctx, '{"x":"-9223372036854775808"}') + self.assertEqual(-9223372036854775808, decoded.x) + + def test_u64_decodes_from_the_legacy_numeric_form(self): + decoded = UInts_JsonCodec.instance().decode(self.ctx, '{"a":1,"b":2,"c":3,"d":42}') + self.assertEqual(42, decoded.d) + + def test_u64_decodes_from_the_string_form(self): + decoded = UInts_JsonCodec.instance().decode(self.ctx, '{"a":1,"b":2,"c":3,"d":"42"}') + self.assertEqual(42, decoded.d) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/rs-stub/tests/legacy_int64_wire_test.rs b/test/rs-stub/tests/legacy_int64_wire_test.rs new file mode 100644 index 000000000..d75a09adc --- /dev/null +++ b/test/rs-stub/tests/legacy_int64_wire_test.rs @@ -0,0 +1,36 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. + +use baboon_rs_stub::identifier::ok::long_id::LongId; +use baboon_rs_stub::identifier::ok::u_ints::UInts; + +#[test] +fn i64_decodes_from_the_legacy_numeric_form() { + let decoded = LongId::from_json(r#"{"x":-9007199254740991}"#).expect("decode"); + assert_eq!(decoded.x, -9007199254740991i64); +} + +#[test] +fn i64_decodes_from_the_string_form() { + let decoded = LongId::from_json(r#"{"x":"-9223372036854775808"}"#).expect("decode"); + assert_eq!(decoded.x, i64::MIN); +} + +#[test] +fn u64_decodes_from_the_legacy_numeric_form() { + let decoded = UInts::from_json(r#"{"a":1,"b":2,"c":3,"d":42}"#).expect("decode"); + assert_eq!(decoded.d, 42u64); +} + +#[test] +fn u64_decodes_from_the_string_form() { + let decoded = UInts::from_json(r#"{"a":1,"b":2,"c":3,"d":"42"}"#).expect("decode"); + assert_eq!(decoded.d, 42u64); +} diff --git a/test/sc-stub/src/test/scala/runtime/LegacyInt64WireSpec.scala b/test/sc-stub/src/test/scala/runtime/LegacyInt64WireSpec.scala new file mode 100644 index 000000000..df042647e --- /dev/null +++ b/test/sc-stub/src/test/scala/runtime/LegacyInt64WireSpec.scala @@ -0,0 +1,41 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. +package runtime + +import baboon.runtime.shared.BaboonCodecContext +import io.circe.parser.parse +import org.scalatest.funsuite.AnyFunSuite + +class LegacyInt64WireSpec extends AnyFunSuite { + + private val ctx = BaboonCodecContext.Compact + + private def json(s: String) = parse(s).getOrElse(fail(s"test setup: invalid JSON literal: $s")) + + test("i64 decodes from the legacy numeric form") { + val decoded = identifier.ok.LongId_JsonCodec.instance.decode(ctx, json("""{"x":-9007199254740991}""")) + assert(decoded == Right(identifier.ok.LongId(-9007199254740991L))) + } + + test("i64 decodes from the string form") { + val decoded = identifier.ok.LongId_JsonCodec.instance.decode(ctx, json("""{"x":"-9223372036854775808"}""")) + assert(decoded == Right(identifier.ok.LongId(Long.MinValue))) + } + + test("u64 decodes from the legacy numeric form") { + val decoded = identifier.ok.UInts_JsonCodec.instance.decode(ctx, json("""{"a":1,"b":2,"c":3,"d":42}""")) + assert(decoded.map(_.d) == Right(42L)) + } + + test("u64 decodes from the string form") { + val decoded = identifier.ok.UInts_JsonCodec.instance.decode(ctx, json("""{"a":1,"b":2,"c":3,"d":"42"}""")) + assert(decoded.map(_.d) == Right(42L)) + } +} diff --git a/test/sw-stub/Tests/RuntimeTests/LegacyInt64WireTests.swift b/test/sw-stub/Tests/RuntimeTests/LegacyInt64WireTests.swift new file mode 100644 index 000000000..8db8d7d33 --- /dev/null +++ b/test/sw-stub/Tests/RuntimeTests/LegacyInt64WireTests.swift @@ -0,0 +1,43 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. + +import XCTest +import Foundation +@testable import BaboonRuntime +@testable import IdentifierOk + +final class LegacyInt64WireTests: XCTestCase { + + private let ctx = BaboonCodecContext.compact + + private func parse(_ s: String) throws -> Any { + try JSONSerialization.jsonObject(with: Data(s.utf8), options: []) + } + + func testI64DecodesFromTheLegacyNumericForm() throws { + let decoded = try LongId_JsonCodec.instance.decode(ctx, parse(#"{"x":-9007199254740991}"#)) + XCTAssertEqual(decoded.x, -9007199254740991) + } + + func testI64DecodesFromTheStringForm() throws { + let decoded = try LongId_JsonCodec.instance.decode(ctx, parse(#"{"x":"-9223372036854775808"}"#)) + XCTAssertEqual(decoded.x, Int64.min) + } + + func testU64DecodesFromTheLegacyNumericForm() throws { + let decoded = try UInts_JsonCodec.instance.decode(ctx, parse(#"{"a":1,"b":2,"c":3,"d":42}"#)) + XCTAssertEqual(decoded.d, 42) + } + + func testU64DecodesFromTheStringForm() throws { + let decoded = try UInts_JsonCodec.instance.decode(ctx, parse(#"{"a":1,"b":2,"c":3,"d":"42"}"#)) + XCTAssertEqual(decoded.d, 42) + } +} diff --git a/test/ts-stub/src/runtime-tests/LegacyInt64Wire.test.ts b/test/ts-stub/src/runtime-tests/LegacyInt64Wire.test.ts new file mode 100644 index 000000000..66bc4f2b9 --- /dev/null +++ b/test/ts-stub/src/runtime-tests/LegacyInt64Wire.test.ts @@ -0,0 +1,41 @@ +// 64-bit integers are written as decimal strings (docs/json-codecs.md, "64-bit integers"). +// Readers stay lenient about the JSON-number form an older compiler produced, and that +// leniency is what keeps documents written before the change readable. +// +// Nothing writes numbers any more, so without this test the number arm of every decoder is +// dead as far as the suite is concerned. TypeScript is the one backend where the leniency +// is bounded: a numeric token above Number.MAX_SAFE_INTEGER was already destroyed by +// JSON.parse, so it is refused rather than rounded (see Int64Precision.test.ts). +// +// Uses the identifier.ok fixture (id LongId { x: i64 }; id UInts { a: u08, b: u16, c: u32, +// d: u64 }); generated symbols are produced by mdl :test-gen-regular-adt. + +import { describe, expect, test } from "vitest"; + +import { BaboonCodecContext } from "../generated/BaboonSharedRuntime"; +import { LongId_JsonCodec } from "../generated/identifier/ok/LongId"; +import { UInts_JsonCodec } from "../generated/identifier/ok/UInts"; + +describe("64-bit integers decode from both wire forms", () => { + const ctx = BaboonCodecContext.Compact; + + test("i64 decodes from the legacy numeric form", () => { + const decoded = LongId_JsonCodec.instance.decode(ctx, JSON.parse('{"x":-9007199254740991}')); + expect(decoded.x).toBe(-9007199254740991n); + }); + + test("i64 decodes from the string form", () => { + const decoded = LongId_JsonCodec.instance.decode(ctx, JSON.parse('{"x":"-9223372036854775808"}')); + expect(decoded.x).toBe(-9223372036854775808n); + }); + + test("u64 decodes from the legacy numeric form", () => { + const decoded = UInts_JsonCodec.instance.decode(ctx, JSON.parse('{"a":1,"b":2,"c":3,"d":42}')); + expect(decoded.d).toBe(42n); + }); + + test("u64 decodes from the string form", () => { + const decoded = UInts_JsonCodec.instance.decode(ctx, JSON.parse('{"a":1,"b":2,"c":3,"d":"42"}')); + expect(decoded.d).toBe(42n); + }); +});