Skip to content

[BUG][code-analyzer] sfge: the ?? null-coalescing operator is unmodelled — four distinct crashes, one of which aborts the entire scan #2088

Description

@willbutler

Have you tried to resolve this issue yourself first?

  • I confirm I have gone through the above steps and still have an issue to report.

Bug Description

Engine: sfge (Salesforce Graph Engine) · Rule: ApexFlsViolation (DevPreview) · Selector: --rule-selector sfge

The Apex null-coalescing operator ?? (API 60 / Spring '24) parses, but the vertex it produces in the sfge graph has zero children — the operands are never attached. Four different consumers then fail, depending on where the operator sits. I believe all four are one fix.

#1813 and #1967 already report one of these shapes (for (X x : list ?? new List<X>())). The other three are unreported, and one of them is much more severe than the for-loop case.

A. ?? in a conditionUnexpectedException: [] at BooleanExpressionVertex.java:25, whose check is if (children.size() != 2) throw new UnexpectedException(children). The payload prints as [] — the node has no operands at all.

if ((a ?? false) && (b ?? false)) { insert new Account(Name = 'x'); }

B. ?? as a method argumentUnexpectedException at ApexValue.java:610 via ApexMapValue.java:218, which asserts put has exactly 2 parameters. It is a 2-argument call in source, so the mis-modelled node changes the parameter count the engine sees.

Map<String, String> record = new Map<String, String>();
record.put('subField', maybeNull ?? '');

C. ?? as a map-literal valueIndexOutOfBoundsException at MapEntryNodeVertex.java:38 (getChildren().get(1) on an entry with one child).

Map<String, Object> row = new Map<String, Object>{ 'id' => '001', 'status' => status ?? 'N/A' };

D. ?? as a for-loop collection (the severe one, = #1813/#1967) — fails at graph build, so it does not abandon one entry point, it aborts the entire scan: a single Sev-1 UnexpectedEngineError and zero analysis of every class in the workspace. I confirmed this by putting a second, unrelated, perfectly analysable class alongside it — it also produced no findings.

for (Account a : aList ?? new List<Account>()) { a.Name = 'x'; }

For calibration, ?? does not crash in a plain assignment (String v = s ?? 'x';) or a return (return a ?? 'default';). It only crashes where the missing children are actually walked, which is probably why this has looked like a narrow for-loop problem.

Output / Logs

# A — ?? in a condition
UnexpectedException: null: com.salesforce.graph.vertex.SFVertexFactory.load(SFVertexFactory.java:245); ...
# the informative cause appears only in the sfge log, not in the violation:
Caused by: com.salesforce.exception.UnexpectedException: []
	at com.salesforce.graph.vertex.BooleanExpressionVertex.<init>(BooleanExpressionVertex.java:25)
	at com.salesforce.graph.vertex.BooleanExpressionVertex.<init>(BooleanExpressionVertex.java:18)

# B — ?? as a method argument
UnexpectedException: MethodCallExpressionVertex{fullMethodName=record.put, ... MethodName=put}:
com.salesforce.graph.symbols.apex.ApexValue.validateParameterSize(ApexValue.java:610);
com.salesforce.graph.symbols.apex.ApexMapValue.apply(ApexMapValue.java:218); ...

# C — ?? as a map-literal value
java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1
	at java.base/java.util.Collections$UnmodifiableList.get(Collections.java:1310)
	at com.salesforce.graph.vertex.MapEntryNodeVertex.getValue(MapEntryNodeVertex.java:38)
	at com.salesforce.graph.symbols.apex.ApexMapValue.setValue(ApexMapValue.java:133)
	at com.salesforce.graph.symbols.apex.ApexMapValue.<init>(ApexMapValue.java:47)
	at com.salesforce.graph.symbols.apex.ApexValueBuilder.buildMap(ApexValueBuilder.java:253)

# D — ?? as a for-loop collection (aborts the whole run)
ERROR Sfge:159 - Unexpected exception while loading graph
com.salesforce.exception.UnexpectedException: vp[FirstChild->true], vp[BeginLine->4], ...
	at com.salesforce.graph.build.GremlinUtil.getChildren(GremlinUtil.java:60)
	at com.salesforce.graph.build.MethodPathBuilderVisitor.visitForEachStatement(MethodPathBuilderVisitor.java:235)
	at com.salesforce.graph.build.MethodPathBuilderVisitor._visit(MethodPathBuilderVisitor.java:130)
	at com.salesforce.graph.build.AbstractApexVertexBuilder.afterInsert(AbstractApexVertexBuilder.java:169)
	at com.salesforce.graph.ops.GraphUtil.loadSourceFilesAndFolders(GraphUtil.java:170)

Steps To Reproduce

  1. Create an empty SFDX project (sfdx-project.json with a single force-app package directory).

  2. Add these four classes under force-app/main/default/classes/, each with a standard .cls-meta.xml (apiVersion 62.0). Put class D in a separate workspace from A-C — it aborts the whole run and will mask the other three.

    // A
    public with sharing class CoalesceInCondition {
        @AuraEnabled
        public static void run(Boolean a, Boolean b) {
            if ((a ?? false) && (b ?? false)) { insert new Account(Name = 'x'); }
        }
    }
    // B
    public with sharing class CoalesceInPutArg {
        @AuraEnabled
        public static void run(String maybeNull) {
            Map<String, String> record = new Map<String, String>();
            record.put('subField', maybeNull ?? '');
            insert new Account(Name = record.get('subField'));
        }
    }
    // C
    public with sharing class CoalesceInMapLiteral {
        @AuraEnabled
        public static void run(String status) {
            Map<String, Object> row = new Map<String, Object>{
                'id' => '001',
                'status' => status ?? 'N/A'
            };
            insert new Account(Name = String.valueOf(row.get('id')));
        }
    }
    // D — run this one in its own workspace
    public with sharing class CoalesceInForLoop {
        @AuraEnabled
        public static void run(List<Account> aList) {
            for (Account a : aList ?? new List<Account>()) { a.Name = 'x'; }
            update aList;
        }
    }
  3. Add code-analyzer.yml:

    engines:
      sfge:
        java_thread_timeout: 900000
        java_thread_count: 4
  4. Run sf code-analyzer run --rule-selector sfge --workspace . --config-file code-analyzer.yml.

  5. A, B and C each report an InternalExecutionError and yield no findings for that entry point. D returns a single Sev-1 UnexpectedEngineError and analyses nothing at all.

Expected Behavior

a ?? b should build a node with both operands attached, so path evaluation can treat it like the equivalent ternary (a != null ? a : b), which sfge already handles.

Failing that, an explicit unsupported-construct violation would at least make the lost coverage visible. Shape D in particular should not be able to take down an entire scan.

Operating System

macOS 26.5.2

Salesforce CLI Version

@salesforce/cli/2.147.7 darwin-arm64 node-v24.5.0

Code Analyzer Plugin (code-analyzer) Version

code-analyzer 5.15.0

Node Version

v24.5.0

Java Version

openjdk version "11.0.32" 2026-07-21

Python Version

N/A

Additional Context (Screenshots, Files, etc)

?? is idiomatic in any codebase on API 60 or later.

Shapes A-C each silently abandon one @AuraEnabled entry point — it produces no FLS findings, and nothing in the summary says coverage was lost. That is quiet in exactly the direction that matters for a security rule. In our codebase these three account for three distinct crash signatures.

Shape D is worse: one ?? in one for-loop header takes a team from a working scan to no security analysis at all. We happen not to have that shape, which is the only reason our runs produce results.

Related history: #1380, #1389 and #1608 covered ?? failing to parse. Parsing was fixed; graph modelling was not. #1240 is the same validateParameterSize throw as shape B but from a different trigger (Cache.Partition.put's 3-argument overload), closed in the 2026-06-30 pre-v5 sweep.

Workaround

Rewrite as an explicit ternary or null check:

String v = (s != null) ? s : '';
for (Account a : (aList != null ? aList : new List<Account>())) { ... }

That is a mechanical change but an unpleasant one to apply across a large codebase, and it means giving up a language feature to keep the security scanner working.

Urgency

High

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions