From c1e9304ecc0ad0a1aec14720bad3e739d126cd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 20:16:32 +0200 Subject: [PATCH 1/7] Make ValueRecorder recording methods generic record() and realizeNas() wrap sub-expressions of rewritten conditions but returned Object, so member access, comparisons, and indexing on a recorded value failed static type checking. Preserving the argument type via a type parameter (same erasure, binary compatible) lets such conditions in @CompileStatic specs compile. --- .../spockframework/runtime/ValueRecorder.java | 7 +- .../smoke/CompileStaticSupport.groovy | 137 ++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy diff --git a/spock-core/src/main/java/org/spockframework/runtime/ValueRecorder.java b/spock-core/src/main/java/org/spockframework/runtime/ValueRecorder.java index fbd4d04636..176fd8ef9f 100644 --- a/spock-core/src/main/java/org/spockframework/runtime/ValueRecorder.java +++ b/spock-core/src/main/java/org/spockframework/runtime/ValueRecorder.java @@ -41,8 +41,10 @@ public ValueRecorder reset() { /** * Records and returns the specified value. Hence an expression can be replaced * with record(expression) without impacting evaluation of the expression. + * The generic return type preserves the static type of the wrapped expression, + * allowing conditions to pass static type checking (e.g. under {@code @CompileStatic}). */ - public Object record(int index, Object value) { + public T record(int index, T value) { realizeNas(index + 1, null); values.set(index, value); @@ -72,8 +74,9 @@ public int startRecordingValue(int index){ /** * Materializes N/A values without recording a new value. + * Generic for the same reason as {@link #record(int, Object)}. */ - public Object realizeNas(int index, Object value) { + public T realizeNas(int index, T value) { for (int i = values.size(); i < index; i++) values.add(ExpressionInfo.VALUE_NOT_AVAILABLE); return value; diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy new file mode 100644 index 0000000000..436b50e5eb --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -0,0 +1,137 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.spockframework.smoke + +import org.spockframework.EmbeddedSpecification +import org.spockframework.runtime.ConditionNotSatisfiedError + +/** + * Specs annotated with {@code @CompileStatic} must compile even though + * Spock rewrites their conditions, and must render failed conditions + * with the recorded values. + */ +class CompileStaticSupport extends EmbeddedSpecification { + + def "comparison conditions on typed variables compile"() { + when: + compiler.compileWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "comparisons"() { + given: + int x = 42 + + expect: + x > 40 + x >= 42 + x < 43 + x <= 42 + } +} +""" + + then: + noExceptionThrown() + } + + def "property access in conditions compiles"() { + when: + compiler.compileWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "property access"() { + given: + Pogo pogo = new Pogo(name: "Fred") + + expect: + pogo.name == "Fred" + } +} + +@groovy.transform.CompileStatic +class Pogo { + String name +} +""" + + then: + noExceptionThrown() + } + + def "indexing in conditions compiles"() { + when: + compiler.compileWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "indexing"() { + given: + List list = ['a'] + + expect: + list[0] == 'a' + } +} +""" + + then: + noExceptionThrown() + } + + def "top-level method call conditions compile"() { + when: + compiler.compileWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "method call condition"() { + given: + List list = ['a'] + + expect: + list.contains('a') + } +} +""" + + then: + noExceptionThrown() + } + + def "failed comparison condition renders recorded values"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "comparison"() { + given: + int x = 42 + + expect: + x > 43 + } +} +""" + + then: + ConditionNotSatisfiedError e = thrown() + e.condition.rendering.trim() == """ +x > 43 +| | +| false +42 +""".trim() + } +} From c686724ea04439bcf696df867bc2cde651eeca09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 20:28:52 +0200 Subject: [PATCH 2/7] Do not record synthesized method names in conditions Earlier rewrite steps synthesize method calls inside conditions, e.g. old() becomes oldImpl() and the collection conditions =~ and ==~ become matchCollections*() calls. Wrapping such a synthesized name in record() turns the call into a dynamic method name call, which fails static compilation with "Target method for method call expression hasn't been set". Names without a plausible source position now only allocate their value slot via recordNa(), keeping slot numbering and rendering intact. This makes old() usable in @CompileStatic specs. --- .../compiler/ConditionRewriter.java | 9 +++++-- .../smoke/CompileStaticSupport.groovy | 24 +++++++++++++++++++ ...tionsAsSet_is_transformed_correctly.groovy | 2 +- ...InAnyOrder_is_transformed_correctly.groovy | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java index 6bfad87439..13b59c083d 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java @@ -116,14 +116,19 @@ public void visitMethodCallExpression(MethodCallExpression expr) { && (!AstUtil.hasPlausibleSourcePosition(expr.getMethod()) // before GROOVY-4344 fix || (expr.getMethod().getColumnNumber() == expr.getObjectExpression().getColumnNumber())); // after GROOVY-4344 fix + // method names without a plausible source position were synthesized by an + // earlier rewrite step (e.g. old() -> oldImpl()); wrapping them in record() + // would turn the call into a dynamic method name call, which does not pass + // static type checking, so only allocate their slot + Expression method = expr.getMethod(); MethodCallExpression conversion = new MethodCallExpression( expr.isImplicitThis() ? expr.getObjectExpression() : convert(expr.getObjectExpression()), objectExprSeenAsMethodNameAtRuntime ? - expr.getMethod() : - convert(expr.getMethod()), + method : + AstUtil.hasPlausibleSourcePosition(method) ? convert(method) : recordNa(method), convert(expr.getArguments())); conversion.setSafe(expr.isSafe()); diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy index 436b50e5eb..93fc732043 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -110,6 +110,30 @@ class ASpec extends Specification { noExceptionThrown() } + def "old() compiles and evaluates"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "old"() { + given: + List list = ['a'] + int before = list.size() + + when: + list.add('b') + int after = list.size() + + then: + after == old(before) + 1 + } +} +""" + + then: + noExceptionThrown() + } + def "failed comparison condition renders recorded values"() { when: runner.runWithImports """ diff --git a/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsAsSet_is_transformed_correctly.groovy b/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsAsSet_is_transformed_correctly.groovy index 559426607e..77824f2171 100644 --- a/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsAsSet_is_transformed_correctly.groovy +++ b/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsAsSet_is_transformed_correctly.groovy @@ -13,7 +13,7 @@ public void $spock_feature_0_0() { org.spockframework.runtime.SpockRuntime.callBlockExited(this, 0) org.spockframework.runtime.SpockRuntime.callBlockEntered(this, 1) try { - org.spockframework.runtime.SpockRuntime.verifyMethodCondition($spock_errorCollector, $spock_valueRecorder.reset(), 'x =~ [1]', 4, 9, null, org.spockframework.runtime.SpockRuntime, $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(0), 'matchCollectionsAsSet'), new java.lang.Object[]{$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(1), x), $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(3), [$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(2), 1)])}, $spock_valueRecorder.realizeNas(6, false), false, 5) + org.spockframework.runtime.SpockRuntime.verifyMethodCondition($spock_errorCollector, $spock_valueRecorder.reset(), 'x =~ [1]', 4, 9, null, org.spockframework.runtime.SpockRuntime, 'matchCollectionsAsSet', new java.lang.Object[]{$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(1), x), $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(3), [$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(2), 1)])}, $spock_valueRecorder.realizeNas(6, false), false, 5) } catch (java.lang.Throwable $spock_condition_throwable) { org.spockframework.runtime.SpockRuntime.conditionFailedWithException($spock_errorCollector, $spock_valueRecorder, 'x =~ [1]', 4, 9, null, $spock_condition_throwable)} diff --git a/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsInAnyOrder_is_transformed_correctly.groovy b/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsInAnyOrder_is_transformed_correctly.groovy index 1c5f107dd4..33398d5745 100644 --- a/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsInAnyOrder_is_transformed_correctly.groovy +++ b/spock-specs/src/test/resources/snapshots/org/spockframework/smoke/ast/condition/CollectionConditionAstSpec/collection_condition_matchCollectionsInAnyOrder_is_transformed_correctly.groovy @@ -13,7 +13,7 @@ public void $spock_feature_0_0() { org.spockframework.runtime.SpockRuntime.callBlockExited(this, 0) org.spockframework.runtime.SpockRuntime.callBlockEntered(this, 1) try { - org.spockframework.runtime.SpockRuntime.verifyMethodCondition($spock_errorCollector, $spock_valueRecorder.reset(), 'x ==~ [1]', 4, 9, null, org.spockframework.runtime.SpockRuntime, $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(0), 'matchCollectionsInAnyOrder'), new java.lang.Object[]{$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(1), x), $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(3), [$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(2), 1)])}, $spock_valueRecorder.realizeNas(6, false), false, 5) + org.spockframework.runtime.SpockRuntime.verifyMethodCondition($spock_errorCollector, $spock_valueRecorder.reset(), 'x ==~ [1]', 4, 9, null, org.spockframework.runtime.SpockRuntime, 'matchCollectionsInAnyOrder', new java.lang.Object[]{$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(1), x), $spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(3), [$spock_valueRecorder.record($spock_valueRecorder.startRecordingValue(2), 1)])}, $spock_valueRecorder.realizeNas(6, false), false, 5) } catch (java.lang.Throwable $spock_condition_throwable) { org.spockframework.runtime.SpockRuntime.conditionFailedWithException($spock_errorCollector, $spock_valueRecorder, 'x ==~ [1]', 4, 9, null, $spock_condition_throwable)} From 5333960ac5282dcfddf835aca93b2583c07414a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 20:36:50 +0200 Subject: [PATCH 3/7] Type previous column extractors in data providers Data table cells referencing previous columns are wrapped in a closure that extracts the referenced row values into untyped locals, so such cells failed static type checking even when the feature method declared typed parameters. The extractor locals now use the declared parameter type, coercing the row value like the data processor already does. Without a declared type the generated code is unchanged. --- .../compiler/WhereBlockRewriter.java | 28 +++++++++++++++---- .../smoke/CompileStaticSupport.groovy | 24 ++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/spock-core/src/main/java/org/spockframework/compiler/WhereBlockRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/WhereBlockRewriter.java index 9ec7991d54..8690d67820 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/WhereBlockRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/WhereBlockRewriter.java @@ -804,18 +804,36 @@ private void turnIntoSimpleParameterization(List column) throws Inva private void generatePreviousColumnExtractorStatements(Set referencedPreviousVariables, int row, List statements) { for (String referencedPreviousVariable : referencedPreviousVariables) { + ClassNode declaredType = getDeclaredDataVariableType(referencedPreviousVariable); + Expression rowValue = createDirectMethodCall( + new VariableExpression(getDataTableParameterName(referencedPreviousVariable)), + resources.getAstNodeCache().List_Get, + new ConstantExpression(row)); + if (declaredType != null) { + // use the declared feature parameter type, so that cell expressions + // referencing previous columns pass static type checking + CastExpression coercedRowValue = new CastExpression(declaredType, rowValue); + coercedRowValue.setCoerce(true); + rowValue = coercedRowValue; + } statements.add(new ExpressionStatement( + // Type x = $spock_p_x.get(row) as Type + // or, without a declared parameter type // def x = $spock_p_x.get(row) new DeclarationExpression( - new VariableExpression(referencedPreviousVariable), + new VariableExpression(referencedPreviousVariable, declaredType == null ? ClassHelper.DYNAMIC_TYPE : declaredType), Token.newSymbol(Types.ASSIGN, -1, -1), - createDirectMethodCall( - new VariableExpression(getDataTableParameterName(referencedPreviousVariable)), - resources.getAstNodeCache().List_Get, - new ConstantExpression(row))))); + rowValue))); } } + private ClassNode getDeclaredDataVariableType(String name) { + for (Parameter parameter : whereBlock.getParent().getAst().getParameters()) { + if (parameter.getName().equals(name) && !parameter.isDynamicTyped()) return parameter.getType(); + } + return null; + } + private List getReferencedPreviousVariables(List previousVariables, Expression providerExpression) { return previousVariables .stream() diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy index 93fc732043..4990cc7bff 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -134,6 +134,30 @@ class ASpec extends Specification { noExceptionThrown() } + def "data table cells referencing previous columns compile"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "previous column reference"(int a, int b) { + when: + int expected = a + 1 + + then: + b == expected + + where: + a | b + 1 | a + 1 + 5 | a + 1 + } +} +""" + + then: + noExceptionThrown() + } + def "failed comparison condition renders recorded values"() { when: runner.runWithImports """ From c45c9d03801fbca31495226b93382a23d564359a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 20:42:32 +0200 Subject: [PATCH 4/7] Create fresh this expressions in generated code Generated calls like getSpecificationContext() shared the VariableExpression.THIS_EXPRESSION singleton between all use sites. The static type checker stores inferred types as per-node metadata, so a node used both inside and outside of closures accumulated conflicting types, producing contradictory checkcasts and a ClassCastException at runtime. This broke interaction blocks and mock init closures in @CompileStatic specs. Each generated call site now gets its own node. --- .../org/spockframework/compiler/AstUtil.java | 10 ++++ .../spockframework/compiler/SpecRewriter.java | 4 +- .../compiler/SpecialMethodCall.java | 2 +- .../smoke/CompileStaticSupport.groovy | 48 +++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java b/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java index 194101c631..89f8553a44 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java +++ b/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java @@ -322,6 +322,16 @@ public static boolean isJointCompiled(ClassNode clazz) { return clazz.getModule().getUnit().getConfig().getJointCompilationOptions() != null; } + /** + * Creates a fresh expression referencing {@code this}. Generated code must not share + * {@link VariableExpression#THIS_EXPRESSION} between use sites, because the static type + * checker stores inferred types as per-node metadata; a singleton node used both inside + * and outside of closures ends up with conflicting casts in the generated bytecode. + */ + public static VariableExpression createThisExpression() { + return new VariableExpression("this"); + } + public static MethodCallExpression createMethodCall(Expression target, String methodName, Expression arguments) { MethodCallExpression result = new MethodCallExpression(target, methodName, arguments); // and https://github.com/spockframework/spock/issues/1200 diff --git a/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java index 1911fe7c72..f2dc52561c 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java @@ -470,7 +470,7 @@ private MethodCallExpression createBlockListenerCall(Block block, MethodNode blo new ClassExpression(nodeCache.SpockRuntime), blockListenerMethod, new ArgumentListExpression( - VariableExpression.THIS_EXPRESSION, + AstUtil.createThisExpression(), new ConstantExpression(block.getBlockMetaDataIndex(), true) )); } @@ -693,7 +693,7 @@ public VariableExpression captureOldValue(Expression oldValue) { } public MethodCallExpression getSpecificationContext() { - return createDirectMethodCall(VariableExpression.THIS_EXPRESSION, + return createDirectMethodCall(AstUtil.createThisExpression(), nodeCache.Specification_GetSpecificationContext, ArgumentListExpression.EMPTY_ARGUMENTS); } diff --git a/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java b/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java index dcfaefb304..2bb37e81bd 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java +++ b/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java @@ -174,7 +174,7 @@ public ClosureExpression getClosureExpr() { @Override public void expand() { List args = new ArrayList<>(); - args.add(VariableExpression.THIS_EXPRESSION); + args.add(AstUtil.createThisExpression()); args.add(inferredName); args.add(inferredType); args.addAll(AstUtil.getArgumentList(methodCallExpr)); diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy index 4990cc7bff..9964010f37 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -158,6 +158,54 @@ class ASpec extends Specification { noExceptionThrown() } + def "interactions in mock init closures work"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "mock init closure"() { + given: + List list = Mock(List) { + size() >> 3 + } + + when: + int size = list.size() + + then: + size == 3 + } +} +""" + + then: + noExceptionThrown() + } + + def "interactions in interaction blocks work"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "interaction block"() { + given: + List list = Mock() + + when: + list.add("x") + + then: + interaction { + 1 * list.add(_) + } + } +} +""" + + then: + noExceptionThrown() + } + def "failed comparison condition renders recorded values"() { when: runner.runWithImports """ From 0556658db50f3de4fc14eb7492fbed4d74b11d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 20:57:37 +0200 Subject: [PATCH 5/7] Support method call conditions under static compilation Conditions record the method name of each call for value tracking, which turns the call into a dynamic method name call. Static type checking rejects those, so any condition containing a method call failed to compile in @CompileStatic or @TypeChecked specs. For methods subject to static type checking, detected via the CompileStatic and TypeChecked annotations on the method or its class, constant method names now stay in place and only their value slot is allocated, and the rewritten call keeps the original implicit-this flag so qualified calls are not dispatched to local closure variables of the same name. Dynamic code is rewritten exactly as before, preserving its dispatch semantics, e.g. calling closures in data variables with method syntax and stubbing getProperty() on Java mocks. --- .../org/spockframework/compiler/AstUtil.java | 29 +++++ .../compiler/ConditionRewriter.java | 34 +++++- .../smoke/CompileStaticSupport.groovy | 113 ++++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) diff --git a/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java b/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java index 89f8553a44..9120c34b73 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java +++ b/spock-core/src/main/java/org/spockframework/compiler/AstUtil.java @@ -322,6 +322,35 @@ public static boolean isJointCompiled(ClassNode clazz) { return clazz.getModule().getUnit().getConfig().getJointCompilationOptions() != null; } + /** + * Tells whether the given method is subject to static type checking, i.e. it is + * annotated with {@code @CompileStatic} or {@code @TypeChecked}, or declared in a + * class that is. {@code @CompileDynamic} and {@code TypeCheckingMode.SKIP} opt + * out again. Static type checking applied by other means, e.g. a compiler + * configuration script, is not detected. + */ + public static boolean isStaticallyTypeChecked(MethodNode method) { + Boolean methodLevel = staticTypeCheckingState(method.getAnnotations()); + if (methodLevel != null) return methodLevel; + for (ClassNode clazz = method.getDeclaringClass(); clazz != null; clazz = clazz.getOuterClass()) { + Boolean classLevel = staticTypeCheckingState(clazz.getAnnotations()); + if (classLevel != null) return classLevel; + } + return false; + } + + private static Boolean staticTypeCheckingState(List annotations) { + for (AnnotationNode annotation : annotations) { + String name = annotation.getClassNode().getName(); + if ("groovy.transform.CompileDynamic".equals(name)) return false; + if ("groovy.transform.CompileStatic".equals(name) || "groovy.transform.TypeChecked".equals(name)) { + Expression typeCheckingMode = annotation.getMember("value"); + return typeCheckingMode == null || !typeCheckingMode.getText().endsWith("SKIP"); + } + } + return null; + } + /** * Creates a fresh expression referencing {@code this}. Generated code must not share * {@link VariableExpression#THIS_EXPRESSION} between use sites, because the static type diff --git a/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java index 13b59c083d..b0e6226c6f 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/ConditionRewriter.java @@ -73,12 +73,16 @@ public class ConditionRewriter extends AbstractExpressionConverter i private final String errorCollectorName; + private final boolean staticallyTypeChecked; + private int recordCount = 0; private ConditionRewriter(IRewriteResources resources, String valueRecorderSuffix, String errorCollectorSuffix) { this.resources = resources; valueRecorderName = SpockNames.VALUE_RECORDER + valueRecorderSuffix; errorCollectorName = SpockNames.ERROR_COLLECTOR + errorCollectorSuffix; + org.spockframework.compiler.model.Method currentMethod = resources.getCurrentMethod(); + staticallyTypeChecked = currentMethod != null && AstUtil.isStaticallyTypeChecked(currentMethod.getAst()); } public static Statement rewriteExplicitCondition(AssertStatement stat, IRewriteResources resources) { @@ -116,10 +120,6 @@ public void visitMethodCallExpression(MethodCallExpression expr) { && (!AstUtil.hasPlausibleSourcePosition(expr.getMethod()) // before GROOVY-4344 fix || (expr.getMethod().getColumnNumber() == expr.getObjectExpression().getColumnNumber())); // after GROOVY-4344 fix - // method names without a plausible source position were synthesized by an - // earlier rewrite step (e.g. old() -> oldImpl()); wrapping them in record() - // would turn the call into a dynamic method name call, which does not pass - // static type checking, so only allocate their slot Expression method = expr.getMethod(); MethodCallExpression conversion = new MethodCallExpression( @@ -128,15 +128,39 @@ public void visitMethodCallExpression(MethodCallExpression expr) { convert(expr.getObjectExpression()), objectExprSeenAsMethodNameAtRuntime ? method : - AstUtil.hasPlausibleSourcePosition(method) ? convert(method) : recordNa(method), + convertMethodName(method), convert(expr.getArguments())); conversion.setSafe(expr.isSafe()); conversion.setSpreadSafe(expr.isSpreadSafe()); + if (staticallyTypeChecked) { + // must be kept in sync, otherwise a qualified call with a constant method + // name would be dispatched as an implicit-this call, e.g. to a local + // closure variable of the same name; only done for statically checked code + // to keep the dispatch of dynamic code untouched + conversion.setImplicitThis(expr.isImplicitThis()); + } conversion.setSourcePosition(expr); result = record(conversion); } + /** + * Wrapping a method name in record() turns the call into a dynamic method name + * call, which does not pass static type checking. Method names synthesized by an + * earlier rewrite step (e.g. old() -> oldImpl()) and, in statically type checked + * code, all constant method names therefore stay as they are; their value slot is + * still allocated to keep the numbering intact, it just stays N/A, which the + * renderer never displays for method names anyway. Dynamic code keeps the recorded + * method name to leave its dispatch semantics untouched. + */ + private Expression convertMethodName(Expression method) { + if (method instanceof ConstantExpression + && (staticallyTypeChecked || !AstUtil.hasPlausibleSourcePosition(method))) { + return recordNa(method); + } + return convert(method); + } + // only used for statically imported methods called by their simple name @Override public void visitStaticMethodCallExpression(StaticMethodCallExpression expr) { diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy index 9964010f37..a3e84f6346 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -18,6 +18,7 @@ package org.spockframework.smoke import org.spockframework.EmbeddedSpecification import org.spockframework.runtime.ConditionNotSatisfiedError +import org.spockframework.runtime.SpockComparisonFailure /** * Specs annotated with {@code @CompileStatic} must compile even though @@ -206,6 +207,118 @@ class ASpec extends Specification { noExceptionThrown() } + def "method calls in conditions compile"() { + when: + compiler.compileWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "method call conditions"() { + given: + List list = ['a', 'b'] + + expect: + list.size() == 2 + !list.isEmpty() + list.first().toUpperCase() == 'A' + this.helper(1) == 2 + helper(1) == 2 + } + + int helper(int i) { + i + 1 + } +} +""" + + then: + noExceptionThrown() + } + + def "method-level CompileStatic annotation is honored"() { + when: + runner.runWithImports """ +class ASpec extends Specification { + @groovy.transform.CompileStatic + def "statically checked feature"() { + given: + List list = ['a'] + + expect: + list.size() == 1 + } +} +""" + + then: + noExceptionThrown() + } + + def "exception property access in conditions compiles"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "thrown message"() { + when: + throw new IllegalStateException("bam") + + then: + IllegalStateException e = thrown() + e.message == "bam" + } +} +""" + + then: + noExceptionThrown() + } + + def "method calls on the delegate of with blocks compile"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "with block"() { + given: + List list = ['a', 'b'] + + expect: + with(list) { + size() == 2 + first() == 'a' + } + } +} +""" + + then: + noExceptionThrown() + } + + def "failed method call condition renders recorded values"() { + when: + runner.runWithImports """ +@groovy.transform.CompileStatic +class ASpec extends Specification { + def "method call"() { + given: + List list = ['a'] + + expect: + list.size() == 2 + } +} +""" + + then: + SpockComparisonFailure e = thrown() + e.condition.rendering.trim() == """ +list.size() == 2 +| | | +[a] 1 false +""".trim() + } + def "failed comparison condition renders recorded values"() { when: runner.runWithImports """ From 415cd2545b7e3bcd1c75ec29dad252ba540a3c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 21:00:02 +0200 Subject: [PATCH 6/7] Document statically compiled specifications Adds a primer section describing how to use @CompileStatic and @TypeChecked with specs, the typing rules that apply, and the remaining limitations, plus a release notes entry. --- docs/release_notes.adoc | 1 + docs/spock_primer.adoc | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/release_notes.adoc b/docs/release_notes.adoc index 4be8ecafb8..27d68d43c4 100644 --- a/docs/release_notes.adoc +++ b/docs/release_notes.adoc @@ -8,6 +8,7 @@ include::include.adoc[] === Enhancements +* Add support for statically compiled specifications: conditions, interactions, `old()`, and data tables referencing previous columns now work in specs or feature methods annotated with `@CompileStatic` or `@TypeChecked`, see <> spockPull:2397[] * Add support for `final` local variables in `where:` blocks, declared at their beginning and evaluated once per feature, scoped to the where-block spockIssue:138[] * Improve `TooManyInvocationsError` now reports unsatisfied interactions with argument mismatch details, making it easier to diagnose why invocations didn't match expected interactions spockPull:2315[] diff --git a/docs/spock_primer.adoc b/docs/spock_primer.adoc index 74d16d7e8a..52a615ba95 100644 --- a/docs/spock_primer.adoc +++ b/docs/spock_primer.adoc @@ -788,6 +788,32 @@ will be rendered as include::{snapshotdir}/primer/VerifyEachDocSpec/verifyEach_with_index_method.txt[] ---- +[[statically-compiled-specifications]] +== Statically Compiled Specifications + +Specifications can be compiled with Groovy's static compilation by annotating the specification class or individual +feature methods with `@CompileStatic` or `@TypeChecked`. All major features work under static compilation: conditions +including their failure rendering, exception conditions, interactions and stubbing, `old()`, `with`, `verifyAll`, and +data driven features. + +Since the static type checker analyzes the code after Spock has rewritten it, a few rules apply: + +* Mock creation must use a typed variable declaration, e.g. `List list = Mock()` or `List list = Mock(List)`. + With `def list = Mock(List)` the variable is typed as `Object`, so calls on it will not pass type checking. +* Data variables in `where:` blocks default to `Object`. Declare typed feature method parameters to use them + with methods or operators, e.g. `def "maximum of two numbers"(int a, int b, int c)`. +* Precondition closures of `@IgnoreIf` and `@Requires` need an explicitly typed parameter, e.g. + `@IgnoreIf({ PreconditionContext it -> it.os.windows })`. + +Some limitations remain: + +* Global Groovy mocks (e.g. `GroovySpy(Foo, global: true)`) rely on Groovy's meta-object protocol and therefore + cannot intercept calls made from statically compiled code. The real method is called instead. +* Dynamic language features, like dynamic method names or `methodMissing`, are not available, as in any + statically compiled Groovy code. +* Spock detects static compilation via the annotations. If static compilation is instead applied globally through + a compiler configuration script, Spock does not see it and conditions will fail to compile. + [[specifications-as-documentation]] == Specifications as Documentation From e1bb1c3e5adcbb3f76ac73dc2964293611bb21a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Mon, 20 Jul 2026 21:51:47 +0200 Subject: [PATCH 7/7] Add condition rendering specs for statically compiled code Mirrors representative cases from ConditionRendering with typed locals in a @CompileStatic spec, verifying that failed conditions render the same as in dynamic code. One deliberate difference is pinned and documented: the target class of a static method call is not rendered, because statically compiled code never evaluates the receiver of a static method call, so its value is never recorded. Also imports CompileStatic in CompileStaticSupport instead of using the fully qualified annotation in the embedded sources. --- docs/spock_primer.adoc | 3 + .../smoke/CompileStaticSupport.groovy | 36 ++- .../CompileStaticConditionRendering.groovy | 267 ++++++++++++++++++ 3 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 spock-specs/src/test/groovy/org/spockframework/smoke/condition/CompileStaticConditionRendering.groovy diff --git a/docs/spock_primer.adoc b/docs/spock_primer.adoc index 52a615ba95..42ad5f27d7 100644 --- a/docs/spock_primer.adoc +++ b/docs/spock_primer.adoc @@ -813,6 +813,9 @@ Some limitations remain: statically compiled Groovy code. * Spock detects static compilation via the annotations. If static compilation is instead applied globally through a compiler configuration script, Spock does not see it and conditions will fail to compile. +* Condition rendering can differ in minor ways. For example, the target class of a static method call like + `Math.max(a, b)` is not rendered, because statically compiled code never evaluates the receiver of a static + method call, so there is no value to record. [[specifications-as-documentation]] == Specifications as Documentation diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy index a3e84f6346..3871f8fad7 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/CompileStaticSupport.groovy @@ -16,6 +16,7 @@ package org.spockframework.smoke +import groovy.transform.CompileStatic import org.spockframework.EmbeddedSpecification import org.spockframework.runtime.ConditionNotSatisfiedError import org.spockframework.runtime.SpockComparisonFailure @@ -27,10 +28,15 @@ import org.spockframework.runtime.SpockComparisonFailure */ class CompileStaticSupport extends EmbeddedSpecification { + def setup() { + compiler.addClassImport(CompileStatic) + runner.addClassImport(CompileStatic) + } + def "comparison conditions on typed variables compile"() { when: compiler.compileWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "comparisons"() { given: @@ -52,7 +58,7 @@ class ASpec extends Specification { def "property access in conditions compiles"() { when: compiler.compileWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "property access"() { given: @@ -63,7 +69,7 @@ class ASpec extends Specification { } } -@groovy.transform.CompileStatic +@CompileStatic class Pogo { String name } @@ -76,7 +82,7 @@ class Pogo { def "indexing in conditions compiles"() { when: compiler.compileWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "indexing"() { given: @@ -95,7 +101,7 @@ class ASpec extends Specification { def "top-level method call conditions compile"() { when: compiler.compileWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "method call condition"() { given: @@ -114,7 +120,7 @@ class ASpec extends Specification { def "old() compiles and evaluates"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "old"() { given: @@ -138,7 +144,7 @@ class ASpec extends Specification { def "data table cells referencing previous columns compile"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "previous column reference"(int a, int b) { when: @@ -162,7 +168,7 @@ class ASpec extends Specification { def "interactions in mock init closures work"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "mock init closure"() { given: @@ -186,7 +192,7 @@ class ASpec extends Specification { def "interactions in interaction blocks work"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "interaction block"() { given: @@ -210,7 +216,7 @@ class ASpec extends Specification { def "method calls in conditions compile"() { when: compiler.compileWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "method call conditions"() { given: @@ -238,7 +244,7 @@ class ASpec extends Specification { when: runner.runWithImports """ class ASpec extends Specification { - @groovy.transform.CompileStatic + @CompileStatic def "statically checked feature"() { given: List list = ['a'] @@ -256,7 +262,7 @@ class ASpec extends Specification { def "exception property access in conditions compiles"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "thrown message"() { when: @@ -276,7 +282,7 @@ class ASpec extends Specification { def "method calls on the delegate of with blocks compile"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "with block"() { given: @@ -298,7 +304,7 @@ class ASpec extends Specification { def "failed method call condition renders recorded values"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "method call"() { given: @@ -322,7 +328,7 @@ list.size() == 2 def "failed comparison condition renders recorded values"() { when: runner.runWithImports """ -@groovy.transform.CompileStatic +@CompileStatic class ASpec extends Specification { def "comparison"() { given: diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/condition/CompileStaticConditionRendering.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/condition/CompileStaticConditionRendering.groovy new file mode 100644 index 0000000000..0dad856e9c --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/condition/CompileStaticConditionRendering.groovy @@ -0,0 +1,267 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.spockframework.smoke.condition + +import groovy.transform.CompileStatic + +/** + * Describes rendering of conditions in statically compiled code. + * The rendered output must match the one of dynamic code, see {@link ConditionRendering}. + */ +@CompileStatic +class CompileStaticConditionRendering extends ConditionRenderingSpec { + def "simple condition"() { + expect: + isRendered """ +x == 1 +| | +2 false + """, { + int x = 2 + assert x == 1 + } + } + + private int one(int x) { 0 } + + def "MethodCallExpression with implicit target"() { + expect: + isRendered """ +one(a) +| | +0 1 + """, { + int a = 1 + assert one(a) + } + } + + def "MethodCallExpression with explicit target"() { + expect: + isRendered """ +a.get(b) == null +| | | | +| 1 0 false +[1] + """, { + List a = [1] + int b = 0 + assert a.get(b) == null + } + } + + def "MethodCallExpression invoking static method"() { + expect: + // unlike in dynamic code, the target class of a static method call is not + // rendered, because statically compiled code never evaluates the receiver + // expression of a static method call, so its value is never recorded + isRendered """ +Math.max(a,b) == c + | | | | | + 2 1 2 | 0 + false + """, { + int a = 1 + int b = 2 + int c = 0 + assert Math.max(a,b) == c + } + } + + def "MethodCallExpression with spread-dot operator"() { + expect: + isRendered """ +["1", "22"]*.size() == null + | | + [1, 2] false + """, { + assert ["1", "22"]*.size() == null + } + } + + def "MethodCallExpression with safe operator"() { + expect: + isRendered """ +a?.length() +| | +| null +null + """, { + String a = null + assert a?.length() + } + } + + def "top-level MethodCallExpression"() { + expect: + isRendered """ +a.contains(b) +| | | +| false 2 +[0] + """, { + List a = [0] + int b = 2 + assert a.contains(b) + } + } + + def "chained MethodCallExpressions"() { + expect: + isRendered """ +a.first().toUpperCase() == "B" +| | | | +| a A false +[a] 1 difference (0% similarity) + (A) + (B) + """, { + List a = ["a"] + assert a.first().toUpperCase() == "B" + } + } + + def "TernaryExpression"() { + expect: + isRendered """ +a ? b : c +| | +1 0 + """, { + int a = 1 + int b = 0 + int c = 1 + assert a ? b : c + } + } + + def "BinaryExpression"() { + expect: + isRendered """ +a * b +| | | +0 0 1 + """, { + int a = 0 + int b = 1 + assert a * b + } + + isRendered """ +a[b] +||| +||0 +|false +[false] + """, { + List a = [false] + int b = 0 + assert a[b] + } + } + + def "comparison operators"() { + expect: + isRendered """ +x > 43 +| | +| false +42 + """, { + int x = 42 + assert x > 43 + } + } + + def "BooleanExpression"() { + expect: + isRendered """ +a +| +null + """, { + String a = null + assert a + } + } + + def "PropertyExpression"() { + expect: + isRendered """ +a.empty == true +| | | +| false false +[9] + """, { + List a = [9] + assert a.empty == true + } + } + + def "instanceof expression"() { + expect: + isRendered """ +x instanceof Integer +| | | +| false class java.lang.Integer +foo (java.lang.String) + """, { + Object x = "foo" + assert x instanceof Integer + } + } + + def "VariableExpression"() { + expect: + isRendered """ +x +| +0 + """, { + Integer x = 0 + assert x + } + } + + def "GStringExpression"() { + expect: + isRendered ''' +"$a and ${b + c}" == null + | | | | | + 1 2 5 3 false + ''', { + int a = 1 + int b = 2 + int c = 3 + assert "$a and ${b + c}" == null + } + } + + def "condition in with block"() { + expect: + isRendered """ +size() == 3 +| | +2 false + """, { + List list = ["a", "b"] + with(list) { + assert size() == 3 + } + } + } +}