Skip to content

Commit 9927e5e

Browse files
reid-spencerclaude
andcommitted
Add AIHelperPass and advise command
Implement a new AI-friendly validation pass that transforms resolution and validation output into actionable Tips for iterative model building. Includes riddlc advise command, RiddlLib/RiddlAPI integration, and TypeScript declarations. - Add Tip message type (severity 0, cyan log level) - AIHelperPass with two entry points and two-path logic - Tip generation: empty containers, entity/handler completeness, context completeness, documentation - Resolution error rewriting into actionable Tips - AdviseCommand: --tips-only, --no-snippets options - RiddlLib: analyzeForTips/analyzeSourceForTips (JVM/JS/Native) - 34 AIHelperPass tests, 4 RiddlLib tests, all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 373055c commit 9927e5e

15 files changed

Lines changed: 1922 additions & 0 deletions

File tree

commands/jvm/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ object CommandLoader:
2424
if io.options.verbose then io.log.info(s"Loading command: $name") else ()
2525
name match
2626
case "about" => Right(AboutCommand())
27+
case "advise" => Right(AdviseCommand())
2728
case "bastify" => Right(BastifyCommand())
2829
case "dump" => Right(DumpCommand())
2930
case "unbastify" => Right(UnbastifyCommand())
@@ -45,6 +46,7 @@ object CommandLoader:
4546
def commandOptionsParser(using io: PlatformContext): OParser[Unit, ?] = {
4647
val optionParsers = Seq(
4748
AboutCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
49+
AdviseCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
4850
BastifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
4951
DumpCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
5052
UnbastifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],

commands/native/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ object CommandLoader:
2424
if io.options.verbose then io.log.info(s"Loading command: $name") else ()
2525
name match
2626
case "about" => Right(AboutCommand())
27+
case "advise" => Right(AdviseCommand())
2728
case "bastify" => Right(BastifyCommand())
2829
case "dump" => Right(DumpCommand())
2930
case "flatten" => Right(FlattenCommand())
@@ -45,6 +46,7 @@ object CommandLoader:
4546
def commandOptionsParser(using io: PlatformContext): OParser[Unit, ?] =
4647
val optionParsers = Seq(
4748
AboutCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
49+
AdviseCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
4850
BastifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
4951
DumpCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],
5052
FlattenCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]],

commands/shared/src/main/scala/com/ossuminc/riddl/command/CommonOptionsHelper.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ object CommonOptionsHelper:
229229
val showCompletenessWarnings =
230230
if obj.hasPath(show_completeness_warnings) then obj.getBoolean(show_completeness_warnings)
231231
else default.showCompletenessWarnings
232+
val showTipMessages =
233+
if obj.hasPath("show-tip-messages") then obj.getBoolean("show-tip-messages") else default.showTipMessages
232234
val showInfoMessages =
233235
if obj.hasPath(show_info_messages) then obj.getBoolean(show_info_messages) else default.showInfoMessages
234236
val maxParallelParsing =
@@ -255,6 +257,7 @@ object CommonOptionsHelper:
255257
showStyleWarnings,
256258
showUsageWarnings,
257259
showCompletenessWarnings,
260+
showTipMessages,
258261
showInfoMessages,
259262
debugV,
260263
sortMessagesByLocation,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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.command.{Command, CommandOptions}
10+
import com.ossuminc.riddl.language.Messages
11+
import com.ossuminc.riddl.language.Messages.Messages
12+
import com.ossuminc.riddl.language.parsing.RiddlParserInput
13+
import com.ossuminc.riddl.passes.{PassesResult, PassInput}
14+
import com.ossuminc.riddl.passes.ai.{AIHelperOutput, AIHelperPass}
15+
import com.ossuminc.riddl.utils.{Await, PlatformContext}
16+
import org.ekrich.config.Config
17+
import scopt.OParser
18+
19+
import java.io.File
20+
import java.nio.file.Path
21+
import scala.concurrent.ExecutionContext
22+
import scala.concurrent.duration.DurationInt
23+
24+
object AdviseCommand {
25+
val cmdName: String = "advise"
26+
27+
case class Options(
28+
inputFile: Option[Path] = None,
29+
command: String = cmdName,
30+
tipsOnly: Boolean = false,
31+
noSnippets: Boolean = false
32+
) extends CommandOptions
33+
}
34+
35+
/** Analyze a RIDDL model and produce AI-friendly tips
36+
* for improving it.
37+
*
38+
* Usage:
39+
* riddlc advise <input.riddl>
40+
* riddlc advise --tips-only <input.riddl>
41+
* riddlc advise --no-snippets <input.riddl>
42+
* riddlc --no-ansi-messages advise <input.riddl>
43+
*/
44+
class AdviseCommand(using pc: PlatformContext)
45+
extends Command[AdviseCommand.Options](
46+
AdviseCommand.cmdName
47+
) {
48+
import AdviseCommand.Options
49+
50+
override def getOptionsParser: (OParser[Unit, Options], Options) = {
51+
import builder.*
52+
cmd(AdviseCommand.cmdName)
53+
.text("Analyze a RIDDL model and produce AI-friendly tips for improvement")
54+
.children(
55+
opt[Unit]('T', "tips-only")
56+
.optional()
57+
.action((_, c) => c.copy(tipsOnly = true))
58+
.text("Show only Tip messages, suppress errors and warnings"),
59+
opt[Unit]("no-snippets")
60+
.optional()
61+
.action((_, c) => c.copy(noSnippets = true))
62+
.text("Suppress RIDDL code snippets in tip output"),
63+
inputFile((v, c) => c.copy(inputFile = Some(v.toPath)))
64+
) -> Options()
65+
}
66+
67+
override def interpretConfig(config: Config): Options = {
68+
val obj = config.getObject(commandName).toConfig
69+
val inputFile = Path.of(obj.getString("input-file"))
70+
val tipsOnly =
71+
if obj.hasPath("tips-only") then obj.getBoolean("tips-only")
72+
else false
73+
val noSnippets =
74+
if obj.hasPath("no-snippets") then obj.getBoolean("no-snippets")
75+
else false
76+
Options(Some(inputFile), commandName, tipsOnly, noSnippets)
77+
}
78+
79+
override protected def replaceInputFile(
80+
opts: Options,
81+
inputFile: Path
82+
): Options = {
83+
opts.copy(inputFile = Some(inputFile))
84+
}
85+
86+
override def run(
87+
options: Options,
88+
outputDirOverride: Option[Path]
89+
): Either[Messages, PassesResult] = {
90+
options.withInputFile { (inputFile: Path) =>
91+
implicit val ec: ExecutionContext = pc.ec
92+
val future = RiddlParserInput.fromPath(
93+
inputFile.toString
94+
).map { rpi =>
95+
AIHelperPass.analyzeSource(rpi) match
96+
case Left(parseErrors) =>
97+
Left(parseErrors)
98+
case Right(result) =>
99+
val aiOutput = result
100+
.outputOf[AIHelperOutput](AIHelperPass.name)
101+
aiOutput match
102+
case Some(output) =>
103+
logTips(output.messages, options)
104+
case None => ()
105+
Right(result)
106+
}
107+
Await.result(future, 10.seconds)
108+
}
109+
}
110+
111+
private def logTips(
112+
msgs: Messages,
113+
options: Options
114+
): Unit = {
115+
val toShow =
116+
if options.tipsOnly then msgs.justTips
117+
else msgs
118+
119+
for msg <- toShow do
120+
val text =
121+
if options.noSnippets then
122+
stripSnippets(msg.format)
123+
else
124+
msg.format
125+
msg.kind match
126+
case Messages.Tip =>
127+
pc.log.tip(text)
128+
case Messages.Error | Messages.SevereError =>
129+
pc.log.error(text)
130+
case Messages.Warning =>
131+
pc.log.warn(text)
132+
case Messages.CompletenessWarning =>
133+
pc.log.completeness(text)
134+
case _ =>
135+
pc.log.info(text)
136+
end for
137+
}
138+
139+
private def stripSnippets(text: String): String = {
140+
val idx = text.indexOf("\nSuggested RIDDL:")
141+
if idx >= 0 then text.substring(0, idx)
142+
else text
143+
}
144+
145+
override def loadOptionsFrom(
146+
configFile: Path
147+
): Either[Messages, Options] = {
148+
super.loadOptionsFrom(configFile).map { options =>
149+
resolveInputFileToConfigFile(options, configFile)
150+
}
151+
}
152+
}

commands/shared/src/main/scala/com/ossuminc/riddl/commands/Commands.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ object Commands:
3232
if io.options.verbose then io.log.info(s"Loading command: $name") else ()
3333
name match
3434
case "about" => Right(AboutCommand())
35+
case "advise" => Right(AdviseCommand())
3536
case "bastify" => Right(BastifyCommand())
3637
case "dump" => Right(DumpCommand())
3738
case "flatten" => Right(FlattenCommand())

language/shared/src/main/scala/com/ossuminc/riddl/language/Messages.scala

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ object Messages {
3232

3333
def isCompleteness: Boolean = false
3434

35+
def isTip: Boolean = false
36+
3537
def isInfo: Boolean = false
3638

3739
def severity: Int
@@ -42,6 +44,15 @@ object Messages {
4244
def compare(that: KindOfMessage): Int = { this.severity - that.severity }
4345
}
4446

47+
/** A case object for the Tip kind of message, used by AIHelperPass
48+
* to provide proactive guidance for improving RIDDL models.
49+
*/
50+
case object Tip extends KindOfMessage {
51+
override def isTip: Boolean = true
52+
override def toString: String = "Tip"
53+
def severity = 0
54+
}
55+
4556
/** A case object for the Info kind of message */
4657
case object Info extends KindOfMessage {
4758
override def isInfo: Boolean = true
@@ -143,6 +154,7 @@ object Messages {
143154
case class Message(loc: At, message: String, kind: KindOfMessage = Error, context: String = "")
144155
extends Ordered[Message] {
145156
def isInfo: Boolean = kind.isInfo
157+
def isTip: Boolean = kind.isTip
146158
def isMissing: Boolean = kind.isMissing
147159
def isWarning: Boolean = kind.isWarning
148160
def isStyle: Boolean = kind.isStyle
@@ -177,6 +189,11 @@ object Messages {
177189
Message(loc, message, StyleWarning)
178190
}
179191

192+
/** Generate a tip message */
193+
@JSExport def tip(message: String, loc: At = At.empty): Message = {
194+
Message(loc, message, Tip)
195+
}
196+
180197
/** Generate a missing warning */
181198
@JSExport def missing(message: String, loc: At = At.empty): Message = {
182199
Message(loc, message, MissingWarning)
@@ -273,6 +290,9 @@ object Messages {
273290
/** Return a filtered list of just the [[Info]] messages. */
274291
@JSExport def justInfo: Messages = msgs.filter(_.isInfo)
275292

293+
/** Return a filtered list of just the [[Tip]] messages. */
294+
@JSExport def justTips: Messages = msgs.filter(_.isTip)
295+
276296
/** Return a filtered list of just the [[MissingWarning]] messages. */
277297
@JSExport def justMissing: Messages = msgs.filter(_.isMissing)
278298

@@ -313,6 +333,7 @@ object Messages {
313333

314334
private def logMessage(message: Message)(using io: PlatformContext): Unit = {
315335
message.kind match {
336+
case Tip => io.log.tip(message.format)
316337
case Info => io.log.info(message.format)
317338
case StyleWarning => io.log.style(message.format)
318339
case MissingWarning => io.log.missing(message.format)
@@ -335,6 +356,8 @@ object Messages {
335356
val messages = maybeMessages.getOrElse(Seq.empty[Message])
336357
if messages.nonEmpty then {
337358
kind match {
359+
case Tip =>
360+
io.log.tip(s"""$kind Message Count: ${messages.length}""")
338361
case UsageWarning =>
339362
io.log.usage(s"""$kind Message Count: ${messages.length}""")
340363
case StyleWarning =>
@@ -374,6 +397,9 @@ object Messages {
374397
logMsgs(StyleWarning, groups.get(StyleWarning))
375398
}
376399
}
400+
if io.options.showTipMessages then {
401+
logMsgs(Tip, groups.get(Tip))
402+
}
377403
logMsgs(Info, groups.get(Info))
378404
}
379405
}
@@ -412,6 +438,8 @@ object Messages {
412438
if pc.options.showMissingWarnings && pc.options.showWarnings then msgs.append(message)
413439
case UsageWarning =>
414440
if pc.options.showUsageWarnings && pc.options.showWarnings then msgs.append(message)
441+
case Tip =>
442+
if pc.options.showTipMessages then msgs.append(message)
415443
case Info =>
416444
if pc.options.showInfoMessages then msgs.append(message)
417445
case Error | SevereError => msgs.append(message)
@@ -514,6 +542,19 @@ object Messages {
514542
add(Message(loc, msg, CompletenessWarning))
515543
}
516544

545+
/** Add a [[Tip]] message to the accumulated [[Messages]]
546+
*
547+
* @param loc
548+
* The location in the source related to the message.
549+
* @param msg
550+
* The text of the message to add
551+
* @return
552+
* This type, so you can chain another call to this accumulator
553+
*/
554+
@inline def addTip(loc: At, msg: String)(using pc: PlatformContext): this.type = {
555+
add(Message(loc, msg, Tip))
556+
}
557+
517558
/** Add a [[MissingWarning]] message to the accumulated [[Messages]]
518559
*
519560
* @param msg
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright 2019-2026 Ossum Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.passes.ai
8+
9+
import com.ossuminc.riddl.utils.pc
10+
11+
class JVMAIHelperPassTest extends SharedAIHelperPassTest

0 commit comments

Comments
 (0)