Skip to content

Unified: captured variables - #22726

Merged
asgerf merged 41 commits into
github:mainfrom
asgerf:unified/captured-variables
Oct 15, 2026
Merged

asgerf merged 41 commits into
github:mainfrom
asgerf:unified/captured-variables

Conversation

@asgerf

@asgerf asgerf commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Adds flow through captured variables and improves handling of capture-declarations on closure expressions.

Data flow node splitting

The TDataFlowNode type is now split into multiple stages, and the language plugin currently only contributes to the first stage (before SSA and capture instantiation).

This is a slightly large change to bring out just for capture, but it is also needed for other useful features like transform steps and withContent/withoutContent steps. For capture we use it to perform simple alias analysis for closures without duplicating logic already expressed in the form of data flow steps.

Capture declarations

Closures can specify that certain variables are captured "by value"

let closure1 = { print(x) }        // capture 'x' by reference
let closure2 = { [x] in print(x) } // capture 'x' by value

We interpret such capture-declarations essentially as if they were a let-bound variable sitting immediately around the closure expression. For example, the second closure is essentially seen as:

let closure2 = let x' = x in { print (x') }

This is implemented via two changes:

  • [x] is desugared to [x = x] that is, the variable x is initialised to x. Shadowing rules ensure that a new x binding is introduced and its RHS refers to the outer x binding.
  • Scoping and CFG rules are updated so FunctionExpr.getCaptureDeclaration(_) is placed "around" the closure, rather than inside it.

Comment thread unified/ql/lib/codeql/unified/internal/dataflow/DataFlowNode.qll Fixed
Comment on lines +2159 to +2161
/**
* The given `read` reads multiple variables at once. `var` is bound to one of them.
*/
@github-actions github-actions Bot added C# JS Java Python Ruby Rust Pull requests that update Rust code labels Oct 5, 2026
@asgerf
asgerf force-pushed the unified/captured-variables branch from d880379 to 9ce4785 Compare October 9, 2026 08:13
Comment on lines +1 to +3
/**
* Provides data-flow modelling of constructor patterns / enum-case constructors.
*/
@github-actions github-actions Bot removed C# JS Java Python Ruby Rust Pull requests that update Rust code labels Oct 9, 2026
@asgerf

asgerf commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

@asgerf
asgerf force-pushed the unified/captured-variables branch 2 times, most recently from b9702e7 to b9910e3 Compare October 9, 2026 12:34
@asgerf
asgerf requested a balanced review from Copilot October 9, 2026 12:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It substantially changes shared SSA, control-flow, and data-flow construction and warrants final human validation.

1 open finding
What changed in this PR

Adds captured-variable support to Unified Swift data flow, including closure ownership semantics and local-function captures.

Changes:

  • Integrates capture SSA and callable/callee data-flow nodes.
  • Models strong, weak, and unowned Swift captures.
  • Adds capture tests and updates generated expectations.
File Description
unified/​ql/​test/​library-tests/​local-name-binding/​test.swift Updates local-function binding expectation.
unified/​ql/​test/​library-tests/​local-name-binding/​self_access.swift Tests explicit self capture modes.
unified/​ql/​test/​library-tests/​dataflow/​test.expected Updates generated capture-flow results.
unified/​ql/​test/​library-tests/​dataflow/​capture.swift Adds capture data-flow tests.
unified/​ql/​test/​library-tests/​controlflow/​cfg.swift Updates CFG expectations for function values.
unified/​ql/​test/​library-tests/​controlflow/​cfg.expected Updates generated CFG results.
unified/​ql/​test/​library-tests/​controlflow/​basicblock-slices.expected Updates generated basic-block results.
unified/​ql/​lib/​codeql/​unified/​internal/​LocalNameBinding.qll Generalizes child ordering and capture binding.
unified/​ql/​lib/​codeql/​unified/​internal/​FacadeAst.qll Associates function names with enclosing callables.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​ParameterPositions.qll Adds implicit callee positions.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​LocalVariableRefNode.qll Introduces reusable variable-reference nodes.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​LocalSsa.qll Adapts local SSA to staged nodes.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowPluginSwift.qll Models weak and unowned captures.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowPlugin.qll Moves plugin steps to builder nodes.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowNode.qll Adds staged, callable, and capture nodes.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowInstantiation.qll Integrates capture and lambda flow.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowGraph.qll Adds callable and capture graph edges.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowCallable.qll Adds callable conversion support.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​DataFlowCall.qll Adds call conversion support.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​Content.qll Adds captured-variable content.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​CaptureSsa.qll Implements capture SSA integration.
unified/​ql/​lib/​codeql/​unified/​internal/​dataflow/​AllDataFlow.qll Re-exports new capture components.
unified/​ql/​lib/​codeql/​unified/​internal/​ControlFlowGraph.qll Adds function-name CFG children.
unified/​ql/​consistency-queries/​CaptureSsaConsistency.ql Enables capture SSA consistency checks.
unified/​extractor/​tests/​corpus/​swift/​closures/​closure-with-capture-list.output Updates generated capture AST output.
unified/​extractor/​src/​languages/​swift/​swift.rs Expands capture-list initializers.
shared/​dataflow/​codeql/​dataflow/​VariableCapture.qll Refines shared capture analysis integration.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread unified/extractor/src/languages/swift/swift.rs Outdated
@asgerf
asgerf marked this pull request as ready for review October 9, 2026 13:42
@asgerf
asgerf requested review from a team as code owners October 9, 2026 13:43
@asgerf asgerf added the no-change-note-required This PR does not need a change note label Oct 9, 2026
@asgerf
asgerf force-pushed the unified/captured-variables branch from 5bf8d21 to 1b94078 Compare October 14, 2026 08:12
Comment on lines +839 to +842
match val {
Some(val) => val,
None => tree!((identifier #{name})),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could use unwrap_or_else.

Suggested change
match val {
Some(val) => val,
None => tree!((identifier #{name})),
}
val.unwrap_or_else(|| tree!((identifier #{name})))


LocalVariableRefNode() { this = TLocalVariableRefNode(repr, var, kind) }

predicate hasCfgNode(BasicBlock bb, int i) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When this was factored out, the (kind.isRead() or kind.isPostUpdate()) and condition was dropped. Was that intentional?

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.

Good question. It was technically unnecessary to begin with - I think it added some clarity originally because we never needed an Expr to represent a write, but there's no harm in having Expr representatives for writes.

predicate hasBody(Callable body) { callable = body }

predicate hasAliasedAccess(Expr f) {
callableHasLocalAlias(callable, f) and not f.(LocalVariableRefNode).getRefKind().isWrite()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This relation was not meant to be reflexive. Also, it was not meant to include all the aliases - just the ones that might escape or represent an invocation.

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.

I'll leave a TODO for now. It's easy to accidentally make something too under-approximate and not notice because FNs are hard to detect. I want to over-approximate this first and defer escape analysis until we are in a better position to measure its correctness at scale.

}

class CapturedParameter extends CapturedVariable {
// This class is not needed. Variables aren't the first point of contact with parameter values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you elaborate?

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.

Fair enough, expanded on the comment.

)
}

class ClosureExpr extends Expr instanceof TCallableNode {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This equates closure expressions and callables. I'm not sure if this happens in Swift, but that won't work in general. Some languages allow the construction of closure objects with multiple methods, e.g. anonymous class instances in Java (of which a Java lambda is but one example).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A local class definition is another example where capture might happen in an object with multiple methods (again, I'm not sure which languages this applies to, but I know it's valid Java).

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.

As discussed offline, I'll add a TODO. Swift local classes can't capture variables so we can't exercise this feature with Swift.


private newtype TParameterPosition =
TReceiverParameter() or
TCalleeParameter() or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need to introduce this? I would expect the receiver parameter to handle this role. I know that we already have the distinction between receiver parameter and callee parameter in some of our languages, but I'd like to question whether that's really necessary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note also that in some languages (e.g. Java), you can't tell the difference between these two cases.

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.

Summary from our offline discussion:

  • The two parameter positions are strictly necessary for JS so attempts to merge them into one isn't going to be future proof.
  • The argument positions for a call x.foo() can be provided without knowing if this is a method call or a field read followed by a lambda call: x is the receiver argument and x.foo is the callee argument. This is important for dynamic languages where there is no type system to tell the difference.
  • Given the knowledge that call sites always pass in the receiver and callee as specified above, CaptureSsa.qll should be able to pick the right parameter position to use on a case by case basis (depending on the language and/or node type). We expect it will eventually use a mix of receiver/callee parameters when we start supporting a language where classes can capture variables (which Swift does not).

}
}

DataFlowCall getDataFlowCall(CallExpr call) { result.asExplicitCall() = call }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The get prefix doesn't feel exactly right to me. How about toDataFlowCall instead?

Location getLocation() { result = this.asSourceCallable().getLocation() }
}

DataFlowCallable getDataFlowCallable(Callable callable) { result.asSourceCallable() = callable }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here.

}

predicate clearsContent(Node n, ContentSet c) { none() } // TODO
predicate clearsContent(Node n, ContentSet c) { captureSsaClearsContent(n, c) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe leave a TODO comment in place for the regular store-into-field clearsContent.

@aschackmull aschackmull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's plenty more to figure out for variable capture as already discussed, but that's not for this PR. So LGTM for merge!

@asgerf
asgerf merged commit 0c40f4d into github:main Oct 15, 2026
110 of 113 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants