Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,9 +712,8 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat
`gh pr merge --admin --merge --delete-branch=false` to bypass
branch protection when merging development→main for releases
45. **RiddlLib.ast2bast(root)** - Converts parsed AST to BAST
binary bytes. Shared trait returns `Array[Byte]`, JS facade
returns `Int8Array`. Uses BASTWriterPass internally. Test
verifies BAST magic header bytes
binary bytes. Returns `RiddlResult[Array[Byte]]` (shared) /
`RiddlResult<Int8Array>` (TS). Uses BASTWriterPass internally
46. **Consumer update notes** - `RIDDL-UPDATE-NOTES.md` in
synapify, riddl-mcp-server, ossum.ai covers 1.5.0 breaking
change (opaque Root) and 1.7.0 new functions. Separate from
Expand All @@ -734,3 +733,11 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat
handlers. The parent-keyed overload fails because the
resolution pass stores refs keyed under the OnMessageClause
parent, not the adaptor's parent
50. **RiddlResult[T] replaces Either[Messages, T]** — Sealed
ADT with `Success[T]` and `Failure` cases. All RiddlLib
methods that previously returned `Either` now return
`RiddlResult`. Use `result.toEither` for backward compat.
TypeScript: `RiddlResult<T>` (deprecated `ParseResult<T>`
alias kept). Lives in `RiddlResult.scala` alongside
`RiddlLib.scala`. `ast2bast` now surfaces errors via
`RiddlResult` instead of silently returning empty array
74 changes: 52 additions & 22 deletions NOTEBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This is the central engineering notebook for the RIDDL project. It tracks curren

## Current Status

**Last Updated**: February 11, 2026
**Last Updated**: February 14, 2026

**Scala Version**: 3.7.4 (overrides sbt-ossuminc's 3.3.7 LTS
default due to compiler infinite loop bug with opaque
Expand Down Expand Up @@ -248,29 +248,59 @@ The `pseudoCodeBlock` parser now allows comments before and/or after `???`:

---

## Changes Since v1.7.0

- Added `RiddlLib.ast2bast(root)` — converts parsed AST to
BAST binary bytes (shared trait + JS facade)
- Added TypeScript declarations for `getHandlerCompleteness`,
`getMessageFlow`, `getEntityLifecycles`, `ast2bast` with
return type interfaces
- Added `ast2bast` test in `RiddlLibTest`
- ValidationPass: Schema kind-specific deep checks (Flat,
TimeSeries, Hierarchical, Star data/link structure warnings)
- ValidationPass: Relational FK type mismatch upgraded from
Warning to Error
- ValidationPass: Handler message type vs container type
checks (Repository handles events → warning, Projector
handles commands/queries → warning)
- ValidationPass: Adaptor message direction compatibility
(inbound handling commands → error, outbound handling
events → error)
- StreamingValidation: Sink reverse reachability check (warns
if connected sink has no upstream path from any source)
## Changes Since v1.8.2

- **RiddlResult[T] ADT** — New sealed trait replacing
`Either[Messages, T]` as return type for all RiddlLib
methods. `Success[T]` and `Failure` cases with `map`,
`flatMap`, `toEither`. TypeScript: `RiddlResult<T>`
(deprecated `ParseResult<T>` alias kept for migration)
- **ast2bast returns RiddlResult** — Previously returned
`Array[Byte]` (silently empty on failure); now returns
`RiddlResult[Array[Byte]]` with proper error reporting
- **RiddlResult.scala** — New file in riddlLib/shared
- **TypeScript index.d.ts** — `ParseResult<T>` renamed to
`RiddlResult<T>` across all method signatures

## Session Log

### February 14, 2026 (RiddlResult ADT)

**Focus**: Add cross-platform `RiddlResult[T]` result type to
replace ad-hoc `Either[Messages, T]` returns in RiddlLib.

**Work Completed**:
1. **Created `RiddlResult.scala`** — Sealed trait with
`Success[T]` and `Failure` cases. Includes `map`,
`flatMap`, `toEither`, `fromEither` for interop.
2. **Updated `RiddlLib.scala`** — Changed all 10 methods
returning `Either[Messages, T]` to `RiddlResult[T]`.
Changed `ast2bast` from `Array[Byte]` to
`RiddlResult[Array[Byte]]` (surfaces errors properly).
3. **Updated `RiddlAPI.scala`** — `toJsResult` now accepts
`RiddlResult[T]`. `ast2bast` wrapped in `toJsResult`.
4. **Updated `index.d.ts`** — `ParseResult<T>` renamed to
`RiddlResult<T>`. Deprecated alias kept. `ast2bast`
return type changed to `RiddlResult<Int8Array>`.
5. **Updated `RiddlLibTest.scala`** — All tests use
`RiddlResult.Success`/`RiddlResult.Failure` matching.

**Test Results**: 13/13 JVM, 11/11 JS — all pass.

**Version**: 1.9.0 (MINOR — new `RiddlResult` type, API
return type changes with `toEither` migration path)

**Files Created**:
- `riddlLib/shared/.../RiddlResult.scala`

**Files Modified**:
- `riddlLib/shared/.../RiddlLib.scala`
- `riddlLib/js/.../RiddlAPI.scala`
- `riddlLib/js/types/index.d.ts`
- `riddlLib/shared/test/.../RiddlLibTest.scala`

---

### February 11, 2026 (ValidationPass Gap Analysis Completion)

**Focus**: Implement remaining 4 items from the Feb 7 gap
Expand Down Expand Up @@ -1737,4 +1767,4 @@ Tool(
## Git Information

**Branch**: `development`
**Latest release**: 1.8.0 (February 11, 2026)
**Latest release**: 1.9.0 (February 14, 2026)
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
* Copyright 2019-2026 Ossum, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/

package com.ossuminc.riddl.commands

import com.ossuminc.riddl.utils.{pc, PlatformContext}
import org.ekrich.config.*
import org.scalatest.matchers.must.Matchers
import org.scalatest.wordspec.AnyWordSpec

import java.nio.file.{Files, Path}
import scala.jdk.StreamConverters.*

/** Comprehensive BAST round-trip test against all riddl-models.
*
* For each model in the riddl-models repository:
* 1. Validate the original RIDDL source
* 2. Bastify (RIDDL -> BAST)
* 3. Unbastify (BAST -> RIDDL via PrettifyPass with flatten=true)
* 4. Prettify original with --single-file (same code path)
* 5. Compare unbastify output with prettified original
*
* Both outputs go through PrettifyPass, so any PrettifyPass
* formatting quirks cancel out. What we're testing is that
* the BAST serialization/deserialization is lossless.
*/
class RiddlModelsRoundTripTest extends AnyWordSpec with Matchers {

given io: PlatformContext = pc

private val riddlModelsDir = Path.of("../riddl-models")

private val commonArgs = Array(
"--quiet",
"--show-missing-warnings=false",
"--show-style-warnings=false",
"--show-usage-warnings=false"
)

"BAST round-trip" should {
assume(
Files.isDirectory(riddlModelsDir),
"riddl-models not found at ../riddl-models — skipping"
)

val confFiles = discoverModels(riddlModelsDir)
assume(confFiles.nonEmpty, "No .conf files found in riddl-models")

confFiles.foreach { case (confFile, riddlFile) =>
val relPath = riddlModelsDir.relativize(confFile.getParent)

s"round-trip $relPath" in {
roundTripTest(confFile, riddlFile)
}
}
}

/** Discover models: find .conf files at depth 3, parse input-file */
private def discoverModels(base: Path): Seq[(Path, Path)] = {
val allConf = Files
.walk(base, 5)
.filter(p =>
p.toString.endsWith(".conf") && Files.isRegularFile(p)
)
.toScala(Seq)

allConf.flatMap { confFile =>
val depth = base.relativize(confFile).getNameCount - 1
if depth == 3 then
parseInputFile(confFile).map(riddlFile =>
(confFile, riddlFile)
)
else None
}
}

/** Parse a .conf file to extract the input-file path */
private def parseInputFile(confFile: Path): Option[Path] = {
try {
val config = ConfigFactory.parseFile(confFile.toFile)
if config.hasPath("validate.input-file") then
val inputFile = config.getString("validate.input-file")
Some(confFile.getParent.resolve(inputFile))
else None
} catch {
case _: ConfigException => None
}
}

/** Run the round-trip for a single model:
* 1. Validate original
* 2. Bastify
* 3. Unbastify (produces flattened .riddl)
* 4. Prettify original with --single-file
* 5. Compare unbastify output with prettified original
*/
private def roundTripTest(
confFile: Path,
riddlFile: Path
): Unit = {
val tempDir = Files.createTempDirectory("bast-roundtrip")
val unbastDir = tempDir.resolve("unbast")
val prettyDir = tempDir.resolve("pretty-original")

try {
val riddlPath = riddlFile.toAbsolutePath.toString
val bastPath = riddlPath.replaceAll("\\.riddl$", ".bast")

// Step 1: Validate original
val validateArgs =
commonArgs ++ Array("validate", riddlPath)
Commands.runMainForTest(validateArgs) match {
case Left(messages) =>
fail(
s"Step 1 (validate original) failed:\n" +
s"${messages.format}"
)
case Right(_) => // ok
}

// Step 2: Bastify
val bastifyArgs =
commonArgs ++ Array("bastify", riddlPath)
Commands.runMainForTest(bastifyArgs) match {
case Left(messages) =>
fail(s"Step 2 (bastify) failed:\n${messages.format}")
case Right(_) =>
assert(
Files.exists(Path.of(bastPath)),
s"BAST file not created: $bastPath"
)
}

try {
// Step 3: Unbastify (uses PrettifyPass with flatten=true)
val unbastifyArgs = commonArgs ++ Array(
"unbastify",
bastPath,
"-o",
unbastDir.toAbsolutePath.toString
)
Commands.runMainForTest(unbastifyArgs) match {
case Left(messages) =>
fail(
s"Step 3 (unbastify) failed:\n${messages.format}"
)
case Right(_) =>
assert(
Files.exists(unbastDir),
s"Unbastify output dir not created"
)
}

// Find the unbastify output file
val outputRiddlFiles = Files
.list(unbastDir)
.filter(p => p.toString.endsWith(".riddl"))
.toScala(Seq)
assert(
outputRiddlFiles.nonEmpty,
"No .riddl files in unbastify output"
)
val unbastContent =
Files.readString(outputRiddlFiles.head)

// Step 4: Prettify original with --single-file
val prettyArgs = commonArgs ++ Array(
"prettify",
riddlPath,
"-o",
prettyDir.toAbsolutePath.toString,
"-s",
"true"
)
Commands.runMainForTest(prettyArgs) match {
case Left(messages) =>
fail(
s"Step 4 (prettify original) failed:\n" +
s"${messages.format}"
)
case Right(_) => // ok
}

val prettyFile =
prettyDir.resolve("prettify-output.riddl")
assert(
Files.exists(prettyFile),
"Prettified original not found"
)
val prettyContent = Files.readString(prettyFile)

// Step 5: Compare unbastify output with prettified
// original. Both go through PrettifyPass, so format
// quirks cancel out. Differences = BAST data loss.
if unbastContent != prettyContent then
val lines1 =
prettyContent.linesIterator.toIndexedSeq
val lines2 =
unbastContent.linesIterator.toIndexedSeq
val firstDiff = lines1
.zipAll(lines2, "<missing>", "<missing>")
.zipWithIndex
.find { case ((a, b), _) => a != b }

firstDiff match {
case Some(((line1, line2), idx)) =>
fail(
s"Round-trip differs at line ${idx + 1}:\n" +
s" original: $line1\n" +
s" round-trip: $line2"
)
case None =>
if lines1.length != lines2.length then
fail(
s"Round-trip differs in length: " +
s"${lines1.length} vs ${lines2.length}"
)
end if
}
end if
} finally {
// Clean up .bast file created next to source
Files.deleteIfExists(Path.of(bastPath))
}
} finally {
deleteRecursively(tempDir)
}
}

private def deleteRecursively(path: Path): Unit = {
if Files.isDirectory(path) then
Files
.list(path)
.forEach(p =>
deleteRecursively(p.asInstanceOf[Path])
)
end if
Files.deleteIfExists(path)
}
}
Loading
Loading