Repository navigation
Unified: captured variables - #22726
Conversation
753f0d4 to
d880379
Compare
| /** | ||
| * The given `read` reads multiple variables at once. `var` is bound to one of them. | ||
| */ |
d880379 to
9ce4785
Compare
Rerun has been triggered: 5 restarted 🚀 |
b9702e7 to
b9910e3
Compare
There was a problem hiding this comment.
🔵 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.
This appeared after an LLM-assisted rebase
Rebasing on the Optional<T> bypass makes this part of the test pass.
5bf8d21 to
1b94078
Compare
| match val { | ||
| Some(val) => val, | ||
| None => tree!((identifier #{name})), | ||
| } |
There was a problem hiding this comment.
Could use unwrap_or_else.
| 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) { |
There was a problem hiding this comment.
When this was factored out, the (kind.isRead() or kind.isPostUpdate()) and condition was dropped. Was that intentional?
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Fair enough, expanded on the comment.
| ) | ||
| } | ||
|
|
||
| class ClosureExpr extends Expr instanceof TCallableNode { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Note also that in some languages (e.g. Java), you can't tell the difference between these two cases.
There was a problem hiding this comment.
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:xis the receiver argument andx.foois 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.qllshould 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 } |
There was a problem hiding this comment.
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 } |
| } | ||
|
|
||
| predicate clearsContent(Node n, ContentSet c) { none() } // TODO | ||
| predicate clearsContent(Node n, ContentSet c) { captureSsaClearsContent(n, c) } |
There was a problem hiding this comment.
Maybe leave a TODO comment in place for the regular store-into-field clearsContent.
aschackmull
left a comment
There was a problem hiding this comment.
There's plenty more to figure out for variable capture as already discussed, but that's not for this PR. So LGTM for merge!

Adds flow through captured variables and improves handling of capture-declarations on closure expressions.
Data flow node splitting
The
TDataFlowNodetype 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"
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:
This is implemented via two changes:
[x]is desugared to[x = x]that is, the variablexis initialised tox. Shadowing rules ensure that a newxbinding is introduced and its RHS refers to the outerxbinding.FunctionExpr.getCaptureDeclaration(_)is placed "around" the closure, rather than inside it.