Skip to content

Commit 6851047

Browse files
reid-spencerclaude
andcommitted
Add AnalysisPass and AnalysisResult from riddl-gen
Copy pass orchestration and consolidated result type into the passes module so all downstream consumers can access comprehensive model analysis without depending on riddl-gen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56daf03 commit 6851047

4 files changed

Lines changed: 823 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/*
2+
* Copyright 2019-2026 Ossum Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.passes.analysis
8+
9+
import com.ossuminc.riddl.language.parsing.{RiddlParserInput, TopLevelParser}
10+
import com.ossuminc.riddl.utils.{pc, ec}
11+
import org.scalatest.wordspec.AnyWordSpec
12+
import org.scalatest.matchers.must.Matchers
13+
14+
class AnalysisPassSpec extends AnyWordSpec with Matchers {
15+
16+
private val simpleModel = """
17+
|domain TestDomain {
18+
| context TestContext {
19+
| type TestType = String
20+
| entity TestEntity {
21+
| state main of TestType
22+
| }
23+
| }
24+
|}
25+
|""".stripMargin
26+
27+
private val modelWithRelationships = """
28+
|domain Shop {
29+
| context Orders {
30+
| type OrderId = Id(Orders.Order)
31+
| type CustomerId = Id(Customers.Customer)
32+
| type OrderState = record { orderId: OrderId, customerId: CustomerId }
33+
|
34+
| entity Order {
35+
| state main of OrderState
36+
| }
37+
| }
38+
|
39+
| context Customers {
40+
| type CustomerId = Id(Customers.Customer)
41+
| type CustomerState = record { id: CustomerId, name: String }
42+
|
43+
| entity Customer {
44+
| state main of CustomerState
45+
| }
46+
| }
47+
|}
48+
|""".stripMargin
49+
50+
"AnalysisPass" should {
51+
"analyze a simple model" in {
52+
val input = RiddlParserInput(simpleModel, "test")
53+
TopLevelParser.parseInput(input) match
54+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
55+
case Right(root) =>
56+
val result = AnalysisPass.analyze(root)
57+
58+
result.token.value must not be empty
59+
result.metadata.rootDomainName mustBe Some("TestDomain")
60+
result.domains must have size 1
61+
result.contexts must have size 1
62+
result.entities must have size 1
63+
}
64+
65+
"collect statistics" in {
66+
val input = RiddlParserInput(simpleModel, "test")
67+
TopLevelParser.parseInput(input) match
68+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
69+
case Right(root) =>
70+
val result = AnalysisPass.analyze(root)
71+
72+
result.countByKind("Domain") mustBe 1
73+
result.countByKind("Context") mustBe 1
74+
result.countByKind("Entity") mustBe 1
75+
result.maxDepth must be > 0
76+
}
77+
78+
"analyze from parser input" in {
79+
val input = RiddlParserInput(simpleModel, "test")
80+
TopLevelParser.parseInput(input) match
81+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
82+
case Right(root) =>
83+
val result = AnalysisPass.analyze(root)
84+
result.domains must have size 1
85+
}
86+
87+
"return errors for invalid input" in {
88+
val invalidModel = "this is not valid RIDDL"
89+
val input = RiddlParserInput(invalidModel, "test")
90+
91+
AnalysisPass.analyzeInput(input) match
92+
case Left(errors) =>
93+
errors.hasErrors mustBe true
94+
case Right(_) =>
95+
fail("Should have returned errors for invalid input")
96+
}
97+
98+
"provide pass creators" in {
99+
val passes = AnalysisPass.analysisPasses
100+
passes must have size 8
101+
}
102+
}
103+
104+
"AnalysisResult convenience methods" should {
105+
"provide parent lookups" in {
106+
val input = RiddlParserInput(simpleModel, "test")
107+
TopLevelParser.parseInput(input) match
108+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
109+
case Right(root) =>
110+
val result = AnalysisPass.analyze(root)
111+
val entity = result.entities.head
112+
113+
result.contextOf(entity) mustBe defined
114+
result.parentsOf(entity) must not be empty
115+
}
116+
117+
"provide definition accessors by kind" in {
118+
val input = RiddlParserInput(modelWithRelationships, "test")
119+
TopLevelParser.parseInput(input) match
120+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
121+
case Right(root) =>
122+
val result = AnalysisPass.analyze(root)
123+
result.domains must have size 1
124+
result.contexts must have size 2
125+
result.entities must have size 2
126+
result.types must not be empty
127+
result.sagas mustBe empty
128+
result.epics mustBe empty
129+
result.repositories mustBe empty
130+
result.streamlets mustBe empty
131+
result.projectors mustBe empty
132+
result.adaptors mustBe empty
133+
result.functions mustBe empty
134+
}
135+
136+
"provide qualifiedNameOf" in {
137+
val input = RiddlParserInput(simpleModel, "test")
138+
TopLevelParser.parseInput(input) match
139+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
140+
case Right(root) =>
141+
val result = AnalysisPass.analyze(root)
142+
val entity = result.entities.head
143+
val qn = result.qualifiedNameOf(entity)
144+
qn must include("TestDomain")
145+
qn must include("TestContext")
146+
qn must include("TestEntity")
147+
}
148+
149+
"provide context dependency graph" in {
150+
val input = RiddlParserInput(modelWithRelationships, "test")
151+
TopLevelParser.parseInput(input) match
152+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
153+
case Right(root) =>
154+
val result = AnalysisPass.analyze(root)
155+
result.contextDependencies must not be empty
156+
result.contextDependents must not be empty
157+
}
158+
159+
"provide new pass outputs" in {
160+
val input = RiddlParserInput(simpleModel, "test")
161+
TopLevelParser.parseInput(input) match
162+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
163+
case Right(root) =>
164+
val result = AnalysisPass.analyze(root)
165+
result.handlerCompleteness mustBe a[Seq[?]]
166+
result.messageFlow mustBe a[MessageFlowOutput]
167+
result.entityLifecycles mustBe a[Map[?, ?]]
168+
result.dependencies mustBe a[DependencyOutput]
169+
}
170+
171+
"provide glossary entries" in {
172+
val input = RiddlParserInput(simpleModel, "test")
173+
TopLevelParser.parseInput(input) match
174+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
175+
case Right(root) =>
176+
val result = AnalysisPass.analyze(root)
177+
val entries = result.glossaryEntries
178+
entries must not be empty
179+
entries.map(_.name) must contain("TestDomain")
180+
entries.map(_.name) must contain("TestContext")
181+
entries.map(_.name) must contain("TestEntity")
182+
entries.foreach { entry =>
183+
entry.kind must not be empty
184+
entry.qualifiedPath must not be empty
185+
}
186+
}
187+
188+
"provide incomplete definitions" in {
189+
val incompleteModel = """
190+
|domain IncDomain {
191+
| context EmptyCtx is { ??? }
192+
| context DocCtx is {
193+
| type DocType = String
194+
| } with { briefly "Has docs" }
195+
|}
196+
|""".stripMargin
197+
val input = RiddlParserInput(incompleteModel, "test")
198+
TopLevelParser.parseInput(input) match
199+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
200+
case Right(root) =>
201+
val result = AnalysisPass.analyze(root)
202+
val incomplete = result.incompleteDefinitions
203+
incomplete.map(_.name) must contain("EmptyCtx")
204+
incomplete.map(_.name) must not contain "DocCtx"
205+
}
206+
207+
"provide message types by kind" in {
208+
val msgModel = """
209+
|domain MsgDomain {
210+
| context MsgContext {
211+
| type CreateOrder = command { orderId: Id(MsgContext), item: String }
212+
| type OrderCreated = event { orderId: Id(MsgContext), item: String }
213+
| type GetOrder = query { orderId: Id(MsgContext) }
214+
| type OrderResult = result { orderId: Id(MsgContext), item: String }
215+
| }
216+
|}
217+
|""".stripMargin
218+
val input = RiddlParserInput(msgModel, "test")
219+
TopLevelParser.parseInput(input) match
220+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
221+
case Right(root) =>
222+
val result = AnalysisPass.analyze(root)
223+
val msgTypes = result.messageTypes
224+
msgTypes must not be empty
225+
val flat = result.messageTypesFlat
226+
flat.map(_.typ.id.value) must contain("CreateOrder")
227+
flat.map(_.typ.id.value) must contain("OrderCreated")
228+
flat.map(_.typ.id.value) must contain("GetOrder")
229+
flat.map(_.typ.id.value) must contain("OrderResult")
230+
}
231+
232+
"provide diagram accessors" in {
233+
val input = RiddlParserInput(simpleModel, "test")
234+
TopLevelParser.parseInput(input) match
235+
case Left(errors) => fail(s"Parse failed: ${errors.format}")
236+
case Right(root) =>
237+
val result = AnalysisPass.analyze(root)
238+
val ctx = result.contexts.head
239+
val domain = result.domains.head
240+
result.dataFlowDiagramFor(ctx) mustBe a[Option[?]]
241+
result.domainDiagramFor(domain) mustBe a[Option[?]]
242+
result.allDomainDiagrams mustBe a[Map[?, ?]]
243+
}
244+
}
245+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2019-2026 Ossum Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.passes.analysis
8+
9+
import org.scalatest.wordspec.AnyWordSpec
10+
import org.scalatest.matchers.must.Matchers
11+
12+
import java.time.Instant
13+
14+
class AnalysisResultSpec extends AnyWordSpec with Matchers {
15+
16+
"AnalysisToken" should {
17+
"generate unique tokens" in {
18+
val token1 = AnalysisToken.generate()
19+
val token2 = AnalysisToken.generate()
20+
token1.value must not equal token2.value
21+
}
22+
23+
"round-trip through string conversion" in {
24+
val original = AnalysisToken.generate()
25+
val restored = AnalysisToken.fromString(original.value)
26+
restored.value mustEqual original.value
27+
}
28+
}
29+
30+
"AnalysisMetadata" should {
31+
"capture analysis timestamp" in {
32+
val before = Instant.now()
33+
val metadata = AnalysisMetadata(
34+
analyzedAt = Instant.now(),
35+
rootDomainName = Some("TestDomain"),
36+
sourceLocation = None,
37+
sourceHash = None,
38+
riddlVersion = None
39+
)
40+
val after = Instant.now()
41+
42+
metadata.analyzedAt must (be >= before and be <= after)
43+
metadata.rootDomainName mustBe Some("TestDomain")
44+
}
45+
}
46+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright 2019-2026 Ossum Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.passes.analysis
8+
9+
import com.ossuminc.riddl.language.AST.Root
10+
import com.ossuminc.riddl.language.Messages
11+
import com.ossuminc.riddl.language.parsing.{RiddlParserInput, TopLevelParser}
12+
import com.ossuminc.riddl.passes.{Pass, PassCreators, PassInput, PassesResult}
13+
import com.ossuminc.riddl.passes.diagrams.DiagramsPass
14+
import com.ossuminc.riddl.passes.resolve.ResolutionPass
15+
import com.ossuminc.riddl.passes.stats.StatsPass
16+
import com.ossuminc.riddl.passes.symbols.SymbolsPass
17+
import com.ossuminc.riddl.passes.validate.ValidationPass
18+
import com.ossuminc.riddl.utils.PlatformContext
19+
20+
/** Comprehensive model analysis that produces an AnalysisResult.
21+
*
22+
* AnalysisPass runs the required passes to collect all analysis data:
23+
* - SymbolsPass: Symbol table and hierarchy
24+
* - ResolutionPass: Reference resolution and usage tracking
25+
* - ValidationPass: Handler completeness categorization
26+
* - StatsPass: Metrics and completeness
27+
* - DiagramsPass: Context relationships and use case data
28+
* - MessageFlowPass: Message producer/consumer graph
29+
* - EntityLifecyclePass: Entity state machines
30+
* - DependencyAnalysisPass: Cross-context/entity/type dependencies
31+
*/
32+
object AnalysisPass:
33+
34+
/** The passes required for comprehensive analysis.
35+
*
36+
* Order matters - each pass may depend on outputs from previous passes.
37+
*/
38+
def analysisPasses(using PlatformContext): PassCreators =
39+
Seq(
40+
SymbolsPass.creator(),
41+
ResolutionPass.creator(),
42+
ValidationPass.creator(),
43+
StatsPass.creator(),
44+
DiagramsPass.creator(),
45+
MessageFlowPass.creator(),
46+
EntityLifecyclePass.creator(),
47+
DependencyAnalysisPass.creator()
48+
)
49+
50+
/** Run analysis passes on a parsed model.
51+
*
52+
* @param root
53+
* The parsed RIDDL model
54+
* @return
55+
* AnalysisResult containing consolidated pass outputs
56+
*/
57+
def analyze(root: Root)(using PlatformContext): AnalysisResult =
58+
val input = PassInput(root)
59+
val passesResult = Pass.runThesePasses(input, analysisPasses)
60+
AnalysisResult.fromPassesResult(passesResult)
61+
62+
/** Run analysis on a PassesResult that already has required passes run.
63+
*
64+
* @param passesResult
65+
* Result from running passes (must include required passes)
66+
* @return
67+
* AnalysisResult containing consolidated pass outputs
68+
*/
69+
def fromPassesResult(passesResult: PassesResult): AnalysisResult =
70+
AnalysisResult.fromPassesResult(passesResult)
71+
72+
/** Parse RIDDL source and run analysis.
73+
*
74+
* @param input
75+
* The RIDDL parser input
76+
* @return
77+
* Either parse/analysis errors or the AnalysisResult
78+
*/
79+
def analyzeInput(input: RiddlParserInput)(using
80+
PlatformContext
81+
): Either[Messages.Messages, AnalysisResult] =
82+
TopLevelParser.parseInput(input) match
83+
case Left(messages) => Left(messages)
84+
case Right(root) =>
85+
val result = analyze(root)
86+
if result.messages.hasErrors then Left(result.messages)
87+
else Right(result)
88+
89+
end AnalysisPass

0 commit comments

Comments
 (0)