Skip to content

Commit 1e59e2a

Browse files
authored
Merge pull request ossuminc#722 from ossuminc/development
Release 1.9.0: Add RiddlResult[T] ADT
2 parents ce423e0 + 33adc77 commit 1e59e2a

16 files changed

Lines changed: 902 additions & 256 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -712,9 +712,8 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat
712712
`gh pr merge --admin --merge --delete-branch=false` to bypass
713713
branch protection when merging development→main for releases
714714
45. **RiddlLib.ast2bast(root)** - Converts parsed AST to BAST
715-
binary bytes. Shared trait returns `Array[Byte]`, JS facade
716-
returns `Int8Array`. Uses BASTWriterPass internally. Test
717-
verifies BAST magic header bytes
715+
binary bytes. Returns `RiddlResult[Array[Byte]]` (shared) /
716+
`RiddlResult<Int8Array>` (TS). Uses BASTWriterPass internally
718717
46. **Consumer update notes** - `RIDDL-UPDATE-NOTES.md` in
719718
synapify, riddl-mcp-server, ossum.ai covers 1.5.0 breaking
720719
change (opaque Root) and 1.7.0 new functions. Separate from
@@ -734,3 +733,11 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat
734733
handlers. The parent-keyed overload fails because the
735734
resolution pass stores refs keyed under the OnMessageClause
736735
parent, not the adaptor's parent
736+
50. **RiddlResult[T] replaces Either[Messages, T]** — Sealed
737+
ADT with `Success[T]` and `Failure` cases. All RiddlLib
738+
methods that previously returned `Either` now return
739+
`RiddlResult`. Use `result.toEither` for backward compat.
740+
TypeScript: `RiddlResult<T>` (deprecated `ParseResult<T>`
741+
alias kept). Lives in `RiddlResult.scala` alongside
742+
`RiddlLib.scala`. `ast2bast` now surfaces errors via
743+
`RiddlResult` instead of silently returning empty array

NOTEBOOK.md

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This is the central engineering notebook for the RIDDL project. It tracks curren
66

77
## Current Status
88

9-
**Last Updated**: February 11, 2026
9+
**Last Updated**: February 14, 2026
1010

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

249249
---
250250

251-
## Changes Since v1.7.0
252-
253-
- Added `RiddlLib.ast2bast(root)` — converts parsed AST to
254-
BAST binary bytes (shared trait + JS facade)
255-
- Added TypeScript declarations for `getHandlerCompleteness`,
256-
`getMessageFlow`, `getEntityLifecycles`, `ast2bast` with
257-
return type interfaces
258-
- Added `ast2bast` test in `RiddlLibTest`
259-
- ValidationPass: Schema kind-specific deep checks (Flat,
260-
TimeSeries, Hierarchical, Star data/link structure warnings)
261-
- ValidationPass: Relational FK type mismatch upgraded from
262-
Warning to Error
263-
- ValidationPass: Handler message type vs container type
264-
checks (Repository handles events → warning, Projector
265-
handles commands/queries → warning)
266-
- ValidationPass: Adaptor message direction compatibility
267-
(inbound handling commands → error, outbound handling
268-
events → error)
269-
- StreamingValidation: Sink reverse reachability check (warns
270-
if connected sink has no upstream path from any source)
251+
## Changes Since v1.8.2
252+
253+
- **RiddlResult[T] ADT** — New sealed trait replacing
254+
`Either[Messages, T]` as return type for all RiddlLib
255+
methods. `Success[T]` and `Failure` cases with `map`,
256+
`flatMap`, `toEither`. TypeScript: `RiddlResult<T>`
257+
(deprecated `ParseResult<T>` alias kept for migration)
258+
- **ast2bast returns RiddlResult** — Previously returned
259+
`Array[Byte]` (silently empty on failure); now returns
260+
`RiddlResult[Array[Byte]]` with proper error reporting
261+
- **RiddlResult.scala** — New file in riddlLib/shared
262+
- **TypeScript index.d.ts**`ParseResult<T>` renamed to
263+
`RiddlResult<T>` across all method signatures
271264

272265
## Session Log
273266

267+
### February 14, 2026 (RiddlResult ADT)
268+
269+
**Focus**: Add cross-platform `RiddlResult[T]` result type to
270+
replace ad-hoc `Either[Messages, T]` returns in RiddlLib.
271+
272+
**Work Completed**:
273+
1. **Created `RiddlResult.scala`** — Sealed trait with
274+
`Success[T]` and `Failure` cases. Includes `map`,
275+
`flatMap`, `toEither`, `fromEither` for interop.
276+
2. **Updated `RiddlLib.scala`** — Changed all 10 methods
277+
returning `Either[Messages, T]` to `RiddlResult[T]`.
278+
Changed `ast2bast` from `Array[Byte]` to
279+
`RiddlResult[Array[Byte]]` (surfaces errors properly).
280+
3. **Updated `RiddlAPI.scala`**`toJsResult` now accepts
281+
`RiddlResult[T]`. `ast2bast` wrapped in `toJsResult`.
282+
4. **Updated `index.d.ts`**`ParseResult<T>` renamed to
283+
`RiddlResult<T>`. Deprecated alias kept. `ast2bast`
284+
return type changed to `RiddlResult<Int8Array>`.
285+
5. **Updated `RiddlLibTest.scala`** — All tests use
286+
`RiddlResult.Success`/`RiddlResult.Failure` matching.
287+
288+
**Test Results**: 13/13 JVM, 11/11 JS — all pass.
289+
290+
**Version**: 1.9.0 (MINOR — new `RiddlResult` type, API
291+
return type changes with `toEither` migration path)
292+
293+
**Files Created**:
294+
- `riddlLib/shared/.../RiddlResult.scala`
295+
296+
**Files Modified**:
297+
- `riddlLib/shared/.../RiddlLib.scala`
298+
- `riddlLib/js/.../RiddlAPI.scala`
299+
- `riddlLib/js/types/index.d.ts`
300+
- `riddlLib/shared/test/.../RiddlLibTest.scala`
301+
302+
---
303+
274304
### February 11, 2026 (ValidationPass Gap Analysis Completion)
275305

276306
**Focus**: Implement remaining 4 items from the Feb 7 gap
@@ -1737,4 +1767,4 @@ Tool(
17371767
## Git Information
17381768

17391769
**Branch**: `development`
1740-
**Latest release**: 1.8.0 (February 11, 2026)
1770+
**Latest release**: 1.9.0 (February 14, 2026)
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2019-2026 Ossum, Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.commands
8+
9+
import com.ossuminc.riddl.utils.{pc, PlatformContext}
10+
import org.ekrich.config.*
11+
import org.scalatest.matchers.must.Matchers
12+
import org.scalatest.wordspec.AnyWordSpec
13+
14+
import java.nio.file.{Files, Path}
15+
import scala.jdk.StreamConverters.*
16+
17+
/** Comprehensive BAST round-trip test against all riddl-models.
18+
*
19+
* For each model in the riddl-models repository:
20+
* 1. Validate the original RIDDL source
21+
* 2. Bastify (RIDDL -> BAST)
22+
* 3. Unbastify (BAST -> RIDDL via PrettifyPass with flatten=true)
23+
* 4. Prettify original with --single-file (same code path)
24+
* 5. Compare unbastify output with prettified original
25+
*
26+
* Both outputs go through PrettifyPass, so any PrettifyPass
27+
* formatting quirks cancel out. What we're testing is that
28+
* the BAST serialization/deserialization is lossless.
29+
*/
30+
class RiddlModelsRoundTripTest extends AnyWordSpec with Matchers {
31+
32+
given io: PlatformContext = pc
33+
34+
private val riddlModelsDir = Path.of("../riddl-models")
35+
36+
private val commonArgs = Array(
37+
"--quiet",
38+
"--show-missing-warnings=false",
39+
"--show-style-warnings=false",
40+
"--show-usage-warnings=false"
41+
)
42+
43+
"BAST round-trip" should {
44+
assume(
45+
Files.isDirectory(riddlModelsDir),
46+
"riddl-models not found at ../riddl-models — skipping"
47+
)
48+
49+
val confFiles = discoverModels(riddlModelsDir)
50+
assume(confFiles.nonEmpty, "No .conf files found in riddl-models")
51+
52+
confFiles.foreach { case (confFile, riddlFile) =>
53+
val relPath = riddlModelsDir.relativize(confFile.getParent)
54+
55+
s"round-trip $relPath" in {
56+
roundTripTest(confFile, riddlFile)
57+
}
58+
}
59+
}
60+
61+
/** Discover models: find .conf files at depth 3, parse input-file */
62+
private def discoverModels(base: Path): Seq[(Path, Path)] = {
63+
val allConf = Files
64+
.walk(base, 5)
65+
.filter(p =>
66+
p.toString.endsWith(".conf") && Files.isRegularFile(p)
67+
)
68+
.toScala(Seq)
69+
70+
allConf.flatMap { confFile =>
71+
val depth = base.relativize(confFile).getNameCount - 1
72+
if depth == 3 then
73+
parseInputFile(confFile).map(riddlFile =>
74+
(confFile, riddlFile)
75+
)
76+
else None
77+
}
78+
}
79+
80+
/** Parse a .conf file to extract the input-file path */
81+
private def parseInputFile(confFile: Path): Option[Path] = {
82+
try {
83+
val config = ConfigFactory.parseFile(confFile.toFile)
84+
if config.hasPath("validate.input-file") then
85+
val inputFile = config.getString("validate.input-file")
86+
Some(confFile.getParent.resolve(inputFile))
87+
else None
88+
} catch {
89+
case _: ConfigException => None
90+
}
91+
}
92+
93+
/** Run the round-trip for a single model:
94+
* 1. Validate original
95+
* 2. Bastify
96+
* 3. Unbastify (produces flattened .riddl)
97+
* 4. Prettify original with --single-file
98+
* 5. Compare unbastify output with prettified original
99+
*/
100+
private def roundTripTest(
101+
confFile: Path,
102+
riddlFile: Path
103+
): Unit = {
104+
val tempDir = Files.createTempDirectory("bast-roundtrip")
105+
val unbastDir = tempDir.resolve("unbast")
106+
val prettyDir = tempDir.resolve("pretty-original")
107+
108+
try {
109+
val riddlPath = riddlFile.toAbsolutePath.toString
110+
val bastPath = riddlPath.replaceAll("\\.riddl$", ".bast")
111+
112+
// Step 1: Validate original
113+
val validateArgs =
114+
commonArgs ++ Array("validate", riddlPath)
115+
Commands.runMainForTest(validateArgs) match {
116+
case Left(messages) =>
117+
fail(
118+
s"Step 1 (validate original) failed:\n" +
119+
s"${messages.format}"
120+
)
121+
case Right(_) => // ok
122+
}
123+
124+
// Step 2: Bastify
125+
val bastifyArgs =
126+
commonArgs ++ Array("bastify", riddlPath)
127+
Commands.runMainForTest(bastifyArgs) match {
128+
case Left(messages) =>
129+
fail(s"Step 2 (bastify) failed:\n${messages.format}")
130+
case Right(_) =>
131+
assert(
132+
Files.exists(Path.of(bastPath)),
133+
s"BAST file not created: $bastPath"
134+
)
135+
}
136+
137+
try {
138+
// Step 3: Unbastify (uses PrettifyPass with flatten=true)
139+
val unbastifyArgs = commonArgs ++ Array(
140+
"unbastify",
141+
bastPath,
142+
"-o",
143+
unbastDir.toAbsolutePath.toString
144+
)
145+
Commands.runMainForTest(unbastifyArgs) match {
146+
case Left(messages) =>
147+
fail(
148+
s"Step 3 (unbastify) failed:\n${messages.format}"
149+
)
150+
case Right(_) =>
151+
assert(
152+
Files.exists(unbastDir),
153+
s"Unbastify output dir not created"
154+
)
155+
}
156+
157+
// Find the unbastify output file
158+
val outputRiddlFiles = Files
159+
.list(unbastDir)
160+
.filter(p => p.toString.endsWith(".riddl"))
161+
.toScala(Seq)
162+
assert(
163+
outputRiddlFiles.nonEmpty,
164+
"No .riddl files in unbastify output"
165+
)
166+
val unbastContent =
167+
Files.readString(outputRiddlFiles.head)
168+
169+
// Step 4: Prettify original with --single-file
170+
val prettyArgs = commonArgs ++ Array(
171+
"prettify",
172+
riddlPath,
173+
"-o",
174+
prettyDir.toAbsolutePath.toString,
175+
"-s",
176+
"true"
177+
)
178+
Commands.runMainForTest(prettyArgs) match {
179+
case Left(messages) =>
180+
fail(
181+
s"Step 4 (prettify original) failed:\n" +
182+
s"${messages.format}"
183+
)
184+
case Right(_) => // ok
185+
}
186+
187+
val prettyFile =
188+
prettyDir.resolve("prettify-output.riddl")
189+
assert(
190+
Files.exists(prettyFile),
191+
"Prettified original not found"
192+
)
193+
val prettyContent = Files.readString(prettyFile)
194+
195+
// Step 5: Compare unbastify output with prettified
196+
// original. Both go through PrettifyPass, so format
197+
// quirks cancel out. Differences = BAST data loss.
198+
if unbastContent != prettyContent then
199+
val lines1 =
200+
prettyContent.linesIterator.toIndexedSeq
201+
val lines2 =
202+
unbastContent.linesIterator.toIndexedSeq
203+
val firstDiff = lines1
204+
.zipAll(lines2, "<missing>", "<missing>")
205+
.zipWithIndex
206+
.find { case ((a, b), _) => a != b }
207+
208+
firstDiff match {
209+
case Some(((line1, line2), idx)) =>
210+
fail(
211+
s"Round-trip differs at line ${idx + 1}:\n" +
212+
s" original: $line1\n" +
213+
s" round-trip: $line2"
214+
)
215+
case None =>
216+
if lines1.length != lines2.length then
217+
fail(
218+
s"Round-trip differs in length: " +
219+
s"${lines1.length} vs ${lines2.length}"
220+
)
221+
end if
222+
}
223+
end if
224+
} finally {
225+
// Clean up .bast file created next to source
226+
Files.deleteIfExists(Path.of(bastPath))
227+
}
228+
} finally {
229+
deleteRecursively(tempDir)
230+
}
231+
}
232+
233+
private def deleteRecursively(path: Path): Unit = {
234+
if Files.isDirectory(path) then
235+
Files
236+
.list(path)
237+
.forEach(p =>
238+
deleteRecursively(p.asInstanceOf[Path])
239+
)
240+
end if
241+
Files.deleteIfExists(path)
242+
}
243+
}

0 commit comments

Comments
 (0)