From 60baec6745bbe75ee416244bacb0d353370b191b Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 20:33:53 +0200 Subject: [PATCH 01/41] unified: Add test showing problem with literal getValue() getValue() returns an empty string for various literals --- unified/ql/test/library-tests/BasicTest/strings.swift | 1 + unified/ql/test/library-tests/BasicTest/test.expected | 2 ++ unified/ql/test/library-tests/BasicTest/test.ql | 2 ++ 3 files changed, 5 insertions(+) create mode 100644 unified/ql/test/library-tests/BasicTest/strings.swift diff --git a/unified/ql/test/library-tests/BasicTest/strings.swift b/unified/ql/test/library-tests/BasicTest/strings.swift new file mode 100644 index 000000000000..db9ae84e980b --- /dev/null +++ b/unified/ql/test/library-tests/BasicTest/strings.swift @@ -0,0 +1 @@ +let x = "hello" diff --git a/unified/ql/test/library-tests/BasicTest/test.expected b/unified/ql/test/library-tests/BasicTest/test.expected index b9f4eafe8653..6043574bef25 100644 --- a/unified/ql/test/library-tests/BasicTest/test.expected +++ b/unified/ql/test/library-tests/BasicTest/test.expected @@ -35,3 +35,5 @@ nameExpr | test.swift:87:38:87:43 | NameExpr | values | | test.swift:87:49:87:57 | NameExpr | transform | unsupported +stringValue +| strings.swift:1:9:1:15 | | | diff --git a/unified/ql/test/library-tests/BasicTest/test.ql b/unified/ql/test/library-tests/BasicTest/test.ql index 5e70f9303687..8e30af381d61 100644 --- a/unified/ql/test/library-tests/BasicTest/test.ql +++ b/unified/ql/test/library-tests/BasicTest/test.ql @@ -3,3 +3,5 @@ import unified query predicate nameExpr(NameExpr node, string value) { value = node.getIdentifier().getValue() } query predicate unsupported(UnsupportedNode node, string value) { value = node.getValue() } + +query predicate stringValue(StringLiteral e, string value) { value = e.getValue() } From 51fe2730855f84aa1e26cf91fc3d3ec0749a253f Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 20:32:46 +0200 Subject: [PATCH 02/41] unified: Fix extraction of literals --- unified/extractor/src/languages/swift/swift.rs | 12 ++++++------ .../ql/test/library-tests/BasicTest/test.expected | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index ccc1cc7f725d..e76e7ef212d4 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -158,12 +158,12 @@ fn translation_rules() -> Vec> { // swift-syntax does not distinguish the lexical integer/string forms // (hex/binary/octal, single- vs multi-line, raw): each is a single // `*LiteralExpr` kind, so one rule per literal type suffices. - rule!((integerLiteralExpr) => (int_literal)), - rule!((floatLiteralExpr) => (float_literal)), - rule!((booleanLiteralExpr) => (boolean_literal)), - rule!((nilLiteralExpr) => (builtin_expr)), - rule!((stringLiteralExpr) => (string_literal)), - rule!((regexLiteralExpr) => (regex_literal)), + rule!((integerLiteralExpr) @@node => (int_literal #{node})), + rule!((floatLiteralExpr) @@node => (float_literal #{node})), + rule!((booleanLiteralExpr) @@node => (boolean_literal #{node})), + rule!((nilLiteralExpr) @@node => (builtin_expr #{node})), + rule!((stringLiteralExpr) @@node => (string_literal #{node})), + rule!((regexLiteralExpr) @@node => (regex_literal #{node})), // ---- Names ---- // A function reference spelled with argument labels (`f(x:y:z:)`) is a // `declReferenceExpr` carrying `argumentNames`. Mark it unsupported for diff --git a/unified/ql/test/library-tests/BasicTest/test.expected b/unified/ql/test/library-tests/BasicTest/test.expected index 6043574bef25..301cd90ca2a5 100644 --- a/unified/ql/test/library-tests/BasicTest/test.expected +++ b/unified/ql/test/library-tests/BasicTest/test.expected @@ -36,4 +36,4 @@ nameExpr | test.swift:87:49:87:57 | NameExpr | transform | unsupported stringValue -| strings.swift:1:9:1:15 | | | +| strings.swift:1:9:1:15 | "hello" | "hello" | From b71ff8f233772efd41db45affde7f8b0a2a04b00 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 10:00:57 +0200 Subject: [PATCH 03/41] unified: Factor some code into CommentUtil.qll --- unified/ql/lib/utils/test/CommentUtil.qll | 20 +++++++++++++++++++ .../library-tests/local-name-binding/test.ql | 20 +------------------ 2 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 unified/ql/lib/utils/test/CommentUtil.qll diff --git a/unified/ql/lib/utils/test/CommentUtil.qll b/unified/ql/lib/utils/test/CommentUtil.qll new file mode 100644 index 000000000000..6747aed57187 --- /dev/null +++ b/unified/ql/lib/utils/test/CommentUtil.qll @@ -0,0 +1,20 @@ +private import unified + +/** Holds if a comment with `text` appears at `filepath:line`, excluding the text in a `$` section. */ +predicate plainCommentAt(string filepath, int line, string text) { + exists(Comment comment | + comment.getLocation().hasLocationInfo(filepath, line, _, _, _) and + text = comment.getCommentText().regexpReplaceAll("\\$([^/]|/[^/])*", "") + ) +} + +/** Holds if a `key=value` comment appears on `filepath:line` (not in the `$` section). */ +predicate keyValueCommentAt(string filepath, int line, string key, string value) { + exists(string text, string regexp, string match | + plainCommentAt(filepath, line, text) and + regexp = "(\\w+)=([\\w.]+)" and + match = text.regexpFind(regexp, _, _) and + key = match.regexpCapture(regexp, 1) and + value = match.regexpCapture(regexp, 2) + ) +} diff --git a/unified/ql/test/library-tests/local-name-binding/test.ql b/unified/ql/test/library-tests/local-name-binding/test.ql index 0fcae36dd31e..e430f16ba48d 100644 --- a/unified/ql/test/library-tests/local-name-binding/test.ql +++ b/unified/ql/test/library-tests/local-name-binding/test.ql @@ -1,26 +1,8 @@ import unified import utils.test.InlineExpectationsTest +import utils.test.CommentUtil import codeql.unified.internal.LocalNameBinding -/** Holds if a comment with `text` appears at `filepath:line`, excluding the text in a `$` section. */ -predicate plainCommentAt(string filepath, int line, string text) { - exists(Comment comment | - comment.getLocation().hasLocationInfo(filepath, line, _, _, _) and - text = comment.getCommentText().regexpReplaceAll("\\$([^/]|/[^/])*", "") - ) -} - -/** Holds if a `key=value` comment appears on `filepath:line` (not in the `$` section). */ -predicate keyValueCommentAt(string filepath, int line, string key, string value) { - exists(string text, string regexp, string match | - plainCommentAt(filepath, line, text) and - regexp = "(\\w+)=([\\w.]+)" and - match = text.regexpFind(regexp, _, _) and - key = match.regexpCapture(regexp, 1) and - value = match.regexpCapture(regexp, 2) - ) -} - module VariableAccessTest implements TestSig { string getARelevantTag() { result = "access" } From c9fc793d008fe7542b1b3dcb783e88dbe7a3c4f2 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 11:00:46 +0200 Subject: [PATCH 04/41] unified: Fix mapping for compound type names --- .../extractor/src/languages/swift/swift.rs | 10 +- .../corpus/swift/types/qualified-type.output | 139 ++++++++++++++++++ .../corpus/swift/types/qualified-type.swift | 8 + 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 unified/extractor/tests/corpus/swift/types/qualified-type.output create mode 100644 unified/extractor/tests/corpus/swift/types/qualified-type.swift diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index e76e7ef212d4..77314ceb678c 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -1014,9 +1014,13 @@ fn translation_rules() -> Vec> { // A named type (`Int`). `identifierType.name` is the type-name token. rule!((identifierType name: @@n) => (named_type_expr name: (identifier #{n}))), // A qualified type (`Outer.Inner`, `NSString.CompareOptions`). swift-syntax - // nests these as `memberType` nodes; we keep the whole dotted path as the - // opaque `named_type_expr` name. - rule!((memberType) @ty => (named_type_expr name: (identifier #{ty}))), + // nests these as `memberType` nodes; preserve the nesting in the + // named_type_expr qualifier field. + rule!( + (memberType baseType: @base name: @@name) + => + (named_type_expr qualifier: {base} name: (identifier #{name})) + ), // Sugared types desugar to `generic_type_expr`: `T?` -> Optional, // `[T]` -> Array, `[K: V]` -> Dictionary. rule!( diff --git a/unified/extractor/tests/corpus/swift/types/qualified-type.output b/unified/extractor/tests/corpus/swift/types/qualified-type.output new file mode 100644 index 000000000000..f561dc6ab2dc --- /dev/null +++ b/unified/extractor/tests/corpus/swift/types/qualified-type.output @@ -0,0 +1,139 @@ +struct Outer { + struct Inner { + struct Deep {} + } +} + +let value: Outer.Inner +let nested: Outer.Inner.Deep + +--- + +sourceFile + endOfFileToken: endOfFile + statements: + codeBlockItem + item: + structDecl + attributes: + name: identifier "Outer" + memberBlock: + memberBlock + leftBrace: { + rightBrace: } + members: + memberBlockItem + decl: + structDecl + attributes: + name: identifier "Inner" + memberBlock: + memberBlock + leftBrace: { + rightBrace: } + members: + memberBlockItem + decl: + structDecl + attributes: + name: identifier "Deep" + memberBlock: + memberBlock + leftBrace: { + rightBrace: } + members: + modifiers: + structKeyword: struct + modifiers: + structKeyword: struct + modifiers: + structKeyword: struct + codeBlockItem + item: + variableDecl + attributes: + modifiers: + bindingSpecifier: let + bindings: + patternBinding + pattern: + identifierPattern + identifier: identifier "value" + typeAnnotation: + typeAnnotation + colon: : + type: + memberType + name: identifier "Inner" + baseType: + identifierType + name: identifier "Outer" + period: . + codeBlockItem + item: + variableDecl + attributes: + modifiers: + bindingSpecifier: let + bindings: + patternBinding + pattern: + identifierPattern + identifier: identifier "nested" + typeAnnotation: + typeAnnotation + colon: : + type: + memberType + name: identifier "Deep" + baseType: + memberType + name: identifier "Inner" + baseType: + identifierType + name: identifier "Outer" + period: . + period: . + +--- + +top_level + body: + block + stmt: + class_like_declaration + modifier: modifier "struct" + name: identifier "Outer" + member: + class_like_declaration + modifier: modifier "struct" + name: identifier "Inner" + member: + class_like_declaration + modifier: modifier "struct" + name: identifier "Deep" + variable_declaration + modifier: modifier "let" + pattern: + name_pattern + identifier: identifier "value" + type: + named_type_expr + qualifier: + named_type_expr + name: identifier "Outer" + name: identifier "Inner" + variable_declaration + modifier: modifier "let" + pattern: + name_pattern + identifier: identifier "nested" + type: + named_type_expr + qualifier: + named_type_expr + qualifier: + named_type_expr + name: identifier "Outer" + name: identifier "Inner" + name: identifier "Deep" diff --git a/unified/extractor/tests/corpus/swift/types/qualified-type.swift b/unified/extractor/tests/corpus/swift/types/qualified-type.swift new file mode 100644 index 000000000000..9c5fbae3cf7b --- /dev/null +++ b/unified/extractor/tests/corpus/swift/types/qualified-type.swift @@ -0,0 +1,8 @@ +struct Outer { + struct Inner { + struct Deep {} + } +} + +let value: Outer.Inner +let nested: Outer.Inner.Deep From f50c80a753b4c6c83092a21d978b4bcd32dc6474 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 10:47:36 +0200 Subject: [PATCH 05/41] unified: Initial static name binding pass This supports only a minimal set of features but sets up the structure we'll be using for supporting more features. --- .../lib/codeql/unified/internal/FacadeAst.qll | 8 + .../unified/internal/LocalNameBinding.qll | 67 ++++-- .../unified/internal/NameBindingPlugin.qll | 37 ++++ .../internal/NameBindingPluginSwift.qll | 24 +++ .../unified/internal/StaticNameBinding.qll | 197 ++++++++++++++++++ ...Graph.ql => debugLocalNameBindingGraph.ql} | 6 +- .../dev/debugStaticNameBindingGraph.ql | 16 ++ .../static-name-binding/test.expected | 0 .../library-tests/static-name-binding/test.ql | 51 +++++ .../static-name-binding/test.swift | 44 ++++ 10 files changed, 428 insertions(+), 22 deletions(-) create mode 100644 unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll create mode 100644 unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll create mode 100644 unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll rename unified/ql/lib/codeql/unified/internal/dev/{debugScopeGraph.ql => debugLocalNameBindingGraph.ql} (72%) create mode 100644 unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql create mode 100644 unified/ql/test/library-tests/static-name-binding/test.expected create mode 100644 unified/ql/test/library-tests/static-name-binding/test.ql create mode 100644 unified/ql/test/library-tests/static-name-binding/test.swift diff --git a/unified/ql/lib/codeql/unified/internal/FacadeAst.qll b/unified/ql/lib/codeql/unified/internal/FacadeAst.qll index a25fb3447c1e..a5d057135d87 100644 --- a/unified/ql/lib/codeql/unified/internal/FacadeAst.qll +++ b/unified/ql/lib/codeql/unified/internal/FacadeAst.qll @@ -17,6 +17,14 @@ module Unified { mod.getValue() = text ) } + + /** Gets the nearest enclosing class declaration, possibly this node itself. */ + ClassLikeDeclaration getEnclosingClass() { + result = this + or + not this instanceof ClassLikeDeclaration and + result = this.getParent().getEnclosingClass() + } } /** The base class for all patterns. */ diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index 0df235d521fd..59f94b433ba7 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -184,79 +184,92 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig; module Public { @@ -319,6 +334,20 @@ module Public { /** Gets the name of this local, as a string. */ string getName() { result = super.getName() } } + + /** An identifier that appears as the declaration site of a name, such as the `x` in `let x = 123`. */ + class NameDeclaration extends Identifier { + NameDeclaration() { LocalNameBindingInput::bindingContext(this, _, _) } + + /** Gets the statement-like node declaring this name, such as a `VariableDeclaration` or `CatchClause`. */ + AstNode getDeclaration() { LocalNameBindingInput::bindingContext(this, _, result) } + + /** Gets the name being declared. */ + string getName() { result = this.getValue() } + + /** Gets the representative for the local name introduced by this declaration. */ + LocalName getLocalName() { result = this.(LocalNameBindingOutput::LocalAccess).getLocal() } + } } /** @@ -342,7 +371,7 @@ class PotentialLocalNameAccess extends Identifier { or this = any(NamedTypeExpr e | not exists(e.getQualifier())).getName() or - LocalNameBindingInput::bindingContext(this, _) + this instanceof NameDeclaration } LocalName getLocalName() { result = this.(LocalNameBindingOutput::LocalAccess).getLocal() } @@ -350,5 +379,5 @@ class PotentialLocalNameAccess extends Identifier { string getName() { result = this.getValue() } /** Holds if this is one of the declaration sites for a name, such as the `x` in `let x = 123`. */ - predicate isDeclarationSite() { LocalNameBindingInput::bindingContext(this, _) } + predicate isDeclarationSite() { this instanceof NameDeclaration } } diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll new file mode 100644 index 000000000000..5ef34d6fe2da --- /dev/null +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll @@ -0,0 +1,37 @@ +private import unified +private import codeql.util.Unit +private import codeql.unified.internal.NameBindingPluginSwift // ensure overrides are seen + +/** Extension point for language-specific inputs to name binding. */ +class NameBindingPlugin extends Unit { + /** + * Holds if `member` is an instance member. + * + * The caller has already restricted `member` to be a member of `cls`, and + * ensured that `member` is a `VariableDeclaration` or `FunctionDeclaration`. + */ + bindingset[cls, member] + predicate isInstanceMember(ClassLikeDeclaration cls, Member member) { none() } + + /** + * Holds if `member` is only visible in its local scope, and can thus be entirely resolved + * by local name-binding, suppressing any store-steps that would otherwise be induced from the member. + * + * Need only be implemented for members that occur in the context of class or top-level, as other + * contexts are considered local already. + */ + predicate isPrivateToLocalScope(Member member) { none() } +} + +/** Holds if `member` is an instance member. */ +predicate isInstanceMember(Member member) { + (member instanceof VariableDeclaration or member instanceof FunctionDeclaration) and + exists(ClassLikeDeclaration cls | cls.getAMember() = member | + any(NameBindingPlugin p).isInstanceMember(cls, member) + ) +} + +/** Holds if `member` is only visible in its local scope. */ +predicate isPrivateToLocalScope(Member member) { + any(NameBindingPlugin p).isPrivateToLocalScope(member) +} diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll new file mode 100644 index 000000000000..98d30c5d779f --- /dev/null +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll @@ -0,0 +1,24 @@ +/** + * Provides Swift-specific name binding rules. + */ + +private import unified +private import codeql.unified.internal.NameBindingPlugin + +class NameBindingPluginSwift extends NameBindingPlugin { + // Note: For now we assume all code is Swift, but in the future we must restrict these rules to Swift-files + bindingset[cls, member] + override predicate isInstanceMember(ClassLikeDeclaration cls, Member member) { + exists(cls) and + not member.hasModifier(["static", "class", "enum_case"]) + } + + override predicate isPrivateToLocalScope(Member member) { + // Private top-level members + member = any(TopLevel top).getBody().getAStmt() and + member.hasModifier(["private", "fileprivate"]) + // + // Note: Private class members can be seen within type-extensions in the same file, + // so we can't declare those private to their local scope. + } +} diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll new file mode 100644 index 000000000000..f662a46b7de3 --- /dev/null +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -0,0 +1,197 @@ +/** + * Provides classes for reasoning about static references to the members of classes and top-levels. + */ + +private import unified +private import codeql.unified.internal.LocalNameBinding +private import codeql.unified.internal.NameBindingPlugin + +private newtype TNameBindingNode = + TIdentifier(Identifier n) or + TLocalName(LocalName local) or + TExportedNamespace(ClassLikeDeclaration cls) + +/** + * A node in a graph, in which name-binding rules are represented as edges between nodes. + */ +class NameBindingNode extends TNameBindingNode { + predicate isIdentifier(Identifier n) { this = TIdentifier(n) } + + Identifier asIdentifier() { this.isIdentifier(result) } + + predicate isLocalName(LocalName local) { this = TLocalName(local) } + + /** Holds if this represents the set of static members available in the given namespace (currently restricted to classes) */ + predicate isExportedNamespace(ClassLikeDeclaration cls) { this = TExportedNamespace(cls) } + + string toString() { + exists(Identifier n | this.isIdentifier(n) and result = "Identifier(" + n + ")") + or + exists(LocalName local | this.isLocalName(local) and result = "LocalName(" + local + ")") + or + exists(ClassLikeDeclaration cls | + this.isExportedNamespace(cls) and result = "ExportedNamespace(" + cls + ")" + ) + } + + Location getLocation() { + exists(Identifier n | this.isIdentifier(n) and result = n.getLocation()) + or + exists(LocalName local | this.isLocalName(local) and result = local.getLocation()) + or + exists(ClassLikeDeclaration cls | this.isExportedNamespace(cls) and result = cls.getLocation()) + } +} + +Identifier getIdentifierFromRef(AstNode n) { + result = n.(NameExpr).getIdentifier() + or + result = n.(NamePattern).getIdentifier() + or + result = n.(MemberAccessExpr).getMember() + or + result = n.(NamedTypeExpr).getName() +} + +NameBindingNode getNodeFromRef(AstNode n) { result.isIdentifier(getIdentifierFromRef(n)) } + +predicate readStep(NameBindingNode node1, string name, NameBindingNode node2) { + exists(MemberAccessExpr expr | + node1 = getNodeFromRef(expr.getBase()) and + name = expr.getMember().getValue() and + node2 = getNodeFromRef(expr) + ) + or + exists(NamedTypeExpr expr | + node1 = getNodeFromRef(expr.getQualifier()) and + name = expr.getName().getValue() and + node2 = getNodeFromRef(expr) + ) +} + +predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { + exists(ClassLikeDeclaration cls, Member member, NameDeclaration nameDecl | + member = cls.getAMember() and + not isInstanceMember(member) and + not isPrivateToLocalScope(member) and + nameDecl.getDeclaration() = member + | + node1.isIdentifier(nameDecl) and + name = nameDecl.getName() and + node2.isExportedNamespace(cls) + ) +} + +predicate valueStep(NameBindingNode node1, NameBindingNode node2) { + exists(PotentialLocalNameAccess access | + access.isDeclarationSite() and + node1.isIdentifier(access) and + node2.isLocalName(access.getLocalName()) + or + node1.isLocalName(access.getLocalName()) and + node2.isIdentifier(access) + ) + or + exists(ClassLikeDeclaration cls | + node1.isExportedNamespace(cls) and + node2.isIdentifier(cls.getName()) + ) +} + +signature module TrackInputSig { + /** Holds if the forward-flow of `node` should be tracked. */ + predicate shouldTrack(NameBindingNode node); +} + +/** Creates a module for tracking flow through the name-binding graph. */ +module Track { + private import Input + + /** Gets a name-binding node to which `node` can flow. */ + NameBindingNode track(NameBindingNode node) { + shouldTrack(node) and + result = node + or + exists(NameBindingNode prev | prev = track(node) | valueStepEx(prev, result)) + } +} + +/** + * Holds if `node1 -> node2` is derived by combining a store and a read step. + */ +pragma[nomagic] +private predicate derivedStoreReadStep(NameBindingNode node1, NameBindingNode node2) { + exists(NamespaceNode namespace, string name | + node1 = namespace.getMember(name) and + readStep(namespace.ref(), name, node2) + ) +} + +/** Holds if there is an effective value step `node1 -> node2`. */ +pragma[inline] +private predicate valueStepEx(NameBindingNode node1, NameBindingNode node2) { + valueStep(node1, node2) + or + derivedStoreReadStep(node1, node2) +} + +/** A name-binding node that has members. */ +class NamespaceNode extends NameBindingNode { + NamespaceNode() { storeStep(_, _, this) } + + /** Gets a name-binding node that may refer to this namespace. */ + NameBindingNode ref() { result = TrackNamespace::track(this) } + + /** Gets a member of this namespace of the given name. */ + NameBindingNode getMember(string name) { storeStep(result, name, this) } +} + +private module TrackNamespaceInput implements TrackInputSig { + predicate shouldTrack(NameBindingNode node) { node instanceof NamespaceNode } +} + +private module TrackNamespace = Track; + +private module TrackNameDeclarationInput implements TrackInputSig { + predicate shouldTrack(NameBindingNode node) { node.isIdentifier(any(NameDeclaration d)) } +} + +private module TrackNameDeclaration = Track; + +/** Gets a name-binding node that may refer to the given declaration. */ +NameBindingNode trackNameDeclaration(NameDeclaration decl) { + exists(NameBindingNode start | + start.isIdentifier(decl) and + result = TrackNameDeclaration::track(start) + ) +} + +/** Holds if `node` should be included in the debug view. */ +private signature predicate relevantFileSig(File node); + +module DebugGraph { + private predicate relevantNode(NameBindingNode node) { + relevantFile(node.getLocation().getFile()) + } + + query predicate nodes(NameBindingNode node, string key, string value) { + relevantNode(node) and + key = "semmle.label" and + value = node.toString() + } + + query predicate edges(NameBindingNode node1, NameBindingNode node2, string key, string value) { + key = "semmle.label" and + ( + valueStep(node1, node2) and value = "" + or + exists(string name | + readStep(node1, name, node2) and + value = "read(" + name + ")" + or + storeStep(node1, name, node2) and + value = "store(" + name + ")" + ) + ) + } +} diff --git a/unified/ql/lib/codeql/unified/internal/dev/debugScopeGraph.ql b/unified/ql/lib/codeql/unified/internal/dev/debugLocalNameBindingGraph.ql similarity index 72% rename from unified/ql/lib/codeql/unified/internal/dev/debugScopeGraph.ql rename to unified/ql/lib/codeql/unified/internal/dev/debugLocalNameBindingGraph.ql index a77f8912d6fa..71887f476f16 100644 --- a/unified/ql/lib/codeql/unified/internal/dev/debugScopeGraph.ql +++ b/unified/ql/lib/codeql/unified/internal/dev/debugLocalNameBindingGraph.ql @@ -1,8 +1,8 @@ /** - * @name Debug scope graph - * @description Renders the graph used to perform local variable lookups + * @name Debug local name-binding graph + * @description Renders the graph used to perform local name lookups * @kind graph - * @id unified/debug-scope-graph + * @id unified/debug-local-name-binding-graph */ private import unified diff --git a/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql b/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql new file mode 100644 index 000000000000..740943fc66db --- /dev/null +++ b/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql @@ -0,0 +1,16 @@ +/** + * @name Debug static name-binding graph + * @description Renders the graph used to perform static name lookups + * @kind graph + * @id unified/debug-static-name-binding-graph + */ + +private import unified +private import codeql.unified.internal.StaticNameBinding + +/** + * Holds if graphs related to `file` should be shown in the graph. + */ +predicate relevantFile(File file) { file.getBaseName() = "test.swift" } + +import DebugGraph diff --git a/unified/ql/test/library-tests/static-name-binding/test.expected b/unified/ql/test/library-tests/static-name-binding/test.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/unified/ql/test/library-tests/static-name-binding/test.ql b/unified/ql/test/library-tests/static-name-binding/test.ql new file mode 100644 index 000000000000..272606e2bd20 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/test.ql @@ -0,0 +1,51 @@ +import unified +import utils.test.InlineExpectationsTest +import utils.test.CommentUtil +import codeql.unified.internal.StaticNameBinding + +module StaticDeclAccess implements TestSig { + string getARelevantTag() { result = "access" } + + private string deriveClassName(ClassLikeDeclaration cls) { + not exists(cls.getParent().getEnclosingClass()) and + result = cls.getName().getValue() + or + result = deriveClassName(cls.getParent().getEnclosingClass()) + "." + cls.getName().getValue() + } + + private string defaultName(NameDeclaration decl) { + exists(ClassLikeDeclaration cls | + decl.getDeclaration() = cls.getAMember() and + result = deriveClassName(cls) + "." + decl.getName() + ) + or + not decl.getDeclaration() = any(ClassLikeDeclaration cls).getAMember() and + result = decl.getName() + } + + additional predicate declAt(NameDeclaration v, string filepath, int line) { + v.getLocation().hasLocationInfo(filepath, line, _, _, _) + } + + private predicate decl(NameDeclaration v, string alias) { + exists(string filepath, int line | declAt(v, filepath, line) | + keyValueCommentAt(filepath, line, "name", alias) + or + not keyValueCommentAt(filepath, line, "name", _) and + alias = defaultName(v) + ) + } + + predicate hasActualResult(Location location, string element, string tag, string value) { + exists(NameDeclaration decl, Identifier access | + access = trackNameDeclaration(decl).asIdentifier() and + not access instanceof NameDeclaration and + location = access.getLocation() and + element = access.toString() and + decl(decl, value) and + tag = "access" + ) + } +} + +import MakeTest diff --git a/unified/ql/test/library-tests/static-name-binding/test.swift b/unified/ql/test/library-tests/static-name-binding/test.swift new file mode 100644 index 000000000000..7698e714291f --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/test.swift @@ -0,0 +1,44 @@ +class B {} // name=top.B + +class A { + class B { + class C {} + } +} + +let x1: B = nil; // $ access=top.B +let x2: A.B = nil; // $ access=A access=A.B +let x3: A.B.C = nil; // $ access=A access=A.B access=A.B.C + +class D { + let x1: B = nil; // $ access=top.B + let x2: A.B = nil; // $ access=A access=A.B + let x3: A.B.C = nil; // $ access=A access=A.B access=A.B.C + + func member() { + let x1: B = nil; // $ access=top.B + let x2: A.B = nil; // $ access=A access=A.B + let x3: A.B.C = nil; // $ access=A access=A.B access=A.B.C + } +} + +class E { + class A { + class B { + } + } + + let x1: A = nil; // $ access=E.A + let x2: A.B = nil; // $ access=E.A access=E.A.B +} + +class F { + static let field = 1 + static let (a,b) = (1,2) + + static func foo() { + F.field // $ access=F access=F.field + F.a // $ access=F access=F.a + F.b // $ access=F access=F.b + } +} From 375924ed293e340815c86d37618a842b1fa32807 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 11:48:48 +0200 Subject: [PATCH 06/41] unified: Track through aliases --- .../unified/internal/StaticNameBinding.qll | 28 +++++++++++++------ .../static-name-binding/test.swift | 9 ++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index f662a46b7de3..3f60b2b28c85 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -101,6 +101,8 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { signature module TrackInputSig { /** Holds if the forward-flow of `node` should be tracked. */ predicate shouldTrack(NameBindingNode node); + + default predicate additionalValueStep(NameBindingNode node1, NameBindingNode node2) { none() } } /** Creates a module for tracking flow through the name-binding graph. */ @@ -114,6 +116,16 @@ module Track { or exists(NameBindingNode prev | prev = track(node) | valueStepEx(prev, result)) } + + /** Holds if there is an effective value step `node1 -> node2`. */ + pragma[inline] + private predicate valueStepEx(NameBindingNode node1, NameBindingNode node2) { + valueStep(node1, node2) + or + derivedStoreReadStep(node1, node2) + or + additionalValueStep(node1, node2) + } } /** @@ -127,14 +139,6 @@ private predicate derivedStoreReadStep(NameBindingNode node1, NameBindingNode no ) } -/** Holds if there is an effective value step `node1 -> node2`. */ -pragma[inline] -private predicate valueStepEx(NameBindingNode node1, NameBindingNode node2) { - valueStep(node1, node2) - or - derivedStoreReadStep(node1, node2) -} - /** A name-binding node that has members. */ class NamespaceNode extends NameBindingNode { NamespaceNode() { storeStep(_, _, this) } @@ -148,6 +152,14 @@ class NamespaceNode extends NameBindingNode { private module TrackNamespaceInput implements TrackInputSig { predicate shouldTrack(NameBindingNode node) { node instanceof NamespaceNode } + + predicate additionalValueStep(NameBindingNode node1, NameBindingNode node2) { + // Namespace-tracking goes through aliases, but declaration-tracking does not + exists(TypeAliasDeclaration decl | + node1 = getNodeFromRef(decl.getType()) and + node2.isIdentifier(decl.getName()) + ) + } } private module TrackNamespace = Track; diff --git a/unified/ql/test/library-tests/static-name-binding/test.swift b/unified/ql/test/library-tests/static-name-binding/test.swift index 7698e714291f..27453ec34351 100644 --- a/unified/ql/test/library-tests/static-name-binding/test.swift +++ b/unified/ql/test/library-tests/static-name-binding/test.swift @@ -42,3 +42,12 @@ class F { F.b // $ access=F access=F.b } } + +typealias G = A // $ access=A + +// Members can be accessed through aliases, but references to the alias itself do not bypass the alias. +class H { + let x1: G = nil; // $ access=G + let x2: G.B = nil; // $ access=G access=A.B + let x3: G.B.C = nil; // $ access=G access=A.B access=A.B.C +} From db8b97a0ddcdd3e514abb457a431797ad0844916 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 12:48:41 +0200 Subject: [PATCH 07/41] unified: Add inheritance steps --- .../unified/internal/StaticNameBinding.qll | 33 +++++++++++++++++-- .../static-name-binding/inheritance.swift | 19 +++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 unified/ql/test/library-tests/static-name-binding/inheritance.swift diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 3f60b2b28c85..ca1f670fb8ff 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -98,6 +98,14 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { ) } +predicate inheritanceStep(NameBindingNode supertype, NameBindingNode subtype) { + exists(ClassLikeDeclaration cls, BaseType base | + base = cls.getABaseType() and + supertype = getNodeFromRef(base.getType()) and + subtype.isExportedNamespace(cls) + ) +} + signature module TrackInputSig { /** Holds if the forward-flow of `node` should be tracked. */ predicate shouldTrack(NameBindingNode node); @@ -141,13 +149,31 @@ private predicate derivedStoreReadStep(NameBindingNode node1, NameBindingNode no /** A name-binding node that has members. */ class NamespaceNode extends NameBindingNode { - NamespaceNode() { storeStep(_, _, this) } + NamespaceNode() { storeStep(_, _, this) or inheritanceStep(_, this) } /** Gets a name-binding node that may refer to this namespace. */ NameBindingNode ref() { result = TrackNamespace::track(this) } + /** Gets an own (non-inherited) member of this namespace of the given name. */ + NameBindingNode getOwnMember(string name) { storeStep(result, name, this) } + + /** Holds if this namespace has an own-member of the given name */ + predicate hasOwnMember(string name) { exists(this.getOwnMember(name)) } + + /** Gets a namespace from which this namespace inherits directly. */ + NamespaceNode getAnInheritanceParent() { inheritanceStep(result.ref(), this) } + + /** Gets a namespace that directly inherits from this one. */ + NamespaceNode getAnInheritanceChild() { result.getAnInheritanceParent() = this } + /** Gets a member of this namespace of the given name. */ - NameBindingNode getMember(string name) { storeStep(result, name, this) } + pragma[nomagic] + NameBindingNode getMember(string name) { + result = this.getOwnMember(name) + or + not this.hasOwnMember(name) and + result = this.getAnInheritanceParent().getMember(name) + } } private module TrackNamespaceInput implements TrackInputSig { @@ -204,6 +230,9 @@ module DebugGraph { storeStep(node1, name, node2) and value = "store(" + name + ")" ) + or + inheritanceStep(node1, node2) and + value = "inheritedBy" ) } } diff --git a/unified/ql/test/library-tests/static-name-binding/inheritance.swift b/unified/ql/test/library-tests/static-name-binding/inheritance.swift new file mode 100644 index 000000000000..fcf1967d4842 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/inheritance.swift @@ -0,0 +1,19 @@ +class A { + class B { + class C {} + } +} + +class D: A {} // $ access=A +class E: D.B {} // $ access=D access=A.B + +// Members of base classes can be accessed through derived classes +func t1() { + let x1: D = nil; // $ access=D + let x2: D.B = nil; // $ access=D access=A.B + let x3: D.B.C = nil; // $ access=D access=A.B access=A.B.C + + // The base class of 'E' is itself resolved through inheritance + let x4: E = nil; // $ access=E + let x5: E.C = nil; // $ access=E access=A.B.C +} From 6341bba0e198901e753c2bbe0deb4b49870012eb Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 13:36:29 +0200 Subject: [PATCH 08/41] unified: Support unqualified access --- .../codeql/namebinding/LocalNameBinding.qll | 29 +++++++++++++++++ .../unified/internal/LocalNameBinding.qll | 9 ++++++ .../unified/internal/StaticNameBinding.qll | 31 ++++++++++++++++++- .../unqualified-access.swift | 20 ++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 unified/ql/test/library-tests/static-name-binding/unqualified-access.swift diff --git a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll index 57c792027ba8..c4d4abdead45 100644 --- a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll +++ b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll @@ -136,6 +136,12 @@ signature module LocalNameBindingInputSig { * full control of scope resolution for specific types of references. */ default predicate lookupStartsAt(AstNode n, AstNode scope) { none() } + + /** + * Holds if the set of names available in `scope` is not known ahead of time, + * and thus any lookup chain that goes through `scope` may need to be reconciled at a later stage. + */ + default predicate uncertainScope(AstNode scope) { none() } } /** @@ -154,6 +160,8 @@ module LocalNameBinding implicitDeclInScope(_, this) or isTopScope(this) + or + uncertainScope(this) } } @@ -353,6 +361,27 @@ module LocalNameBinding ) } + /** + * Holds if `name`, when resolved from `lookup`, may resolve to one of the uncertain members of `scope`. + */ + pragma[nomagic] + private predicate lookupInUncertainScope(string name, Scope lookup, Scope scope) { + lookupInScope(name, lookup, scope) and + uncertainScope(scope) and + not declInScope(_, name, scope) and + not implicitDeclInScope(name, scope) + } + + /** + * Gets an uncertain scope that the given `accessCand` pair may resolve to. + */ + AstNode getAnUncertainScope(AstNode access, string name) { + exists(Scope lookup | + accessCandInLookupScope(access, name, lookup) and + lookupInUncertainScope(name, lookup, result) + ) + } + cached private newtype TLocal = TExplicitLocal(AstNode definingNode, string name, AstNode scope) { diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index 59f94b433ba7..97268e05af3f 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -314,6 +314,15 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig Date: Thu, 6 Aug 2026 15:37:52 +0200 Subject: [PATCH 09/41] unified: Allow numbers in key=value comments --- unified/ql/lib/utils/test/CommentUtil.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/lib/utils/test/CommentUtil.qll b/unified/ql/lib/utils/test/CommentUtil.qll index 6747aed57187..bd6f887a4010 100644 --- a/unified/ql/lib/utils/test/CommentUtil.qll +++ b/unified/ql/lib/utils/test/CommentUtil.qll @@ -12,7 +12,7 @@ predicate plainCommentAt(string filepath, int line, string text) { predicate keyValueCommentAt(string filepath, int line, string key, string value) { exists(string text, string regexp, string match | plainCommentAt(filepath, line, text) and - regexp = "(\\w+)=([\\w.]+)" and + regexp = "(\\w+)=([\\w.0-9]+)" and match = text.regexpFind(regexp, _, _) and key = match.regexpCapture(regexp, 1) and value = match.regexpCapture(regexp, 2) From 7e3a4e36a31e0faae3e3d5715fd2b80be2790e7d Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 6 Aug 2026 15:41:46 +0200 Subject: [PATCH 10/41] unified: Cross-file name binding --- unified/ql/lib/codeql/files/FileSystem.qll | 2 + .../lib/codeql/unified/internal/FacadeAst.qll | 27 +++++++ .../unified/internal/LocalNameBinding.qll | 4 + .../unified/internal/NameBindingPlugin.qll | 41 ++++++++++ .../internal/NameBindingPluginSwift.qll | 76 +++++++++++++++++++ .../unified/internal/StaticNameBinding.qll | 62 ++++++++++++++- .../package1/Package.swift | 11 +++ .../package1/Sources/Target1/File1.swift | 2 + .../package1/Sources/Target1/File2.swift | 1 + .../package1/Sources/Target2/File3.swift | 1 + 10 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Package.swift create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift diff --git a/unified/ql/lib/codeql/files/FileSystem.qll b/unified/ql/lib/codeql/files/FileSystem.qll index 6cc771fad9d2..a50c1cd3432a 100644 --- a/unified/ql/lib/codeql/files/FileSystem.qll +++ b/unified/ql/lib/codeql/files/FileSystem.qll @@ -31,6 +31,8 @@ class Container = Impl::Container; class Folder = Impl::Folder; +module Folder = Impl::Folder; + /** A file. */ class File extends Container, Impl::File { /** Holds if this file was extracted from ordinary source code. */ diff --git a/unified/ql/lib/codeql/unified/internal/FacadeAst.qll b/unified/ql/lib/codeql/unified/internal/FacadeAst.qll index a5d057135d87..cb00bae7eeca 100644 --- a/unified/ql/lib/codeql/unified/internal/FacadeAst.qll +++ b/unified/ql/lib/codeql/unified/internal/FacadeAst.qll @@ -4,12 +4,17 @@ overlay[local?] module; +private import codeql.files.FileSystem + module Unified { private import Ast::Unified as G import G /** The base class for all AST nodes. */ class AstNode extends G::AstNode { + /** Gets the file containing this AST node. */ + File getFile() { result = this.getLocation().getFile() } + /** Holds if this AST node has a modifier with the given text. */ predicate hasModifier(string text) { exists(Modifier mod | @@ -27,6 +32,28 @@ module Unified { } } + /** An expression */ + class Expr extends G::Expr { + /** Gets the string value of this expression, if it is a known string constant. */ + string getStringValue() { + // TODO: we'll want to cook the string literals extractor-side, but for now + // just strip the quotes here and ignore escape sequences. + result = this.(StringLiteral).getValue().regexpCapture("\"(.*)\"", 1) + } + } + + /** A function call */ + class CallExpr extends G::CallExpr { + /** Gets the named argument with the given `name`. */ + Expr getNamedArgument(string name) { + exists(Argument arg | + arg = this.getAnArgument() and + arg.getName().getValue() = name and + result = arg.getValue() + ) + } + } + /** The base class for all patterns. */ class Pattern extends G::Pattern { /** Gets the immediately-enclosing pattern in which this is a nested pattern. */ diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index 97268e05af3f..faa2be5fe43e 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -322,6 +322,10 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig; diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll index 98d30c5d779f..b2bba1763ed7 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll @@ -22,3 +22,79 @@ class NameBindingPluginSwift extends NameBindingPlugin { // so we can't declare those private to their local scope. } } + +private predicate predefinedSourceFolders(string folder, int ordering) { + folder = "Sources,Source,src,srcs".splitAt(",", ordering) +} + +bindingset[targetKind] +private predicate predefinedSourceFoldersByTarget(string targetKind, string folder, int ordering) { + predefinedSourceFolders(folder, ordering) + or + ordering = -1 and + ( + targetKind = "testTarget" and + folder = "Tests" + or + targetKind = "plugin" and + folder = "Plugins" + ) +} + +/** + * A call to `.target()` or similar target spec, in a `Package.swift` file. + */ +class SwiftPackageTarget extends ModuleScopeRepr, CallExpr { + private string targetKind; + + SwiftPackageTarget() { + this.getFile().getBaseName() = "Package.swift" and + this.getCallee().(MemberAccessExpr).getMember().getValue() = targetKind and + targetKind = + [ + "target", "executableTarget", "testTarget", "systemLibrary", "binaryTarget", "plugin", + "macro" + ] + } + + Folder getFolder() { result = this.getFile().getParentContainer() } + + /** Gets the intermediate folder such as `Sources/` containing the sources, but without the target name. */ + Folder getSourceMidFolder() { + result = + min(int i, string name, Folder subfolder | + predefinedSourceFoldersByTarget(targetKind, name, i) and + subfolder = this.getFolder().getFolder(name) + | + subfolder order by i + ) + } + + /** Gets the source folder to use if no explicit `path:` if given, typically `Sources/` */ + Folder getDefaultSourceFolder() { + exists(Folder subfolder | subfolder = this.getSourceMidFolder() | + result = subfolder.getFolder(this.getName()) + or + not exists(subfolder.getFolder(this.getName())) and + result = subfolder + ) + } + + string getName() { result = this.getNamedArgument("name").getStringValue() } + + string getExplicitPath() { result = this.getNamedArgument("path").getStringValue() } + + override predicate shouldInclude(Container c, string path) { + c = this.getFolder() and + path = this.getExplicitPath() + "/**/*.swift" + or + not exists(this.getExplicitPath()) and + c = this.getDefaultSourceFolder() and + path = "**/*.swift" + } + + override predicate hasImportableName(string name) { + targetKind = "target" and + name = this.getName() + } +} diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index f976520a7258..02ce277bc895 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -10,7 +10,13 @@ private newtype TNameBindingNode = TIdentifier(Identifier n) or TLocalName(LocalName local) or TExportedNamespace(ClassLikeDeclaration cls) or - TLocalNamespace(ClassLikeDeclaration cls) + TLocalNamespace(AstNode n) { + n = any(TopLevel t) or // Module names come in scope here + n = any(TopLevel t).getBody() or // Imported names come in scope here (shadowing module names) + n instanceof ClassLikeDeclaration + } or + TModuleScope(ModuleScopeRepr repr) or + TModuleRoot() /** * A node in a graph, in which name-binding rules are represented as edges between nodes. @@ -25,8 +31,14 @@ class NameBindingNode extends TNameBindingNode { /** Holds if this represents the set of static members available in the given namespace (currently restricted to classes) */ predicate isExportedNamespace(ClassLikeDeclaration cls) { this = TExportedNamespace(cls) } - /** Holds if this represents the set of members that can be accessed unqualified within the given scope (currently restricted to classes) */ - predicate isLocalNamespace(ClassLikeDeclaration cls) { this = TLocalNamespace(cls) } + /** Holds if this represents the set of members that can be accessed unqualified within the given scope. */ + predicate isLocalNamespace(AstNode n) { this = TLocalNamespace(n) } + + /** Holds if this represents the given module scope. */ + predicate isModuleScopeNode(ModuleScopeRepr repr) { this = TModuleScope(repr) } + + /** Holds if this represents the root namespace in which all named modules are members. */ + predicate isModuleRoot() { this = TModuleRoot() } string toString() { exists(Identifier n | this.isIdentifier(n) and result = "Identifier(" + n + ")") @@ -40,6 +52,12 @@ class NameBindingNode extends TNameBindingNode { exists(ClassLikeDeclaration cls | this.isLocalNamespace(cls) and result = "LocalNamespace(" + cls + ")" ) + or + exists(ModuleScopeRepr repr | + this.isModuleScopeNode(repr) and result = "ModuleScope(" + repr + ")" + ) + or + this.isModuleRoot() and result = "ModuleRoot" } Location getLocation() { @@ -50,6 +68,11 @@ class NameBindingNode extends TNameBindingNode { exists(ClassLikeDeclaration cls | this.isExportedNamespace(cls) and result = cls.getLocation()) or exists(ClassLikeDeclaration cls | this.isLocalNamespace(cls) and result = cls.getLocation()) + or + exists(ModuleScopeRepr repr | this.isModuleScopeNode(repr) and result = repr.getLocation()) + or + this.isModuleRoot() and + exists(ModuleScopeRepr repr | result = repr.getLocation()) } } @@ -65,12 +88,21 @@ Identifier getIdentifierFromRef(AstNode n) { NameBindingNode getNodeFromRef(AstNode n) { result.isIdentifier(getIdentifierFromRef(n)) } +NameBindingNode getModuleNodeFromFile(File f) { + exists(ModuleScopeRepr mod | + mod.getAnIncludedFile() = f and + result.isModuleScopeNode(mod) + ) +} + /** Gets the name-binding node associated with the given uncertain scope node. */ private NameBindingNode getNodeFromUncertainScope(AstNode n) { exists(ClassLikeDeclaration cls | n = cls.getAMember() and // note: must align with LocalNameBindingInput::uncertainScope result.isLocalNamespace(cls) ) + or + result.isLocalNamespace(n) } predicate readStep(NameBindingNode node1, string name, NameBindingNode node2) { @@ -104,6 +136,22 @@ predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { name = nameDecl.getName() and node2.isExportedNamespace(cls) ) + or + exists(TopLevel top, Stmt stmt, NameDeclaration nameDecl | + stmt = top.getBody().getAStmt() and + not isPrivateToLocalScope(stmt) and + nameDecl.getDeclaration() = stmt + | + node1.isIdentifier(nameDecl) and + name = nameDecl.getName() and + node2 = getModuleNodeFromFile(top.getFile()) + ) + or + exists(ModuleScopeRepr mod | + node1.isModuleScopeNode(mod) and + mod.hasImportableName(name) and + node2.isModuleRoot() + ) } predicate valueStep(NameBindingNode node1, NameBindingNode node2) { @@ -125,6 +173,14 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { node1.isExportedNamespace(cls) and node2.isLocalNamespace(cls) ) + or + exists(TopLevel top | + node1.isModuleRoot() and + node2.isLocalNamespace(top) // module names in outermost scope + or + node1 = getModuleNodeFromFile(top.getFile()) and + node2.isLocalNamespace(top.getBody()) // implicitly import own module + ) } predicate inheritanceStep(NameBindingNode supertype, NameBindingNode subtype) { diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Package.swift b/unified/ql/test/library-tests/static-name-binding/package1/Package.swift new file mode 100644 index 000000000000..0789887c4b4c --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Package1", + targets: [ + .target(name: "Target1"), + .target(name: "Target2"), + ] +) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift new file mode 100644 index 000000000000..fff0a56e90f3 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift @@ -0,0 +1,2 @@ +let x: A; // $ access=Target1.A +let y: Target2.A; // $ access=Target2.A diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift new file mode 100644 index 000000000000..fa515af0eaf5 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift @@ -0,0 +1 @@ +class A {} // name=Target1.A diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift new file mode 100644 index 000000000000..47c3f4da77ed --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift @@ -0,0 +1 @@ +class A {} // name=Target2.A From 574a5c95059dafcf6227fe86d3140054a8b9802d Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 08:38:58 +0200 Subject: [PATCH 11/41] unified: Handle bracketed generic array constructors --- .../extractor/src/languages/swift/swift.rs | 16 ++++ .../expressions/array-type-constructor.output | 74 +++++++++++++++++++ .../expressions/array-type-constructor.swift | 1 + 3 files changed, 91 insertions(+) create mode 100644 unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output create mode 100644 unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 77314ceb678c..b192669ae9a2 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -633,6 +633,22 @@ fn translation_rules() -> Vec> { default: {val})) } ), + // Swift's `[T](...)` array-type constructor syntax is parsed as a call + // whose callee is an `arrayExpr` containing `T`. For a generic `T`, + // translating that callee as an array literal would place a type + // expression in an expression-only element field. Normalize it to an + // `Array` generic type constructor instead. + rule!( + (functionCallExpr + calledExpression: (arrayExpr elements: (arrayElement expression: (genericSpecializationExpr) @element)) + arguments: _* @args) + => + (call_expr + callee: (generic_type_expr + base: (named_type_expr name: (identifier "Array")) + type_argument: {element}) + argument: {args}) + ), // A function/method call (`foo(1, 2)`). `calledExpression` is the callee // and `arguments` is an (elided) list of `labeledExpr`, each translated // to an `argument` below. A trailing closure (`xs.map { … }`) becomes a diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output new file mode 100644 index 000000000000..e721ba9cfde2 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output @@ -0,0 +1,74 @@ +let values = [Result]() + +--- + +sourceFile + endOfFileToken: endOfFile + statements: + codeBlockItem + item: + variableDecl + attributes: + modifiers: + bindingSpecifier: let + bindings: + patternBinding + initializer: + initializerClause + equal: = + value: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + additionalTrailingClosures: + calledExpression: + arrayExpr + elements: + arrayElement + expression: + genericSpecializationExpr + expression: + declReferenceExpr + baseName: identifier "Result" + genericArgumentClause: + genericArgumentClause + arguments: + genericArgument + argument: + identifierType + name: identifier "Void" + leftAngle: < + rightAngle: > + leftSquare: [ + rightSquare: ] + pattern: + identifierPattern + identifier: identifier "values" + +--- + +top_level + body: + block + stmt: + variable_declaration + modifier: modifier "let" + pattern: + name_pattern + identifier: identifier "values" + value: + call_expr + callee: + generic_type_expr + base: + named_type_expr + name: identifier "Array" + type_argument: + generic_type_expr + base: + named_type_expr + name: identifier "Result" + type_argument: + named_type_expr + name: identifier "Void" diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift new file mode 100644 index 000000000000..b3cccb6ae2a1 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift @@ -0,0 +1 @@ +let values = [Result]() \ No newline at end of file From 55b96f1f7bad28bdc8a806d04e8378d3ad68ef7e Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 08:44:40 +0200 Subject: [PATCH 12/41] unified: Handle bracketed generic array metatypes --- .../extractor/src/languages/swift/swift.rs | 14 ++++ .../expressions/array-type-metatype.output | 75 +++++++++++++++++++ .../expressions/array-type-metatype.swift | 1 + 3 files changed, 90 insertions(+) create mode 100644 unified/extractor/tests/corpus/swift/expressions/array-type-metatype.output create mode 100644 unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index b192669ae9a2..a52ec352cca2 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -699,6 +699,20 @@ fn translation_rules() -> Vec> { // `declReferenceExpr`; pull its `baseName` out as the member identifier. // A leading-dot access (`.foo`) has no explicit base — the base is an // `inferred_type_expr`. The base-ful form is matched first. + // A bracketed generic array type used as a metatype or static-member + // base (`[T].self`) is parsed as an `arrayExpr`; preserve its type + // meaning as `Array` rather than an array literal. + rule!( + (memberAccessExpr + base: (arrayExpr elements: (arrayElement expression: (genericSpecializationExpr) @element)) + declName: (declReferenceExpr baseName: @member)) + => + (member_access_expr + base: (generic_type_expr + base: (named_type_expr name: (identifier "Array")) + type_argument: {element}) + member: (identifier #{member})) + ), rule!( (memberAccessExpr base: @base declName: (declReferenceExpr baseName: @member)) => diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.output b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.output new file mode 100644 index 000000000000..f06917190b3a --- /dev/null +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.output @@ -0,0 +1,75 @@ +let type = [Result].self + +--- + +sourceFile + endOfFileToken: endOfFile + statements: + codeBlockItem + item: + variableDecl + attributes: + modifiers: + bindingSpecifier: let + bindings: + patternBinding + initializer: + initializerClause + equal: = + value: + memberAccessExpr + period: . + declName: + declReferenceExpr + baseName: self + base: + arrayExpr + elements: + arrayElement + expression: + genericSpecializationExpr + expression: + declReferenceExpr + baseName: identifier "Result" + genericArgumentClause: + genericArgumentClause + arguments: + genericArgument + argument: + identifierType + name: identifier "Void" + leftAngle: < + rightAngle: > + leftSquare: [ + rightSquare: ] + pattern: + identifierPattern + identifier: identifier "type" + +--- + +top_level + body: + block + stmt: + variable_declaration + modifier: modifier "let" + pattern: + name_pattern + identifier: identifier "type" + value: + member_access_expr + base: + generic_type_expr + base: + named_type_expr + name: identifier "Array" + type_argument: + generic_type_expr + base: + named_type_expr + name: identifier "Result" + type_argument: + named_type_expr + name: identifier "Void" + member: identifier "self" diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift new file mode 100644 index 000000000000..d0ee51283459 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift @@ -0,0 +1 @@ +let type = [Result].self \ No newline at end of file From 171aa7a623660f63214181a073673896e82ec8ce Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 08:56:30 +0200 Subject: [PATCH 13/41] unified: Use '.' as location for inferred_type_expr --- unified/extractor/src/languages/swift/swift.rs | 4 ++-- .../corpus/swift/functions/leading-dot-expression-call.output | 2 +- .../swift/functions/leading-dot-expression-value.output | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index a52ec352cca2..065e91ad3be4 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -719,9 +719,9 @@ fn translation_rules() -> Vec> { (member_access_expr base: {base} member: (identifier #{member})) ), rule!( - (memberAccessExpr declName: (declReferenceExpr baseName: @member)) + (memberAccessExpr period: @dot declName: (declReferenceExpr baseName: @member)) => - (member_access_expr base: (inferred_type_expr) member: (identifier #{member})) + (member_access_expr base: (inferred_type_expr #{dot}) member: (identifier #{member})) ), // Control transfer, one rule per keyword. `return` carries an optional // value; `break` / `continue` an optional target label; `throw` its diff --git a/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-call.output b/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-call.output index d0a844d02afa..a5b38e1e2bea 100644 --- a/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-call.output +++ b/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-call.output @@ -51,7 +51,7 @@ top_level call_expr callee: member_access_expr - base: inferred_type_expr ".some" + base: inferred_type_expr "." member: identifier "some" argument: argument diff --git a/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-value.output b/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-value.output index bec014593d91..41128b61818e 100644 --- a/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-value.output +++ b/unified/extractor/tests/corpus/swift/functions/leading-dot-expression-value.output @@ -39,5 +39,5 @@ top_level identifier: identifier "x" value: member_access_expr - base: inferred_type_expr ".foo" + base: inferred_type_expr "." member: identifier "foo" From a9792e5be85e83c20ad60840beb3ea5396086393 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 08:35:30 +0200 Subject: [PATCH 14/41] unified: Fix handling of exprPattern --- .../extractor/src/languages/swift/swift.rs | 144 +++++++++------- .../nested-enum-case-pattern.output | 160 ++++++++++++++++++ .../nested-enum-case-pattern.swift | 6 + .../optional-enum-case-binding.output | 117 +++++++++++++ .../optional-enum-case-binding.swift | 3 + 5 files changed, 368 insertions(+), 62 deletions(-) create mode 100644 unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output create mode 100644 unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift create mode 100644 unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.output create mode 100644 unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 065e91ad3be4..556345c18d21 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -158,12 +158,30 @@ fn translation_rules() -> Vec> { // swift-syntax does not distinguish the lexical integer/string forms // (hex/binary/octal, single- vs multi-line, raw): each is a single // `*LiteralExpr` kind, so one rule per literal type suffices. - rule!((integerLiteralExpr) @@node => (int_literal #{node})), - rule!((floatLiteralExpr) @@node => (float_literal #{node})), - rule!((booleanLiteralExpr) @@node => (boolean_literal #{node})), - rule!((nilLiteralExpr) @@node => (builtin_expr #{node})), - rule!((stringLiteralExpr) @@node => (string_literal #{node})), - rule!((regexLiteralExpr) @@node => (regex_literal #{node})), + rule!((integerLiteralExpr) @@node => expr { + let value = tree!((int_literal #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), + rule!((floatLiteralExpr) @@node => expr { + let value = tree!((float_literal #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), + rule!((booleanLiteralExpr) @@node => expr { + let value = tree!((boolean_literal #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), + rule!((nilLiteralExpr) @@node => expr { + let value = tree!((builtin_expr #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), + rule!((stringLiteralExpr) @@node => expr { + let value = tree!((string_literal #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), + rule!((regexLiteralExpr) @@node => expr { + let value = tree!((regex_literal #{node})); + if ctx.in_pattern { tree!((expr_equality_pattern expr: {value})) } else { value } + }), // ---- Names ---- // A function reference spelled with argument labels (`f(x:y:z:)`) is a // `declReferenceExpr` carrying `argumentNames`. Mark it unsupported for @@ -176,6 +194,14 @@ fn translation_rules() -> Vec> { => (unsupported_node) ), + rule!((declReferenceExpr baseName: (identifier) @name) => expr { + let name = tree!((name_expr identifier: (identifier #{name}))); + if ctx.in_pattern { + tree!((expr_equality_pattern expr: {name})) + } else { + name + } + }), // A bare name reference (`x`), and an operator used as a value (`+` in // `reduce(0, +)`), are both `declReferenceExpr`; its `baseName` is the // referenced identifier / operator symbol. @@ -507,29 +533,6 @@ fn translation_rules() -> Vec> { // introduces a new binding; it unwraps to its inner pattern (a // `name_pattern`). rule!((valueBindingPattern pattern: @p) => pattern { p }), - // An enum-case pattern with associated values (`case .foo(let x)`, - // `case Color.foo(let x)`) is an expression pattern wrapping a call of a - // member access. It becomes a `constructor_pattern`; its arguments are - // translated as pattern elements (see the `labeledExpr` rules, gated by - // `ctx.in_pattern`). Matched before the generic `expressionPattern` rule. - // The base is optional: a leading-dot form (`.foo`) has none, so the - // constructor's base is an `inferred_type_expr`. - rule!( - (expressionPattern expression: (functionCallExpr - calledExpression: (memberAccessExpr base: _? @base period: @dot declName: (declReferenceExpr baseName: @name)) - arguments: _* @@args)) - => - constructor_pattern { - ctx.in_pattern = true; - let elements = ctx.translate(args)?; - let base = base.unwrap_or_else(|| tree!((inferred_type_expr #{dot}))); - tree!((constructor_pattern - constructor: (member_access_expr - base: {base} - member: (identifier #{name})) - element: {elements})) - } - ), // A tuple destructuring pattern (`let (a, b) = …`). A labelled element // (`let (x: a) = …`) carries its label through as the `pattern_element` // key; unlabelled elements have no key. @@ -544,36 +547,16 @@ fn translation_rules() -> Vec> { // handling in the future. (Redundant with the catch-all fallback, but // kept as a signpost.) rule!((isTypePattern) => (unsupported_node)), - // A standalone wildcard pattern (`case _:`, `if case _`): swift-syntax - // models the bare `_` as an `expressionPattern` wrapping a - // `discardAssignmentExpr`. Matched before the generic `expressionPattern` - // rule so `_` becomes an `ignore_pattern` rather than an equality match. - // (Wildcards *inside* an enum-case argument list are handled by the - // `labeledExpr`/`discardAssignmentExpr` rules.) - rule!((expressionPattern expression: (discardAssignmentExpr)) => (ignore_pattern)), // A wildcard *binding* pattern (`let _ = x`, `for _ in xs`). swift-syntax - // models this as a `wildcardPattern` — distinct from the `_` *match* - // pattern above, which is an `expressionPattern` over a - // `discardAssignmentExpr`. + // models this as a `wildcardPattern`, distinct from the `_` match form + // handled by the context-aware `discardAssignmentExpr` rule. rule!((wildcardPattern) => (ignore_pattern)), - // A tuple pattern in a match position (`case (let a, 3):`) is parsed by - // swift-syntax as an `expressionPattern` wrapping a `tupleExpr` — unlike a - // binding tuple (`let (a, b)`), which is a real `tuplePattern`. Recognise - // it as a `tuple_pattern`; its `labeledExpr` elements translate to - // `pattern_element`s under `ctx.in_pattern` (a binding element becomes a - // `name_pattern`, any other expression an `expr_equality_pattern`). - rule!( - (expressionPattern expression: (tupleExpr elements: _* @@els)) - => - tuple_pattern { - ctx.in_pattern = true; - let elements = ctx.translate(els)?; - tree!((tuple_pattern element: {elements})) - } - ), - // A bare expression pattern (`case 1:`, `case someConstant:`) matches by - // equality. - rule!((expressionPattern expression: @e) => (expr_equality_pattern expr: {e})), + // An expression pattern only establishes pattern context; its child + // determines the concrete pattern shape. + rule!((expressionPattern expression: @@e) => expr { + ctx.in_pattern = true; + ctx.translate(e)?.into_iter().next().ok_or("expression pattern has no child")? + }), // ---- Functions ---- // A function declaration (parameters/return type/body optional). The // parameters and return type nest under `signature`; the body is a @@ -661,7 +644,13 @@ fn translation_rules() -> Vec> { rule!( (functionCallExpr calledExpression: @callee arguments: _* @args) => - (call_expr callee: {callee} argument: {args}) + expr { + if ctx.in_pattern { + tree!((constructor_pattern constructor: {callee} element: {args})) + } else { + tree!((call_expr callee: {callee} argument: {args})) + } + } ), // A call argument or an enum-case pattern argument. When translating an // enum-case `constructor_pattern`'s arguments (`ctx.in_pattern`), a @@ -672,6 +661,27 @@ fn translation_rules() -> Vec> { // Otherwise the argument keeps its label as the `name` and its value. // The pattern-only shapes (`patternExpr`, `discardAssignmentExpr`) are // matched first; they never occur as ordinary call arguments. + rule!( + (labeledExpr + label: _? @@lbl + expression: (functionCallExpr + calledExpression: @constructor + arguments: _* @elements)) + => + argument { + if ctx.in_pattern { + tree!((pattern_element + key: (identifier #{lbl})? + pattern: (constructor_pattern + constructor: {constructor} + element: {elements}))) + } else { + tree!((argument + name: (identifier #{lbl})? + value: (call_expr callee: {constructor} argument: {elements}))) + } + } + ), rule!( (labeledExpr label: _? @@lbl expression: (patternExpr pattern: @p)) => @@ -689,7 +699,7 @@ fn translation_rules() -> Vec> { if ctx.in_pattern { tree!((pattern_element key: (identifier #{lbl})? - pattern: (expr_equality_pattern expr: {val}))) + pattern: {val})) } else { tree!((argument name: (identifier #{lbl})? value: {val})) } @@ -933,7 +943,18 @@ fn translation_rules() -> Vec> { ), // ---- Optionals and errors ---- // Optional chaining — unwrap the marker - rule!((optionalChainingExpr expression: @inner) => expr { inner }), + rule!((optionalChainingExpr expression: @@inner) => expr { + let inner = ctx.translate(inner)?.into_iter().next().ok_or("optional chaining expression has no child")?; + if ctx.in_pattern { + tree!((constructor_pattern + constructor: (member_access_expr + base: (named_type_expr name: (identifier "Optional")) + member: (identifier "some")) + element: (pattern_element pattern: {inner}))) + } else { + inner + } + }), // try/try?/try! expr → unary_expr with operator "try", "try?" or "try!" rule!( (tryExpr questionOrExclamationMark: _? @@m expression: @e) @@ -1023,8 +1044,7 @@ fn translation_rules() -> Vec> { // becomes a `modifier`; its source text is the modifier spelling. rule!((attribute) @m => (modifier #{m})), rule!((declModifier) @m => (modifier #{m})), - // A `super` expression. (`self` needs no rule: swift-syntax models it as - // an ordinary `declReferenceExpr`, already mapped to a `name_expr`.) + // A `super` expression. rule!((superExpr) => (super_expr)), // Type expressions. A generic type applied with explicit arguments // (`Set`) becomes a `generic_type_expr` whose `base` is the type diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output new file mode 100644 index 000000000000..22d9b86bf996 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output @@ -0,0 +1,160 @@ +switch event { +case let .received(.some(value), timestamp): + print(value, timestamp) +default: + break +} + +--- + +sourceFile + endOfFileToken: endOfFile + statements: + codeBlockItem + item: + expressionStmt + expression: + switchExpr + leftBrace: { + rightBrace: } + cases: + switchCase + label: + switchCaseLabel + colon: : + caseKeyword: case + caseItems: + switchCaseItem + pattern: + valueBindingPattern + pattern: + expressionPattern + expression: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + patternExpr + pattern: + identifierPattern + identifier: identifier "value" + additionalTrailingClosures: + calledExpression: + memberAccessExpr + period: . + declName: + declReferenceExpr + baseName: identifier "some" + trailingComma: , + labeledExpr + expression: + patternExpr + pattern: + identifierPattern + identifier: identifier "timestamp" + additionalTrailingClosures: + calledExpression: + memberAccessExpr + period: . + declName: + declReferenceExpr + baseName: identifier "received" + bindingSpecifier: let + statements: + codeBlockItem + item: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + declReferenceExpr + baseName: identifier "value" + trailingComma: , + labeledExpr + expression: + declReferenceExpr + baseName: identifier "timestamp" + additionalTrailingClosures: + calledExpression: + declReferenceExpr + baseName: identifier "print" + switchCase + label: + switchDefaultLabel + colon: : + defaultKeyword: default + statements: + codeBlockItem + item: + breakStmt + breakKeyword: break + subject: + declReferenceExpr + baseName: identifier "event" + switchKeyword: switch + +--- + +top_level + body: + block + stmt: + switch_expr + value: + name_expr + identifier: identifier "event" + case: + switch_case + pattern: + constructor_pattern + constructor: + member_access_expr + base: inferred_type_expr "." + member: identifier "received" + element: + pattern_element + pattern: + constructor_pattern + constructor: + member_access_expr + base: inferred_type_expr "." + member: identifier "some" + element: + pattern_element + pattern: + name_pattern + identifier: identifier "value" + pattern_element + pattern: + name_pattern + identifier: identifier "timestamp" + body: + block + stmt: + call_expr + callee: + name_expr + identifier: identifier "print" + argument: + argument + value: + name_expr + identifier: identifier "value" + argument + value: + name_expr + identifier: identifier "timestamp" + switch_case + body: + block + stmt: break_expr "break" diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift new file mode 100644 index 000000000000..c603e2a3b12c --- /dev/null +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift @@ -0,0 +1,6 @@ +switch event { +case let .received(.some(value), timestamp): + print(value, timestamp) +default: + break +} \ No newline at end of file diff --git a/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.output b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.output new file mode 100644 index 000000000000..7b1eb1eea3b4 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.output @@ -0,0 +1,117 @@ +if case .some(let value)? = input { + print(value) +} + +--- + +sourceFile + endOfFileToken: endOfFile + statements: + codeBlockItem + item: + expressionStmt + expression: + ifExpr + body: + codeBlock + leftBrace: { + rightBrace: } + statements: + codeBlockItem + item: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + declReferenceExpr + baseName: identifier "value" + additionalTrailingClosures: + calledExpression: + declReferenceExpr + baseName: identifier "print" + conditions: + conditionElement + condition: + matchingPatternCondition + initializer: + initializerClause + equal: = + value: + declReferenceExpr + baseName: identifier "input" + pattern: + expressionPattern + expression: + optionalChainingExpr + expression: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + patternExpr + pattern: + valueBindingPattern + pattern: + identifierPattern + identifier: identifier "value" + bindingSpecifier: let + additionalTrailingClosures: + calledExpression: + memberAccessExpr + period: . + declName: + declReferenceExpr + baseName: identifier "some" + questionMark: ? + caseKeyword: case + ifKeyword: if + +--- + +top_level + body: + block + stmt: + if_expr + condition: + pattern_guard_expr + pattern: + constructor_pattern + constructor: + member_access_expr + base: + named_type_expr + name: identifier "Optional" + member: identifier "some" + element: + pattern_element + pattern: + constructor_pattern + constructor: + member_access_expr + base: inferred_type_expr "." + member: identifier "some" + element: + pattern_element + pattern: + name_pattern + identifier: identifier "value" + value: + name_expr + identifier: identifier "input" + then: + block + stmt: + call_expr + callee: + name_expr + identifier: identifier "print" + argument: + argument + value: + name_expr + identifier: identifier "value" diff --git a/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift new file mode 100644 index 000000000000..ef9eac3661c9 --- /dev/null +++ b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift @@ -0,0 +1,3 @@ +if case .some(let value)? = input { + print(value) +} \ No newline at end of file From 8080bac9cf5f681ca9efeacce6498794b1134521 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 08:59:44 +0200 Subject: [PATCH 15/41] unified: Add newlines at EOF --- .../corpus/swift/control-flow/nested-enum-case-pattern.swift | 2 +- .../tests/corpus/swift/expressions/array-type-constructor.swift | 2 +- .../tests/corpus/swift/expressions/array-type-metatype.swift | 2 +- .../swift/optionals-and-errors/optional-enum-case-binding.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift index c603e2a3b12c..876d9995aee6 100644 --- a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift @@ -3,4 +3,4 @@ case let .received(.some(value), timestamp): print(value, timestamp) default: break -} \ No newline at end of file +} diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift index b3cccb6ae2a1..49191aee851d 100644 --- a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift @@ -1 +1 @@ -let values = [Result]() \ No newline at end of file +let values = [Result]() diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift index d0ee51283459..85d770be76cb 100644 --- a/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-metatype.swift @@ -1 +1 @@ -let type = [Result].self \ No newline at end of file +let type = [Result].self diff --git a/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift index ef9eac3661c9..c0e58852bef1 100644 --- a/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift +++ b/unified/extractor/tests/corpus/swift/optionals-and-errors/optional-enum-case-binding.swift @@ -1,3 +1,3 @@ if case .some(let value)? = input { print(value) -} \ No newline at end of file +} From 0cf2871385d5bb8140ded73738500cd50f680bc4 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 09:42:19 +0200 Subject: [PATCH 16/41] unified: Update comment The restriction to classes was more permanent that anticipated, since top-level scopes instead target a TModuleScope --- unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 02ce277bc895..8f7b7ae7404e 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -28,7 +28,7 @@ class NameBindingNode extends TNameBindingNode { predicate isLocalName(LocalName local) { this = TLocalName(local) } - /** Holds if this represents the set of static members available in the given namespace (currently restricted to classes) */ + /** Holds if this represents the set of static members available in the given namespace. */ predicate isExportedNamespace(ClassLikeDeclaration cls) { this = TExportedNamespace(cls) } /** Holds if this represents the set of members that can be accessed unqualified within the given scope. */ From a3f21a5a20bf63b4dcff1b6ac011817963420170 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 10:44:24 +0200 Subject: [PATCH 17/41] unified: Support scoped imports Scoped imports like 'import class B.C' are mapped to an AST of form ImportDeclartion pattern: NamePattern "C" importedExpr: MemberAccessExpr base: "B" member: "C" The NamePattern introduces a local alias for 'C', but unlike type aliases we also resolve to the ultimate target, when it's coming through an import. --- unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll | 5 +++++ .../static-name-binding/package1/Sources/Target1/File2.swift | 5 +++++ .../static-name-binding/package1/Sources/Target2/File3.swift | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 8f7b7ae7404e..adf505281eb9 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -181,6 +181,11 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { node1 = getModuleNodeFromFile(top.getFile()) and node2.isLocalNamespace(top.getBody()) // implicitly import own module ) + or + exists(ImportDeclaration imprt | + node1 = getNodeFromRef(imprt.getImportedExpr()) and + node2 = getNodeFromRef(imprt.getPattern()) + ) } predicate inheritanceStep(NameBindingNode supertype, NameBindingNode subtype) { diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift index fa515af0eaf5..4705d8f65558 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift @@ -1 +1,6 @@ class A {} // name=Target1.A + +private import class Target2.B // $ access=Target2.B // name=LocalB + +// Note: currently the local name 'B' introduced by the scoped import is also resolved as a target +private let x: B.C; // $ access=Target2.B access=Target2.B.C access=LocalB diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift index 47c3f4da77ed..c49329b168f3 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift @@ -1 +1,5 @@ class A {} // name=Target2.A + +class B { // name=Target2.B + class C {} // name=Target2.B.C +} From 50f934bbc4f0836c87786548e08b95b24d7a8d8f Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 10:55:32 +0200 Subject: [PATCH 18/41] unified: Don't track trivial name aliasse --- .../unified/internal/StaticNameBinding.qll | 20 ++++++++++++++++++- .../package1/Sources/Target1/File2.swift | 5 ++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index adf505281eb9..5afbf1ab6bb2 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -280,8 +280,26 @@ private module TrackNamespaceInput implements TrackInputSig { private module TrackNamespace = Track; +/** + * Holds if `decl` is a trivial local alias for an imported name. + * + * Declaration-tracking usually stops at type-aliases, but trivial aliases + * will be passed through. + */ +predicate isTrivialNameAlias(NameDeclaration decl) { + exists(ImportDeclaration imprt | + decl = getIdentifierFromRef(imprt.getPattern()) and + decl.getName() = getIdentifierFromRef(imprt.getImportedExpr()).getValue() + ) +} + private module TrackNameDeclarationInput implements TrackInputSig { - predicate shouldTrack(NameBindingNode node) { node.isIdentifier(any(NameDeclaration d)) } + predicate shouldTrack(NameBindingNode node) { + exists(NameDeclaration decl | + node.isIdentifier(decl) and + not isTrivialNameAlias(decl) + ) + } } private module TrackNameDeclaration = Track; diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift index 4705d8f65558..62dbfcbf5c04 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File2.swift @@ -1,6 +1,5 @@ class A {} // name=Target1.A -private import class Target2.B // $ access=Target2.B // name=LocalB +private import class Target2.B // $ access=Target2.B -// Note: currently the local name 'B' introduced by the scoped import is also resolved as a target -private let x: B.C; // $ access=Target2.B access=Target2.B.C access=LocalB +private let x: B.C; // $ access=Target2.B access=Target2.B.C From a79c32ae9f5fe61bfb12de2b61ef9cd88b3fc03f Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 13:46:59 +0200 Subject: [PATCH 19/41] unified: Bulk imports --- .../unified/internal/LocalNameBinding.qll | 3 +++ .../unified/internal/NameBindingPlugin.qll | 4 +-- .../internal/NameBindingPluginSwift.qll | 5 +++- .../unified/internal/StaticNameBinding.qll | 27 ++++++++++++++++++- .../package1/Sources/Target1/File3.swift | 4 +++ 5 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File3.swift diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index faa2be5fe43e..a7ab44dbbb8e 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -326,6 +326,9 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig Date: Fri, 7 Aug 2026 13:48:21 +0200 Subject: [PATCH 20/41] unified: Fix access level in test case Program was not valid unless these were public --- .../package1/Sources/Target2/File3.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift index c49329b168f3..8309118a867b 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target2/File3.swift @@ -1,5 +1,5 @@ -class A {} // name=Target2.A +public class A {} // name=Target2.A -class B { // name=Target2.B - class C {} // name=Target2.B.C +public class B { // name=Target2.B + public class C {} // name=Target2.B.C } From 659da08a38f0b6954cff3dc5c351c5e33b4baca0 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 13:53:21 +0200 Subject: [PATCH 21/41] unified: Fix incorrect expectation Module names can only be referenced by an import declaration, they cannot appear directly on front of a type name unless the module is also imported. --- .../static-name-binding/package1/Sources/Target1/File1.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift index fff0a56e90f3..1c9d6f81d06c 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target1/File1.swift @@ -1,2 +1,2 @@ let x: A; // $ access=Target1.A -let y: Target2.A; // $ access=Target2.A +let y: Target2.A; // $ SPURIOUS: access=Target2.A From 35c54b15c7ed6916a38244497e10349fdc7539e7 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 14:26:12 +0200 Subject: [PATCH 22/41] unified: Fix toString and getLocation for TLocalNamespace --- .../ql/lib/codeql/unified/internal/StaticNameBinding.qll | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 8a58e640b470..9c9967920704 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -54,9 +54,7 @@ class NameBindingNode extends TNameBindingNode { this.isExportedNamespace(cls) and result = "ExportedNamespace(" + cls + ")" ) or - exists(ClassLikeDeclaration cls | - this.isLocalNamespace(cls) and result = "LocalNamespace(" + cls + ")" - ) + exists(AstNode n | this.isLocalNamespace(n) and result = "LocalNamespace(" + n + ")") or exists(ModuleScopeRepr repr | this.isModuleScopeNode(repr) and result = "ModuleScope(" + repr + ")" @@ -74,7 +72,7 @@ class NameBindingNode extends TNameBindingNode { or exists(ClassLikeDeclaration cls | this.isExportedNamespace(cls) and result = cls.getLocation()) or - exists(ClassLikeDeclaration cls | this.isLocalNamespace(cls) and result = cls.getLocation()) + exists(AstNode n | this.isLocalNamespace(n) and result = n.getLocation()) or exists(ModuleScopeRepr repr | this.isModuleScopeNode(repr) and result = repr.getLocation()) or From af2f5a2654394ff56975db8db6625f89958b0098 Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 14:26:27 +0200 Subject: [PATCH 23/41] unified: Remove location of TModuleRoot --- unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll | 3 --- 1 file changed, 3 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 9c9967920704..a753cc5cce55 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -75,9 +75,6 @@ class NameBindingNode extends TNameBindingNode { exists(AstNode n | this.isLocalNamespace(n) and result = n.getLocation()) or exists(ModuleScopeRepr repr | this.isModuleScopeNode(repr) and result = repr.getLocation()) - or - this.isModuleRoot() and - exists(ModuleScopeRepr repr | result = repr.getLocation()) } } From 970e4c637361e486ca7ce907891c03c92bd0ffcd Mon Sep 17 00:00:00 2001 From: Asger F Date: Fri, 7 Aug 2026 14:30:31 +0200 Subject: [PATCH 24/41] unified: Fix handling of unscoped imports An `import X` declaration now does two things: - X becomes a local name binding - X is bulk-imported into the local scope The AST mapping now maps it to X with a bulk-importing pattern as a sub-pattern. Module names can no longer be referenced anywhere except as the leading qualifier of an import statement. --- unified/extractor/ast_types.yml | 1 + .../extractor/src/languages/swift/swift.rs | 11 ++++---- ...with-deeply-nested-path-three-parts.output | 5 +++- .../import-with-dotted-path-two-parts.output | 5 +++- .../simple-import-with-single-name.output | 5 +++- .../ql/lib/codeql/unified/internal/Ast.qll | 9 ++++++- .../unified/internal/LocalNameBinding.qll | 2 -- .../unified/internal/NameBindingPlugin.qll | 3 ++- .../unified/internal/StaticNameBinding.qll | 27 +++++++++++++++---- unified/ql/lib/unified.dbscheme | 5 ++++ .../package1/Sources/Target1/File1.swift | 2 +- 11 files changed, 56 insertions(+), 19 deletions(-) diff --git a/unified/extractor/ast_types.yml b/unified/extractor/ast_types.yml index ca5f6d45edc6..854829a17cb4 100644 --- a/unified/extractor/ast_types.yml +++ b/unified/extractor/ast_types.yml @@ -413,6 +413,7 @@ named: name_pattern: modifier*: modifier identifier: identifier + sub_pattern?: pattern # A pattern matching anything, binding no variables, usually using the syntax "_" ignore_pattern: diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 556345c18d21..0762cc66b291 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -1023,13 +1023,12 @@ fn translation_rules() -> Vec> { path: (importPathComponent name: @@parts)*) => import_declaration { - let pattern = match kind { - Some(_) => { - let last = *parts.last().ok_or("import has no path")?; - tree!((name_pattern identifier: (identifier #{last}))) - } - None => tree!((bulk_importing_pattern)), + let bulk_import = match kind { + None => Some(tree!((bulk_importing_pattern))), + Some(_) => None, // scoped import, no bulk import }; + let last = *parts.last().ok_or("import has no path")?; + let pattern = tree!((name_pattern identifier: (identifier #{last}) sub_pattern: {bulk_import})); tree!((import_declaration modifier: (modifier #{kind})? modifier: {attrs} diff --git a/unified/extractor/tests/corpus/swift/desugar/import-with-deeply-nested-path-three-parts.output b/unified/extractor/tests/corpus/swift/desugar/import-with-deeply-nested-path-three-parts.output index cacdc64a46de..ed16e68e219d 100644 --- a/unified/extractor/tests/corpus/swift/desugar/import-with-deeply-nested-path-three-parts.output +++ b/unified/extractor/tests/corpus/swift/desugar/import-with-deeply-nested-path-three-parts.output @@ -37,4 +37,7 @@ top_level identifier: identifier "Foundation" member: identifier "Networking" member: identifier "URLSession" - pattern: bulk_importing_pattern "import Foundation.Networking.URLSession" + pattern: + name_pattern + identifier: identifier "URLSession" + sub_pattern: bulk_importing_pattern "import Foundation.Networking.URLSession" diff --git a/unified/extractor/tests/corpus/swift/desugar/import-with-dotted-path-two-parts.output b/unified/extractor/tests/corpus/swift/desugar/import-with-dotted-path-two-parts.output index 4fc053a7bc53..88f08baa19ce 100644 --- a/unified/extractor/tests/corpus/swift/desugar/import-with-dotted-path-two-parts.output +++ b/unified/extractor/tests/corpus/swift/desugar/import-with-dotted-path-two-parts.output @@ -31,4 +31,7 @@ top_level name_expr identifier: identifier "Foundation" member: identifier "Networking" - pattern: bulk_importing_pattern "import Foundation.Networking" + pattern: + name_pattern + identifier: identifier "Networking" + sub_pattern: bulk_importing_pattern "import Foundation.Networking" diff --git a/unified/extractor/tests/corpus/swift/desugar/simple-import-with-single-name.output b/unified/extractor/tests/corpus/swift/desugar/simple-import-with-single-name.output index 583f33563d13..31261cd952c3 100644 --- a/unified/extractor/tests/corpus/swift/desugar/simple-import-with-single-name.output +++ b/unified/extractor/tests/corpus/swift/desugar/simple-import-with-single-name.output @@ -25,4 +25,7 @@ top_level imported_expr: name_expr identifier: identifier "Foundation" - pattern: bulk_importing_pattern "import Foundation" + pattern: + name_pattern + identifier: identifier "Foundation" + sub_pattern: bulk_importing_pattern "import Foundation" diff --git a/unified/ql/lib/codeql/unified/internal/Ast.qll b/unified/ql/lib/codeql/unified/internal/Ast.qll index 20ff74e6eaf7..d5ffa0218523 100644 --- a/unified/ql/lib/codeql/unified/internal/Ast.qll +++ b/unified/ql/lib/codeql/unified/internal/Ast.qll @@ -1078,9 +1078,14 @@ module Unified { /** Gets the node corresponding to the field `modifier`. */ final F::Modifier getAModifier() { result = this.getModifier(_) } + /** Gets the node corresponding to the field `sub_pattern`. */ + final F::Pattern getSubPattern() { unified_name_pattern_sub_pattern(this, result) } + /** Gets a field or child node of this node. */ final override F::AstNode getAFieldOrChild() { - unified_name_pattern_def(this, result) or unified_name_pattern_modifier(this, _, result) + unified_name_pattern_def(this, result) or + unified_name_pattern_modifier(this, _, result) or + unified_name_pattern_sub_pattern(this, result) } } @@ -1895,6 +1900,8 @@ module Unified { or result = node.(NamePattern).getModifier(i) and name = "getModifier" or + result = node.(NamePattern).getSubPattern() and i = -1 and name = "getSubPattern" + or result = node.(NamedTypeExpr).getName() and i = -1 and name = "getName" or result = node.(NamedTypeExpr).getQualifier() and i = -1 and name = "getQualifier" diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index a7ab44dbbb8e..d7020c153465 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -323,8 +323,6 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig Date: Mon, 10 Aug 2026 11:42:33 +0200 Subject: [PATCH 25/41] unified: Fix Swift source folder documentation --- .../ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll index fb47c577b79e..6fd2187f47a1 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll @@ -73,7 +73,7 @@ class SwiftPackageTarget extends ModuleScopeRepr, CallExpr { ) } - /** Gets the source folder to use if no explicit `path:` if given, typically `Sources/` */ + /** Gets the source folder to use if no explicit `path:` is given, typically `Sources/`. */ Folder getDefaultSourceFolder() { exists(Folder subfolder | subfolder = this.getSourceMidFolder() | result = subfolder.getFolder(this.getName()) From d6164c3231b53ee95e6015e25e7e0de249699248 Mon Sep 17 00:00:00 2001 From: Asger F Date: Mon, 10 Aug 2026 11:43:16 +0200 Subject: [PATCH 26/41] unified: Preserve array constructor trailing closures --- .../extractor/src/languages/swift/swift.rs | 13 ++ .../expressions/array-type-constructor.output | 120 ++++++++++++++++++ .../expressions/array-type-constructor.swift | 3 + 3 files changed, 136 insertions(+) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 0762cc66b291..e615f07388a9 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -621,6 +621,19 @@ fn translation_rules() -> Vec> { // translating that callee as an array literal would place a type // expression in an expression-only element field. Normalize it to an // `Array` generic type constructor instead. + rule!( + (functionCallExpr + calledExpression: (arrayExpr elements: (arrayElement expression: (genericSpecializationExpr) @element)) + arguments: _* @args + trailingClosure: @tc) + => + (call_expr + callee: (generic_type_expr + base: (named_type_expr name: (identifier "Array")) + type_argument: {element}) + argument: {args} + argument: (argument value: {tc})) + ), rule!( (functionCallExpr calledExpression: (arrayExpr elements: (arrayElement expression: (genericSpecializationExpr) @element)) diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output index e721ba9cfde2..43f1c8a553da 100644 --- a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.output @@ -1,4 +1,7 @@ let values = [Result]() +let initialized = [Result](unsafeUninitializedCapacity: 1) { _, count in + count = 0 +} --- @@ -45,6 +48,79 @@ sourceFile pattern: identifierPattern identifier: identifier "values" + codeBlockItem + item: + variableDecl + attributes: + modifiers: + bindingSpecifier: let + bindings: + patternBinding + initializer: + initializerClause + equal: = + value: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + colon: : + label: identifier "unsafeUninitializedCapacity" + expression: + integerLiteralExpr + literal: integerLiteral "1" + additionalTrailingClosures: + calledExpression: + arrayExpr + elements: + arrayElement + expression: + genericSpecializationExpr + expression: + declReferenceExpr + baseName: identifier "Result" + genericArgumentClause: + genericArgumentClause + arguments: + genericArgument + argument: + identifierType + name: identifier "Void" + leftAngle: < + rightAngle: > + leftSquare: [ + rightSquare: ] + trailingClosure: + closureExpr + leftBrace: { + rightBrace: } + signature: + closureSignature + attributes: + inKeyword: in + parameterClause: + closureShorthandParameter + name: _ + trailingComma: , + closureShorthandParameter + name: identifier "count" + statements: + codeBlockItem + item: + infixOperatorExpr + operator: + assignmentExpr + equal: = + leftOperand: + declReferenceExpr + baseName: identifier "count" + rightOperand: + integerLiteralExpr + literal: integerLiteral "0" + pattern: + identifierPattern + identifier: identifier "initialized" --- @@ -72,3 +148,47 @@ top_level type_argument: named_type_expr name: identifier "Void" + variable_declaration + modifier: modifier "let" + pattern: + name_pattern + identifier: identifier "initialized" + value: + call_expr + callee: + generic_type_expr + base: + named_type_expr + name: identifier "Array" + type_argument: + generic_type_expr + base: + named_type_expr + name: identifier "Result" + type_argument: + named_type_expr + name: identifier "Void" + argument: + argument + name: identifier "unsafeUninitializedCapacity" + value: int_literal "1" + argument + value: + function_expr + parameter: + parameter + pattern: + name_pattern + identifier: identifier "_" + parameter + pattern: + name_pattern + identifier: identifier "count" + body: + block + stmt: + assign_expr + target: + name_expr + identifier: identifier "count" + value: int_literal "0" diff --git a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift index 49191aee851d..bd25a9cbb4ce 100644 --- a/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift +++ b/unified/extractor/tests/corpus/swift/expressions/array-type-constructor.swift @@ -1 +1,4 @@ let values = [Result]() +let initialized = [Result](unsafeUninitializedCapacity: 1) { _, count in + count = 0 +} From a658c7742bec431d0ed102893e8e1668583b5066 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 11 Aug 2026 10:16:46 +0200 Subject: [PATCH 27/41] unified: Add test showing AST mapping error Notice the type error in the output --- .../nested-enum-case-pattern.output | 78 +++++++++++++++++++ .../nested-enum-case-pattern.swift | 2 + 2 files changed, 80 insertions(+) diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output index 22d9b86bf996..a85ba8a8cca8 100644 --- a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output @@ -1,6 +1,8 @@ switch event { case let .received(.some(value), timestamp): print(value, timestamp) +case Type.some(let value): + print(value) default: break } @@ -88,6 +90,54 @@ sourceFile calledExpression: declReferenceExpr baseName: identifier "print" + switchCase + label: + switchCaseLabel + colon: : + caseKeyword: case + caseItems: + switchCaseItem + pattern: + expressionPattern + expression: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + patternExpr + pattern: + valueBindingPattern + pattern: + identifierPattern + identifier: identifier "value" + bindingSpecifier: let + additionalTrailingClosures: + calledExpression: + memberAccessExpr + period: . + declName: + declReferenceExpr + baseName: identifier "some" + base: + declReferenceExpr + baseName: identifier "Type" + statements: + codeBlockItem + item: + functionCallExpr + leftParen: ( + rightParen: ) + arguments: + labeledExpr + expression: + declReferenceExpr + baseName: identifier "value" + additionalTrailingClosures: + calledExpression: + declReferenceExpr + baseName: identifier "print" switchCase label: switchDefaultLabel @@ -154,6 +204,34 @@ top_level value: name_expr identifier: identifier "timestamp" + switch_case + pattern: + constructor_pattern + constructor: + member_access_expr + base: + expr_equality_pattern <-- ERROR: The field member_access_expr.base should contain expr_or_type, but got expr_equality_pattern + expr: + name_expr + identifier: identifier "Type" + member: identifier "some" + element: + pattern_element + pattern: + name_pattern + identifier: identifier "value" + body: + block + stmt: + call_expr + callee: + name_expr + identifier: identifier "print" + argument: + argument + value: + name_expr + identifier: identifier "value" switch_case body: block diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift index 876d9995aee6..ed1a6b738a4b 100644 --- a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.swift @@ -1,6 +1,8 @@ switch event { case let .received(.some(value), timestamp): print(value, timestamp) +case Type.some(let value): + print(value) default: break } From c58a096608d9bc9578c1ae4596650ff0d366ea78 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 11 Aug 2026 10:19:21 +0200 Subject: [PATCH 28/41] unified: Fix translation of callee in constructor pattern --- unified/extractor/src/languages/swift/swift.rs | 7 ++++++- .../swift/control-flow/nested-enum-case-pattern.output | 6 ++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index e615f07388a9..6396d4addd75 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -655,9 +655,14 @@ fn translation_rules() -> Vec> { (call_expr callee: {callee} argument: {args} argument: (argument value: {tc})) ), rule!( - (functionCallExpr calledExpression: @callee arguments: _* @args) + (functionCallExpr calledExpression: @@rawCallee arguments: _* @args) => expr { + // Always translate the callee in non-pattern context. + let callee = ctx.scoped(|ctx| { + ctx.in_pattern = false; + ctx.translate(rawCallee) + })?; if ctx.in_pattern { tree!((constructor_pattern constructor: {callee} element: {args})) } else { diff --git a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output index a85ba8a8cca8..66a6f32dab4d 100644 --- a/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output +++ b/unified/extractor/tests/corpus/swift/control-flow/nested-enum-case-pattern.output @@ -210,10 +210,8 @@ top_level constructor: member_access_expr base: - expr_equality_pattern <-- ERROR: The field member_access_expr.base should contain expr_or_type, but got expr_equality_pattern - expr: - name_expr - identifier: identifier "Type" + name_expr + identifier: identifier "Type" member: identifier "some" element: pattern_element From 39998944caafaee40ed5adb1abc5018f8a456811 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:04:33 +0200 Subject: [PATCH 29/41] unified: Clarify description of derivedStoreReadStep --- .../ql/lib/codeql/unified/internal/StaticNameBinding.qll | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 4a677769f5c0..c2734381845d 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -264,13 +264,14 @@ module Track { } /** - * Holds if `node1 -> node2` is derived by combining a store and a read step. + * Holds if `node1 -> node2` is derived by combining a store and a read step, with zero or more value steps and inheritance steps in-between. */ pragma[nomagic] private predicate derivedStoreReadStep(NameBindingNode node1, NameBindingNode node2) { exists(NamespaceNode namespace, string name | - node1 = namespace.getMember(name) and - readStep(namespace.ref(), name, node2) + node1 = namespace.getMember(name) and // getMember() combines a store step with subsequent inheritance steps + readStep(namespace.ref(), name, node2) and + node1 != node2 ) } From e88dde7791c0ac2cdd2166b1c6ab0d35069cbca0 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:18:40 +0200 Subject: [PATCH 30/41] unified: Make debug graph subset more configurable --- .../unified/internal/StaticNameBinding.qll | 38 ++++++++++++++++--- .../dev/debugStaticNameBindingGraph.ql | 9 +++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index c2734381845d..bf334a52f5e6 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -42,6 +42,23 @@ class NameBindingNode extends TNameBindingNode { /** Holds if this represents the root namespace in which all named modules are members. */ predicate isModuleRoot() { this = TModuleRoot() } + /** + * Gets an AST node wrapped by this name-binding node, if such a node exists. + * + * Mainly for debugging purposes. + */ + AstNode getWrappedAstNode() { + this.isIdentifier(result) + or + this.isBulkImport(result) + or + this.isExportedNamespace(result) + or + this.isLocalNamespace(result) + or + this.isModuleScopeNode(result) + } + string toString() { exists(Identifier n | this.isIdentifier(n) and result = "Identifier(" + n + ")") or @@ -351,21 +368,32 @@ NameBindingNode trackNameDeclaration(NameDeclaration decl) { } /** Holds if `node` should be included in the debug view. */ -private signature predicate relevantFileSig(File node); +private signature predicate relevantNodeSig(AstNode node); -module DebugGraph { - private predicate relevantNode(NameBindingNode node) { - relevantFile(node.getLocation().getFile()) +module DebugGraph { + private predicate relevantNameBindingNode(NameBindingNode node) { + relevantNode(node.getWrappedAstNode()) + or + // Also consider LocalName to be relevant if any of its accesses are relevant + exists(LocalName name | + node.isLocalName(name) and + relevantNode(any(PotentialLocalNameAccess ac | ac.getLocalName() = name)) + ) + or + // Always include module root + node.isModuleRoot() } query predicate nodes(NameBindingNode node, string key, string value) { - relevantNode(node) and + relevantNameBindingNode(node) and key = "semmle.label" and value = node.toString() } query predicate edges(NameBindingNode node1, NameBindingNode node2, string key, string value) { key = "semmle.label" and + relevantNameBindingNode(node1) and + relevantNameBindingNode(node2) and ( valueStep(node1, node2) and value = "" or diff --git a/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql b/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql index 740943fc66db..f365d3915f89 100644 --- a/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql +++ b/unified/ql/lib/codeql/unified/internal/dev/debugStaticNameBindingGraph.ql @@ -9,8 +9,11 @@ private import unified private import codeql.unified.internal.StaticNameBinding /** - * Holds if graphs related to `file` should be shown in the graph. + * Holds if `node` should be shown in the graph. */ -predicate relevantFile(File file) { file.getBaseName() = "test.swift" } +predicate relevantNode(AstNode node) { + // Match an ancestor node by location so its whole subtree is shown. + node.getParent*().getLocation().toString().matches("%test.swift@13:%") +} -import DebugGraph +import DebugGraph From d30b7a0d6bc300a9dde31aae5f5878569249e328 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:37:39 +0200 Subject: [PATCH 31/41] unified: Simplify uncertain scopes Marking Members as uncertain scopes led to inaccurate resolution when a member referred to itself, because the uncertain scope (Member) appeared in the scope chain before the LocalName scope (ClassLikeDeclaration). Meanwhile, one of the arguments for doing it, avoiding base classes etc from hitting the uncertain scope, was already fixed by local scope tree-rewrites. It gets harder to detect if a given access appears in static or instance context, but perhaps we'll probably want to expose such information through a separate predicate anyway. --- .../ql/lib/codeql/unified/internal/LocalNameBinding.qll | 6 +----- .../ql/lib/codeql/unified/internal/StaticNameBinding.qll | 9 +-------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index d7020c153465..da0bd0540ea2 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -317,11 +317,7 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig Date: Thu, 13 Aug 2026 11:43:02 +0200 Subject: [PATCH 32/41] unified: Don't step into declaration sites --- unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll | 2 ++ 1 file changed, 2 insertions(+) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 037d66b13bd9..b1242cfb8a27 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -134,6 +134,7 @@ predicate readStep(NameBindingNode node1, string name, NameBindingNode node2) { ) or exists(PotentialLocalNameAccess access | + not access.isDeclarationSite() and name = access.getName() and node1 = getNodeFromUncertainScope(LocalNameBindingOutput::getAnUncertainScope(access, name)) and node2.isIdentifier(access) @@ -182,6 +183,7 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { node1.isIdentifier(access) and node2.isLocalName(access.getLocalName()) or + not access.isDeclarationSite() and node1.isLocalName(access.getLocalName()) and node2.isIdentifier(access) ) From bec213b4c9c5ebfcc3239e421e277adf816ce344 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:43:16 +0200 Subject: [PATCH 33/41] shared: Factor out declInScope(name, scope) --- .../codeql/namebinding/LocalNameBinding.qll | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll index c4d4abdead45..1aac957d7cea 100644 --- a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll +++ b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll @@ -354,13 +354,17 @@ module LocalNameBinding or exists(Scope mid | lookupInScope(name, lookup, mid) and - not declInScope(_, name, mid) and - not implicitDeclInScope(name, mid) and + not declInScope(name, mid) and not isTopScope(mid) and scope = getEnclosingScope(mid) ) } + private predicate declInScope(string name, AstNode scope) { + declInScope(_, name, scope) or + implicitDeclInScope(name, scope) + } + /** * Holds if `name`, when resolved from `lookup`, may resolve to one of the uncertain members of `scope`. */ @@ -368,8 +372,7 @@ module LocalNameBinding private predicate lookupInUncertainScope(string name, Scope lookup, Scope scope) { lookupInScope(name, lookup, scope) and uncertainScope(scope) and - not declInScope(_, name, scope) and - not implicitDeclInScope(name, scope) + not declInScope(name, scope) } /** From 4eade9eae1dd5ff7d3edc86f053fb9944b36255d Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:44:11 +0200 Subject: [PATCH 34/41] shared: Rephrase a qldoc --- shared/namebinding/codeql/namebinding/LocalNameBinding.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll index 1aac957d7cea..4a9c5b61db92 100644 --- a/shared/namebinding/codeql/namebinding/LocalNameBinding.qll +++ b/shared/namebinding/codeql/namebinding/LocalNameBinding.qll @@ -376,7 +376,7 @@ module LocalNameBinding } /** - * Gets an uncertain scope that the given `accessCand` pair may resolve to. + * Gets an uncertain scope in which the `accessCand` pair may resolve. */ AstNode getAnUncertainScope(AstNode access, string name) { exists(Scope lookup | From 828816c2734bf45293774d53da279b65f0dc0525 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:45:12 +0200 Subject: [PATCH 35/41] unified: QLdoc fix --- unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll index de9c09f5bb70..352d39bd5f00 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll @@ -37,7 +37,7 @@ predicate isPrivateToLocalScope(Stmt member) { } /** - * Representative for a module scope. + * A representative for a module scope. * * Module scopes can encompass a set of files, and is the canonical representative * for the top-level members collectively exported from those files. From 78671cd4cc61e6b78b6fb11e5f2bff8208209acf Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 11:46:04 +0200 Subject: [PATCH 36/41] unified: Prefer 'and' instead of '|' --- .../ql/lib/codeql/unified/internal/StaticNameBinding.qll | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index b1242cfb8a27..16ae5ad2602e 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -153,8 +153,7 @@ predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { member = cls.getAMember() and not isInstanceMember(member) and not isPrivateToLocalScope(member) and - nameDecl.getDeclaration() = member - | + nameDecl.getDeclaration() = member and node1.isIdentifier(nameDecl) and name = nameDecl.getName() and node2.isExportedNamespace(cls) @@ -163,8 +162,7 @@ predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { exists(TopLevel top, Stmt stmt, NameDeclaration nameDecl | stmt = top.getBody().getAStmt() and not isPrivateToLocalScope(stmt) and - nameDecl.getDeclaration() = stmt - | + nameDecl.getDeclaration() = stmt and node1.isIdentifier(nameDecl) and name = nameDecl.getName() and node2 = getModuleNodeFromFile(top.getFile()) From 5d7e64e5d2687bdf1b9571f7064625ce998bbb7f Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 12:46:11 +0200 Subject: [PATCH 37/41] unified: Add test with @_exported import --- .../lib/codeql/unified/internal/StaticNameBinding.qll | 1 + .../static-name-binding/package1/Package.swift | 2 ++ .../package1/Sources/Target3/ReExport.swift | 3 +++ .../package1/Sources/Target4/UseReExport.swift | 11 +++++++++++ 4 files changed, 17 insertions(+) create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target3/ReExport.swift create mode 100644 unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 16ae5ad2602e..780b1fbd850a 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -212,6 +212,7 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { | node2 = getNodeFromUncertainScope(scope) or + // TODO: Add support for '@_exported' with tests, so this gets exercised // Bulk re-exporting declarations exists(TopLevel top | declaration = top.getBody().getAStmt() and diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Package.swift b/unified/ql/test/library-tests/static-name-binding/package1/Package.swift index 0789887c4b4c..0c046ec542c7 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Package.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Package.swift @@ -7,5 +7,7 @@ let package = Package( targets: [ .target(name: "Target1"), .target(name: "Target2"), + .target(name: "Target3"), + .target(name: "Target4"), ] ) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target3/ReExport.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target3/ReExport.swift new file mode 100644 index 000000000000..f802bbe9d308 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target3/ReExport.swift @@ -0,0 +1,3 @@ +@_exported import Target2 + +public class C {} // name=Target3.C diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift new file mode 100644 index 000000000000..d9ef7c904c18 --- /dev/null +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift @@ -0,0 +1,11 @@ +import Target3 // re-exports Target2 + +private protocol P { + let x1: A; // $ MISSING: access=Target2.A + let x2: B.C; // $ MISSING: access=Target2.B access=Target2.B.C + let x3: C; // $ access=Target3.C + + let x4: Target3.A; // $ MISSING: access=Target2.A + let x5: Target3.B.C; // $ MISSING: access=Target2.B access=Target2.B.C + let x6: Target3.C; // $ access=Target3.C +} From 61b372029b13ffe3d34a76562a6ccca461b5b4a1 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 12:49:22 +0200 Subject: [PATCH 38/41] unified: Support @_exported imports --- .../codeql/unified/internal/NameBindingPluginSwift.qll | 5 +++-- .../ql/lib/codeql/unified/internal/StaticNameBinding.qll | 1 - .../package1/Sources/Target4/UseReExport.swift | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll index 6fd2187f47a1..52c52833c279 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll @@ -18,8 +18,9 @@ class NameBindingPluginSwift extends NameBindingPlugin { member = any(TopLevel top).getBody().getAStmt() and member.hasModifier(["private", "fileprivate"]) or - // Imports are always file-local - member instanceof ImportDeclaration + // Imports are always file-local, except `@_exported` import which re-export everything + member instanceof ImportDeclaration and + not member.hasModifier("@_exported") // // Note: Private class members can be seen within type-extensions in the same file, // so we can't declare those private to their local scope. diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 780b1fbd850a..16ae5ad2602e 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -212,7 +212,6 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { | node2 = getNodeFromUncertainScope(scope) or - // TODO: Add support for '@_exported' with tests, so this gets exercised // Bulk re-exporting declarations exists(TopLevel top | declaration = top.getBody().getAStmt() and diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift index d9ef7c904c18..ba30deeff57a 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift @@ -1,11 +1,11 @@ import Target3 // re-exports Target2 private protocol P { - let x1: A; // $ MISSING: access=Target2.A - let x2: B.C; // $ MISSING: access=Target2.B access=Target2.B.C + let x1: A; // $ access=Target2.A + let x2: B.C; // $ access=Target2.B access=Target2.B.C let x3: C; // $ access=Target3.C - let x4: Target3.A; // $ MISSING: access=Target2.A - let x5: Target3.B.C; // $ MISSING: access=Target2.B access=Target2.B.C + let x4: Target3.A; // $ access=Target2.A + let x5: Target3.B.C; // $ access=Target2.B access=Target2.B.C let x6: Target3.C; // $ access=Target3.C } From 811932fc23bd23fa581e9309519d08cfaf4a5f76 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 12:52:09 +0200 Subject: [PATCH 39/41] unified: Add test for spurious re-export --- .../package1/Sources/Target4/UseReExport.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift index ba30deeff57a..e0c454454073 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift @@ -8,4 +8,7 @@ private protocol P { let x4: Target3.A; // $ access=Target2.A let x5: Target3.B.C; // $ access=Target2.B access=Target2.B.C let x6: Target3.C; // $ access=Target3.C + + // `@_exported import Target2` should not re-export the local 'Target2' name, only its contents + let x7: Target3.Target2.A; // $ SPURIOUS: access=Target2.A } From 1f380d260ec47d6cda5ead9a0184ce42455bb8b2 Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 13:07:24 +0200 Subject: [PATCH 40/41] unified: Fix spurious resolution by refining isPrivateToLocalScope --- .../unified/internal/NameBindingPlugin.qll | 23 +++++++++++++++---- .../internal/NameBindingPluginSwift.qll | 8 +++++-- .../unified/internal/StaticNameBinding.qll | 6 ++--- .../Sources/Target4/UseReExport.swift | 2 +- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll index 352d39bd5f00..a0a7028dfe92 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPlugin.qll @@ -1,5 +1,6 @@ private import unified private import codeql.util.Unit +private import codeql.unified.internal.LocalNameBinding private import codeql.unified.internal.NameBindingPluginSwift // ensure overrides are seen /** Extension point for language-specific inputs to name binding. */ @@ -14,13 +15,16 @@ class NameBindingPlugin extends Unit { predicate isInstanceMember(ClassLikeDeclaration cls, Member member) { none() } /** - * Holds if `member` is only visible in its local scope, and can thus be entirely resolved + * Holds if `binding`, declared by `member` is only visible in its local scope, and can thus be entirely resolved * by local name-binding, suppressing any store-steps that would otherwise be induced from the member. * * Need only be implemented for members that occur in the context of class or top-level, as other * contexts are considered local already. + * + * `binding` refers to an `Identifier` or `BulkImportingPattern` bound by the member. */ - predicate isPrivateToLocalScope(Stmt member) { none() } + bindingset[member, binding] + predicate isPrivateToLocalScope(Stmt member, AstNode binding) { none() } } /** Holds if `member` is an instance member. */ @@ -31,9 +35,18 @@ predicate isInstanceMember(Member member) { ) } -/** Holds if `member` is only visible in its local scope. */ -predicate isPrivateToLocalScope(Stmt member) { - any(NameBindingPlugin p).isPrivateToLocalScope(member) +/** Holds if `binding` is only visible in its local scope. */ +pragma[nomagic] +predicate isPrivateToLocalScope(AstNode binding) { + exists(Stmt member | + bindingContext(binding, _, member) and + ( + member = any(ClassLikeDeclaration cls).getAMember() or + member = any(TopLevel t).getBody().getAStmt() + ) and + (binding instanceof NameDeclaration or binding instanceof BulkImportingPattern) and + any(NameBindingPlugin p).isPrivateToLocalScope(member, binding) + ) } /** diff --git a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll index 52c52833c279..eaa8f46c65f4 100644 --- a/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll +++ b/unified/ql/lib/codeql/unified/internal/NameBindingPluginSwift.qll @@ -13,14 +13,18 @@ class NameBindingPluginSwift extends NameBindingPlugin { not member.hasModifier(["static", "class", "enum_case"]) } - override predicate isPrivateToLocalScope(Stmt member) { + bindingset[member, binding] + override predicate isPrivateToLocalScope(Stmt member, AstNode binding) { // Private top-level members member = any(TopLevel top).getBody().getAStmt() and member.hasModifier(["private", "fileprivate"]) or // Imports are always file-local, except `@_exported` import which re-export everything member instanceof ImportDeclaration and - not member.hasModifier("@_exported") + not ( + member.hasModifier("@_exported") and + binding instanceof BulkImportingPattern + ) // // Note: Private class members can be seen within type-extensions in the same file, // so we can't declare those private to their local scope. diff --git a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll index 16ae5ad2602e..907cc9f14010 100644 --- a/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/StaticNameBinding.qll @@ -152,7 +152,7 @@ predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { exists(ClassLikeDeclaration cls, Member member, NameDeclaration nameDecl | member = cls.getAMember() and not isInstanceMember(member) and - not isPrivateToLocalScope(member) and + not isPrivateToLocalScope(nameDecl) and nameDecl.getDeclaration() = member and node1.isIdentifier(nameDecl) and name = nameDecl.getName() and @@ -161,7 +161,7 @@ predicate storeStep(NameBindingNode node1, string name, NameBindingNode node2) { or exists(TopLevel top, Stmt stmt, NameDeclaration nameDecl | stmt = top.getBody().getAStmt() and - not isPrivateToLocalScope(stmt) and + not isPrivateToLocalScope(nameDecl) and nameDecl.getDeclaration() = stmt and node1.isIdentifier(nameDecl) and name = nameDecl.getName() and @@ -215,7 +215,7 @@ predicate valueStep(NameBindingNode node1, NameBindingNode node2) { // Bulk re-exporting declarations exists(TopLevel top | declaration = top.getBody().getAStmt() and - not isPrivateToLocalScope(declaration) and + not isPrivateToLocalScope(p) and node2 = getModuleNodeFromFile(top.getFile()) ) ) diff --git a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift index e0c454454073..677cd6a67299 100644 --- a/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift +++ b/unified/ql/test/library-tests/static-name-binding/package1/Sources/Target4/UseReExport.swift @@ -10,5 +10,5 @@ private protocol P { let x6: Target3.C; // $ access=Target3.C // `@_exported import Target2` should not re-export the local 'Target2' name, only its contents - let x7: Target3.Target2.A; // $ SPURIOUS: access=Target2.A + let x7: Target3.Target2.A; // should not resolve } From 719e8b41963127137c261289fa6dde0867ceb11b Mon Sep 17 00:00:00 2001 From: Asger F Date: Thu, 13 Aug 2026 14:21:38 +0200 Subject: [PATCH 41/41] unified: Prefer instanceof --- unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll index da0bd0540ea2..e9ffcd8a29af 100644 --- a/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll +++ b/unified/ql/lib/codeql/unified/internal/LocalNameBinding.qll @@ -317,7 +317,7 @@ private module LocalNameBindingInput implements LocalNameBindingInputSig