Skip to content

Commit b05c541

Browse files
reid-spencerclaude
andcommitted
[1.15] numDoStatements, and [1.14] an unknown -type is now an error
**1.15** renames the stored field and keeps `numPromptStatements` as a deprecated accessor -- derived, not a second field, because two fields describing one count can disagree. It also corrects the note that deferred the rename. That note said the old name was part of the published JS/TypeScript API because the class is `@JSExportTopLevel`. That is not how Scala.js works: exporting a CLASS does not export its members, there is no `@JSExportAll` here, and no reader exists anywhere in the repo. The rename was deferred on a premise that did not hold. **1.14** makes a typo in `-type` a parameter error (exit 7) instead of `0 matched` and exit 0 -- which was indistinguishable from a correct query with no hits, the confident-answer-over-nothing failure `find` exists to end. The message names close matches: `unknown -type 'entty'; did you mean entity?`. The vocabulary is the union of the categories, `Keyword.allKeywordsSet`, and 87 node kinds OBSERVED across both corpora -- not guessed. `ProjectionPass.kindOf` derives a kind from `RiddlValue.kind` at runtime, so there is no static list to read, and a guessed one would fail in the direction that matters: rejecting a legitimate query. `FindTypeVocabularyTest` re-derives the vocabulary from the corpus and fails on drift. **Its first canary did not redden, and that was the useful part**: removing `entity` changed nothing because `entity` is also a RIDDL keyword, so the union still covered it. A canary has to target something only the removed set supplies -- `value-reference` does, and the test then failed naming exactly what to add. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ba7a608 commit b05c541

3 files changed

Lines changed: 181 additions & 11 deletions

File tree

commands/src/main/scala/com/ossuminc/riddl/commands/find/FindPredicates.scala

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package com.ossuminc.riddl.commands.find
88

99
import com.ossuminc.riddl.commands.project.{ProjectedNode, ProjectionPass}
1010
import com.ossuminc.riddl.language.AST.*
11+
import com.ossuminc.riddl.language.parsing.Keyword
1112
// The trailing `*` is required: `Contents` is opaque and its extension methods live at PACKAGE
1213
// level, so importing only the object leaves `contents.isEmpty` unresolvable.
1314
import com.ossuminc.riddl.language.{Contents, *}
@@ -25,6 +26,57 @@ object FindPredicates {
2526
* Structural nodes with no keyword of their own — on-clauses especially — deliberately get NO
2627
* `-type` name; the generalities below are what covers them.
2728
*/
29+
/** Node kinds `-type` accepts, beyond the categories and RIDDL's own keywords.
30+
*
31+
* **Observed, not guessed**: this is every distinct `kind` the projection emits across both
32+
* corpora -- 190 riddl-models entry points and 9 riddl-examples -- which between them exercise
33+
* essentially every construct in the language. `ProjectionPass.kindOf` derives a kind from
34+
* `RiddlValue.kind` at runtime, so there is no static list to read it from, and a hand-guessed
35+
* one would be wrong in the direction that matters: rejecting a legitimate `-type`.
36+
*
37+
* `FindTypeVocabularyTest` re-derives it from the corpus and fails on drift, which keeps it
38+
* honest as the AST grows. Widening it is always safe; a MISSING entry turns a working query
39+
* into a parameter error, so add on drift rather than debating.
40+
*/
41+
// `Set` is qualified because `AST.Set` shadows `scala.Set` under the wildcard import above
42+
// -- the gotcha recorded in CLAUDE.md.
43+
private[commands] val knownKinds: scala.collection.immutable.Set[String] =
44+
scala.collection.immutable.Set(
45+
"adaptor", "arbitrary-interaction", "author", "become-statement", "button", "command",
46+
"connector", "constant", "context", "correlation", "do-statement", "document", "domain",
47+
"entity", "enumerator", "epic", "error-statement", "event", "field", "flow",
48+
"focus-on-group", "foreach-statement", "form", "forward-statement", "function", "group",
49+
"handler", "inlet", "input", "invariant", "item", "let-statement", "linecomment", "list",
50+
"match-statement", "method", "methodargument", "module", "morph-statement", "on-event",
51+
"on-init", "on-other", "on-term", "onmessageclause", "optional-interaction", "outlet",
52+
"output", "parallel-interaction", "projector", "put-statement", "query", "record",
53+
"reply-statement", "repository", "repositoryref", "require-statement", "requires",
54+
"result", "return-statement", "returns", "router", "saga", "sagastep", "schema",
55+
"send-message-interaction", "send-statement", "sequential-interaction", "set-statement",
56+
"show-output-interaction", "shownby", "sink", "source", "split", "state", "table",
57+
"take-input-interaction", "tell-statement", "terminate-statement", "type", "usecase",
58+
"user", "vague-interaction", "value-reference", "version", "void", "when-statement",
59+
"yield-statement"
60+
)
61+
62+
/** Everything `-type` accepts: the categories below, RIDDL's keywords, and the node kinds.
63+
*
64+
* Deliberately a UNION rather than the kinds alone. A `-type` value is documented as "a RIDDL
65+
* keyword where one exists, or a category", and `allKeywordsSet` covers spellings the corpus
66+
* happens not to contain.
67+
*/
68+
private[commands] def typeVocabulary: scala.collection.immutable.Set[String] =
69+
categories.keySet ++ Keyword.allKeywordsSet ++ knownKinds
70+
71+
/** Names the closest legal values rather than dumping all of them. */
72+
private def unknownTypeMessage(want: String): String =
73+
val near = typeVocabulary.toSeq
74+
.filter(v => v.startsWith(want.take(3)) || want.startsWith(v.take(3)) || v.contains(want))
75+
.sorted
76+
.take(6)
77+
val hint = if near.isEmpty then "" else s"; did you mean ${near.mkString(", ")}?"
78+
s"unknown -type '$want'$hint"
79+
2880
private val categories: Map[String, ProjectedNode => Boolean] = Map(
2981
"statement" -> (_.value.isInstanceOf[Statement]),
3082
"processor" -> (_.value.isInstanceOf[Processor[?]]),
@@ -52,7 +104,11 @@ object FindPredicates {
52104
case "-type" :: rest =>
53105
arg("-type", rest) { v =>
54106
val want = v.toLowerCase
55-
Right(FindExpr.Pred(s"-type $v", (n, _) => matchesType(n, want)))
107+
// An unknown `-type` is a PARAMETER ERROR, not zero matches. A typo used to yield
108+
// `0 matched` and exit 0 -- indistinguishable from a correct query with no hits, which
109+
// is the confident-answer-over-nothing failure this command exists to end.
110+
if !typeVocabulary.contains(want) then Left(unknownTypeMessage(want))
111+
else Right(FindExpr.Pred(s"-type $v", (n, _) => matchesType(n, want)))
56112
}
57113
case "-name" :: rest =>
58114
arg("-name", rest)(v => Right(globPred(s"-name $v", v, idOf, ci = false)))
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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.commands.find.FindPredicates
10+
import com.ossuminc.riddl.commands.project.{ProjectionOutput, ProjectionPass}
11+
import com.ossuminc.riddl.language.parsing.RiddlParserInput
12+
import com.ossuminc.riddl.passes.{Pass, PassInput, PassesOutput, Riddl}
13+
import com.ossuminc.riddl.utils.{Await, PlatformContext, pc}
14+
import org.scalatest.matchers.must.Matchers
15+
import org.scalatest.wordspec.AnyWordSpec
16+
17+
import java.nio.file.{Files, Path}
18+
import scala.concurrent.duration.DurationInt
19+
import scala.jdk.CollectionConverters.*
20+
21+
/** `find -type` must accept every kind the projection can emit.
22+
*
23+
* **`-type` rejects an unknown value as a parameter error**, which is only safe while the
24+
* vocabulary is complete. A MISSING entry turns a working query into a failure — the opposite
25+
* defect from the one the guard fixes, and a worse one, because the user is told their correct
26+
* query is wrong.
27+
*
28+
* `ProjectionPass.kindOf` derives a kind from `RiddlValue.kind` at RUNTIME, so there is no static
29+
* list to check against. This re-derives the real vocabulary from the corpus — which between its
30+
* models exercises essentially every construct in the language — and fails on drift.
31+
*
32+
* SKIPS when the sibling checkout is absent, per [1.3], so a developer without it is not blocked.
33+
*/
34+
class FindTypeVocabularyTest extends AnyWordSpec with Matchers {
35+
36+
given io: PlatformContext = pc
37+
38+
private val corpora = Seq(Path.of("../riddl-models"), Path.of("../riddl-examples"))
39+
40+
private def entryPoints(root: Path): Seq[Path] =
41+
if !Files.isDirectory(root) then Nil
42+
else
43+
Files
44+
.walk(root)
45+
.iterator()
46+
.asScala
47+
.filter(p => p.toString.endsWith(".conf") && !p.toString.contains("/target/"))
48+
.flatMap { conf =>
49+
val base = conf.getFileName.toString.stripSuffix(".conf")
50+
val src = conf.getParent.resolve(s"$base.riddl")
51+
if Files.isRegularFile(src) then Some(src) else None
52+
}
53+
.toSeq
54+
55+
private def kindsIn(model: Path): Set[String] =
56+
given scala.concurrent.ExecutionContext = pc.ec
57+
val future = RiddlParserInput.fromPathSafe(model.toString).map {
58+
case Left(_) => Set.empty[String]
59+
case Right(rpi) =>
60+
Riddl.parseAndValidate(rpi, shouldFailOnError = false) match
61+
case Left(_) => Set.empty[String]
62+
case Right(result) =>
63+
val projection = Pass.runPass[ProjectionOutput](
64+
PassInput(result.root),
65+
PassesOutput(),
66+
ProjectionPass(PassInput(result.root), result.outputs)
67+
)
68+
projection.records.flatMap(_.value.get("kind").map(_.str)).toSet
69+
}
70+
Await.result(future, 60.seconds)
71+
72+
"the -type vocabulary" should {
73+
74+
"contain every kind the corpus can produce" in {
75+
val models = corpora.flatMap(entryPoints)
76+
if models.isEmpty then cancel("no sibling corpus checkout; see BACKLOG [1.3]")
77+
else {
78+
// Guard the guard: a truncated corpus would make this vacuously pass, which is the
79+
// `0 mustBe 0` shape this repo keeps recording.
80+
withClue("the corpus must be present and whole, or this test proves nothing: ") {
81+
models.size must be >= 190
82+
}
83+
val observed = models.map(kindsIn).reduce(_ ++ _)
84+
withClue(s"the corpus produced ${observed.size} distinct kinds: ") {
85+
observed.size must be >= 80
86+
}
87+
val missing = (observed -- FindPredicates.typeVocabulary).toSeq.sorted
88+
withClue(
89+
s"add these to FindPredicates.knownKinds -- `-type` currently REJECTS them as unknown, " +
90+
s"so a correct query fails:\n ${missing.mkString("\n ")}\n"
91+
) {
92+
missing mustBe empty
93+
}
94+
}
95+
}
96+
}
97+
}

passes/src/main/scala/com/ossuminc/riddl/passes/stats/StatsPass.scala

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,24 @@ case class DefinitionStats(
6161
numOptions: Long = 0, // number of options declared
6262
numIncludes: Long = 0,
6363
numStatements: Long = 0,
64-
// NOT renamed with PromptStatement -> DoStatement (2026-08-25): `DefinitionStats` and
65-
// `KindStats` are @JSExportTopLevel, so this field name is part of the published JS/TypeScript
66-
// API and renaming it is a separate decision from the AST rename. It counts `do "..."`.
67-
numPromptStatements: Long = 0, // `do "..."` count (formerly spelled `prompt "..."`)
64+
// Renamed with PromptStatement -> DoStatement; `numPromptStatements` survives as a deprecated
65+
// accessor below. **Correcting the note that stood here**: it claimed the old name was part of
66+
// the published JS/TypeScript API because the class is `@JSExportTopLevel`. That is not how
67+
// Scala.js works -- exporting a CLASS does not export its members, and there is no
68+
// `@JSExportAll` here, so these fields were never reachable from JavaScript at all. Nor did any
69+
// reader exist anywhere in the repo. The rename was deferred on a premise that did not hold.
70+
numDoStatements: Long = 0, // `do "..."` count (spelled `prompt "..."` before 2.0)
6871
numExecutableStatements: Long = 0 // tell/send/morph/set/become/error/code
69-
)
72+
) {
73+
74+
/** Retained for consumers written against the pre-2.0 spelling.
75+
*
76+
* A derived accessor rather than a second field, deliberately: two fields describing one count
77+
* can disagree, and this repo keeps recording that shape as a defect.
78+
*/
79+
@deprecated("Use numDoStatements instead", "2.0.0")
80+
def numPromptStatements: Long = numDoStatements
81+
}
7082

7183
@JSExportTopLevel("KindStats")
7284
class KindStats(
@@ -81,9 +93,14 @@ class KindStats(
8193
var numOptions: Long = 0,
8294
var numIncludes: Long = 0,
8395
var numStatements: Long = 0,
84-
var numPromptStatements: Long = 0,
96+
var numDoStatements: Long = 0,
8597
var numExecutableStatements: Long = 0
8698
) {
99+
100+
/** Retained for consumers written against the pre-2.0 spelling. Derived, not a second field. */
101+
@deprecated("Use numDoStatements instead", "2.0.0")
102+
def numPromptStatements: Long = numDoStatements
103+
87104
def completeness: Double = (numCompleted.toDouble / numSpecifications) * 100.0d
88105
def complexity: Double =
89106
((numCompleted + numContained + numTerms + descriptionLines + numAuthors + numTerms + numOptions + numIncludes) /
@@ -226,7 +243,7 @@ case class StatsPass(input: PassInput, outputs: PassesOutput)(using PlatformCont
226243
numTerms = terms,
227244
numIncludes = includes,
228245
numStatements = counts.total,
229-
numPromptStatements = counts.prompts,
246+
numDoStatements = counts.prompts,
230247
numExecutableStatements = counts.executables
231248
)
232249
)
@@ -251,7 +268,7 @@ case class StatsPass(input: PassInput, outputs: PassesOutput)(using PlatformCont
251268
numOptions = defStats.numOptions,
252269
numIncludes = defStats.numIncludes,
253270
numStatements = defStats.numStatements,
254-
numPromptStatements = defStats.numPromptStatements,
271+
numDoStatements = defStats.numDoStatements,
255272
numExecutableStatements = defStats.numExecutableStatements
256273
)
257274
) { (ks: KindStats) =>
@@ -265,7 +282,7 @@ case class StatsPass(input: PassInput, outputs: PassesOutput)(using PlatformCont
265282
ks.numOptions += defStats.numOptions
266283
ks.numIncludes += defStats.numIncludes
267284
ks.numStatements += defStats.numStatements
268-
ks.numPromptStatements += defStats.numPromptStatements
285+
ks.numDoStatements += defStats.numDoStatements
269286
ks.numExecutableStatements += defStats.numExecutableStatements
270287
ks
271288
}
@@ -284,7 +301,7 @@ case class StatsPass(input: PassInput, outputs: PassesOutput)(using PlatformCont
284301
total.numEmpty += next.numEmpty
285302
total.numAuthors += next.numAuthors
286303
total.numStatements += next.numStatements
287-
total.numPromptStatements += next.numPromptStatements
304+
total.numDoStatements += next.numDoStatements
288305
total.numExecutableStatements += next.numExecutableStatements
289306
total
290307
}

0 commit comments

Comments
 (0)