Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: "macOS Universal"
runner: macos-26
platform: "macOS"
xcode: "26.0.1"
xcode: "26.2"
arch: "universal"
comprehensive_test: true
lint_check: true
Expand Down
102 changes: 71 additions & 31 deletions Sources/SwiftDependencyAuditLib/SwiftSyntaxPackageParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,13 @@ private class PackageVisitor: SyntaxVisitor {
extractPackageInfo(from: node)
}

// Handle .target(), .executableTarget(), etc.
// Handle .target(), .executableTarget(), etc. only in Package targets context
if let memberAccess = node.calledExpression.as(MemberAccessExprSyntax.self) {
switch memberAccess.declName.baseName.text {
case "target", "executableTarget", "testTarget", "macro", "systemLibrary", "binaryTarget":
if let target = extractTarget(from: node, type: memberAccess.declName.baseName.text) {
if isTargetContext(functionCall: node),
let target = extractTarget(from: node, type: memberAccess.declName.baseName.text)
{
targets.append(target)
}
case "plugin":
Expand All @@ -150,7 +152,7 @@ private class PackageVisitor: SyntaxVisitor {
if let product = extractProduct(from: node, type: memberAccess.declName.baseName.text) {
products.append(product)
}
} else {
} else if isTargetContext(functionCall: node) {
if let target = extractTarget(from: node, type: memberAccess.declName.baseName.text) {
targets.append(target)
}
Expand Down Expand Up @@ -212,7 +214,7 @@ private class PackageVisitor: SyntaxVisitor {
customPath = extractStringLiteralValue(stringLiteral)
}
case "dependencies":
dependencyInfos = extractDependencies(from: argument.expression, targetName: name ?? "")
dependencyInfos = extractDependencies(from: argument.expression)
default:
break
}
Expand All @@ -223,43 +225,66 @@ private class PackageVisitor: SyntaxVisitor {
return Target(name: targetName, type: targetType, dependencyInfo: dependencyInfos, path: customPath)
}

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

if let arrayExpr = expression.as(ArrayExprSyntax.self) {
for element in arrayExpr.elements {
if let functionCall = element.expression.as(FunctionCallExprSyntax.self),
let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self),
memberAccess.declName.baseName.text == "product"
let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self)
{

// Extract .product(name: "...", package: "...")
var productName: String?
var packageName: String?

for argument in functionCall.arguments {
switch argument.label?.text {
case "name":
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
productName = extractStringLiteralValue(stringLiteral)
switch memberAccess.declName.baseName.text {
case "product":
// Extract .product(name: "...", package: "...")
var productName: String?
var packageName: String?

for argument in functionCall.arguments {
switch argument.label?.text {
case "name":
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
productName = extractStringLiteralValue(stringLiteral)
}
case "package":
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
packageName = extractStringLiteralValue(stringLiteral)
}
default:
break
}
case "package":
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
packageName = extractStringLiteralValue(stringLiteral)
}

if let prodName = productName, let pkgName = packageName {
let lineNumber = getLineNumber(for: functionCall)
dependencies.append(
DependencyInfo(
name: prodName,
type: .product(packageName: pkgName),
lineNumber: lineNumber
))
}
case "target", "byName":
// Extract .target(name: "...") or .byName(name: "...")
var dependencyTargetName: String?
for argument in functionCall.arguments {
if argument.label?.text == "name",
let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self)
{
dependencyTargetName = extractStringLiteralValue(stringLiteral)
}
default:
break
}
}

if let prodName = productName, let pkgName = packageName {
let lineNumber = getLineNumber(for: functionCall)
dependencies.append(
DependencyInfo(
name: prodName,
type: .product(packageName: pkgName),
lineNumber: lineNumber
))
if let name = dependencyTargetName {
let lineNumber = getLineNumber(for: functionCall)
dependencies.append(
DependencyInfo(
name: name,
type: .target,
lineNumber: lineNumber
))
}
default:
break
}
} else if let stringLiteral = element.expression.as(StringLiteralExprSyntax.self) {
// Simple string dependency
Expand Down Expand Up @@ -434,6 +459,21 @@ private class PackageVisitor: SyntaxVisitor {
}
return false
}

private func isTargetContext(functionCall: FunctionCallExprSyntax) -> Bool {
// Walk up the AST to determine if this call is a direct element of a targets array
var current: SyntaxProtocol? = functionCall.parent
while let node = current {
if let arrayExpr = node.as(ArrayExprSyntax.self) {
if let argumentExpr = arrayExpr.parent?.as(LabeledExprSyntax.self) {
return argumentExpr.label?.text == "targets"
}
return false
}
current = node.parent
}
return false
}
}

// Helper struct for product information during parsing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// swift-tools-version:5.9

import PackageDescription

// Regression: a dependency call inside an unlabeled array nested under dependencies
// should not be treated as a target declaration.
func helper(_ deps: [Target.Dependency]) -> [Target.Dependency] {
deps
}

let package = Package(
name: "NestedArrayDependencyRegression",
products: [
.library(name: "App", targets: ["App"]),
],
targets: [
.target(
name: "App",
dependencies: [
helper([.target(name: "Shared")]),
.target(name: "Core"),
]
),
.target(name: "Core"),
.target(name: "Shared"),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// swift-tools-version: 6.1
import PackageDescription

// Regression: dependency entries like .target(name:) must not be parsed as target declarations.
let package = Package(
name: "DemoPackage",
targets: [
.target(
name: "Core",
dependencies: [
.target(name: "Shared"),
.target(name: "SharedUI", condition: .when(platforms: [.iOS]))
]
),
.target(
name: "Shared",
dependencies: []
),
.target(
name: "SharedUI",
dependencies: []
)
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,41 @@ struct SwiftSyntaxPackageParserTests {
print("Regex parser found \(regexTarget.dependencies.count) dependencies: \(regexTarget.dependencies)")
}

@Test("SwiftSyntax parser does not treat Target.Dependency.target as a target declaration")
func testTargetDependencyDoesNotCreateTarget() async throws {
let testBundle = Bundle.module
let fixtureURL = testBundle.url(
forResource: "TargetDependencyTargetsArray", withExtension: "swift",
subdirectory: "Fixtures/Regression")!
let packageContent = try String(contentsOf: fixtureURL)

let parser = SwiftSyntaxPackageParser()
let packageInfo = try await parser.parseContent(packageContent, packageDirectory: "/tmp")

#expect(packageInfo.targets.count == 3)
#expect(packageInfo.targets.map(\.name).sorted() == ["Core", "Shared", "SharedUI"])

let coreTarget = packageInfo.targets.first { $0.name == "Core" }
#expect(coreTarget?.dependencies.count == 2)
#expect(coreTarget?.dependencies.contains("Shared") == true)
#expect(coreTarget?.dependencies.contains("SharedUI") == true)
}

@Test("SwiftSyntax parser ignores dependency targets inside nested unlabeled arrays")
func testNestedArrayDependencyDoesNotCreateTarget() async throws {
let testBundle = Bundle.module
let fixtureURL = testBundle.url(
forResource: "TargetDependencyNestedArray", withExtension: "swift",
subdirectory: "Fixtures/Regression")!
let packageContent = try String(contentsOf: fixtureURL)

let parser = SwiftSyntaxPackageParser()
let packageInfo = try await parser.parseContent(packageContent, packageDirectory: "/tmp")

#expect(packageInfo.targets.count == 3)
#expect(packageInfo.targets.map(\.name).sorted() == ["App", "Core", "Shared"])
}

@Test("Compare line number accuracy with regex parser")
func testLineNumberAccuracy() async throws {
let testBundle = Bundle.module
Expand Down