Skip to content

Commit 14511dd

Browse files
authored
Merge pull request #28 from tonyarnold/fix/dependency-parsing-regression
Fix SwiftSyntax parsing of target dependencies in dependency arrays
2 parents e4e7cd4 + a585c65 commit 14511dd

File tree

5 files changed

+158
-32
lines changed

5 files changed

+158
-32
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- name: "macOS Universal"
2222
runner: macos-26
2323
platform: "macOS"
24-
xcode: "26.0.1"
24+
xcode: "26.2"
2525
arch: "universal"
2626
comprehensive_test: true
2727
lint_check: true

Sources/SwiftDependencyAuditLib/SwiftSyntaxPackageParser.swift

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,13 @@ private class PackageVisitor: SyntaxVisitor {
137137
extractPackageInfo(from: node)
138138
}
139139

140-
// Handle .target(), .executableTarget(), etc.
140+
// Handle .target(), .executableTarget(), etc. only in Package targets context
141141
if let memberAccess = node.calledExpression.as(MemberAccessExprSyntax.self) {
142142
switch memberAccess.declName.baseName.text {
143143
case "target", "executableTarget", "testTarget", "macro", "systemLibrary", "binaryTarget":
144-
if let target = extractTarget(from: node, type: memberAccess.declName.baseName.text) {
144+
if isTargetContext(functionCall: node),
145+
let target = extractTarget(from: node, type: memberAccess.declName.baseName.text)
146+
{
145147
targets.append(target)
146148
}
147149
case "plugin":
@@ -150,7 +152,7 @@ private class PackageVisitor: SyntaxVisitor {
150152
if let product = extractProduct(from: node, type: memberAccess.declName.baseName.text) {
151153
products.append(product)
152154
}
153-
} else {
155+
} else if isTargetContext(functionCall: node) {
154156
if let target = extractTarget(from: node, type: memberAccess.declName.baseName.text) {
155157
targets.append(target)
156158
}
@@ -212,7 +214,7 @@ private class PackageVisitor: SyntaxVisitor {
212214
customPath = extractStringLiteralValue(stringLiteral)
213215
}
214216
case "dependencies":
215-
dependencyInfos = extractDependencies(from: argument.expression, targetName: name ?? "")
217+
dependencyInfos = extractDependencies(from: argument.expression)
216218
default:
217219
break
218220
}
@@ -223,43 +225,66 @@ private class PackageVisitor: SyntaxVisitor {
223225
return Target(name: targetName, type: targetType, dependencyInfo: dependencyInfos, path: customPath)
224226
}
225227

226-
private func extractDependencies(from expression: ExprSyntax, targetName: String) -> [DependencyInfo] {
228+
private func extractDependencies(from expression: ExprSyntax) -> [DependencyInfo] {
227229
var dependencies: [DependencyInfo] = []
228230

229231
if let arrayExpr = expression.as(ArrayExprSyntax.self) {
230232
for element in arrayExpr.elements {
231233
if let functionCall = element.expression.as(FunctionCallExprSyntax.self),
232-
let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self),
233-
memberAccess.declName.baseName.text == "product"
234+
let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self)
234235
{
235-
236-
// Extract .product(name: "...", package: "...")
237-
var productName: String?
238-
var packageName: String?
239-
240-
for argument in functionCall.arguments {
241-
switch argument.label?.text {
242-
case "name":
243-
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
244-
productName = extractStringLiteralValue(stringLiteral)
236+
switch memberAccess.declName.baseName.text {
237+
case "product":
238+
// Extract .product(name: "...", package: "...")
239+
var productName: String?
240+
var packageName: String?
241+
242+
for argument in functionCall.arguments {
243+
switch argument.label?.text {
244+
case "name":
245+
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
246+
productName = extractStringLiteralValue(stringLiteral)
247+
}
248+
case "package":
249+
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
250+
packageName = extractStringLiteralValue(stringLiteral)
251+
}
252+
default:
253+
break
245254
}
246-
case "package":
247-
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
248-
packageName = extractStringLiteralValue(stringLiteral)
255+
}
256+
257+
if let prodName = productName, let pkgName = packageName {
258+
let lineNumber = getLineNumber(for: functionCall)
259+
dependencies.append(
260+
DependencyInfo(
261+
name: prodName,
262+
type: .product(packageName: pkgName),
263+
lineNumber: lineNumber
264+
))
265+
}
266+
case "target", "byName":
267+
// Extract .target(name: "...") or .byName(name: "...")
268+
var dependencyTargetName: String?
269+
for argument in functionCall.arguments {
270+
if argument.label?.text == "name",
271+
let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self)
272+
{
273+
dependencyTargetName = extractStringLiteralValue(stringLiteral)
249274
}
250-
default:
251-
break
252275
}
253-
}
254276

255-
if let prodName = productName, let pkgName = packageName {
256-
let lineNumber = getLineNumber(for: functionCall)
257-
dependencies.append(
258-
DependencyInfo(
259-
name: prodName,
260-
type: .product(packageName: pkgName),
261-
lineNumber: lineNumber
262-
))
277+
if let name = dependencyTargetName {
278+
let lineNumber = getLineNumber(for: functionCall)
279+
dependencies.append(
280+
DependencyInfo(
281+
name: name,
282+
type: .target,
283+
lineNumber: lineNumber
284+
))
285+
}
286+
default:
287+
break
263288
}
264289
} else if let stringLiteral = element.expression.as(StringLiteralExprSyntax.self) {
265290
// Simple string dependency
@@ -434,6 +459,21 @@ private class PackageVisitor: SyntaxVisitor {
434459
}
435460
return false
436461
}
462+
463+
private func isTargetContext(functionCall: FunctionCallExprSyntax) -> Bool {
464+
// Walk up the AST to determine if this call is a direct element of a targets array
465+
var current: SyntaxProtocol? = functionCall.parent
466+
while let node = current {
467+
if let arrayExpr = node.as(ArrayExprSyntax.self) {
468+
if let argumentExpr = arrayExpr.parent?.as(LabeledExprSyntax.self) {
469+
return argumentExpr.label?.text == "targets"
470+
}
471+
return false
472+
}
473+
current = node.parent
474+
}
475+
return false
476+
}
437477
}
438478

439479
// Helper struct for product information during parsing
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// swift-tools-version:5.9
2+
3+
import PackageDescription
4+
5+
// Regression: a dependency call inside an unlabeled array nested under dependencies
6+
// should not be treated as a target declaration.
7+
func helper(_ deps: [Target.Dependency]) -> [Target.Dependency] {
8+
deps
9+
}
10+
11+
let package = Package(
12+
name: "NestedArrayDependencyRegression",
13+
products: [
14+
.library(name: "App", targets: ["App"]),
15+
],
16+
targets: [
17+
.target(
18+
name: "App",
19+
dependencies: [
20+
helper([.target(name: "Shared")]),
21+
.target(name: "Core"),
22+
]
23+
),
24+
.target(name: "Core"),
25+
.target(name: "Shared"),
26+
]
27+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// swift-tools-version: 6.1
2+
import PackageDescription
3+
4+
// Regression: dependency entries like .target(name:) must not be parsed as target declarations.
5+
let package = Package(
6+
name: "DemoPackage",
7+
targets: [
8+
.target(
9+
name: "Core",
10+
dependencies: [
11+
.target(name: "Shared"),
12+
.target(name: "SharedUI", condition: .when(platforms: [.iOS]))
13+
]
14+
),
15+
.target(
16+
name: "Shared",
17+
dependencies: []
18+
),
19+
.target(
20+
name: "SharedUI",
21+
dependencies: []
22+
)
23+
]
24+
)

Tests/SwiftDependencyAuditTests/SwiftSyntaxPackageParserTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,41 @@ struct SwiftSyntaxPackageParserTests {
164164
print("Regex parser found \(regexTarget.dependencies.count) dependencies: \(regexTarget.dependencies)")
165165
}
166166

167+
@Test("SwiftSyntax parser does not treat Target.Dependency.target as a target declaration")
168+
func testTargetDependencyDoesNotCreateTarget() async throws {
169+
let testBundle = Bundle.module
170+
let fixtureURL = testBundle.url(
171+
forResource: "TargetDependencyTargetsArray", withExtension: "swift",
172+
subdirectory: "Fixtures/Regression")!
173+
let packageContent = try String(contentsOf: fixtureURL)
174+
175+
let parser = SwiftSyntaxPackageParser()
176+
let packageInfo = try await parser.parseContent(packageContent, packageDirectory: "/tmp")
177+
178+
#expect(packageInfo.targets.count == 3)
179+
#expect(packageInfo.targets.map(\.name).sorted() == ["Core", "Shared", "SharedUI"])
180+
181+
let coreTarget = packageInfo.targets.first { $0.name == "Core" }
182+
#expect(coreTarget?.dependencies.count == 2)
183+
#expect(coreTarget?.dependencies.contains("Shared") == true)
184+
#expect(coreTarget?.dependencies.contains("SharedUI") == true)
185+
}
186+
187+
@Test("SwiftSyntax parser ignores dependency targets inside nested unlabeled arrays")
188+
func testNestedArrayDependencyDoesNotCreateTarget() async throws {
189+
let testBundle = Bundle.module
190+
let fixtureURL = testBundle.url(
191+
forResource: "TargetDependencyNestedArray", withExtension: "swift",
192+
subdirectory: "Fixtures/Regression")!
193+
let packageContent = try String(contentsOf: fixtureURL)
194+
195+
let parser = SwiftSyntaxPackageParser()
196+
let packageInfo = try await parser.parseContent(packageContent, packageDirectory: "/tmp")
197+
198+
#expect(packageInfo.targets.count == 3)
199+
#expect(packageInfo.targets.map(\.name).sorted() == ["App", "Core", "Shared"])
200+
}
201+
167202
@Test("Compare line number accuracy with regex parser")
168203
func testLineNumberAccuracy() async throws {
169204
let testBundle = Bundle.module

0 commit comments

Comments
 (0)