You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@grails.apache.org by "matrei (via GitHub)" <gi...@apache.org> on 2026/10/02 14:13:23 UTC
[PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
matrei opened a new pull request, #16482:
URL: https://github.com/apache/grails-core/pull/16482
Fixes #16481
A follow-up to #16463. The constant-text tracking that PR added let some locals holding a parameter value pass as constant text. Each case listed in the issue was a build error before #16463, compiles cleanly on `8.0.x`, and is a build error again with this change. The fixpoint walk that #16463 added also made compile time grow exponentially with nesting depth.
### Changes
- **Locals are tracked per declaration, not per name.** The tracking state is keyed by the declaration the compiler resolved each reference to. A loop variable, closure parameter or later local that reuses a name no longer inherits the constant-text status of an earlier, out-of-scope local. This also replaces the bookkeeping that re-armed a suppressed name when it was declared again.
- **Assignments the walk cannot place in order no longer count as constant text.** Some assignments sit in places the walk cannot order:
- inside another expression, such as a ternary or Elvis branch, an `&&`/`||` operand, a method argument or a loop condition, including a `do`/`while` condition
- in a multiple assignment
- inside a closure, for a local declared outside it
A pre-scan of each method finds these. A local assigned in one of these ways, or declared outside a closure and read inside it, is constant text only if every assignment to it in the method is constant text. The dynamic-restriction idioms still compile cleanly, including constant text appended inside a closure, a constant chosen by a ternary assignment, and a constant fragment read inside a closure. New no-warning tests cover each of these.
- **Bodies that cannot change the state are walked once.** A loop or closure body that assigns no local declared outside it cannot change the state at its head, so it no longer gets a fixpoint. A nested body that has already settled silently is not walked again just to report. With 24 levels of nested closures or loops, compiling the test class takes 0.06–0.34 s, against about 33 s on `8.0.x`.
- **Constructors, field initialisers and object initialisers get a fresh state each.** They are analysed like methods, so `@SuppressWarnings("GormUnsafeQueryString")` on a constructor now takes effect as it does on a method.
- **The error message no longer recommends `StringBuilder`**, which the check cannot follow.
- **The guide is updated.** It now explains when a local that a closure touches counts as constant text. It also says plainly that text joined from a `List`, built with a `StringBuilder` or returned from a helper is treated as data: it is reported when it is interpolated or concatenated, and not at all when it is passed to the query method on its own.
### Testing
- `GormQuerySafetyTransformerSpec` has 22 new cases:
- every case from the issue, plus a safe-navigation argument and a closure called after the local it reads was reassigned
- guards that a value flattened inside a closure is still caught after the closure, on a later run, and after a `return`
- no-warning counterparts for the constant-text idioms above
- compile-time bounds for 24 levels of nested closures and loops, with and without an outer assignment
- a flattened query in a constructor, with and without the suppression on the constructor
- With this spec run against the transformer on `8.0.x`, exactly 15 cases fail: the 10 false negatives, the 4 nesting bounds and the constructor suppression. Everything else passes on both versions.
- The full `grails-datamapping-core` suite passes: 1493 tests, 1 skipped, 0 failures. Every module's main and test classes compile with the check active, and `aggregateViolations` is clean.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16482:
URL: https://github.com/apache/grails-core/pull/16482#discussion_r4166817508
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformer.java:
##########
@@ -138,11 +143,20 @@
* end of every path. A {@code catch} block starts from the merge of every state its {@code try}
* block passed through, since an exception may leave the block after any statement, and a
* {@code finally} block is checked against every path into it. A loop body or closure body may
- * run any number of times, so it is re-walked from the merged loop-head state until that state
- * is stable before findings are reported: an assignment late in the body is seen by a use
- * earlier in it, which the next iteration reaches. {@code break}, {@code continue} and, in a
- * closure, {@code return} carry their state to the exit or head they jump to, and a path ending
- * in such a jump or in {@code throw} contributes nothing to the state after the statement.
+ * run any number of times, so a body that assigns a local declared outside it is re-walked from
+ * the merged loop-head state until that state is stable before findings are reported: an
+ * assignment late in the body is seen by a use earlier in it, which the next iteration reaches.
+ * {@code break}, {@code continue} and, in a closure, {@code return} carry their state to the
+ * exit or head they jump to, and a path ending in such a jump or in {@code throw} contributes
Review Comment:
A `break` or `continue` inside a `try` that has a `finally` takes the state from the moment of the jump to its target. But the `finally` block runs before the jump lands, so anything it assigns is lost:
```groovy
static void sorted(String sort, List<String> sorts) {
String frag = ' title'
for (String s in sorts) {
try {
if (s == 'stop') break
} finally {
frag = sort
}
frag = ' id'
}
String q = "from Book order by ${frag}"
executeQuery(q) // compiles cleanly; with sorts == ['stop'] it runs as "from Book order by <sort>"
}
```
The same happens with a `continue` when the query is at the top of the loop body, and with a `break` out of a `switch` case. All three compile cleanly on this branch and on `8.0.x`, and fail before #16463. I ran each one to confirm that the query really contains `sort`. The gap comes from #16463, not from this change. It is the same kind of false negative as the cases in #16481, though, so could it be closed here as well?
One way to do it: give each jump made inside the `try` or a `catch` the state the `finally` block ends with, instead of the state at the jump. The walk from `intoFinally` already starts from a merge of every jump's state. So merging its end state into the exit, or head, of each target jumped to from inside would be enough, and it errs on the safe side.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformer.java:
##########
@@ -795,46 +917,239 @@ void reset() {
}
/**
- * Records what {@code variableName} now holds after being assigned {@code rightExpression},
+ * An assignment to a local found by the pre-scan: the value assigned, or {@code null} for an
+ * assignment that is not string building this check understands.
+ */
+ private static final class Write {
+
+ final Object key;
+ final Expression value;
+
+ Write(Object key, Expression value) {
+ this.key = key;
+ this.value = value;
+ }
+ }
+
+ /**
+ * What a pre-scan of the code about to be walked finds: the facts about its locals that do not
+ * depend on the order its statements run in.
+ */
+ private final class CodeFacts extends CodeVisitorSupport {
+
+ /** Every local declared in the code. */
+ final Set<Object> declared = new HashSet<>();
+ /** Every assignment to a local, declarations with a value included. */
+ final List<Write> writes = new ArrayList<>();
+ /** The locals whose declaration carries the suppression. */
+ final Set<Object> suppressed = new HashSet<>();
+ /**
+ * The locals assigned where the walk cannot place the assignment in order: inside another
+ * expression (a ternary or Elvis branch, an {@code &&} or {@code ||} operand, a method
+ * argument, a loop condition), by multiple assignment, or inside a closure that does not
+ * declare them, which may run at any later point.
+ */
+ final Set<Object> unordered = new HashSet<>();
+ /** The locals and parameters each closure declares. */
+ final Map<ClosureExpression, Set<Object>> closureLocals = new IdentityHashMap<>();
+ /**
+ * The locals that hold only constant text however their assignments are ordered, filled
+ * in by {@link #settleConstants} once the scan is complete.
+ */
+ final Set<Object> constant = new HashSet<>();
+
+ /** The loop and closure bodies that assign a local declared outside them. */
+ final Set<Statement> bodiesAssigningOuterLocals = Collections.newSetFromMap(new IdentityHashMap<>());
+
+ private final Set<Expression> statementExpressions = Collections.newSetFromMap(new IdentityHashMap<>());
+ private final Deque<Set<Object>> closureScopes = new ArrayDeque<>();
+ /** The loop and closure bodies being scanned, innermost first. */
+ private final Deque<BodyScope> openBodies = new ArrayDeque<>();
+
+ @Override
+ public void visitExpressionStatement(ExpressionStatement statement) {
+ statementExpressions.add(statement.getExpression());
+ super.visitExpressionStatement(statement);
+ }
+
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
Review Comment:
A closure that assigns a local declared outside it is now covered, but an anonymous inner class that does the same is not. The pre-scan does not descend into the class body. The class itself is analysed separately, with its own state, so the enclosing method never learns of the assignment:
```groovy
static void sorted(String sort) {
String frag = ' title'
Runnable r = new Runnable() { void run() { frag = sort } }
r.run()
String q = "from Book order by ${frag}"
executeQuery(q) // compiles cleanly; runs as "from Book order by <sort>"
}
```
This compiles cleanly on this branch and on `8.0.x`, and fails before #16463. A Java-style lambda doing the same assignment is already caught, because it is a `ClosureExpression`. Could the pre-scan treat the method bodies of an anonymous inner class (`ConstructorCallExpression.isUsingAnonymousInnerClass()`) like a closure body? A local of the enclosing method assigned there would then count as unordered.
##########
grails-doc/src/en/guide/security/securingAgainstAttacks.adoc:
##########
@@ -118,7 +118,7 @@ Book.executeQuery("from Book b where 1 = 1 ${restriction}", [title: params.title
// runs as: from Book b where 1 = 1 :p0 -- the fragment is bound as a value
----
-The check tells query text and values apart. An interpolation that is *constant text* cannot carry user input, so it is not flagged even when the `GString` is coerced to a `String`. Constant text is a string literal, a `static final` field initialized from constant text, a ternary or Elvis expression choosing between constant text, a `+` concatenation or `GString` made only of constant text, and a local variable that only ever held constant text on every path leading to the query, whichever `if`/`else`, `switch`, `try`/`catch`, loop or closure those paths run through. This is what lets restrictions be chosen at runtime while the values stay bound:
+The check tells query text and values apart. An interpolation that is *constant text* cannot carry user input, so it is not flagged even when the `GString` is coerced to a `String`. Constant text is a string literal, a `static final` field initialized from constant text, a ternary or Elvis expression choosing between constant text, a `+` concatenation or `GString` made only of constant text, and a local variable that only ever held constant text on every path leading to the query, whichever `if`/`else`, `switch`, `try`/`catch` or loop those paths run through. A closure can run at any point after it is defined, and an assignment inside another expression — a ternary or Elvis branch, an `&&` or `||` operand, a method argument or a loop condition — or a multiple assignment may or may not run, so a local that a closure reads or assigns, or that is assigned in one of those ways, counts as constant text only if every assignment to it in the method is constant text. This is what lets r
estrictions be chosen at runtime while the values stay bound:
Review Comment:
Nit: "a local that a closure reads or assigns" also covers a local declared inside the closure, which is still tracked in order. The Javadoc wording is more precise: "a local declared outside a closure that the closure reads or assigns".
##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformerSpec.groovy:
##########
@@ -462,6 +467,156 @@ class Book {
'a loop iteration that returns before reaching the query' | 'String q = "from Book b"\n for (String t in titles) {\n if (t == title) {\n q = "from Book b where b.title = ${t}"\n return [q]\n }\n }'
}
+ @Unroll
+ void "test a use inside #construct sees data assigned where the walk cannot place it"() {
+ when:
+ new GroovyClassLoader().parseClass("""
+import grails.gorm.annotation.Entity
+
+@Entity
+class Book {
+ String title
+
+ static void sorted(String sort, List<String> sorts) {
+ String frag = " title"
+ $body
+ }
+}
+""")
+
+ then:
+ def e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('GormUnsafeQueryString')
+ e.message.contains("passed to 'executeQuery'")
+
+ where:
+ construct | body
+ 'a do/while body assigned in its condition' | 'do {\n String q = "from Book order by ${frag}"\n executeQuery(q)\n } while ((frag = sorts.remove(0)) != null)'
+ 'a closure called after a reassignment' | 'Closure run = {\n String q = "from Book order by ${frag}"\n executeQuery(q)\n }\n frag = sort\n run()'
+ }
+
+ @Unroll
+ void "test #construct reusing the name of an earlier constant local is not treated as constant text"() {
+ when:
+ new GroovyClassLoader().parseClass("""
+import grails.gorm.annotation.Entity
+
+@Entity
+class Book {
+ String title
+
+ static void sorted(String sort, List<String> sorts) {
+ $body
+ }
+}
+""")
+
+ then:
+ def e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('GormUnsafeQueryString')
+ e.message.contains("passed to 'executeQuery'")
+
+ where:
+ construct | body
+ 'a loop variable after a try block' | 'try {\n String part = " title"\n } finally {\n }\n for (String part in sorts) {\n String q = "from Book order by ${part}"\n executeQuery(q)\n }'
+ 'a loop variable after an if/else' | 'if (sort) {\n String part = " id"\n } else {\n String part = " title"\n }\n for (String part in sorts) {\n String q = "from Book order by ${part}"\n executeQuery(q)\n }'
+ 'a closure parameter after an if/else' | 'if (sort) {\n String part = " id"\n } else {\n String part = " title"\n }\n sorts.each { String part ->\n String q = "from Book order by ${part}"\n executeQuery(q)\n }'
+ }
+
+ @Unroll
+ void "test a query flattened inside a closure is still flagged when #construct"() {
+ when:
+ new GroovyClassLoader().parseClass("""
+import grails.gorm.annotation.Entity
+
+@Entity
+class Book {
+ String title
+
+ static void byTitle(List<String> titles, String title) {
+ String q = "from Book"
+ $body
+ }
+}
+""")
+
+ then:
+ def e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('GormUnsafeQueryString')
+ e.message.contains("passed to 'executeQuery'")
+
+ where:
+ construct | body
+ 'it is used after the closure is called' | 'Closure flatten = { q = "from Book where title = ${title}" }\n flatten()\n executeQuery(q)'
+ 'a later run of the closure reaches the use' | 'titles.each {\n executeQuery(q)\n q = "from Book where title = ${it}"\n }'
+ 'the closure returns after flattening it' | 'Closure flatten = {\n if (title) {\n q = "from Book where title = ${title}"\n return\n }\n q = "from Book"\n }\n flatten()\n executeQuery(q)'
+ }
+
+ void "test a constant local read inside a closure compiles cleanly with no warnings"() {
+ when:
+ List<WarningMessage> warnings = compileAndCollectWarnings('''
+import grails.gorm.annotation.Entity
+
+@Entity
+class Book {
+ String title
+
+ static void search(List<String> titles, Map queryParams) {
+ String restriction = ""
+ if (queryParams.title) {
+ restriction = " and b.title = :title"
+ }
+ titles.each { String t ->
+ String q = "from Book b where b.title <> :t ${restriction}"
+ executeQuery(q, queryParams + [t: t])
+ }
+ }
+}
+''')
+
+ then:
+ warnings.empty
+ }
+
+ @Unroll
+ void "test #construct nested #depth deep compiles without the check slowing it down exponentially"() {
+ given:
+ String open = (1..depth).collect { opening.replace('#', it.toString()) }.join('\n')
+ String close = (1..depth).collect { '}' }.join('\n')
+ String source = """
+import grails.gorm.annotation.Entity
+
+@Entity
+class Book {
+ String title
+
+ static List search(List<String> titles) {
+ String q = "from Book b where 1 = 1"
+ $open
+ $innermost
+ $close
+ executeQuery(q)
+ }
+}
+"""
+
+ when:
+ long started = System.nanoTime()
+ List<WarningMessage> warnings = compileAndCollectWarnings(source)
+ long elapsedMillis = (System.nanoTime() - started).intdiv(1_000_000)
+
+ then: 'every level walked twice would take minutes at this depth'
+ warnings.empty
+ elapsedMillis < 10_000
+
+ where:
+ construct | depth | opening | innermost
+ 'closures' | 24 | 'titles.each { String t# ->' | 'println(t1)'
+ 'for loops' | 24 | 'for (String t# in titles) {' | 'println(t1)'
+ 'for loops appending to a query' | 24 | 'for (String t# in titles) {' | 'q += " and b.title is not null"'
+ 'closures appending to a query' | 24 | 'titles.each { String t# ->' | 'q += " and b.title is not null"'
Review Comment:
Optional, low priority. Nested `finally` blocks still cost exponential time, because each `finally` is walked twice (silently from the normal exits, then from `intoFinally` to report), and a `try` nested inside it is walked twice on each of those walks. Compiling `try { } finally { try { } finally { ... } }`:
| depth | this branch | before #16463 |
|---|---|---|
| 16 | 133 ms | 11 ms |
| 18 | 270 ms | 10 ms |
| 20 | 973 ms | 11 ms |
Nobody writes code like this, so it doesn't matter in practice. Still, when `silentPasses > 0` and `afterFinally != null`, the walk from `intoFinally` throws away both its findings and its end state, so it could be skipped there. That would leave every walk in a silent pass linear. If the fix for jumps through `finally` ends up using the end state of that walk, skip it only when the `try` contains no jump. A `nested finally` row here would keep the time bounded.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16482:
URL: https://github.com/apache/grails-core/pull/16482#issuecomment-5956169494
## ✅ All tests passed ✅
🏷️ Commit: b570363fb812f2017a85840af1c64a1432f4eabf
▶️ Tests: 90836 executed
⚪️ Checks: 85/85 completed
---
_Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty merged PR #16482:
URL: https://github.com/apache/grails-core/pull/16482
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16482:
URL: https://github.com/apache/grails-core/pull/16482#issuecomment-5959854461
I've closed all of the remaining gaps and will merge this. we can iterate if we find more.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Close the constant-text gaps in the query safety check and keep its cost linear in nesting depth [grails-core]
Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #16482:
URL: https://github.com/apache/grails-core/pull/16482#issuecomment-5955213948
## [Codecov](https://app.codecov.io/gh/apache/grails-core/pull/16482?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:x: Patch coverage is `93.65854%` with `13 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 59.0837%. Comparing base ([`f604a6b`](https://app.codecov.io/gh/apache/grails-core/commit/f604a6bfce0430f57c3790e4ac9f0ef25db552b8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`b570363`](https://app.codecov.io/gh/apache/grails-core/commit/b570363fb812f2017a85840af1c64a1432f4eabf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
:warning: Report is 13 commits behind head on 8.0.x.
| [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16482?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
|---|---|---|
| [...rm/query/transform/GormQuerySafetyTransformer.java](https://app.codecov.io/gh/apache/grails-core/pull/16482?src=pr&el=tree&filepath=grails-datamapping-core%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fdatastore%2Fgorm%2Fquery%2Ftransform%2FGormQuerySafetyTransformer.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWRhdGFtYXBwaW5nLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvZGF0YXN0b3JlL2dvcm0vcXVlcnkvdHJhbnNmb3JtL0dvcm1RdWVyeVNhZmV0eVRyYW5zZm9ybWVyLmphdmE=) | 93.6585% | [8 Missing and 5 partials :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
<details><summary>Additional details and impacted files</summary>
[](https://app.codecov.io/gh/apache/grails-core/pull/16482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
```diff
@@ Coverage Diff @@
## 8.0.x #16482 +/- ##
==================================================
+ Coverage 59.0342% 59.0837% +0.0494%
- Complexity 24550 24578 +28
==================================================
Files 2175 2175
Lines 107668 107820 +152
Branches 19618 19650 +32
==================================================
+ Hits 63561 63704 +143
- Misses 35224 35230 +6
- Partials 8883 8886 +3
```
| [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16482?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [...rm/query/transform/GormQuerySafetyTransformer.java](https://app.codecov.io/gh/apache/grails-core/pull/16482?src=pr&el=tree&filepath=grails-datamapping-core%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fdatastore%2Fgorm%2Fquery%2Ftransform%2FGormQuerySafetyTransformer.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWRhdGFtYXBwaW5nLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvZGF0YXN0b3JlL2dvcm0vcXVlcnkvdHJhbnNmb3JtL0dvcm1RdWVyeVNhZmV0eVRyYW5zZm9ybWVyLmphdmE=) | `87.9389% <93.6585%> (+1.1539%)` | :arrow_up: |
... and [7 files with indirect coverage changes](https://app.codecov.io/gh/apache/grails-core/pull/16482/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
</details>
<details><summary> :rocket: New features to boost your workflow: </summary>
- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org