Skip to content

Commit d4bbfa4

Browse files
reid-spencerclaude
andcommitted
Statement records carry their value operand
Reid: "the statement should carry its value operand or it is incomplete." A set-statement record named `target` and refused to say what was assigned to it -- half the fact, and the half a consumer asking what a model DOES needs most. Nine statement kinds gained their operands: set, let (with its binding and declared type), put, return, terminate, error, require (condition AND `with` argument), morph's record, become's handler, foreach's element and collection, when's condition, code's language and body, and match's subject. `do` exposes its prose via `.text`, which is what riddlg actually reads and which now carries the multi-line form correctly. Measured on reactive-bbq: 1734 statement records, 0 without an operand. It was 20 before the match subject went in, and match was the last kind I had left returning unit. The dispatch is now TOTAL over all 19 Statement kinds with no wildcard, and the reason is recorded on the function: `commands` compiles with --no-warnings, so a non-exhaustive match here would NOT be reported and a new statement kind would silently project with no facts. Enumerating every kind makes a new one fail loudly at the first test that reaches it, which is the standing rule -- falling through to an error is fine, carrying on as if nothing happened is not. A test asserts the property over OUTPUT as well, since that is what caught the equivalent gap in the rule ids. A literal operand is rendered as the value it IS rather than as a link, which is consistent with the ruling that a literal is not a value-reference: it has no referent to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 55c4e30 commit d4bbfa4

2 files changed

Lines changed: 165 additions & 2 deletions

File tree

commands/src/main/scala/com/ossuminc/riddl/commands/project/ProjectionPass.scala

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,31 @@ case class ProjectionPass(
299299
)
300300
case _ => Seq.empty
301301

302+
/** A statement's VALUE operand, rendered as data.
303+
*
304+
* **A statement that names its target but not its value is incomplete** (Reid, 2026-08-26). A
305+
* `set-statement` record carrying only `"target": "S.total"` says a field was assigned and
306+
* refuses to say what it was assigned -- which is half the fact, and the half a consumer asking
307+
* "what does this model DO" needs least.
308+
*
309+
* A reference is rendered as a resolving `refRecord` so a consumer can follow it; anything else
310+
* is rendered as its source form. A LITERAL is rendered here, and that is consistent with the
311+
* ruling that a literal is not a `value-reference`: it has no referent to resolve, so it is
312+
* reported as the value it is rather than as a reference to something.
313+
*/
314+
private def valueOperand(v: Value, parents: Parents): ujson.Value = v match
315+
case vr: ValueRef => ujson.Obj("value" -> ujson.Str(vr.path.format))
316+
case c: Constructor => refRecord(c.ref.pathId, parents)
317+
case other => ujson.Obj("value" -> ujson.Str(other.format))
318+
319+
/** **Total over every `Statement` kind, with no wildcard, deliberately.**
320+
*
321+
* `commands` compiles with `--no-warnings`, so a non-exhaustive match here would NOT be reported
322+
* -- a new statement kind would simply project with no facts, which is the silent-fall-through
323+
* this codebase keeps recording. Enumerating every kind means a new one fails loudly at the
324+
* first test that reaches it instead, which is the standing rule: falling through to an error is
325+
* fine, carrying on as if nothing happened is not.
326+
*/
302327
private def addStatementFacts(s: Statement, obj: ujson.Obj, parents: Parents): Unit = s match {
303328
case t: TellStatement =>
304329
obj("target") = t.target match
@@ -316,10 +341,55 @@ case class ProjectionPass(
316341
case m: MorphStatement =>
317342
obj("target") = refRecord(m.entity.pathId, parents)
318343
obj("state") = refRecord(m.state.pathId, parents)
319-
case st: SetStatement => obj("target") = ujson.Str(st.field.pathId.format)
344+
obj("value") = m.value match
345+
case rr: RecordRef => refRecord(rr.pathId, parents)
346+
case c: Constructor => refRecord(c.ref.pathId, parents)
347+
case vr: ValueRef => ujson.Obj("value" -> ujson.Str(vr.path.format))
348+
case b: BecomeStatement =>
349+
obj("target") = refRecord(b.entity.pathId, parents)
350+
obj("handler") = refRecord(b.handler.pathId, parents)
351+
case st: SetStatement =>
352+
obj("target") = ujson.Str(st.field.pathId.format)
353+
obj("value") = valueOperand(st.value, parents)
320354
case y: YieldStatement => messageOperand(y.msg, parents).foreach(m => obj("message") = m)
321355
case r: ReplyStatement => messageOperand(r.msg, parents).foreach(m => obj("message") = m)
322-
case _ => ()
356+
case l: LetStatement =>
357+
obj("binds") = ujson.Str(l.identifier.value)
358+
l.typeRef.foreach(tr => obj("declaredType") = refRecord(tr.pathId, parents))
359+
obj("value") = valueOperand(l.expression, parents)
360+
case p: PutStatement =>
361+
obj("target") = refRecord(p.output.pathId, parents)
362+
obj("value") = valueOperand(p.value, parents)
363+
case r: ReturnStatement => obj("value") = valueOperand(r.value, parents)
364+
case tm: TerminateStatement =>
365+
obj("target") = valueOperand(tm.target, parents)
366+
case e: ErrorStatement => obj("message") = ujson.Obj("value" -> ujson.Str(e.message.s))
367+
case rq: RequireStatement =>
368+
obj("condition") = rq.condition match
369+
case ir: InvariantRef => refRecord(ir.pathId, parents)
370+
case other => ujson.Obj("value" -> ujson.Str(other.format))
371+
rq.argument.foreach(a => obj("value") = valueOperand(a, parents))
372+
case d: DoStatement =>
373+
// The prose riddlg reads. `.text` joins a multi-line `do` with newlines; a single-line one is
374+
// a Seq of one, so this is the same string it always was.
375+
obj("prose") = ujson.Str(d.text)
376+
case c: CodeStatement =>
377+
obj("language") = ujson.Str(c.language.s)
378+
obj("body") = ujson.Str(c.body)
379+
case fe: ForeachStatement =>
380+
obj("binds") = ujson.Str(fe.element.value)
381+
fe.valueElement.foreach(v => obj("bindsValue") = ujson.Str(v.value))
382+
obj("collection") = fe.collection match
383+
case fr: FieldRef => refRecord(fr.pathId, parents)
384+
case id: Identifier => ujson.Obj("value" -> ujson.Str(id.value))
385+
case w: WhenStatement =>
386+
obj("condition") = ujson.Obj("value" -> ujson.Str(w.condition.format))
387+
case ms: MatchStatement =>
388+
// The subject IS a `match`'s value operand, so omitting it leaves the record saying a match
389+
// happened without saying on what. `cases` is deliberately NOT expanded: each case's own
390+
// statements are projected as their own records, so listing them here would duplicate them.
391+
obj("value") = ujson.Obj("value" -> ujson.Str(ms.expression.format))
392+
obj("cases") = ujson.Num(ms.cases.size)
323393
}
324394

325395
// ---------------------------------------------------------------------------------------------

commands/src/test/scala/com/ossuminc/riddl/commands/DumpProjectionTest.scala

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,97 @@ class DumpProjectionTest extends AbstractValidatingTest {
125125
entity("shape").str mustBe "sink"
126126
}
127127
}
128+
129+
"a statement record" should {
130+
131+
"carry its VALUE operand, not just its target" in { (td: TestData) =>
132+
// Reid, 2026-08-26: "the statement should carry its value operand or it is incomplete."
133+
// A `set-statement` naming only `target` says a field was assigned and refuses to say what
134+
// it was assigned -- half the fact, and the half a consumer asking what the model DOES needs
135+
// most.
136+
val recs = project(
137+
"""domain D is {
138+
| context C is {
139+
| command Take is { note: String(1,9) }
140+
| entity E is {
141+
| record F is { total: Integer }
142+
| initial state S of record C.E.F
143+
| handler H is {
144+
| on command C.Take { set field S.total to 42 }
145+
| }
146+
| }
147+
| } with { briefly "c" described as "c" }
148+
|} with { briefly "d" described as "d" }
149+
|""".stripMargin,
150+
td
151+
)
152+
val sets = ofKind(recs, "set-statement")
153+
sets must not be empty
154+
val set = sets.head
155+
set.value.get("target") mustBe defined
156+
withClue(ujson.write(set)) {
157+
// The literal is rendered as the value it IS. Consistent with the ruling that a literal is
158+
// not a `value-reference`: it has no referent to resolve, so it is reported rather than
159+
// linked.
160+
set.value.get("value").map(ujson.write(_)).getOrElse("") must include("42")
161+
}
162+
}
163+
164+
"expose a `do` statement's prose, which is what a generator reads" in { (td: TestData) =>
165+
val recs = project(
166+
"""domain D is {
167+
| context C is {
168+
| command Take is { note: String(1,9) }
169+
| entity E is {
170+
| record F is { total: Integer }
171+
| initial state S of record C.E.F
172+
| handler H is {
173+
| on command C.Take { do { "first line" "second line" } }
174+
| }
175+
| }
176+
| } with { briefly "c" described as "c" }
177+
|} with { briefly "d" described as "d" }
178+
|""".stripMargin,
179+
td
180+
)
181+
val dos = ofKind(recs, "do-statement")
182+
dos must not be empty
183+
// `.text` joins a multi-line `do` with newlines; a single-line one is a Seq of one, so this
184+
// is the same string it always was.
185+
dos.head.value.get("prose").map(_.str).getOrElse("") mustBe "first line\nsecond line"
186+
}
187+
188+
"leave no statement kind without an operand" in { (td: TestData) =>
189+
// The dispatch is total over all 19 Statement kinds with no wildcard, because `commands`
190+
// compiles with --no-warnings: a non-exhaustive match would NOT be reported, and a new
191+
// statement kind would silently project with no facts. This asserts the property over
192+
// OUTPUT, which is what caught the equivalent gap in the rule ids.
193+
val recs = project(
194+
"""domain D is {
195+
| context C is {
196+
| command Take is { note: String(1,9) }
197+
| entity E is {
198+
| record F is { total: Integer }
199+
| initial state S of record C.E.F
200+
| handler H is {
201+
| on command C.Take {
202+
| let n = 5
203+
| set field S.total to n
204+
| do "something"
205+
| error "nope"
206+
| }
207+
| }
208+
| }
209+
| } with { briefly "c" described as "c" }
210+
|} with { briefly "d" described as "d" }
211+
|""".stripMargin,
212+
td
213+
)
214+
val stmts = recs.filter(_.value.get("kind").exists(_.str.endsWith("statement")))
215+
stmts must not be empty
216+
val keys = Set("value", "prose", "message", "condition", "target", "collection")
217+
val bare = stmts.filterNot(r => keys.exists(r.value.contains))
218+
withClue(bare.map(ujson.write(_)).mkString("\n")) { bare mustBe empty }
219+
}
220+
}
128221
}

0 commit comments

Comments
 (0)