Skip to content

Commit a0f58d4

Browse files
Merge pull request #1 from swizzlr/feat/use-package-path
Fix false positives for SPM packages using custom path: argument
2 parents 5031ded + a57b018 commit a0f58d4

10 files changed

Lines changed: 140 additions & 7 deletions

File tree

Sources/SPMParsing/PackageSwiftFileVisitor.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ final class PackageSwiftFileVisitor: SyntaxVisitor {
5555
targetType = .regular
5656
}
5757

58+
let pathArgument = targetCall.argumentList.first(where: { $0.label?.text == "path" })?
59+
.expression.as(StringLiteralExprSyntax.self)?.segments.description
60+
.trimmingCharacters(in: .punctuationCharacters)
61+
5862
let dependenciesArray = targetCall.argumentList.first(where: { $0.label?.text == "dependencies" })?.expression.as(ArrayExprSyntax.self)?.elements
5963
let dependencies = dependenciesArray?.compactMap { element -> String? in
6064
if let stringLiteral = element.expression.as(StringLiteralExprSyntax.self) {
@@ -76,7 +80,8 @@ final class PackageSwiftFileVisitor: SyntaxVisitor {
7680
type: targetType,
7781
dependencies: dependenciesSet,
7882
duplicateDependencies: findDuplicateDependencies(dependencies),
79-
layerNumber: layers[targetName]
83+
layerNumber: layers[targetName],
84+
path: pathArgument
8085
)
8186
targets.append(target)
8287
}

Sources/SPMParsing/PackagesParser.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,28 @@ final class PackagesParser {
4545
guard target.duplicateDependencies.isEmpty else {
4646
throw PackagesParser.Error.duplicateDependencies(targetName: target.name, dependencies: target.duplicateDependencies)
4747
}
48-
var swiftFilesPath = path + "/" + package.name + target.type.intermediatePath + target.name
49-
if !FileManager.default.fileExists(atPath: swiftFilesPath) {
50-
swiftFilesPath = path + "/" + package.name + target.type.intermediatePath
48+
var swiftFilesPath: String
49+
if let customPath = target.path {
50+
// Normalize path: strip leading/trailing slashes to avoid double-slash issues
51+
let normalizedPath = customPath
52+
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
53+
// Use explicit path from Package.swift
54+
swiftFilesPath = path + "/" + package.name + "/" + normalizedPath
55+
// Error if custom path doesn't exist - don't silently scan nothing
56+
guard FileManager.default.fileExists(atPath: swiftFilesPath) else {
57+
throw PackagesParser.Error.customPathNotFound(
58+
targetName: target.name,
59+
path: customPath,
60+
resolvedPath: swiftFilesPath
61+
)
62+
}
63+
} else {
64+
// Fall back to convention: Sources/TargetName or Tests/TargetName
65+
swiftFilesPath = path + "/" + package.name + target.type.intermediatePath + target.name
66+
// Only fall back to parent directory if convention path doesn't exist
67+
if !FileManager.default.fileExists(atPath: swiftFilesPath) {
68+
swiftFilesPath = path + "/" + package.name + target.type.intermediatePath
69+
}
5170
}
5271
let swiftFilesParser = SwiftFilesParser(
5372
rootURL: URL(fileURLWithPath: swiftFilesPath),
@@ -95,13 +114,16 @@ extension PackagesParser {
95114
enum Error: Swift.Error, CustomStringConvertible, Equatable {
96115
case failedToParsePackage(path: String)
97116
case duplicateDependencies(targetName: String, dependencies: [String])
117+
case customPathNotFound(targetName: String, path: String, resolvedPath: String)
98118

99119
var description: String {
100120
switch self {
101121
case .failedToParsePackage(let path):
102122
"Failed to parse Package.swift at path: \(path)"
103123
case .duplicateDependencies(let targetName, let dependencies):
104124
"❌ Target \(targetName) has duplicate dependencies: \(dependencies.joined(separator: ", "))"
125+
case .customPathNotFound(let targetName, let path, let resolvedPath):
126+
"❌ Target \(targetName) specifies path: \"\(path)\" but directory not found at: \(resolvedPath)"
105127
}
106128
}
107129
}

Sources/SPMParsing/SwiftPackageTarget.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ struct SwiftPackageTarget: Equatable {
1111
let dependencies: Set<String>
1212
let duplicateDependencies: [String]
1313
let layerNumber: Int?
14+
let path: String?
1415
}

Tests/SwiftImportChecksTests/DiagramBuilder/DiagramBuilderTests.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ struct DiagramBuilderTests {
137137
type: .test,
138138
dependencies: [],
139139
duplicateDependencies: [],
140-
layerNumber: nil
140+
layerNumber: nil,
141+
path: nil
141142
)
142143
]
143144
)
@@ -173,7 +174,8 @@ struct DiagramBuilderTests {
173174
type: .regular,
174175
dependencies: [],
175176
duplicateDependencies: [],
176-
layerNumber: nil
177+
layerNumber: nil,
178+
path: nil
177179
)
178180
]
179181
)
@@ -212,7 +214,8 @@ struct DiagramBuilderTests {
212214
type: .regular,
213215
dependencies: [],
214216
duplicateDependencies: [],
215-
layerNumber: 0
217+
layerNumber: 0,
218+
path: nil
216219
)
217220
]
218221
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import Foundation
2+
import XCTest
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import Foundation
2+
import CoreDependency
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import Foundation
2+
import UndeclaredDependency
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "CustomPathPackage",
8+
dependencies: [],
9+
targets: [
10+
.target(
11+
name: "TestModule",
12+
dependencies: ["CoreDependency"],
13+
path: "Sources/Core"
14+
),
15+
.testTarget(
16+
name: "TestModuleTests",
17+
dependencies: ["XCTest"],
18+
path: "CustomTests"
19+
)
20+
]
21+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "InvalidPathPackage",
8+
dependencies: [],
9+
targets: [
10+
.target(
11+
name: "MissingModule",
12+
dependencies: [],
13+
path: "Sources/DoesNotExist"
14+
)
15+
]
16+
)

Tests/SwiftImportChecksTests/SPMParsing/PackagesParserTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,63 @@ struct PackagesParserTests {
144144
)
145145
#expect(messages == expectedMessages)
146146
}
147+
148+
@Test("test parsePackages given valid path with custom path argument uses correct target path")
149+
func parsePackagesGivenValidPathWithCustomPathArgument() throws {
150+
// Given
151+
// This test verifies that:
152+
// 1. Files in Sources/Core are scanned for TestModule (has CoreDependency import)
153+
// 2. Files in Sources/UI are NOT scanned (has UndeclaredDependency import that would fail)
154+
// 3. Files in CustomTests are scanned for TestModuleTests (testTarget with custom path)
155+
let path: String = URL.Mock.customPathPackageFileDir.relativePath
156+
var messages: [String] = []
157+
let expectedMessages: [String] = [
158+
"Package: CustomPathPackage Target: TestModule - Type: regular",
159+
"✅ All imports for target TestModule are explicit",
160+
"Package: CustomPathPackage Target: TestModuleTests - Type: test",
161+
"✅ All imports for target TestModuleTests are explicit"
162+
]
163+
let sut = makeSUT(path: path)
164+
165+
// When
166+
try sut.parsePackages(
167+
configs: configs,
168+
verbose: verbose,
169+
print: { messages.append($0) }
170+
)
171+
172+
// Then
173+
// If Sources/UI was incorrectly scanned, this would fail with UndeclaredDependency error
174+
#expect(messages == expectedMessages)
175+
}
176+
177+
@Test("test parsePackages given custom path that does not exist throws error")
178+
func parsePackagesGivenCustomPathNotFoundThrowsError() throws {
179+
// Given
180+
let path: String = URL.Mock.invalidCustomPathPackageFileDir.relativePath
181+
var messages: [String] = []
182+
let expectedMessages: [String] = [
183+
"Package: InvalidPathPackage Target: MissingModule - Type: regular"
184+
]
185+
let sut = makeSUT(path: path)
186+
187+
// When, Then
188+
#expect(
189+
throws: PackagesParser.Error.customPathNotFound(
190+
targetName: "MissingModule",
191+
path: "Sources/DoesNotExist",
192+
resolvedPath: path + "/InvalidPathPackage/Sources/DoesNotExist"
193+
),
194+
performing: {
195+
try sut.parsePackages(
196+
configs: configs,
197+
verbose: verbose,
198+
print: { messages.append($0) }
199+
)
200+
}
201+
)
202+
#expect(messages == expectedMessages)
203+
}
147204
}
148205

149206
extension PackagesParserTests {
@@ -162,5 +219,7 @@ private extension URL {
162219
static let secondPackageFileDir = Bundle.module.url(forResource: "Example/SecondPackage/Package", withExtension: "swift")!.deletingLastPathComponent()
163220
static let duplicatesPackageFileDir = Bundle.module.url(forResource: "Example/DuplicatesPackage/Package", withExtension: "swift")!.deletingLastPathComponent()
164221
static let failurePackageFileDir = Bundle.module.url(forResource: "Example/FailurePackage/Package", withExtension: "swift")!.deletingLastPathComponent()
222+
static let customPathPackageFileDir = Bundle.module.url(forResource: "Example/CustomPathPackage/Package", withExtension: "swift")!.deletingLastPathComponent()
223+
static let invalidCustomPathPackageFileDir = Bundle.module.url(forResource: "Example/InvalidCustomPathPackage/Package", withExtension: "swift")!.deletingLastPathComponent()
165224
}
166225
}

0 commit comments

Comments
 (0)