Skip to content

Commit 2af09a8

Browse files
authored
Verify that only targets that are manifest dependencies can be imported. (#3562)
* Verify that only targets that are manifest dependencies can be imported. This change introduces a build verification step that attempts to detect scenarios where a target contains an `import` of another target in the package without declaring the imported target as a dependency in the manifest. This is done via SwiftDriver's import-prescan capability which relies on libSwiftScan to quickly parse a target's sources and identify all `import`ed modules. Related to rdar://79423257 * Skip target-import dependency verification procedure and test, if the host compiler does not support it. * Skip targets that contain or depend-on generated code, because generated source-code may not be available at the time of scan. * Add an opt-out from explicit target dependency import checking. `--disable-explicit-target-dependency-import-checking` prevents the verification code from being run as a just-in-case option to opt-out from this warning * Flip the `--disable-explicit-target-dependency-import-checking` option to an opt-in `enable` option. While we work on optimizing incremental build performance of this check, it would be nice to have it as an opt-in option. * Allow configuring the explicit import checking with either 'error', 'warning', or 'none' modes, with 'none' being the default.
1 parent 9c673af commit 2af09a8

File tree

11 files changed

+229
-11
lines changed

11 files changed

+229
-11
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version:5.1
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "VerificationTestPackage",
6+
products: [
7+
.executable(name: "BExec", targets: ["B"]),
8+
],
9+
dependencies: [
10+
11+
],
12+
targets: [
13+
.target(
14+
name: "A",
15+
dependencies: []),
16+
.target(
17+
name: "B",
18+
dependencies: []),
19+
]
20+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import B
2+
public func bar(x: Int) -> Int {
3+
return 11 + foo(x: x)
4+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
public func foo(x: Int) -> Int {
2+
return 11 + x
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
print(baz(x: 11))

Sources/Build/BuildOperation.swift

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import class TSCUtility.MultiLineNinjaProgressAnimation
2525
import class TSCUtility.NinjaProgressAnimation
2626
import protocol TSCUtility.ProgressAnimationProtocol
2727

28+
@_implementationOnly import SwiftDriver
29+
2830
public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildSystem, BuildErrorAdviceProvider {
2931

3032
/// The delegate used by the build system.
@@ -156,12 +158,80 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
156158
buildSystem?.cancel()
157159
}
158160

161+
// Emit a warning if a target imports another target in this build
162+
// without specifying it as a dependency in the manifest
163+
private func verifyTargetImports(in description: BuildDescription) throws {
164+
let checkingMode = description.explicitTargetDependencyImportCheckingMode
165+
guard checkingMode != .none else {
166+
return
167+
}
168+
// Ensure the compiler supports the import-scan operation
169+
guard SwiftTargetBuildDescription.checkSupportedFrontendFlags(flags: ["import-prescan"], fileSystem: localFileSystem) else {
170+
return
171+
}
172+
173+
for (target, commandLine) in description.swiftTargetScanArgs {
174+
do {
175+
guard let dependencies = description.targetDependencyMap[target] else {
176+
// Skip target if no dependency information is present
177+
continue
178+
}
179+
let targetDependenciesSet = Set(dependencies)
180+
guard !description.generatedSourceTargetSet.contains(target),
181+
targetDependenciesSet.intersection(description.generatedSourceTargetSet).isEmpty else {
182+
// Skip targets which contain, or depend-on-targets, with generated source-code.
183+
// Such as test discovery targets and targets with plugins.
184+
continue
185+
}
186+
let resolver = try ArgsResolver(fileSystem: localFileSystem)
187+
let executor = SPMSwiftDriverExecutor(resolver: resolver,
188+
fileSystem: localFileSystem,
189+
env: ProcessEnv.vars)
190+
191+
let consumeDiagnostics: DiagnosticsEngine = DiagnosticsEngine(handlers: [])
192+
var driver = try Driver(args: commandLine,
193+
diagnosticsEngine: consumeDiagnostics,
194+
fileSystem: localFileSystem,
195+
executor: executor)
196+
guard !consumeDiagnostics.hasErrors else {
197+
// If we could not init the driver with this command, something went wrong,
198+
// proceed without checking this target.
199+
continue
200+
}
201+
let imports = try driver.performImportPrescan().imports
202+
let nonDependencyTargetsSet =
203+
Set(description.targetDependencyMap.keys.filter { !targetDependenciesSet.contains($0) })
204+
let importedTargetsMissingDependency = Set(imports).intersection(nonDependencyTargetsSet)
205+
if let missedDependency = importedTargetsMissingDependency.first {
206+
switch checkingMode {
207+
case .error:
208+
self.observabilityScope.emit(error: "Target \(target) imports another target (\(missedDependency)) in the package without declaring it a dependency.")
209+
case .warn:
210+
self.observabilityScope.emit(warning: "Target \(target) imports another target (\(missedDependency)) in the package without declaring it a dependency.")
211+
case .none:
212+
fatalError("Explicit import checking is disabled.")
213+
}
214+
}
215+
} catch {
216+
// The above verification is a best-effort attempt to warn the user about a potential manifest
217+
// error. If something went wrong during the import-prescan, proceed silently.
218+
return
219+
}
220+
}
221+
}
222+
159223
/// Perform a build using the given build description and subset.
160224
public func build(subset: BuildSubset) throws {
225+
161226
let buildStartTime = DispatchTime.now()
162227

163228
// Get the build description (either a cached one or newly created).
164-
let buildDescription = try self.getBuildDescription()
229+
230+
// Get the build description
231+
let buildDescription = try getBuildDescription()
232+
233+
// Verify dependency imports on the described targers
234+
try verifyTargetImports(in: buildDescription)
165235

166236
// Create the build system.
167237
let buildSystem = try self.createBuildSystem(buildDescription: buildDescription)

Sources/Build/BuildOperationBuildSystemDelegateHandler.swift

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ final class TestDiscoveryCommand: CustomLLBuildCommand {
9898
let className = iterator.key
9999
stream <<< indent(8) <<< "testCase(\(className).__allTests__\(className)),\n"
100100
}
101-
102101
stream <<< """
103102
]
104103
}
@@ -213,6 +212,7 @@ private final class InProcessTool: Tool {
213212
public struct BuildDescription: Codable {
214213
public typealias CommandName = String
215214
public typealias TargetName = String
215+
public typealias CommandLineFlag = String
216216

217217
/// The Swift compiler invocation targets.
218218
let swiftCommands: [BuildManifest.CmdName : SwiftCompilerTool]
@@ -226,6 +226,19 @@ public struct BuildDescription: Codable {
226226
/// The map of copy commands.
227227
let copyCommands: [BuildManifest.CmdName: LLBuildManifest.CopyTool]
228228

229+
/// A flag that inidcates this build should perform a check for whether targets only import
230+
/// their explicitly-declared dependencies
231+
let explicitTargetDependencyImportCheckingMode: BuildParameters.TargetDependencyImportCheckingMode
232+
233+
/// Every target's set of dependencies.
234+
let targetDependencyMap: [TargetName: [TargetName]]
235+
236+
/// A full swift driver command-line invocation used to dependency-scan a given Swift target
237+
let swiftTargetScanArgs: [TargetName: [CommandLineFlag]]
238+
239+
/// A set of all targets with generated source
240+
let generatedSourceTargetSet: Set<TargetName>
241+
229242
/// The built test products.
230243
public let builtTestProducts: [BuiltTestProduct]
231244

@@ -244,6 +257,28 @@ public struct BuildDescription: Codable {
244257
self.swiftFrontendCommands = swiftFrontendCommands
245258
self.testDiscoveryCommands = testDiscoveryCommands
246259
self.copyCommands = copyCommands
260+
self.explicitTargetDependencyImportCheckingMode = plan.buildParameters.explicitTargetDependencyImportCheckingMode
261+
self.targetDependencyMap = try plan.targets.reduce(into: [TargetName: [TargetName]]()) {
262+
let deps = try $1.target.recursiveTargetDependencies().map { $0.c99name }
263+
$0[$1.target.c99name] = deps
264+
}
265+
var targetCommandLines: [TargetName: [CommandLineFlag]] = [:]
266+
var generatedSourceTargets: [TargetName] = []
267+
for (target, description) in plan.targetMap {
268+
guard case .swift(let desc) = description else {
269+
continue
270+
}
271+
targetCommandLines[target.c99name] =
272+
try desc.emitCommandLine(scanInvocation: true) + ["-driver-use-frontend-path",
273+
plan.buildParameters.toolchain.swiftCompilerPath.pathString]
274+
if desc.isTestDiscoveryTarget {
275+
generatedSourceTargets.append(target.c99name)
276+
}
277+
}
278+
generatedSourceTargets.append(contentsOf: plan.graph.allTargets.filter {$0.type == .plugin}
279+
.map { $0.c99name })
280+
self.swiftTargetScanArgs = targetCommandLines
281+
self.generatedSourceTargetSet = Set(generatedSourceTargets)
247282
self.builtTestProducts = plan.buildProducts.filter{ $0.product.type == .test }.map { desc in
248283
return BuiltTestProduct(
249284
productName: desc.product.name,

Sources/Build/BuildPlan.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -889,23 +889,27 @@ public final class SwiftTargetBuildDescription {
889889
return args
890890
}
891891

892-
public func emitCommandLine() throws -> [String] {
892+
/// When `scanInvocation` argument is set to `true`, omit the side-effect producing arguments
893+
/// such as emitting a module or supplementary outputs.
894+
public func emitCommandLine(scanInvocation: Bool = false) throws -> [String] {
893895
var result: [String] = []
894896
result.append(buildParameters.toolchain.swiftCompilerPath.pathString)
895897

896898
result.append("-module-name")
897899
result.append(target.c99name)
898900

899-
result.append("-emit-dependencies")
901+
if !scanInvocation {
902+
result.append("-emit-dependencies")
900903

901-
// FIXME: Do we always have a module?
902-
result.append("-emit-module")
903-
result.append("-emit-module-path")
904-
result.append(moduleOutputPath.pathString)
904+
// FIXME: Do we always have a module?
905+
result.append("-emit-module")
906+
result.append("-emit-module-path")
907+
result.append(moduleOutputPath.pathString)
905908

906-
result.append("-output-file-map")
907-
// FIXME: Eliminate side effect.
908-
result.append(try writeOutputFileMap().pathString)
909+
result.append("-output-file-map")
910+
// FIXME: Eliminate side effect.
911+
result.append(try writeOutputFileMap().pathString)
912+
}
909913

910914
if buildParameters.useWholeModuleOptimization {
911915
result.append("-whole-module-optimization")

Sources/Commands/Options.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ struct BuildOptions: ParsableArguments {
335335
@Flag()
336336
var useIntegratedSwiftDriver: Bool = false
337337

338+
/// A flag that inidcates this build should check whether targets only import
339+
/// their explicitly-declared dependencies
340+
@Option()
341+
var explicitTargetDependencyImportCheck: TargetDependencyImportCheckingMode = .none
342+
338343
/// Whether to use the explicit module build flow (with the integrated driver)
339344
@Flag(name: .customLong("experimental-explicit-module-build"))
340345
var useExplicitModuleBuild: Bool = false
@@ -370,6 +375,12 @@ struct BuildOptions: ParsableArguments {
370375
case native
371376
case xcode
372377
}
378+
379+
enum TargetDependencyImportCheckingMode : String, Codable, ExpressibleByArgument {
380+
case none
381+
case warn
382+
case error
383+
}
373384
}
374385

375386
struct LinkerOptions: ParsableArguments {

Sources/Commands/SwiftTool.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ public class SwiftTool {
795795
useExplicitModuleBuild: options.build.useExplicitModuleBuild,
796796
isXcodeBuildSystemEnabled: options.build.buildSystem == .xcode,
797797
forceTestDiscovery: options.build.enableTestDiscovery, // backwards compatibility, remove with --enable-test-discovery
798+
explicitTargetDependencyImportCheckingMode: options.build.explicitTargetDependencyImportCheck.modeParameter,
798799
linkerDeadStrip: options.linker.linkerDeadStrip,
799800
verboseOutput: self.logLevel <= .info
800801
)
@@ -1187,6 +1188,19 @@ extension BuildOptions.StoreMode {
11871188
}
11881189
}
11891190

1191+
extension BuildOptions.TargetDependencyImportCheckingMode {
1192+
fileprivate var modeParameter: BuildParameters.TargetDependencyImportCheckingMode {
1193+
switch self {
1194+
case .none:
1195+
return .none
1196+
case .warn:
1197+
return .warn
1198+
case .error:
1199+
return .error
1200+
}
1201+
}
1202+
}
1203+
11901204
extension Basics.Diagnostic.Severity {
11911205
fileprivate var isVerbose: Bool {
11921206
return self <= .info

Sources/SPMBuildCore/BuildParameters.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ public struct BuildParameters: Encodable {
7272
}
7373
}
7474

75+
/// A mode for explicit import checking
76+
public enum TargetDependencyImportCheckingMode : Codable {
77+
case none
78+
case warn
79+
case error
80+
}
81+
7582
/// The path to the data directory.
7683
public var dataPath: AbsolutePath
7784

@@ -128,6 +135,10 @@ public struct BuildParameters: Encodable {
128135
/// Whether to use the explicit module build flow (with the integrated driver)
129136
public var useExplicitModuleBuild: Bool
130137

138+
/// A flag that inidcates this build should check whether targets only import
139+
/// their explicitly-declared dependencies
140+
public var explicitTargetDependencyImportCheckingMode: TargetDependencyImportCheckingMode
141+
131142
/// Whether to create dylibs for dynamic library products.
132143
public var shouldCreateDylibForDynamicProducts: Bool
133144

@@ -199,6 +210,7 @@ public struct BuildParameters: Encodable {
199210
isXcodeBuildSystemEnabled: Bool = false,
200211
enableTestability: Bool? = nil,
201212
forceTestDiscovery: Bool = false,
213+
explicitTargetDependencyImportCheckingMode: TargetDependencyImportCheckingMode = .none,
202214
linkerDeadStrip: Bool = true,
203215
colorizedOutput: Bool = false,
204216
verboseOutput: Bool = false
@@ -236,6 +248,7 @@ public struct BuildParameters: Encodable {
236248
self.enableTestability = enableTestability ?? (.debug == configuration)
237249
// decide if to enable the use of test manifests based on platform. this is likely to change in the future
238250
self.testDiscoveryStrategy = triple.isDarwin() ? .objectiveC : .manifest(generate: forceTestDiscovery)
251+
self.explicitTargetDependencyImportCheckingMode = explicitTargetDependencyImportCheckingMode
239252
self.linkerDeadStrip = linkerDeadStrip
240253
self.colorizedOutput = colorizedOutput
241254
self.verboseOutput = verboseOutput

Tests/CommandsTests/BuildToolTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ final class BuildToolTests: CommandsTestCase {
7575
}
7676
}
7777

78+
func testImportOfMissedDepWarning() throws {
79+
#if swift(<5.5)
80+
try XCTSkipIf(true, "skipping because host compiler doesn't support '-import-prescan'")
81+
#endif
82+
// Verify the warning flow
83+
try fixture(name: "Miscellaneous/ImportOfMissingDependency") { path in
84+
let fullPath = resolveSymlinks(path)
85+
XCTAssertThrowsError(try build(["--explicit-target-dependency-import-check=warn"], packagePath: fullPath)) { error in
86+
guard case SwiftPMProductError.executionFailure(_, _, let stderr) = error else {
87+
XCTFail()
88+
return
89+
}
90+
91+
XCTAssertTrue(stderr.contains("warning: Target A imports another target (B) in the package without declaring it a dependency."))
92+
}
93+
}
94+
95+
// Verify the error flow
96+
try fixture(name: "Miscellaneous/ImportOfMissingDependency") { path in
97+
let fullPath = resolveSymlinks(path)
98+
XCTAssertThrowsError(try build(["--explicit-target-dependency-import-check=error"], packagePath: fullPath)) { error in
99+
guard case SwiftPMProductError.executionFailure(_, _, let stderr) = error else {
100+
XCTFail()
101+
return
102+
}
103+
104+
XCTAssertTrue(stderr.contains("error: Target A imports another target (B) in the package without declaring it a dependency."))
105+
}
106+
}
107+
108+
// Verify that the default does not run the check
109+
try fixture(name: "Miscellaneous/ImportOfMissingDependency") { path in
110+
let fullPath = resolveSymlinks(path)
111+
XCTAssertThrowsError(try build([], packagePath: fullPath)) { error in
112+
guard case SwiftPMProductError.executionFailure(_, _, let stderr) = error else {
113+
XCTFail()
114+
return
115+
}
116+
XCTAssertFalse(stderr.contains("warning: Target A imports another target (B) in the package without declaring it a dependency."))
117+
}
118+
}
119+
}
120+
78121
func testBinPathAndSymlink() throws {
79122
try fixture(name: "ValidLayouts/SingleModule/ExecutableNew") { fixturePath in
80123
let fullPath = resolveSymlinks(fixturePath)

0 commit comments

Comments
 (0)