Skip to content

M6 Bug Fix generated GSP views: scaffolded count model field type and welcome page static compilation - #16322

Open
codeconsole wants to merge 11 commits into
apache:8.0.xfrom
codeconsole:fix/scaffold-count-type-8.0.x
Open

codeconsole wants to merge 11 commits into
apache:8.0.xfrom
codeconsole:fix/scaffold-count-type-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description

Two defects that stop a freshly generated app from working. Both are in the generated GSP views, and both surface the moment you use the app the generator produces.

1. Scaffolded index.gsp declares the count model field as Integer

grails generate-views produces an index.gsp whose typed model directive declares:

@{ model="List<website.Sample> sampleList; Integer sampleCount" }

but the Service.groovy template declares Long count(), and Controller.groovy passes that value straight through as the ${propertyName}Count model entry. Model fields are populated by reflective Field.set, which does no numeric conversion, so every scaffolded index view throws on first render:

java.lang.IllegalArgumentException: Can not set java.lang.Integer field
  ..._views_sample_index_gsp.sampleCount to java.lang.Long
    at org.grails.gsp.GroovyPage.applyModelFieldsFromBinding(GroovyPage.java:185)

The template now declares the count as Number. The generated service supplies a Long, but static scaffold = X goes through RestfulController.countResources(), which supplies an Integer, so no single concrete type fits both.

While tracking this down, the binding itself turned out to be stricter than the rest of the page. <g:set type="int" .../> converts, and so does a plain Groovy assignment, but a model field declared Integer failed outright when given a Long, with a bare JDK reflection message naming the generated class. Model values are now converted to the declared type the way a Groovy assignment converts them, so existing views that declare Integer bookCount keep working.

DefaultTypeTransformation.castToType on its own wraps 3_000_000_000L into -1294967296 and turns 42.9 into 42, silently. So a conversion that would change a numeric value is refused, as is a value that cannot be converted at all, and both name the field and the two types:

GroovyPagesException: Model field 'bookCount' is declared as java.lang.Integer, which cannot
  hold the java.lang.Long 3000000000 the model supplied without changing it.

2. Welcome page does not compile under GSP static compilation

Adding the following to a generated app's build.gradle fails compileGroovyPages:

grails {
    compileStatic {
        all = true
        gsp = true
    }
}
[Static type checking] - Cannot find matching method java.lang.Object#toLowerCase()
[Static type checking] - No such property: name for class: java.lang.Object

Three sort closures in the welcome page take untyped parameters, so static type checking infers Object for the element and rejects the calls on it. Each loses the element type for a different reason:

Site Why the element is Object
plugin list .collect { p, i -> [plugin: p, order: ...] } — map literal values are Object
domainsByPlugin the groupBy closure returns a def local, so the key is Object
mimeTypes applicationContext.getBean('mimeTypes') returns Object

Fixed by typing the closure parameters and converting the values whose static type is Object. The appListeners sort is typed the same way for consistency; its map values are all String, so it compiled without that change. The plugin and mime type comparisons need the concrete element type, so those are cast.

One wrinkle worth recording: grails.plugins.GrailsPlugin cannot be imported into a GSP, because the compiler already auto-imports the unrelated grails.plugins.metadata.GrailsPlugin annotation and the collision fails the build with The name GrailsPlugin is already declared. The cast uses the qualified name instead.

grails-forge-core's resource copy and the web profile skeleton copy of this page were byte-identical, so both are updated and remain identical.

Keeping both from coming back

CI was green on 8.0.x with both defects present. gsp-compile-static now compiles the web profile's welcome page statically alongside its own pages (the forge copy is pinned to the profile copy by GrailsGspSpec), and ScaffoldedIndexViewModelSpec expands the scaffolded index.gsp and renders its model declaration with an Integer, a Long, and a count past Integer.MAX_VALUE.

The scaffolding Service template declares `Long count()` and the
Controller template passes its result as the `${propertyName}Count`
model value, but index.gsp declared the matching typed model field as
`Integer`. Model fields are populated by reflective Field.set, which
performs no numeric conversion, so every scaffolded index view threw
IllegalArgumentException on first render.

Declare the field as Long so it matches what the service returns.

GroovyPage.applyModelFieldsFromBinding only caught IllegalAccessException,
which cannot occur because GroovyPageMetaInfo already makes each model
field accessible. The one failure that can occur, a type mismatch, escaped
uncaught and surfaced as a bare JDK reflection message naming the mangled
generated page class. Catch IllegalArgumentException and report the field,
the declared type, the supplied type and the page instead.
Enabling static GSP compilation in a generated app:

    grails {
        compileStatic {
            all = true
            gsp = true
        }
    }

made compileGroovyPages fail on the welcome page. Four sort closures took
untyped parameters, so static type checking inferred java.lang.Object for
the element and rejected the property and method calls on it:

    Cannot find matching method java.lang.Object#toLowerCase()
    No such property: name for class: java.lang.Object

Type the closure parameters, and convert the values whose static type is
Object rather than String. The plugin and mime type comparisons need the
concrete element type, so those are cast; grails.plugins.GrailsPlugin is
referenced by its qualified name because GSP already auto-imports the
unrelated grails.plugins.metadata.GrailsPlugin annotation.

The forge resource and the web profile skeleton carry byte-identical
copies of this page, so both are updated.
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.23810% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 55.5923%. Comparing base (3067d0a) to head (fc1941a).
⚠️ Report is 40 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ore/src/main/groovy/org/grails/gsp/GroovyPage.java 95.2381% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16322        +/-   ##
==================================================
+ Coverage     55.2582%   55.5923%   +0.3342%     
- Complexity      20976      21127       +151     
==================================================
  Files            2113       2113                
  Lines          101632     101800       +168     
  Branches        18045      18099        +54     
==================================================
+ Hits            56160      56593       +433     
+ Misses          37422      37099       -323     
- Partials         8050       8108        +58     
Files with missing lines Coverage Δ
...ore/src/main/groovy/org/grails/gsp/GroovyPage.java 78.6260% <95.2381%> (+1.1669%) ⬆️

... and 19 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The mime type sort closure spelled out grails.web.mime.MimeType at both
parameters, which is long and reads poorly. Nothing in the GSP default
imports declares a competing MimeType, so a page import works here and
the closure reads as `MimeType a, MimeType b`.

The plugin comparison keeps its qualified name: GSP always imports
grails.plugins.metadata.GrailsPlugin, so importing grails.plugins.GrailsPlugin
fails with "The name GrailsPlugin is already declared".
The previous commit made the page compile statically, but did it by
annotating the comparators rather than by restoring the element types,
which left three closures heavier than the rest of the page. Every other
sort here reads `sort { it.something }`.

Derive the domain grouping key as a String instead of from an untyped
local, and the sort needs no annotation at all. Declare the listener maps
as Map<String, String>, and the toString() calls guarding the comparison
are unnecessary. Sort the plugin rows by a single key so the cast appears
once rather than on both sides.

The plugin comparison still needs its cast and its qualified name: the
rows are heterogeneous maps, so the value is Object, and GSP always
imports grails.plugins.metadata.GrailsPlugin.

Do not name a closure parameter `it`. It compiles ahead of time, but the
page is parsed again at runtime when reloading is on, and that path
rejects it with "The current parameter list already contains a parameter
of the name it".
Changing the field to Long fixed views generated alongside the generated
service, which declares Long count(), but broke dynamic scaffolding. A
controller using `static scaffold = X` is backed by RestfulController,
whose countResources() returns Integer, so those pages then failed the
other way:

    Model field 'authorCount' is declared as java.lang.Long
    but the model supplied an instance of java.lang.Integer

One template serves both paths, so it cannot name either concrete type.
Number accepts what each supplies, and the pagination comparison still
compiles statically against it.

Covered by a test that renders the field from Integer, Long, Short and
BigInteger, so neither supplier can regress the other again.
@codeconsole
codeconsole requested review from jdaugherty and matrei and removed request for jdaugherty September 8, 2026 03:43
@codeconsole codeconsole added this to the grails:8.0.0-RC1 milestone Sep 8, 2026
@codeconsole codeconsole changed the title Fix generated GSP views: scaffolded count model field type and welcome page static compilation M6 Bug Fix generated GSP views: scaffolded count model field type and welcome page static compilation Sep 8, 2026
@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

AI Review Findings

Head 8855b88340, base 8.0.x at 0980623481. The head is 103 commits behind the base but merges clean (git merge-tree reports no conflicts). Both defects in the description are real and both fixes work:

  • Compiling the base-branch welcome page statically (copied into grails-test-examples/gsp-compile-static) fails with 4 type-checking errors at three sites, the plugins sort, the domains-by-plugin sort and the mime-types sort. The PR's page compiles, and the compiled class extends CompileStaticGroovyPage, so it really was compiled statically rather than falling back.
  • The scaffolded index.gsp expanded for a domain class compiles statically with Number and the ${count} > params.int('max') comparison type-checks.
  • GspCompileStaticSpec: 30 tests pass. Checkstyle on grails-gsp-core main: 0 violations.

The findings below are about the choice the GroovyPage change makes, and about what would keep either bug from coming back. None of them changes the welcome page hunks.

[P2] Assign model fields the way Groovy assigns, instead of declaring that model values are not coerced

References:

  • grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:180-197 (applyModelFieldsFromBinding)
  • grails-scaffolding/src/main/templates/scaffolding/index.gsp:1

The new message states a rule, "model values are not coerced", that is the opposite of what the rest of the page does. <g:set type="int" var="total" value="${books.size()}"/> converts (the static compilation guide says so explicitly), and plain Groovy converts too:

Integer i = 42L      // 42, java.lang.Integer
int p = 42L          // 42
String s = "${40+2}" // GString assigned to String

A model field declared Integer bookCount is the one place in a page where the same assignment throws. That declaration is also the natural one to write: GORM's count() returns Integer (GormEntity.groovy:732), the generated service returns Long, and RestfulController.countResources() returns Integer, so any page author who declares the count with the type of whichever source they looked at has a coin-flip chance of a render failure. String fields fed a GString from a controller fail the same way today.

DefaultTypeTransformation.castToType is Groovy's own assignment conversion, so it gives exactly the semantics a def-free Groovy assignment would:

try {
    field.set(this, DefaultTypeTransformation.castToType(value, field.getType()));
} catch (IllegalArgumentException | GroovyCastException e) {
    throw new GroovyPagesException("Model field '" + field.getName() + "' is declared as " +
            field.getType().getName() + " but the model supplied an instance of " +
            value.getClass().getName() + '.', e, -1, getGroovyPageFileName());
}

I ran the PR's spec with that in place plus a data-driven case:

declared supplied result
Integer 42L renders 42
int 42L renders 42
Long 42 renders 42
long 42 as Short renders 42
String "${40 + 2}" (GString) renders 42
Number 42 as BigInteger renders 42
Integer new Date() GroovyPagesException naming the field, cause GroovyCastException

The only existing test that changes is a model value of the wrong type names the field and both types, which would flip from Integer/Long to a pair that genuinely cannot be converted, Integer/Date say. Everything else in the spec passes unchanged. The prototype is not in the working tree.

The Number change in the scaffold template is still the honest type for a value that is Long from one controller and Integer from the other, so keep it either way. With coercion it stops being load-bearing, and every already-generated Integer view in existing 8.0.x applications starts working as well, which the template change alone does not give them.

[P2] Document what a declared model field does with a value of another type

References:

  • grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:28-56 (Declaring the Model)
  • grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:208

The guide says nothing about what happens when the supplied value's type differs from the declaration. The only nearby sentence, at line 208, covers the framework-supplied names and says they fail with a GroovyCastException. Whichever way the finding above is decided, that section needs one sentence: either "a model value is converted the way a Groovy assignment converts it, so a Long count satisfies an Integer field" or "a model value must be assignable to the declared type; a mismatch fails at render naming the field". The behaviour is user-facing and is exactly what this PR changes, so this is the doc coverage the contributing rules ask for.

[P2] Neither failure had a test that could catch it, and this PR adds coverage for the engine but not for the two pages

References:

  • grails-test-examples/gsp-compile-static/build.gradle
  • grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/EmployeeController.groovy:32
  • grails-gsp/core/src/test/groovy/org/grails/gsp/GspCompileStaticSpec.groovy:241-274

CI was green on 8.0.x with both bugs present, and stays green if either regresses:

  • Nothing compiles the welcome page statically. gsp-compile-static has its own small pages only. A Copy step that drops grails-profiles/web/skeleton/grails-app/views/index.gsp into that app's views ahead of compileGroovyPages is the check I ran by hand above, and it fails on the base branch with the four errors. The forge copy is already pinned to the profile copy by test the profile skeleton mirrors the forge welcome templates in GrailsGspSpec, so one copy is enough.
  • Nothing renders a service-backed scaffolded index. scaffolding-fields and hyphenated use static scaffold = Domain, which goes through RestfulServiceController.countResources() and supplies an Integer, so the generated-service Long path is never rendered. The new spec cases prove the engine accepts a Long for a Number field; they do not prove the template declares Number. A test that expands scaffolding/index.gsp for a domain class and renders it with [bookList: [], bookCount: 3L] would pin the template itself.

Nit: the listener sort hunk is not needed

References:

  • grails-profiles/web/skeleton/grails-app/views/index.gsp:570
  • grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp:570

Map<String, String> a, Map<String, String> b on the listener sort compiles either way: the collect above it builds maps whose values are all String, so a.name already infers as String. I reverted that one hunk on the PR's page and it compiled. The description says four closures fail; three do. Harmless, and fine to keep for symmetry with the other three.

Nit: the scaffolding guide describes model names that the templates no longer use

References:

  • grails-doc/src/en/guide/scaffolding.adoc:191

Pre-existing and out of scope, noting it because it is the doc a reader would go to for the count field: it says the standard views expect <propertyName>InstanceList and <propertyName>Instance, while the templates bind <propertyName>List and <propertyName>Count.

Confirmed

  • The grails.plugins.GrailsPlugin fully-qualified cast is needed: GroovyPageParser.DEFAULT_IMPORTS imports grails.plugins.metadata.GrailsPlugin into every page, so a page import of the plugin interface would clash.
  • The rewritten sorts keep their ordering. The one-argument sort on the plugin rows and on the domainsByPlugin entries orders by the same lower-cased key the comparators used, and List.sort(Closure) still sorts in place as before.
  • The groupBy closure now returns a typed String, which is what lets it.key.toLowerCase() type-check on the entry.
  • Reading the model value before the try changes nothing: only IllegalAccessException was caught before, so a failing getProperty propagated then and propagates now.

Verification

  • ./gradlew :grails-gsp-core:test --tests org.grails.gsp.GspCompileStaticSpec: 30 tests, 0 failures on the PR head.
  • ./gradlew :grails-gsp-core:checkstyleMain: 0 violations.
  • ./gradlew :grails-test-examples-gsp-compile-static:compileGroovyPages with the PR's welcome page and an expanded scaffold index.gsp (List<gspstatic.Book> bookList; Number bookCount) copied into the app: success, both classes extend CompileStaticGroovyPage.
  • Same task with the merge-base welcome page: fails, Cannot find matching method java.lang.Object#toLowerCase() at generated lines 42, 413 and 1340 plus No such property: name for class: java.lang.Object at 1340.
  • Same task with the PR's page and only the listener sort hunk reverted: success.
  • Coercion prototype in GroovyPage.java plus 7 temporary spec cases: 37 tests, the single failure being the PR's Integer/Long mismatch case, as expected. Both files restored afterwards; git status shows no tracked changes.
  • The copied pages were removed from grails-test-examples/gsp-compile-static afterwards.

A page could convert everywhere except its declared model: <g:set
type="int"> converts, and so does a plain Groovy assignment, but a model
field declared Integer failed outright when a controller supplied a Long.
That declaration is the natural one to write, since count() and the two
scaffolding controllers do not agree on a type, and it is what views
already generated for 8.0 contain.

Convert with DefaultTypeTransformation.castToType, which is Groovy's own
assignment conversion. On its own it would wrap 3_000_000_000L into
-1294967296 and turn 42.9 into 42 without a word, so a conversion that
changes a numeric value is refused, as is a value that cannot be converted
at all. Both name the field and the two types.

The GSP static compilation guide now says what a declared model does with
a value of another type.
Both defects this branch fixes shipped with CI green, because nothing
compiled the welcome page statically and nothing rendered the scaffolded
index view's model declaration.

gsp-compile-static now stages the web profile's welcome page beside its
own pages and compiles them together. The forge copy is already pinned to
the profile copy by GrailsGspSpec.

ScaffoldedIndexViewModelSpec expands scaffolding/index.gsp the way dynamic
scaffolding does and renders its model declaration with an Integer, a Long
and a count past Integer.MAX_VALUE, which the Integer declaration it used to
carry cannot hold.
The guide said the scaffold views expect <propertyName>InstanceList and
<propertyName>Instance. The templates bind <propertyName>List and
<propertyName>Count, and <propertyName> for a single instance.
@codeconsole

codeconsole commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@matrei

Thanks. All addressed in 0a93f8d, 07afd38 and 0542112.

Model binding: converts now, but refuses a conversion that changes the value. I took the coercion, with one guard. castToType on its own reproduces the truncation #16323 exists to remove. On the Groovy 5.1 jar, Integer ← 3_000_000_000L gives -1294967296, Integer ← Long.MAX_VALUE gives -1, and Integer ← 42.9G gives 42, all silently. So GroovyPage converts with castToType and throws, naming the field and both types, when a numeric value doesn't survive the conversion (checked as BigInteger for whole numbers, BigDecimal otherwise). Integer ← 42L, Integer ← 42.0G and GString → String convert. A Date still fails, with the GroovyCastException as cause. The mismatch spec now uses Integer/Date, plus data-driven conversion and lossy rows. Without the guard, exactly the four lossy rows fail.

Docs: one paragraph under Declaring the Model in gspStaticCompilation.adoc states the conversion and the lossy failure.

Tests:

  • gsp-compile-static stages the profile welcome page beside its own pages for compileGroovyPages. It compiles, and the page class extends CompileStaticGroovyPage.
  • ScaffoldedIndexViewModelSpec expands scaffolding/index.gsp with ModelBuilder, the way dynamic scaffolding does, and renders its model declaration with 3, 3L and 3_000_000_000L. The last row is a count the old Integer declaration can no longer hold.

Nits: the description now says three closures fail, with the listener sort kept for consistency. The scaffolding.adoc model names are fixed too, since it's the page a reader would check for the count field.

Verified: GspCompileStaticSpec (40 tests), ScaffoldedIndexViewModelSpec, gsp-compile-static:compileGroovyPages and grails-gsp-core:checkstyleMain.

@matrei

matrei commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Review Findings, round 2

Head 22163f7f11, base 8.0.x at 3067d0a855. The branch now contains the base head, so the merge-base is the base itself and there is nothing to merge. All three P2s from the first round are addressed: model values are converted with DefaultTypeTransformation.castToType, the static compilation guide says what a mismatched value does, the welcome page is compiled statically by gsp-compile-static, and the scaffolded index.gsp count declaration has a spec. The scaffolding guide nit is fixed too. The listener sort hunk is unchanged, which is fine.

What I ran on the head, all green:

  • :grails-gsp-core:test --tests GspCompileStaticSpec: 40 tests, 0 failures.
  • :grails-scaffolding:test --tests ScaffoldedIndexViewModelSpec: 3 tests, 0 failures.
  • :grails-test-examples-gsp-compile-static:integrationTest: 7 tests, 0 failures. The staged directory holds the app's five pages plus the welcome page, views.properties lists /WEB-INF/grails-app/views/index.gsp, and the compiled class extends CompileStaticGroovyPage.
  • :grails-gsp-core:codeStyle and :grails-scaffolding:codeStyle: no violations. The modules have no PMD or SpotBugs tasks.

One new finding, in the value-change guard the PR adds on top of the coercion, and one small one next to it.

[P2] The value-change guard rejects a Float widened to a Double field

References:

  • grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:198 (the guard)
  • grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:211-219 (sameNumericValue)
  • grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:56

sameNumericValue compares the two numbers through new BigDecimal(n.toString()). Float.toString prints the shortest decimal that rounds back to the float, Double.toString prints the shortest decimal that rounds back to the double, and those differ for the same binary value: 0.1f prints 0.1, and the same value as a double prints 0.10000000149011612. The widening itself is exact, every float is representable as a double, but the decimal forms are not equal, so the guard throws.

I rendered @{ model="Double v"}${v} with [v: 0.1f] through GroovyPagesTemplateEngine on the head:

GroovyPagesException: Model field 'v' is declared as java.lang.Double, which cannot hold the java.lang.Float 0.1 the model supplied without changing it.

Same for double v with 1.1f. Plain Groovy assigns double d = 0.1f without complaint, so the guide's sentence at line 56, "converted to the declared type the way a Groovy assignment converts it", is not true for the one conversion that loses nothing. The same run showed the reverse direction, Float v with 0.1d, rendering 0.1: the narrowing that does drop bits passes because both sides print 0.1. The guard is backwards for the float family.

A Double model field fed a Float is not exotic: a domain property declared Float rendered through a page whose author wrote Double, or BigDecimal for that matter, which works, so the Double failure will read as arbitrary.

The rule the doc states, "a conversion that would change the value fails", holds up if the comparison is done at the precision of the value that came in rather than through decimal strings. One shape that gives that: convert converted back to the original's class and compare it to the original, using compareTo when the original is a BigDecimal so that scale does not matter.

Number back = (Number) DefaultTypeTransformation.castToType(converted, original.getClass());
return original instanceof BigDecimal
        ? ((BigDecimal) original).compareTo((BigDecimal) back) == 0
        : original.equals(back);

Against the cases in the spec plus the ones above: 0.1f to Double comes back as 0.1f and passes; 0.1d to Float comes back as 0.10000000149011612d and fails, which is the lossy one; 3_000_000_000L to Integer comes back as -1294967296L and fails; 42.0G to Integer comes back as 42 and compares equal; 42.9G fails; NaN to Integer comes back as 0.0 and fails; Long.MAX_VALUE to Double comes back as Long.MAX_VALUE because Double.longValue saturates, so it passes the way Java's own long to double widening does. The one thing to guard is a Number subclass Groovy cannot cast back to, AtomicLong say, where castToType throws; falling back to the current comparison for that is enough. Whichever rule is chosen, the spec's "converted the way a Groovy assignment converts it" table at GspCompileStaticSpec.groovy:264 should carry a 'Double' | 0.1f row, since that is the row that fails today.

For the record, two other rejections from the same probe are consistent with the doc sentence and I do not count them as defects: Long.MAX_VALUE into a Double field and 16_777_217 into a Float field both throw, and both really do lose precision.

[P3] NaN into a BigDecimal or BigInteger field escapes as a raw NumberFormatException

References:

  • grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:192-196

castToType(Double.NaN, BigDecimal.class) throws NumberFormatException, not GroovyCastException, so it passes the catch at line 193 and surfaces without the field name:

NumberFormatException: Character N is neither a decimal digit number, decimal point, nor "e" notation exponential mark.

Every other failure in this method is a GroovyPagesException naming the field. Catching GroovyCastException | NumberFormatException (or RuntimeException, since castToType has no checked failure mode) at line 193 keeps that promise for the infinities and NaN too. The Double.compare fallback at line 217 already anticipates these values for the guard; this is the same case one step earlier.

Nit: mirror the plugin's duplicate handling in the staging task

References:

  • grails-test-examples/gsp-compile-static/build.gradle:56-60

The Gradle plugin's own view staging (GroovyPagePlugin.stageGroovyPages) sets duplicatesStrategy = DuplicatesStrategy.EXCLUDE with the application's views listed first, so the app's page wins. The PR's Sync lists the app's views first too but leaves the strategy at the default, which in Gradle 9 fails the task on a duplicate path. Nothing collides today, because the app has no root index.gsp; the day someone adds one, the message will be about Sync rather than about the page. One line keeps it consistent with the plugin, and the comment above the task already explains why the welcome page is there.

Confirmed

  • Setting source on compileGroovyPages to a Directory also sets srcDir (GroovyPageForkCompileTask.setSource), so the staged pages keep their relative names: the welcome page is registered as /WEB-INF/grails-app/views/index.gsp, not under build/generated. The plugin sets source inside the task's registration action, so the build script's tasks.named block runs after it and is not overwritten.
  • The example app maps / to demo/index, so the precompiled welcome page sits in views.properties unused at runtime. Compiling it is the whole point, and it changes nothing the integration tests request.
  • ScaffoldedIndexViewModelSpec expands the template with GStringTemplateEngine the way ScaffoldingViewResolver does, and a model directive sets compileStaticModeSetting in GroovyPageParser, so the declaration really becomes a field. The 3_000_000_000L row is the one that would fail if the template went back to Integer; the 3 and 3L rows pass under either declaration now that values are coerced, which the where: label says.
  • GroovyPagesTemplateEngine.createTemplate(String, String) caches by name, and the spec names the 3 and 3L iterations both scaffoldedIndex3, but each iteration builds a fresh engine and the source is identical, so nothing is reused across rows.
  • Boolean showAll fed the String "false" now renders true. That is Groovy truth, Boolean b = "false" gives the same, and it matches the doc sentence. Before the PR it threw an IllegalArgumentException from Field.set. Noting it so nobody reads it as a regression later.
  • The 42.0G to Integer row passes through the guard because sameNumericValue compares BigDecimal values with compareTo, so scale is ignored. Good.
  • List declared, Set supplied throws a GroovyCastException, same as a plain Groovy assignment, and is reported through the new message with both types.
  • Import order in the spec (org.codehaus.groovy between org.grails.core.gsp and org.grails.taglib) passes CodeNarc.
  • The stale book/index.gsp classes from my round-one hand test were still in the example app's build/gsp-classes and were listed in the regenerated views.properties; I removed them. The compile task does not clear stale output, but that is pre-existing and not touched here.

Verification

  • ./gradlew :grails-gsp-core:test --tests org.grails.gsp.GspCompileStaticSpec :grails-scaffolding:test --tests grails.plugin.scaffolding.ScaffoldedIndexViewModelSpec :grails-test-examples-gsp-compile-static:integrationTest :grails-gsp-core:checkstyleMain --continue: BUILD SUCCESSFUL, 40 + 3 + 7 tests, 0 failures.
  • ./gradlew :grails-gsp-core:codeStyle :grails-scaffolding:codeStyle: BUILD SUCCESSFUL.
  • DefaultTypeTransformation.castToType probed directly with Groovy 5.1.2 for 23 declared/supplied pairs, and 10 of them rendered through GroovyPagesTemplateEngine on the head via a throwaway spec (deleted afterwards). The Double/Float, NaN/BigDecimal and Boolean/"false" results above come from that run.
  • git status shows no tracked changes.

Compare floating-point values at their exact binary precision and decimal/integer values without rounded decimal strings. Keep lossy narrowing and large integer precision loss rejected. Wrap conversion failures with the model field diagnostic, including non-finite decimal conversions. Add regression cases, clarify the guide, and mirror first-input-wins duplicate handling when staging test views.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Addressed in fc1941a.

  • Exact numeric comparisons now accept finite FloatDouble and reject lossy narrowing. I avoided round-tripping so Long.MAX_VALUEDouble stays rejected.
  • Conversion exceptions, including NaN/infinity → BigDecimal/BigInteger, now name the model field and retain the original cause.
  • Staging uses DuplicatesStrategy.EXCLUDE with the application views first.

Added regression cases and clarified the guide. All 227 GSP core tests and the module's style checks passed; a staging check with a duplicate index.gsp also passed.

@testlens-app

testlens-app Bot commented Sep 15, 2026

Copy link
Copy Markdown

🚨 All tests passed but jobs failed 🚨

Failed Jobs without Test Failures

CI / Build Grails Forge (Java 21, indy=false)
CI / Build Grails-Core (Windows JDK 25 shard 0)

🏷️ Commit: fc1941a
▶️ Tests: 84856 executed
⚪️ Checks: 90/90 completed


Learn more about TestLens at testlens.app/docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants