diff --git a/build.sbt b/build.sbt
index a2c04a43..6783ec4b 100644
--- a/build.sbt
+++ b/build.sbt
@@ -153,7 +153,7 @@ lazy val dafny = (project in file("modules/dafny"))
lazy val lint = (project in file("modules/lint"))
.settings(noTestWarts *)
- .dependsOn(ir, parser % Test)
+ .dependsOn(ir, convention, parser % Test)
.settings(
name := "spec-lint",
libraryDependencies ++= commonMainDeps ++ commonTestDeps
diff --git a/docs/content/docs/design/architecture.mdx b/docs/content/docs/design/architecture.mdx
index d96223b6..cad38bcb 100644
--- a/docs/content/docs/design/architecture.mdx
+++ b/docs/content/docs/design/architecture.mdx
@@ -66,7 +66,7 @@ flowchart LR
The parser auto-injects a short preamble (`isValidURI`, `isValidEmail`). See
[Parser Implementation Notes](/pipelines/parser-implementation).
2. IR build. CST becomes `ServiceIR`, a Scala 3 enum/case-class ADT, with span tracking.
-3. Verify. `verify` runs structural lints (L01-L06; see
+3. Verify. `verify` runs structural lints (L01-L07; see
[structural lints](/spec-language#structural-lints)) and then translates each obligation
to either Z3 SMT-LIB or Alloy (non-overlapping routing) and checks it. The translator's
correctness is mechanically validated by the universal `translate_soundness_standalone`
@@ -130,7 +130,7 @@ in [#391](https://github.com/HardMax71/spec_to_rest/issues/391).
modules/
├── ir/ # IR ADT (Isabelle-extracted SpecRestGenerated.scala), circe Serialize, PrettyPrint, VerifyError
├── parser/ # ANTLR4 grammar, Parse, Builder, Preamble injection
-├── lint/ # Structural lints L01-L06
+├── lint/ # Structural lints L01-L07
├── convention/ # M1-M10 classifier, naming, path, schema, validate, Builtins registry (single source of truth for spec-language builtin functions)
├── profile/ # Deployment profiles, type mapping, annotation
├── verify/ # Z3 + Alloy translators, backends, Consistency, Diagnostic, Narration
diff --git a/docs/content/docs/research/spec_language_design/developer-experience.md b/docs/content/docs/research/spec_language_design/developer-experience.md
index 67835ea2..6da262d1 100644
--- a/docs/content/docs/research/spec_language_design/developer-experience.md
+++ b/docs/content/docs/research/spec_language_design/developer-experience.md
@@ -8,7 +8,7 @@ lints over the IR; `verify` hands the operation contracts to a solver. They catc
## Structural lints (`check`)
-Once a parse succeeds, `check` runs six lints over the IR. Each carries a stable code. An error
+Once a parse succeeds, `check` runs seven lints over the IR. Each carries a stable code. An error
exits non-zero; a warning exits zero.
| Code | Level | Catches |
@@ -19,6 +19,7 @@ exits non-zero; a warning exits zero.
| L04 | warning | two operations with the same input and output signature and equivalent `requires` |
| L05 | warning | an entity declared but never referenced |
| L06 | error | mutually recursive predicates or functions, which the verifier's inlining cannot unfold |
+| L07 | warning | an operation whose resolved `204 No Content` success status would drop its declared outputs |
The lints are deliberately narrow. L01 fires only on literals whose type admits no operator at all,
so it catches obvious mistakes without standing in for a full type checker.
diff --git a/docs/content/docs/roadmap.mdx b/docs/content/docs/roadmap.mdx
index f32c5681..7660a37b 100644
--- a/docs/content/docs/roadmap.mdx
+++ b/docs/content/docs/roadmap.mdx
@@ -148,7 +148,7 @@ Per-feature ledger for the verifier. For the prose explanation of each entry, se
| Machine-readable (`--json` / `--json-out`) diagnostics output | shipped |
| Richer suggested-fix templates (op/invariant/field-aware; opt-out via `--no-suggestions`) | shipped |
| Human-readable "why this fails" narration (opt-out via `--no-narration`) | shipped ([#89](https://github.com/HardMax71/spec_to_rest/issues/89)), covers `invariant_violation_by_operation`, `contradictory_invariants`, `unreachable_operation` |
-| Structural spec lints (type mismatch, unused entity, ...) | shipped, `check` runs L01-L06 ([#81](https://github.com/HardMax71/spec_to_rest/issues/81)); L04 ships a syntactic over-approximation, SAT-based overlap is candidate verify-side work |
+| Structural spec lints (type mismatch, unused entity, ...) | shipped, `check` runs L01-L07 ([#81](https://github.com/HardMax71/spec_to_rest/issues/81)); L04 ships a syntactic over-approximation, SAT-based overlap is candidate verify-side work |
| Per-check VC dump (`--dump-vc
`), Z3 SMT-LIB and Alloy `.als` artifacts | shipped |
| Unsat-core extraction (`--explain`), surface contributing spec spans on `unsat` diagnostics | shipped (Z3 always; Alloy when `minisat.prover` is bundled) |
| Native Alloy CLI cross-check job in CI | shipped |
diff --git a/docs/content/docs/spec-language.mdx b/docs/content/docs/spec-language.mdx
index d8f6f1da..a30f3573 100644
--- a/docs/content/docs/spec-language.mdx
+++ b/docs/content/docs/spec-language.mdx
@@ -180,7 +180,7 @@ directly into Python and SMT-LIB: `len`, `dom`, `ran`, `max`, `min`, `now`, `day
## Structural lints
-`spec-to-rest check .spec` runs six solver-free structural lints over the IR after parsing
+`spec-to-rest check .spec` runs seven solver-free structural lints over the IR after parsing
succeeds. Each diagnostic carries a stable code; warnings allow exit `0`, errors cause exit `1`.
| Code | Level | What it catches |
@@ -191,6 +191,7 @@ succeeds. Each diagnostic carries a stable code; warnings allow exit `0`, errors
| L04 | warning | Two operations share input/output signature **and** have equivalent `requires` |
| L05 | warning | `entity` declared but never referenced in state, operations, invariants, or types |
| L06 | error | Mutually-recursive predicates / functions; verifier inlining would diverge |
+| L07 | warning | Operation declares outputs but its success status resolves to `204 No Content`, so they never reach the response |
L01 is intentionally narrow: it only fires on literals whose class admits no operator overload
(e.g., `flag and 5`, `count + true`, `count > true`). The DSL uses `+`/`-` for set/map union and
@@ -205,6 +206,13 @@ caller picks the operation by name; ambiguity only matters at the dispatch layer
overlap check on `requires_A and requires_B` is candidate work for `verify` and is tracked
separately.
+L07 resolves the success status the same way the convention engine does: a per-operation
+`http_status_success` override wins, otherwise the default follows from the resolved HTTP method
+and operation kind (DELETE and delete-like operations default to `204`). A single `Bool` output is
+exempt because that is the designed delete pattern: the flag selects 204-versus-404 and never
+becomes a response body. For anything else, set `.http_status_success = 200` in the
+`conventions` block.
+
## Test-generation coverage
`spec-to-rest compile` (test emission is on by default; opt out with `--no-tests`) walks
diff --git a/modules/arch/src/test/scala/specrest/arch/ArchitectureTest.scala b/modules/arch/src/test/scala/specrest/arch/ArchitectureTest.scala
index 20cce9af..bbdf66df 100644
--- a/modules/arch/src/test/scala/specrest/arch/ArchitectureTest.scala
+++ b/modules/arch/src/test/scala/specrest/arch/ArchitectureTest.scala
@@ -35,7 +35,13 @@ class ArchitectureTest extends CatsEffectSuite:
.layer("bench").definedBy("specrest.bench..")
.layer("cli").definedBy("specrest.cli..")
.whereLayer("parser").mayOnlyBeAccessedByLayers("bench", "cli")
- .whereLayer("convention").mayOnlyBeAccessedByLayers("profile", "codegen", "testgen", "cli")
+ .whereLayer("convention").mayOnlyBeAccessedByLayers(
+ "profile",
+ "codegen",
+ "testgen",
+ "lint",
+ "cli"
+ )
.whereLayer("dafny").mayOnlyBeAccessedByLayers("synth", "cli")
.whereLayer("profile").mayOnlyBeAccessedByLayers("codegen", "testgen", "cli")
.whereLayer("verify").mayOnlyBeAccessedByLayers("bench", "cli")
diff --git a/modules/codegen/src/main/scala/specrest/codegen/python/AdminRouter.scala b/modules/codegen/src/main/scala/specrest/codegen/python/AdminRouter.scala
index 8d42ea7a..1745a2e4 100644
--- a/modules/codegen/src/main/scala/specrest/codegen/python/AdminRouter.scala
+++ b/modules/codegen/src/main/scala/specrest/codegen/python/AdminRouter.scala
@@ -25,8 +25,16 @@ object AdminRouter:
val seeds = ScalarState
.fieldsWithSeeds(ir)
.map((sf, seed) => s"${ScalarState.columnName(stfName(sf))}=$seed")
- .mkString(", ")
- s"\n await session.execute(sa_update(ServiceState).values($seeds))"
+ val single =
+ s" await session.execute(sa_update(ServiceState).values(${seeds.mkString(", ")}))"
+ val stmt =
+ if single.length <= 100 then single
+ else
+ (" await session.execute(" ::
+ " sa_update(ServiceState).values(" ::
+ seeds.map(s => s" $s,") :::
+ List(" )", " )")).mkString("\n")
+ s"\n$stmt"
else ""
val deleteStatements =
if entities.isEmpty && !hasScalars then " pass"
diff --git a/modules/codegen/src/main/scala/specrest/codegen/python/EmitPython.scala b/modules/codegen/src/main/scala/specrest/codegen/python/EmitPython.scala
index 3a3fa98c..3817beb0 100644
--- a/modules/codegen/src/main/scala/specrest/codegen/python/EmitPython.scala
+++ b/modules/codegen/src/main/scala/specrest/codegen/python/EmitPython.scala
@@ -476,10 +476,11 @@ object EmitPython:
private def enumAliasDef(ir: ServiceIRFull, typeName: String): Option[String] =
svcEnums(ir)
.find(e => enumNameFull(e) == typeName)
- .map(e =>
- enumAliasName(typeName) + " = " +
- enumValuesFull(e).map(v => s"\"$v\"").mkString("Literal[", ", ", "]")
- )
+ .map: e =>
+ val values = enumValuesFull(e).map(v => s"\"$v\"").mkString(", ")
+ val single = s"${enumAliasName(typeName)} = Literal[$values]"
+ if single.length <= 100 then single
+ else s"${enumAliasName(typeName)} = Literal[\n $values\n]"
private def schemaInputField(
f: ProfiledField,
diff --git a/modules/codegen/src/test/scala/specrest/codegen/EmitTest.scala b/modules/codegen/src/test/scala/specrest/codegen/EmitTest.scala
index b3113544..cf90c344 100644
--- a/modules/codegen/src/test/scala/specrest/codegen/EmitTest.scala
+++ b/modules/codegen/src/test/scala/specrest/codegen/EmitTest.scala
@@ -634,6 +634,16 @@ class EmitTest extends CatsEffectSuite:
s"url_mapping schema should not import SecretStr; got:\n$schema"
)
+ test("wide enum Literal alias wraps under the ruff line limit"):
+ SpecFixtures.loadProfiled("ecommerce").map: profiled =>
+ val files = Emit.emitProject(profiled).map(f => f.path -> f.content).toMap
+ val schema = files("app/schemas/order.py")
+ val wrapped =
+ """OrderStatusValue = Literal[
+ | "DRAFT", "PLACED", "PAID", "SHIPPED", "DELIVERED", "CANCELLED", "RETURNED"
+ |]""".stripMargin
+ assert(schema.contains(wrapped), s"expected wrapped Literal alias; got:\n$schema")
+
test(
"ci.yml renders GitHub Actions ${{ ... }} expressions literally (not Handlebars-substituted)"
):
diff --git a/modules/lint/src/main/scala/specrest/lint/DroppedOutputs.scala b/modules/lint/src/main/scala/specrest/lint/DroppedOutputs.scala
new file mode 100644
index 00000000..c615d615
--- /dev/null
+++ b/modules/lint/src/main/scala/specrest/lint/DroppedOutputs.scala
@@ -0,0 +1,49 @@
+package specrest.lint
+
+import specrest.convention.Classify
+import specrest.convention.Path
+import specrest.ir.generated.SpecRestGenerated.*
+
+object DroppedOutputs extends LintPass:
+ val code = "L07"
+
+ def run(ir: service_ir): List[LintDiagnostic] = ir match
+ case full: ServiceIRFull => svcOperations(full).flatMap(check(_, full))
+
+ private def check(op: operation_decl, ir: ServiceIRFull): List[LintDiagnostic] =
+ val outputs = operOutputs(op)
+ if outputs.isEmpty || isSingleBoolFlag(outputs) then Nil
+ else
+ val classification = Classify.classifyOperation(op, ir)
+ val conv = svcConventions(ir)
+ val opName = classificationOperationName(classification)
+ val method = resolveMethod(
+ Path.getConvention(conv, opName, "http_method").flatMap(parseHttpMethod),
+ classificationMethod(classification)
+ )
+ val status = resolveStatus(
+ Path.getConvention(conv, opName, "http_status_success"),
+ method,
+ classificationKind(classification)
+ )
+ if status == "204" then
+ val dropped = outputs.map(p => s"'${prmName(p)}'").mkString(", ")
+ List(
+ LintDiagnostic(
+ code,
+ LintLevel.Warning,
+ s"operation '$opName' resolves to HTTP 204 No Content, so its declared outputs ($dropped) never reach the response; set '$opName.http_status_success = 200' in the conventions block",
+ operSpan(op)
+ )
+ )
+ else Nil
+
+ // A lone Bool output on a 204 route is the designed delete pattern: the flag
+ // selects 204-vs-404 and is never a response body.
+ private def isSingleBoolFlag(outputs: List[param_decl]): Boolean =
+ outputs match
+ case p :: Nil =>
+ prmType(p) match
+ case NamedTypeF("Bool", _) => true
+ case _ => false
+ case _ => false
diff --git a/modules/lint/src/main/scala/specrest/lint/Lint.scala b/modules/lint/src/main/scala/specrest/lint/Lint.scala
index d028f71c..6835e85f 100644
--- a/modules/lint/src/main/scala/specrest/lint/Lint.scala
+++ b/modules/lint/src/main/scala/specrest/lint/Lint.scala
@@ -9,7 +9,8 @@ object Lint:
MissingEnsures,
OperationOverlap,
UnusedEntity,
- CircularPredicate
+ CircularPredicate,
+ DroppedOutputs
)
def run(ir: service_ir): List[LintDiagnostic] =
diff --git a/modules/lint/src/test/scala/specrest/lint/DroppedOutputsTest.scala b/modules/lint/src/test/scala/specrest/lint/DroppedOutputsTest.scala
new file mode 100644
index 00000000..e274559e
--- /dev/null
+++ b/modules/lint/src/test/scala/specrest/lint/DroppedOutputsTest.scala
@@ -0,0 +1,72 @@
+package specrest.lint
+
+import munit.CatsEffectSuite
+import specrest.ir.generated.SpecRestGenerated.SpanT
+import specrest.ir.generated.SpecRestGenerated.less_int
+import specrest.lint.testutil.SpecFixtures
+
+class DroppedOutputsTest extends CatsEffectSuite:
+
+ private def deleteSpec(output: String, conventions: String): String =
+ s"""service DropDemo {
+ | entity Item {
+ | id: Int
+ | name: String
+ | }
+ |
+ | state {
+ | items: Int -> Item
+ | }
+ |
+ | operation DeleteItem {
+ | input: id: Int
+ | output: $output
+ |
+ | requires:
+ | id in items
+ |
+ | ensures:
+ | id not in items'
+ | }
+ |$conventions}""".stripMargin
+
+ private val overrideBlock =
+ """ conventions {
+ | DeleteItem.http_status_success = 200
+ | }
+ |""".stripMargin
+
+ List(
+ ("warns on entity output with defaulted 204", "item: Item", "", 1),
+ ("silent when http_status_success overrides to 200", "item: Item", overrideBlock, 0),
+ ("silent on single Bool flag output with 204", "deleted: Bool", "", 0)
+ ).foreach: (label, output, conventions, expected) =>
+ test(s"L07 $label"):
+ SpecFixtures.buildFromSource("DropDemo", deleteSpec(output, conventions)).map: ir =>
+ val diags = DroppedOutputs.run(ir)
+ assertEquals(diags.length, expected, diags.map(_.message).toString)
+
+ test("L07 names the op, the 204, the dropped outputs, and the override"):
+ SpecFixtures.buildFromSource("DropDemo", deleteSpec("item: Item", "")).map: ir =>
+ val diags = DroppedOutputs.run(ir)
+ assertEquals(diags.length, 1)
+ val d = diags.head
+ assertEquals(d.code, "L07")
+ assertEquals(d.level, LintLevel.Warning)
+ assert(d.message.contains("DeleteItem"), d.message)
+ assert(d.message.contains("204"), d.message)
+ assert(d.message.contains("'item'"), d.message)
+ assert(d.message.contains("DeleteItem.http_status_success"), d.message)
+ assert(
+ d.span.exists { case SpanT(line, _, _, _) => less_int(BigInt(0), line) },
+ s"expected span, got ${d.span}"
+ )
+
+ test("L07 silent on the all-lints-pass fixture"):
+ SpecFixtures.loadLintIR("passing").map: ir =>
+ assertEquals(DroppedOutputs.run(ir), Nil)
+
+ List("url_shortener", "todo_list", "auth_service", "ecommerce").foreach: name =>
+ test(s"L07 silent on fixtures/spec/$name.spec"):
+ SpecFixtures.loadIR(name).map: ir =>
+ assertEquals(DroppedOutputs.run(ir), Nil)
diff --git a/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_client.go b/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_client.go
index 12fe4f74..b915e841 100644
--- a/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_client.go
+++ b/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_client.go
@@ -54,8 +54,13 @@ func (r clientResponse) JSON() any {
if len(r.body) == 0 {
return map[string]any{}
}
+ // UseNumber delivers numbers as json.Number: plain Unmarshal rounds
+ // integers through float64, conflating adjacent values above 2^53 before
+ // the runtime's exact int64 comparisons ever see them.
+ dec := json.NewDecoder(bytes.NewReader(r.body))
+ dec.UseNumber()
var v any
- if err := json.Unmarshal(r.body, &v); err != nil {
+ if err := dec.Decode(&v); err != nil {
return map[string]any{}
}
return v
diff --git a/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_runtime.go b/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_runtime.go
index 3a93d54a..b17ec5cd 100644
--- a/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_runtime.go
+++ b/modules/testgen/src/main/resources/testgen-templates/go/chi/tests/conf_runtime.go
@@ -6,15 +6,20 @@
// the set/relation/arithmetic semantics Python gets from built-ins (`in`, `==`
// on sets, `<`, `+`, `|`/`&`/`-`, powerset) are delegated here, operating over
// `any` values decoded from /admin/state and response bodies
-// (objects -> map[string]any, arrays -> []any, numbers -> float64). `_Set` is
-// a named slice so equality is order-independent for sets and ordered for
-// sequences, matching the TypeScript Set-vs-Array distinction.
+// (objects -> map[string]any, arrays -> []any, numbers -> json.Number, the
+// client decodes with UseNumber). Equality and ordering compare integral
+// values exactly as int64, the widest integer the service side can hold (Go
+// int64, sqlite INTEGER); fractional values compare as float64, and
+// arithmetic stays float64. `_Set` is a named slice so equality is
+// order-independent for sets and ordered for sequences, matching the
+// TypeScript Set-vs-Array distinction.
package tests
import (
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
"fmt"
"math"
"regexp"
@@ -38,19 +43,65 @@ func _toF(x any) (float64, bool) {
return float64(v), true
case float64:
return v, true
+ case json.Number:
+ if f, err := v.Float64(); err == nil {
+ return f, true
+ }
+ return 0, false
default:
return 0, false
}
}
+// _toI reports x as an exact int64. json.Number and digit strings (JSON
+// object keys of Int-keyed relations) parse directly, skipping the float64
+// round-trip that conflates neighbors above 2^53; integral floats convert
+// when in int64 range. Integers past int64 cannot originate from the service
+// side (Go ints and sqlite INTEGER are 64-bit), so callers fall back to
+// float64 for whatever fails here.
+func _toI(x any) (int64, bool) {
+ switch v := x.(type) {
+ case int:
+ return int64(v), true
+ case int32:
+ return int64(v), true
+ case int64:
+ return v, true
+ case json.Number:
+ i, err := strconv.ParseInt(v.String(), 10, 64)
+ return i, err == nil
+ case string:
+ i, err := strconv.ParseInt(v, 10, 64)
+ return i, err == nil
+ case float32:
+ return _floatToI(float64(v))
+ case float64:
+ return _floatToI(v)
+ default:
+ return 0, false
+ }
+}
+
+func _floatToI(f float64) (int64, bool) {
+ // 2^63 is exact in float64 and one past int64 max, hence the >=. NaN
+ // fails the Trunc self-equality; infinities fail the range checks.
+ if f != math.Trunc(f) || f < -(1<<63) || f >= 1<<63 {
+ return 0, false
+ }
+ return int64(f), true
+}
+
+// Integral numbers share one canonical string regardless of representation
+// (json.Number, int64, integral float64), so map-key lookups and hashes
+// agree across decoded and computed values.
func _str(x any) string {
if s, ok := x.(string); ok {
return s
}
+ if i, ok := _toI(x); ok {
+ return strconv.FormatInt(i, 10)
+ }
if f, ok := _toF(x); ok {
- if f == float64(int64(f)) {
- return fmt.Sprintf("%d", int64(f))
- }
return fmt.Sprintf("%v", f)
}
return fmt.Sprintf("%v", x)
@@ -139,25 +190,12 @@ func _eq(a, b any) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
- if fa, oka := _toF(a); oka {
- if fb, okb := _toF(b); okb {
- return fa == fb
- }
- // JSON object keys stringify, so an Int-keyed relation iterates as
- // digit strings while ids stay numbers; compare numerically when the
- // string side parses as a number exactly.
- if sb, okb := b.(string); okb {
- if fb, err := strconv.ParseFloat(sb, 64); err == nil {
- return fa == fb
- }
- }
- return false
+ if _, oka := _toF(a); oka {
+ return _numEq(a, b)
}
- if sa, oka := a.(string); oka {
- if fb, okb := _toF(b); okb {
- if fa, err := strconv.ParseFloat(sa, 64); err == nil {
- return fa == fb
- }
+ if _, oka := a.(string); oka {
+ if _, okb := _toF(b); okb {
+ return _numEq(a, b)
}
}
switch av := a.(type) {
@@ -213,6 +251,23 @@ func _eq(a, b any) bool {
}
}
+// Number-vs-number and number-vs-digit-string equality (JSON object keys
+// stringify, so an Int-keyed relation iterates as digit strings while ids
+// stay numbers). Integral pairs compare exactly as int64: json.Number keeps
+// full precision past 2^53, where the float64 round-trip conflates adjacent
+// integers. Fractional operands, and integers past int64 (which the service
+// side cannot produce), fall back to float64.
+func _numEq(a, b any) bool {
+ if ia, oka := _toI(a); oka {
+ if ib, okb := _toI(b); okb {
+ return ia == ib
+ }
+ }
+ fa, oka := _num(a)
+ fb, okb := _num(b)
+ return oka && okb && fa == fb
+}
+
func _in(x, c any) bool {
switch cv := c.(type) {
case string:
@@ -230,11 +285,9 @@ func _in(x, c any) bool {
}
}
-// Digit strings from JSON object keys compare numerically against numbers,
-// matching _eq's coercion (Int-keyed relations stringify their keys). The
-// float64 domain is the ceiling of the whole comparison anyway: encoding/json
-// decodes every number to float64 before these helpers ever see it, so a
-// ParseInt path would imply precision the decoded values no longer carry.
+// Float-context coercion for numbers and numeric strings (JSON object keys).
+// Exact integral comparison goes through _toI first; this is the fallback
+// for fractional values, where float64 is the spec's own numeric domain.
func _num(x any) (float64, bool) {
if f, ok := _toF(x); ok {
return f, true
@@ -275,6 +328,18 @@ func _setEq(a, b any) bool {
}
func _cmp(a, b any) (int, bool) {
+ if ia, oka := _toI(a); oka {
+ if ib, okb := _toI(b); okb {
+ switch {
+ case ia < ib:
+ return -1, true
+ case ia > ib:
+ return 1, true
+ default:
+ return 0, true
+ }
+ }
+ }
if fa, oka := _num(a); oka {
if fb, okb := _num(b); okb {
switch {
@@ -616,15 +681,9 @@ func _call(f any, args ...any) any {
}
func _abs(x any) any {
- // JSON-decoded numbers come in as float64; spec ints survive as int64.
- // Coerce to float64 and delegate to math.Abs, mirroring TS's Math.abs(Number(x)).
- switch v := x.(type) {
- case float64:
- return math.Abs(v)
- case int64:
- return math.Abs(float64(v))
- case int:
- return math.Abs(float64(v))
+ // _toF folds json.Number in; mirrors TS's Math.abs(Number(x)).
+ if f, ok := _toF(x); ok {
+ return math.Abs(f)
}
return math.NaN()
}
@@ -636,8 +695,8 @@ func _now() any {
}
func _sha256Hex(s any) any {
- // Coerce any value (numbers come from JSON as float64) to its canonical
- // string form before hashing, mirroring Python's str(...) coercion.
- sum := sha256.Sum256([]byte(fmt.Sprintf("%v", s)))
+ // _str canonicalizes numbers (json.Number "2", int64 2, and float64 2
+ // all hash as "2") so decoded and computed values agree.
+ sum := sha256.Sum256([]byte(_str(s)))
return hex.EncodeToString(sum[:])
}
diff --git a/modules/testgen/src/test/scala/specrest/testgen/AdminRouterTest.scala b/modules/testgen/src/test/scala/specrest/testgen/AdminRouterTest.scala
index 16daf4c9..8ee5d613 100644
--- a/modules/testgen/src/test/scala/specrest/testgen/AdminRouterTest.scala
+++ b/modules/testgen/src/test/scala/specrest/testgen/AdminRouterTest.scala
@@ -60,6 +60,14 @@ class AdminRouterTest extends CatsEffectSuite:
assert(src.contains("\"count\": state_row.count"), s"src=$src")
assert(src.contains("from app.models.service_state import ServiceState"), s"src=$src")
+ test("ecommerce: multi-counter reset wraps values() one seed per line under the ruff limit"):
+ loadProfiled("fixtures/spec/ecommerce.spec").map: profiled =>
+ val src = AdminRouter.emit(profiled)
+ assert(src.contains("sa_update(ServiceState).values(\n"), s"src=$src")
+ assert(src.contains(" next_order_id=1,"), s"src=$src")
+ val overlong = src.linesIterator.filter(_.length > 100).toList
+ assert(overlong.isEmpty, s"overlong lines: ${overlong.mkString("\n")}")
+
test("entity model imports use snake_case file names matching codegen"):
loadProfiled("fixtures/spec/url_shortener.spec").map: profiled =>
val src = AdminRouter.emit(profiled)