Skip to content

Commit 7a1a1cc

Browse files
reidspencerclaude
andcommitted
Fix BAST metadata ordering, add Include preservation, and document multi-Contents bug
This commit improves BAST serialization with several critical fixes: **Fixes:** - Fixed metadata serialization order: Override traverse() to write metadata count AFTER contents items (was writing before, causing reader/writer desync) - Fixed path normalization: Use loc.source.origin instead of toExternalForm to preserve relative paths correctly - Fixed delta encoding: Only update lastLocation if origin != 'empty' to prevent corruption from identifier nodes **New Features:** - Add BASTParserInput: Custom RiddlParserInput with synthetic line numbering (10,000 chars/line) for correct line/col reconstruction from BAST - Add withIncludes parameter to Pass: When true, Include nodes are preserved in parent hierarchy (needed for BAST serialization) - Add Include node serialization support (NODE_INCLUDE tag) **Testing:** - Add BenchmarkRunner: Performance tests showing 1.8x speedup for small files, 24.6x speedup for large files (when deserialization works) - Add TestRunner: Deep AST comparison tests for round-trip verification - Add DeepASTComparison: Comprehensive structural comparison utility - Add BASTRoundTripTest: Unit tests for round-trip correctness **Known Issues:** - Document critical bug in KNOWN_ISSUES.md: Nodes with multiple Contents fields (SagaStep, IfThenElseStatement) don't serialize correctly because traverse() only processes main .contents field. Fix options documented. - Small files (ToDoodles: 12 nodes) work correctly: 100% round-trip success - Large files (ShopifyCart: 510 nodes) fail deserialization due to multi-Contents bug **Performance Results:** - ToDoodles (13 lines, 12 nodes): 1.8x faster than parsing - ShopifyCart (1,543 lines, 510 nodes): 24.6x faster (when working) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ecf0a70 commit 7a1a1cc

11 files changed

Lines changed: 2822 additions & 81 deletions

File tree

bast/KNOWN_ISSUES.md

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# BAST Known Issues
2+
3+
## Multiple Contents Fields Serialization Bug
4+
5+
**Status**: CRITICAL - Affects serialization/deserialization of larger files
6+
7+
**Discovered**: January 11, 2026
8+
9+
### Symptoms
10+
11+
- Small files (ToDoodles: 12 nodes) serialize/deserialize correctly
12+
- Larger files (ShopifyCart: 510 nodes) fail during deserialization
13+
- Error: "Invalid string table index: 1000019 (table size: 649)"
14+
- The error occurs because the reader/writer become out of sync
15+
16+
### Root Cause
17+
18+
Some AST nodes have **multiple Contents fields**:
19+
20+
1. **`SagaStep`** (will not be removed)
21+
- `doStatements: Contents[Statements]`
22+
- `undoStatements: Contents[Statements]`
23+
24+
2. **`IfThenElseStatement`** (may be removed in future revision)
25+
- `thens: Contents[Statements]`
26+
- `elses: Contents[Statements]`
27+
28+
**The Problem**:
29+
30+
The current serialization architecture has a design flaw:
31+
32+
```scala
33+
// BASTWriter.scala:658-660
34+
private def writeSagaStep(ss: SagaStep): Unit = {
35+
writer.writeU8(NODE_HANDLER)
36+
writeLocation(ss.loc)
37+
writeIdentifier(ss.id)
38+
writeContents(ss.doStatements) // Writes count immediately
39+
writeContents(ss.undoStatements) // Writes count immediately
40+
}
41+
```
42+
43+
The `writeContents()` method writes the count:
44+
45+
```scala
46+
private def writeContents[T <: RiddlValue](contents: Contents[T]): Unit = {
47+
writer.writeVarInt(contents.length)
48+
// Note: Individual elements are written by the main process() method during traversal
49+
}
50+
```
51+
52+
But the `traverse()` override only processes the main `.contents` field:
53+
54+
```scala
55+
override protected def traverse(definition: RiddlValue, parents: ParentStack): Unit = {
56+
definition match {
57+
case branch: Branch[?] with WithMetaData =>
58+
process(branch, parents)
59+
parents.push(branch)
60+
branch.contents.foreach { value => traverse(value, parents) } // Only .contents!
61+
parents.pop()
62+
writeMetadataCount(branch.metadata)
63+
case _ =>
64+
super.traverse(definition, parents)
65+
}
66+
}
67+
```
68+
69+
**Result**:
70+
- Count for `doStatements` is written
71+
- Count for `undoStatements` is written
72+
- Items for `doStatements` are NEVER written (not in `.contents`)
73+
- Items for `undoStatements` are NEVER written (not in `.contents`)
74+
- Reader expects items after the count, reads garbage data
75+
- Deserialization fails with "Invalid string table index"
76+
77+
### Affected Files
78+
79+
**Writer**: `bast/shared/src/main/scala/com/ossuminc/riddl/bast/BASTWriter.scala`
80+
- Lines 655-660: `writeSagaStep()`
81+
- Lines 913-920: `writeIfThenElseStatement()`
82+
- Line 1682-1685: `writeContents()`
83+
84+
**Reader**: `bast/shared/src/main/scala/com/ossuminc/riddl/bast/BASTReader.scala`
85+
- Lines 1660-1673: `readContentsDeferred()`
86+
87+
### Impact
88+
89+
-**Works**: Files without SagaStep or IfThenElseStatement (e.g., ToDoodles)
90+
-**Fails**: Files containing SagaStep or IfThenElseStatement (e.g., ShopifyCart)
91+
92+
### Performance Impact
93+
94+
When working correctly:
95+
- **Small files (12 nodes)**: 1.8x speedup over parsing
96+
- **Large files (510 nodes)**: 24.6x speedup over parsing (observed before deserialization failed)
97+
98+
### Solution Options
99+
100+
#### Option 1: Special-case traverse() for multi-Contents nodes
101+
102+
Extend the `traverse()` override to detect and handle nodes with multiple Contents fields:
103+
104+
```scala
105+
override protected def traverse(definition: RiddlValue, parents: ParentStack): Unit = {
106+
definition match {
107+
case ss: SagaStep =>
108+
process(ss, parents)
109+
parents.push(ss)
110+
ss.doStatements.foreach { value => traverse(value, parents) }
111+
ss.undoStatements.foreach { value => traverse(value, parents) }
112+
parents.pop()
113+
writeMetadataCount(ss.metadata)
114+
115+
case ite: IfThenElseStatement =>
116+
process(ite, parents)
117+
parents.push(ite)
118+
ite.thens.foreach { value => traverse(value, parents) }
119+
ite.elses.foreach { value => traverse(value, parents) }
120+
parents.pop()
121+
// No metadata for statements
122+
123+
case branch: Branch[?] with WithMetaData =>
124+
// ... existing code
125+
}
126+
}
127+
```
128+
129+
**Pros**: Minimal changes, surgical fix
130+
**Cons**: Must remember to update for any future multi-Contents nodes
131+
132+
#### Option 2: Refactor writeContents() to defer writing
133+
134+
Change `writeContents()` to not write anything immediately. Instead, track pending Contents writes and emit them during traversal.
135+
136+
**Pros**: More robust, handles any future cases
137+
**Cons**: Significant refactoring, more complex state management
138+
139+
#### Option 3: Two-phase serialization
140+
141+
Separate count-writing from item-writing phases.
142+
143+
**Pros**: Clean separation of concerns
144+
**Cons**: Requires complete redesign of serialization
145+
146+
### Recommended Fix
147+
148+
**Option 1** (special-case traverse) is recommended because:
149+
1. Minimal code changes
150+
2. Easy to understand and verify
151+
3. Only 2 node types affected (possibly only 1 if IfThenElseStatement is removed)
152+
4. Fast to implement and test
153+
154+
### Test Cases Needed
155+
156+
After fix:
157+
1. ✅ Verify ToDoodles still works (regression test)
158+
2. ✅ Verify ShopifyCart serializes/deserializes correctly
159+
3. ✅ Create test specifically for SagaStep round-trip
160+
4. ✅ Create test for IfThenElseStatement round-trip (if not removed)
161+
5. ✅ Verify performance benchmarks still show speedup
162+
163+
### Related Files
164+
165+
- `bast/jvm/src/test/scala/com/ossuminc/riddl/bast/BenchmarkRunner.scala` - Performance benchmark
166+
- `bast/jvm/src/test/scala/com/ossuminc/riddl/bast/TestRunner.scala` - Round-trip test
167+
- `bast/shared/src/test/scala/com/ossuminc/riddl/bast/DeepASTComparison.scala` - Deep comparison utility
168+
169+
### Notes
170+
171+
- The issue does NOT affect metadata serialization (fixed in earlier session)
172+
- The issue does NOT affect Include node preservation (working correctly)
173+
- The issue does NOT affect location delta encoding (working correctly)
174+
- The issue ONLY affects nodes with multiple Contents fields
175+
176+
---
177+
178+
**Last Updated**: January 11, 2026
179+
**Severity**: High
180+
**Priority**: Must fix before production use
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2019-2026 Ossum, Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.bast
8+
9+
import com.ossuminc.riddl.language.AST.{Root, Nebula}
10+
import com.ossuminc.riddl.language.parsing.{RiddlParserInput, TopLevelParser}
11+
import com.ossuminc.riddl.passes.{Pass, PassInput}
12+
import com.ossuminc.riddl.utils.{pc, ec, Await, URL}
13+
import org.scalatest.TestData
14+
import org.scalatest.wordspec.AnyWordSpec
15+
16+
import java.nio.file.{Files, Paths}
17+
import scala.concurrent.duration.*
18+
19+
/** Round-trip tests for BAST serialization/deserialization
20+
*
21+
* These tests verify that: RIDDL text → AST → BAST binary → AST produces an equivalent AST
22+
*
23+
* This is the CRITICAL test for Phase 2 completion.
24+
*/
25+
class BASTRoundTripTest extends AnyWordSpec {
26+
27+
"BAST Round Trip" should {
28+
29+
"serialize and deserialize comprehensive test file" in { (td: TestData) =>
30+
val url = URL.fromCwdPath("bast/jvm/src/test/resources/comprehensive-test.riddl")
31+
val inputFuture = RiddlParserInput.fromURL(url, td.name)
32+
33+
val result = Await.result(inputFuture.map { input =>
34+
// Step 1: Parse RIDDL text → AST
35+
val parseResult = TopLevelParser.parseInput(input, true)
36+
parseResult match {
37+
case Right(originalRoot: Root) =>
38+
println(s"\n=== Round Trip Test: comprehensive-test.riddl ===")
39+
println(s"Original AST parsed successfully")
40+
41+
// Step 2: Serialize AST → BAST binary
42+
val passInput = PassInput(originalRoot)
43+
val writerResult = Pass.runThesePasses(passInput, Seq(BASTWriter.creator()))
44+
val output = writerResult.outputOf[BASTOutput](BASTWriter.name).get
45+
46+
println(f"BAST written: ${output.bytes.length}%,d bytes (${output.nodeCount}%,d nodes)")
47+
48+
// Step 3: Deserialize BAST binary → AST
49+
BASTReader.read(output.bytes) match {
50+
case Right(reconstructedNebula) =>
51+
println(s"BAST read: Nebula reconstructed")
52+
53+
// Step 4: Compare original and reconstructed
54+
val areEqual = compareRoots(originalRoot, reconstructedNebula)
55+
56+
if areEqual then
57+
println("✓ Round trip successful: Original AST == Reconstructed AST")
58+
else
59+
println("✗ Round trip FAILED: ASTs differ")
60+
end if
61+
62+
areEqual
63+
64+
case Left(errors) =>
65+
println(s"✗ Deserialization failed: ${errors.format}")
66+
false
67+
}
68+
69+
case Left(messages) =>
70+
println(s"Parse failed: ${messages.format}")
71+
false
72+
}
73+
}, 30.seconds)
74+
75+
assert(result, "Round trip test failed: ASTs are not equivalent")
76+
}
77+
78+
"serialize and deserialize ToDoodles project" in { (td: TestData) =>
79+
val examplesPath = Paths.get("/Users/reid/Code/ossuminc/riddl-examples")
80+
if !Files.exists(examplesPath) then
81+
println("riddl-examples not found, skipping test")
82+
succeed
83+
else
84+
val url = URL.fromCwdPath("../riddl-examples/src/riddl/ToDoodles/ToDoodles.riddl")
85+
val inputFuture = RiddlParserInput.fromURL(url, td.name)
86+
87+
val result = Await.result(inputFuture.map { input =>
88+
val parseResult = TopLevelParser.parseInput(input, true)
89+
parseResult match {
90+
case Right(originalRoot: Root) =>
91+
println(s"\n=== Round Trip Test: ToDoodles ===")
92+
93+
// Serialize
94+
val passInput = PassInput(originalRoot)
95+
val writerResult = Pass.runThesePasses(passInput, Seq(BASTWriter.creator()))
96+
val output = writerResult.outputOf[BASTOutput](BASTWriter.name).get
97+
98+
println(f"BAST written: ${output.bytes.length}%,d bytes")
99+
100+
// Deserialize
101+
BASTReader.read(output.bytes) match {
102+
case Right(reconstructedNebula) =>
103+
// Compare
104+
val areEqual = compareRoots(originalRoot, reconstructedNebula)
105+
106+
if areEqual then
107+
println("✓ Round trip successful")
108+
else
109+
println("✗ Round trip FAILED")
110+
end if
111+
112+
areEqual
113+
114+
case Left(errors) =>
115+
println(s"✗ Deserialization failed: ${errors.format}")
116+
false
117+
}
118+
119+
case Left(messages) =>
120+
println(s"Parse failed: ${messages.format}")
121+
false
122+
}
123+
}, 30.seconds)
124+
125+
assert(result, "Round trip test failed for ToDoodles")
126+
}
127+
128+
"serialize and deserialize ReactiveBBQ domain" in { (td: TestData) =>
129+
val examplesPath = Paths.get("/Users/reid/Code/ossuminc/riddl-examples")
130+
if !Files.exists(examplesPath) then
131+
println("riddl-examples not found, skipping test")
132+
succeed
133+
else
134+
val url = URL.fromCwdPath("../riddl-examples/src/riddl/ReactiveBBQ/restaurant/domain.riddl")
135+
val inputFuture = RiddlParserInput.fromURL(url, td.name)
136+
137+
val result = Await.result(inputFuture.map { input =>
138+
val parseResult = TopLevelParser.parseInput(input, true)
139+
parseResult match {
140+
case Right(originalRoot: Root) =>
141+
println(s"\n=== Round Trip Test: ReactiveBBQ Restaurant ===")
142+
143+
// Serialize
144+
val passInput = PassInput(originalRoot)
145+
val writerResult = Pass.runThesePasses(passInput, Seq(BASTWriter.creator()))
146+
val output = writerResult.outputOf[BASTOutput](BASTWriter.name).get
147+
148+
println(f"BAST written: ${output.bytes.length}%,d bytes")
149+
150+
// Deserialize
151+
BASTReader.read(output.bytes) match {
152+
case Right(reconstructedNebula) =>
153+
// Compare
154+
val areEqual = compareRoots(originalRoot, reconstructedNebula)
155+
156+
if areEqual then
157+
println("✓ Round trip successful")
158+
else
159+
println("✗ Round trip FAILED")
160+
end if
161+
162+
areEqual
163+
164+
case Left(errors) =>
165+
println(s"✗ Deserialization failed: ${errors.format}")
166+
false
167+
}
168+
169+
case Left(messages) =>
170+
println(s"Parse failed: ${messages.format}")
171+
false
172+
}
173+
}, 30.seconds)
174+
175+
assert(result, "Round trip test failed for ReactiveBBQ")
176+
}
177+
}
178+
179+
/** Compare Root (original) with Nebula (reconstructed) for deep structural equality
180+
*
181+
* Note: BASTWriter writes Root using NODE_NEBULA tag, so deserialization produces Nebula.
182+
* This is expected - we're comparing the CONTENT, not the container type.
183+
*
184+
* Uses DeepASTComparison to recursively verify all fields, identifiers, locations, and nested content.
185+
*/
186+
private def compareRoots(original: Root, reconstructed: Nebula): Boolean = {
187+
println(s"\n=== Deep Structural Comparison ===")
188+
println(s"Original: Root with ${original.contents.toSeq.size} top-level elements")
189+
println(s"Reconstructed: Nebula with ${reconstructed.contents.toSeq.size} top-level elements")
190+
191+
// Perform deep comparison
192+
val results = DeepASTComparison.compareRootAndNebula(original, reconstructed)
193+
194+
// Generate report
195+
val report = DeepASTComparison.report(results)
196+
println(report)
197+
198+
// Check if all comparisons succeeded
199+
val allSucceeded = results.forall(_.isSuccess)
200+
201+
if allSucceeded then
202+
println("✓ Complete structural reflectivity verified: AST → BAST → AST preserves all data")
203+
else
204+
println("✗ Structural differences detected - see failures above")
205+
end if
206+
207+
allSucceeded
208+
}
209+
}

0 commit comments

Comments
 (0)