Skip to content

Evaluate profile activation conditions lazily - #13139

Merged
gnodet merged 2 commits into
apache:masterfrom
Dev-next-gen:condition-lazy-evaluation
Sep 15, 2026
Merged

gnodet merged 2 commits into
apache:masterfrom
Dev-next-gen:condition-lazy-evaluation

Conversation

@Dev-next-gen

Copy link
Copy Markdown
Contributor

Following this checklist to help us incorporate your
contribution quickly and easily:

  • Your pull request should address just one issue, without pulling in other changes.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body.
    Note that commits might be squashed by a maintainer on merge.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied.
    This may not always be possible but is a best-practice.
  • Run mvn verify to make sure basic checks pass.
    A more thorough check will be performed on your pull request automatically.
  • You have run the Core IT successfully.

If your pull request is about ~20 lines of code you don't need to sign an
Individual Contributor License Agreement if you are unsure
please ask on the developers list.

To make clear that you license your contribution under
the Apache License Version 2.0, January 2004
you have to acknowledge this by using the following check-box.


I tried the "Conditional logic" example from the <condition> documentation in maven.mdo:

if(contains(${java.version}, '-'), substring(${java.version}, 0, indexOf(${java.version}, '-')), ${java.version})

With a java.version that has no - (the test context uses 1.8.0_292, and 21.0.12 behaves the same), the condition fails with StringIndexOutOfBoundsException: Range [0, -1) out of bounds for length 9, and the profile is reported as an error instead of being evaluated. ConditionParser computes every operand while it parses, so both branches of if(..) are always evaluated, even the one that is discarded. && and || have the same problem: length(${p}) >= 3 && substring(${p}, 0, 3) == 'abc' throws when p is shorter than three characters, because the guard can't stop the right-hand side from running.

This change skips the branch that isn't selected in if(..), and skips the right operand of && or || once the left operand has already decided the result. Skipping walks the tokens up to the next terminator outside parentheses (, or ), plus &&/|| depending on the operator). It still rejects a missing operand or unbalanced parentheses, so the existing testParenthesesMismatch cases keep failing the way they did before. For operands that do get evaluated, nothing changes: if still goes through the registered function, which checks the argument count, and a non-boolean on either side of && or || still fails as before.

I added testIfFunctionOnlyEvaluatesSelectedBranch, which uses the documented example verbatim, and testLogicalOperatorsShortCircuit. Without the change to ConditionParser, both fail with the exceptions above. With it, ConditionParserTest and ConditionProfileActivatorTest pass (76 tests), and mvn verify on impl/maven-impl passes: 690 tests, checkstyle and spotless included. I ran that with Maven 3.8.4 and -Denforcer.skip -Drat.skip, which were the only toolchain constraints on that machine. I did not run the core ITs.

Found by a defect-hunting pipeline I build and run (Dev-next-gen), using Claude Code with Anthropic's Claude Opus 5.

The condition parser evaluated every operand as it parsed it, so both
branches of if(..) and the right-hand side of && and || were always
computed. A branch that is not valid for the current input then fails
the whole condition. The if(..) example from the condition
documentation, substring(${java.version}, 0, indexOf(${java.version},
'-')), throws StringIndexOutOfBoundsException on any java.version
without a '-', and a guard such as length(x) >= 3 cannot protect a
substring(x, 0, 3) that follows it with &&.

Skip the unselected if(..) branch and the right operand of && / || when
the left one already decides the result. Skipped operands are still
checked for balanced parentheses, so malformed expressions keep failing.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Core short-circuit logic is sound and correctly fixes the StringIndexOutOfBoundsException documented in the PR description. The skipOperand depth tracking handles nested parentheses correctly, and the error paths (false &&, true || (false) are properly exercised by the new assertThrows cases. Two design concerns and one test gap below.

Metadata suggestions (for maintainers)

  • Label: bug
  • Milestone: 4.x

This review was generated by an AI agent, Hermès on behalf of @gnodet.

while (current < tokens.size() && !tokens.get(current).equals(")")) {
args.add(parseLogicalOr());
int index = args.size();
if ("if".equals(functionName) && (index == 1 || index == 2) && toBoolean(args.get(0)) != (index == 1)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Design coupling: "if" hardcoded in a function-agnostic parser

ConditionParser receives its function set as an injected Map<String, ExpressionFunction> — the parser is supposed to be agnostic about which functions exist. This line breaks that contract: the parser now needs to know the name "if" to apply lazy evaluation.

If a second function needing lazy evaluation is added (e.g., "unless", "cond", a short-circuit "and"), another magic-string guard has to be added here.

Two paths forward:

  1. Minimal (in-PR): Extract the string to a constant to make the coupling visible:
    Suggested change
    if ("if".equals(functionName) && (index == 1 || index == 2) && toBoolean(args.get(0)) != (index == 1)) {
    if ("if".equals(functionName) && (index == 1 || index == 2) && toBoolean(args.get(0)) != (index == 1)) {
    (No behaviour change — but at least the magic string is a named constant in the companion commit, or a private static final String IF_FUNCTION = "if")
  2. Proper long-term: Introduce a LazyExpressionFunction sub-interface whose apply receives a Supplier<Object> or an unevaluated index range, so the parser can dispatch lazily without knowing function names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that this line is the only place where the parser, which otherwise only sees the injected function map, depends on a function name. I took the minimal path in 92a381c: the literal is now private static final String IF_FUNCTION = "if", documented as the function whose unselected branch is skipped, so the coupling is visible from the top of the class. Spotless wrapped the condition over three lines, and ConditionParserTest plus ConditionProfileActivatorTest still pass (76 tests, 3 skipped as before).

I stayed away from a LazyExpressionFunction sub-interface in this PR because it changes the public ExpressionFunction contract, and that feels like a call for the maintainers rather than for a bug fix. If you'd rather have it here, I'm glad to rework the change along those lines.

Replace the "if" literal in parseArgumentList with a named constant, so
the one place where the parser depends on a function name is visible,
and cover the two-argument if(false, 'a') call next to if(true, 'a').

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (commits 72e1ffb + 92a381c)

Both findings from the previous review are addressed:

  • "if" coupling — commit 92a381c extracts the literal to private static final String IF_FUNCTION = "if", documented as the one place where the parser depends on a function name. The coupling is now explicit and visible. The LazyExpressionFunction sub-interface approach is a legitimate design evolution, but deferring it is the right call for a targeted bug fix.

  • Missing if(false, 'a') test — commit 92a381c adds assertThrows(RuntimeException.class, () -> parser.parse("if(false, 'a')")) immediately after the symmetric if(true, 'a') case. The path is correctly exercised: condition=false at index 1 triggers the skip branch, if_() receives [false, null] (size 2) and rejects the two-argument call.

skipOperand logic is correct: the terminator check fires on the token before the depth decrement, so a closing ) at depth 1 is consumed (depth → 0) and the next token triggers the break — the stream is left in the right state for the caller in all chained operator cases. The nested short-circuit test case (false && (substring... || true) && true) exercises this path and passes.

No new issues found. Ready to merge.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@gnodet gnodet added bug Something isn't working backport-to-4.0.x labels Sep 15, 2026
@gnodet gnodet added this to the 4.1.0 milestone Sep 15, 2026
@gnodet
gnodet merged commit 2a555d0 into apache:master Sep 15, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-4.0.x bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants