diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 37761bd2e38..a3708a0f56a 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -198,6 +198,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS /// Alternative path to search for pkg-config `.pc` files. private let pkgConfigDirectories: [AbsolutePath] + public var hasIntegratedAPIDigesterSupport: Bool { false } + public convenience init( productsBuildParameters: BuildParameters, toolsBuildParameters: BuildParameters, @@ -225,7 +227,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS outputStream: outputStream, logLevel: logLevel, fileSystem: fileSystem, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + delegate: nil ) } @@ -242,7 +245,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS outputStream: OutputByteStream, logLevel: Basics.Diagnostic.Severity, fileSystem: Basics.FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + delegate: SPMBuildCore.BuildSystemDelegate? ) { /// Checks if stdout stream is tty. var productsBuildParameters = productsBuildParameters @@ -269,6 +273,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS self.additionalFileRules = additionalFileRules self.pluginConfiguration = pluginConfiguration self.pkgConfigDirectories = pkgConfigDirectories + self.delegate = delegate } public func getPackageGraph() async throws -> ModulesGraph { diff --git a/Sources/Commands/PackageCommands/APIDiff.swift b/Sources/Commands/PackageCommands/APIDiff.swift index f24bfd5633c..9b380d90e9d 100644 --- a/Sources/Commands/PackageCommands/APIDiff.swift +++ b/Sources/Commands/PackageCommands/APIDiff.swift @@ -18,8 +18,10 @@ import PackageGraph import PackageModel import SourceControl import SPMBuildCore +import TSCBasic import TSCUtility import _Concurrency +import Workspace struct DeprecatedAPIDiff: ParsableCommand { static let configuration = CommandConfiguration(commandName: "experimental-api-diff", @@ -57,7 +59,7 @@ struct APIDiff: AsyncSwiftCommand { Each ignored breaking change in the file should appear on its own line and contain the exact message \ to be ignored (e.g. 'API breakage: func foo() has been removed'). """) - var breakageAllowlistPath: AbsolutePath? + var breakageAllowlistPath: Basics.AbsolutePath? @Argument(help: "The baseline treeish to compare to (for example, a commit hash, branch name, tag, and so on).") var treeish: String @@ -75,32 +77,51 @@ struct APIDiff: AsyncSwiftCommand { @Option(name: .customLong("baseline-dir"), help: "The path to a directory used to store API baseline files. If unspecified, a temporary directory will be used.") - var overrideBaselineDir: AbsolutePath? + var overrideBaselineDir: Basics.AbsolutePath? @Flag(help: "Regenerate the API baseline, even if an existing one is available.") var regenerateBaseline: Bool = false func run(_ swiftCommandState: SwiftCommandState) async throws { - let apiDigesterPath = try swiftCommandState.getTargetToolchain().getSwiftAPIDigester() - let apiDigesterTool = SwiftAPIDigester(fileSystem: swiftCommandState.fileSystem, tool: apiDigesterPath) - let packageRoot = try globalOptions.locations.packageDirectory ?? swiftCommandState.getPackageRoot() let repository = GitRepository(path: packageRoot) let baselineRevision = try repository.resolveRevision(identifier: treeish) - // We turn build manifest caching off because we need the build plan. - let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, - traitConfiguration: .init(traitOptions: self.traits), - cacheBuildManifest: false - ) - - let packageGraph = try await buildSystem.getPackageGraph() - let modulesToDiff = try determineModulesToDiff( + let baselineDir = try overrideBaselineDir?.appending(component: baselineRevision.identifier) ?? swiftCommandState.productsBuildParameters.apiDiff.appending(component: "\(baselineRevision.identifier)-baselines") + let packageGraph = try await swiftCommandState.loadPackageGraph() + let modulesToDiff = try Self.determineModulesToDiff( packageGraph: packageGraph, - observabilityScope: swiftCommandState.observabilityScope + productNames: products, + targetNames: targets, + observabilityScope: swiftCommandState.observabilityScope, + diagnoseMissingNames: true, ) + if swiftCommandState.options.build.buildSystem == .swiftbuild { + try await runWithIntegratedAPIDigesterSupport( + swiftCommandState, + baselineRevision: baselineRevision, + baselineDir: baselineDir, + modulesToDiff: modulesToDiff + ) + } else { + let buildSystem = try await swiftCommandState.createBuildSystem( + traitConfiguration: .init(traitOptions: self.traits), + cacheBuildManifest: false, + ) + try await runWithSwiftPMCoordinatedDiffing( + swiftCommandState, + buildSystem: buildSystem, + baselineRevision: baselineRevision, + modulesToDiff: modulesToDiff + ) + } + } + + private func runWithSwiftPMCoordinatedDiffing(_ swiftCommandState: SwiftCommandState, buildSystem: any BuildSystem, baselineRevision: Revision, modulesToDiff: Set) async throws { + let apiDigesterPath = try swiftCommandState.getTargetToolchain().getSwiftAPIDigester() + let apiDigesterTool = SwiftAPIDigester(fileSystem: swiftCommandState.fileSystem, tool: apiDigesterPath) + // Build the current package. try await buildSystem.build() @@ -173,39 +194,180 @@ struct APIDiff: AsyncSwiftCommand { } } - private func determineModulesToDiff(packageGraph: ModulesGraph, observabilityScope: ObservabilityScope) throws -> Set { + private func runWithIntegratedAPIDigesterSupport(_ swiftCommandState: SwiftCommandState, baselineRevision: Revision, baselineDir: Basics.AbsolutePath, modulesToDiff: Set) async throws { + // Build the baseline revision to generate baseline files. + let modulesWithBaselines = try await generateAPIBaselineUsingIntegratedAPIDigesterSupport(swiftCommandState, baselineRevision: baselineRevision, baselineDir: baselineDir, modulesNeedingBaselines: modulesToDiff) + + // Build the package and run a comparison agains the baselines. + var productsBuildParameters = try swiftCommandState.productsBuildParameters + productsBuildParameters.apiDigesterMode = .compareToBaselines( + baselinesDirectory: baselineDir, + modulesToCompare: modulesWithBaselines, + breakageAllowListPath: breakageAllowlistPath + ) + let delegate = DiagnosticsCapturingBuildSystemDelegate() + let buildSystem = try await swiftCommandState.createBuildSystem( + traitConfiguration: .init(traitOptions: self.traits), + cacheBuildManifest: false, + productsBuildParameters: productsBuildParameters, + delegate: delegate + ) + try await buildSystem.build() + + // Report the results of the comparison. + var comparisonResults: [SwiftAPIDigester.ComparisonResult] = [] + for (targetName, diagnosticPaths) in delegate.serializedDiagnosticsPathsByTarget { + guard let targetName, !diagnosticPaths.isEmpty else { + continue + } + var apiBreakingChanges: [SerializedDiagnostics.Diagnostic] = [] + var otherDiagnostics: [SerializedDiagnostics.Diagnostic] = [] + for path in diagnosticPaths { + let contents = try swiftCommandState.fileSystem.readFileContents(path) + guard contents.count > 0 else { + continue + } + let serializedDiagnostics = try SerializedDiagnostics(bytes: contents) + let apiDigesterCategory = "api-digester-breaking-change" + apiBreakingChanges.append(contentsOf: serializedDiagnostics.diagnostics.filter { $0.category == apiDigesterCategory }) + otherDiagnostics.append(contentsOf: serializedDiagnostics.diagnostics.filter { $0.category != apiDigesterCategory }) + } + let result = SwiftAPIDigester.ComparisonResult( + moduleName: targetName, + apiBreakingChanges: apiBreakingChanges, + otherDiagnostics: otherDiagnostics + ) + comparisonResults.append(result) + } + + var detectedBreakingChange = false + for result in comparisonResults.sorted(by: { $0.moduleName < $1.moduleName }) { + if result.hasNoAPIBreakingChanges && !modulesToDiff.contains(result.moduleName) { + continue + } + try printComparisonResult(result, observabilityScope: swiftCommandState.observabilityScope) + detectedBreakingChange = detectedBreakingChange || !result.hasNoAPIBreakingChanges + } + + for module in modulesToDiff.subtracting(modulesWithBaselines) { + print("\nSkipping \(module) because it does not exist in the baseline") + } + + if detectedBreakingChange { + throw ExitCode(1) + } + } + + private func generateAPIBaselineUsingIntegratedAPIDigesterSupport(_ swiftCommandState: SwiftCommandState, baselineRevision: Revision, baselineDir: Basics.AbsolutePath, modulesNeedingBaselines: Set) async throws -> Set { + // Setup a temporary directory where we can checkout and build the baseline treeish. + let baselinePackageRoot = try swiftCommandState.productsBuildParameters.apiDiff.appending("\(baselineRevision.identifier)-checkout") + if swiftCommandState.fileSystem.exists(baselinePackageRoot) { + try swiftCommandState.fileSystem.removeFileTree(baselinePackageRoot) + } + if regenerateBaseline && swiftCommandState.fileSystem.exists(baselineDir) { + try swiftCommandState.fileSystem.removeFileTree(baselineDir) + } + + // Clone the current package in a sandbox and checkout the baseline revision. + let repositoryProvider = GitRepositoryProvider() + let specifier = RepositorySpecifier(path: baselinePackageRoot) + let workingCopy = try await repositoryProvider.createWorkingCopy( + repository: specifier, + sourcePath: swiftCommandState.getPackageRoot(), + at: baselinePackageRoot, + editable: false + ) + + try workingCopy.checkout(revision: baselineRevision) + + // Create the workspace for this package. + let workspace = try Workspace( + forRootPackage: baselinePackageRoot, + cancellator: swiftCommandState.cancellator + ) + + let graph = try await workspace.loadPackageGraph( + rootPath: baselinePackageRoot, + observabilityScope: swiftCommandState.observabilityScope + ) + + let baselineModules = try Self.determineModulesToDiff( + packageGraph: graph, + productNames: products, + targetNames: targets, + observabilityScope: swiftCommandState.observabilityScope, + diagnoseMissingNames: false + ) + + // Don't emit a baseline for a module that didn't exist yet in this revision. + var modulesNeedingBaselines = modulesNeedingBaselines + modulesNeedingBaselines.formIntersection(graph.apiDigesterModules) + + // Abort if we weren't able to load the package graph. + if swiftCommandState.observabilityScope.errorsReported { + throw Diagnostics.fatalError + } + + // Update the data path input build parameters so it's built in the sandbox. + var productsBuildParameters = try swiftCommandState.productsBuildParameters + productsBuildParameters.dataPath = workspace.location.scratchDirectory + productsBuildParameters.apiDigesterMode = .generateBaselines(baselinesDirectory: baselineDir, modulesRequestingBaselines: modulesNeedingBaselines) + + // Build the baseline module. + // FIXME: We need to implement the build tool invocation closure here so that build tool plugins work with the APIDigester. rdar://86112934 + let buildSystem = try await swiftCommandState.createBuildSystem( + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: productsBuildParameters, + packageGraphLoader: { graph } + ) + try await buildSystem.build() + return baselineModules + } + + private static func determineModulesToDiff(packageGraph: ModulesGraph, productNames: [String], targetNames: [String], observabilityScope: ObservabilityScope, diagnoseMissingNames: Bool) throws -> Set { var modulesToDiff: Set = [] - if products.isEmpty && targets.isEmpty { + if productNames.isEmpty && targetNames.isEmpty { modulesToDiff.formUnion(packageGraph.apiDigesterModules) } else { - for productName in products { + for productName in productNames { guard let product = packageGraph .rootPackages .flatMap(\.products) .first(where: { $0.name == productName }) else { - observabilityScope.emit(error: "no such product '\(productName)'") + if diagnoseMissingNames { + observabilityScope.emit(error: "no such product '\(productName)'") + } continue } guard product.type.isLibrary else { - observabilityScope.emit(error: "'\(productName)' is not a library product") + if diagnoseMissingNames { + observabilityScope.emit(error: "'\(productName)' is not a library product") + } continue } modulesToDiff.formUnion(product.modules.filter { $0.underlying is SwiftModule }.map(\.c99name)) } - for targetName in targets { + for targetName in targetNames { guard let target = packageGraph .rootPackages .flatMap(\.modules) .first(where: { $0.name == targetName }) else { - observabilityScope.emit(error: "no such target '\(targetName)'") + if diagnoseMissingNames { + observabilityScope.emit(error: "no such target '\(targetName)'") + } continue } guard target.type == .library else { - observabilityScope.emit(error: "'\(targetName)' is not a library target") + if diagnoseMissingNames { + observabilityScope.emit(error: "'\(targetName)' is not a library target") + } continue } guard target.underlying is SwiftModule else { - observabilityScope.emit(error: "'\(targetName)' is not a Swift language target") + if diagnoseMissingNames { + observabilityScope.emit(error: "'\(targetName)' is not a Swift language target") + } continue } modulesToDiff.insert(target.c99name) diff --git a/Sources/Commands/Utilities/APIDigester.swift b/Sources/Commands/Utilities/APIDigester.swift index 208d78adbea..be402306492 100644 --- a/Sources/Commands/Utilities/APIDigester.swift +++ b/Sources/Commands/Utilities/APIDigester.swift @@ -208,7 +208,7 @@ public struct SwiftAPIDigester { let result = try runTool(args) if !self.fileSystem.exists(outputPath) { - throw Error.failedToGenerateBaseline(module: module) + throw Error.failedToGenerateBaseline(module: module, output: (try? result.utf8Output()) ?? "", error: (try? result.utf8stderrOutput()) ?? "") } try self.fileSystem.readFileContents(outputPath).withData { data in @@ -272,14 +272,14 @@ public struct SwiftAPIDigester { extension SwiftAPIDigester { public enum Error: Swift.Error, CustomStringConvertible { - case failedToGenerateBaseline(module: String) + case failedToGenerateBaseline(module: String, output: String, error: String) case failedToValidateBaseline(module: String) case noSymbolsInBaseline(module: String, toolOutput: String) public var description: String { switch self { - case .failedToGenerateBaseline(let module): - return "failed to generate baseline for \(module)" + case .failedToGenerateBaseline(let module, let output, let error): + return "failed to generate baseline for \(module) (output: \(output), error: \(error)" case .failedToValidateBaseline(let module): return "failed to validate baseline for \(module)" case .noSymbolsInBaseline(let module, let toolOutput): diff --git a/Sources/CoreCommands/BuildSystemSupport.swift b/Sources/CoreCommands/BuildSystemSupport.swift index 4065ec7a7b8..8cd403ed867 100644 --- a/Sources/CoreCommands/BuildSystemSupport.swift +++ b/Sources/CoreCommands/BuildSystemSupport.swift @@ -36,7 +36,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory { packageGraphLoader: (() async throws -> ModulesGraph)?, outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, - observabilityScope: ObservabilityScope? + observabilityScope: ObservabilityScope?, + delegate: BuildSystemDelegate? ) async throws -> any BuildSystem { _ = try await swiftCommandState.getRootPackageInformation(traitConfiguration: traitConfiguration) let testEntryPointPath = productsBuildParameters?.testProductStyle.explicitlySpecifiedEntryPointPath @@ -68,7 +69,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory { outputStream: outputStream ?? self.swiftCommandState.outputStream, logLevel: logLevel ?? self.swiftCommandState.logLevel, fileSystem: self.swiftCommandState.fileSystem, - observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope) + observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope, + delegate: delegate) } } @@ -84,7 +86,8 @@ private struct XcodeBuildSystemFactory: BuildSystemFactory { packageGraphLoader: (() async throws -> ModulesGraph)?, outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, - observabilityScope: ObservabilityScope? + observabilityScope: ObservabilityScope?, + delegate: BuildSystemDelegate? ) throws -> any BuildSystem { return try XcodeBuildSystem( buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters, @@ -97,7 +100,8 @@ private struct XcodeBuildSystemFactory: BuildSystemFactory { outputStream: outputStream ?? self.swiftCommandState.outputStream, logLevel: logLevel ?? self.swiftCommandState.logLevel, fileSystem: self.swiftCommandState.fileSystem, - observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope + observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope, + delegate: delegate ) } } @@ -115,6 +119,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, observabilityScope: ObservabilityScope?, + delegate: BuildSystemDelegate? ) throws -> any BuildSystem { return try SwiftBuildSystem( buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters, @@ -135,6 +140,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { workDirectory: try self.swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory, disableSandbox: self.swiftCommandState.shouldDisableSandbox ), + delegate: delegate ) } } diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index f30c5103afa..a6ec8110f62 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -787,7 +787,8 @@ public final class SwiftCommandState { packageGraphLoader: (() async throws -> ModulesGraph)? = .none, outputStream: OutputByteStream? = .none, logLevel: Basics.Diagnostic.Severity? = nil, - observabilityScope: ObservabilityScope? = .none + observabilityScope: ObservabilityScope? = .none, + delegate: BuildSystemDelegate? = nil ) async throws -> BuildSystem { guard let buildSystemProvider else { fatalError("build system provider not initialized") @@ -806,7 +807,8 @@ public final class SwiftCommandState { packageGraphLoader: packageGraphLoader, outputStream: outputStream, logLevel: logLevel ?? self.logLevel, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + delegate: delegate ) // register the build system with the cancellation handler diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters+APIDigester.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters+APIDigester.swift new file mode 100644 index 00000000000..926df1d6490 --- /dev/null +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters+APIDigester.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2020-2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics + +extension BuildParameters { + public enum APIDigesterMode: Encodable { + case generateBaselines(baselinesDirectory: AbsolutePath, modulesRequestingBaselines: Set) + case compareToBaselines(baselinesDirectory: AbsolutePath, modulesToCompare: Set, breakageAllowListPath: AbsolutePath?) + } +} diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift index 4c77787c44c..6bd5b8804c1 100644 --- a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift @@ -147,6 +147,9 @@ public struct BuildParameters: Encodable { /// Build parameters related to testing. public var testingParameters: Testing + /// The mode to run the API digester in, if any. + public var apiDigesterMode: APIDigesterMode? + public init( destination: Destination, dataPath: Basics.AbsolutePath, @@ -167,7 +170,8 @@ public struct BuildParameters: Encodable { driverParameters: Driver = .init(), linkingParameters: Linking = .init(), outputParameters: Output = .init(), - testingParameters: Testing = .init() + testingParameters: Testing = .init(), + apiDigesterMode: APIDigesterMode? = nil ) throws { let triple = try triple ?? .getHostTriple(usingSwiftCompiler: toolchain.swiftCompilerPath) self.debuggingParameters = debuggingParameters ?? .init( @@ -223,6 +227,7 @@ public struct BuildParameters: Encodable { self.linkingParameters = linkingParameters self.outputParameters = outputParameters self.testingParameters = testingParameters + self.apiDigesterMode = apiDigesterMode } /// The path to the build directory (inside the data directory). diff --git a/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift b/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift index 992d3f9af29..9f29a66fdbc 100644 --- a/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift +++ b/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift @@ -53,6 +53,8 @@ public protocol BuildSystem: Cancellable { func build(subset: BuildSubset) async throws var buildPlan: BuildPlan { get throws } + + var hasIntegratedAPIDigesterSupport: Bool { get } } extension BuildSystem { @@ -128,7 +130,8 @@ public protocol BuildSystemFactory { packageGraphLoader: (() async throws -> ModulesGraph)?, outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, - observabilityScope: ObservabilityScope? + observabilityScope: ObservabilityScope?, + delegate: BuildSystemDelegate? ) async throws -> any BuildSystem } @@ -156,7 +159,8 @@ public struct BuildSystemProvider { packageGraphLoader: (() async throws -> ModulesGraph)? = .none, outputStream: OutputByteStream? = .none, logLevel: Diagnostic.Severity? = .none, - observabilityScope: ObservabilityScope? = .none + observabilityScope: ObservabilityScope? = .none, + delegate: BuildSystemDelegate? = nil ) async throws -> any BuildSystem { guard let buildSystemFactory = self.providers[kind] else { throw Errors.buildSystemProviderNotRegistered(kind: kind) @@ -170,7 +174,8 @@ public struct BuildSystemProvider { packageGraphLoader: packageGraphLoader, outputStream: outputStream, logLevel: logLevel, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + delegate: delegate ) } } diff --git a/Sources/SPMBuildCore/BuildSystem/BuildSystemCommand.swift b/Sources/SPMBuildCore/BuildSystem/BuildSystemCommand.swift index 4fd1273ead4..d861d290ae3 100644 --- a/Sources/SPMBuildCore/BuildSystem/BuildSystemCommand.swift +++ b/Sources/SPMBuildCore/BuildSystem/BuildSystemCommand.swift @@ -10,14 +10,20 @@ // //===----------------------------------------------------------------------===// +import Basics + public struct BuildSystemCommand: Hashable { public let name: String + public let targetName: String? public let description: String public let verboseDescription: String? + public let serializedDiagnosticPaths: [AbsolutePath] - public init(name: String, description: String, verboseDescription: String? = nil) { + public init(name: String, targetName: String? = nil, description: String, verboseDescription: String? = nil, serializedDiagnosticPaths: [AbsolutePath] = []) { self.name = name + self.targetName = targetName self.description = description self.verboseDescription = verboseDescription + self.serializedDiagnosticPaths = serializedDiagnosticPaths } } diff --git a/Sources/SPMBuildCore/BuildSystem/DiagnosticsCapturingBuildSystemDelegate.swift b/Sources/SPMBuildCore/BuildSystem/DiagnosticsCapturingBuildSystemDelegate.swift new file mode 100644 index 00000000000..d193cde4f3f --- /dev/null +++ b/Sources/SPMBuildCore/BuildSystem/DiagnosticsCapturingBuildSystemDelegate.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics + +/// A BuildSystemDelegate implementation which captures serialized diagnostics paths for all completed tasks. +package class DiagnosticsCapturingBuildSystemDelegate: BuildSystemDelegate { + package private(set) var serializedDiagnosticsPathsByTarget: [String?: Set] = [:] + + package init() {} + + package func buildSystem(_ buildSystem: any BuildSystem, didFinishCommand command: BuildSystemCommand) { + serializedDiagnosticsPathsByTarget[command.targetName, default: []].formUnion(command.serializedDiagnosticPaths) + } +} diff --git a/Sources/SPMBuildCore/CMakeLists.txt b/Sources/SPMBuildCore/CMakeLists.txt index eaf92ddd125..ccea44f655f 100644 --- a/Sources/SPMBuildCore/CMakeLists.txt +++ b/Sources/SPMBuildCore/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(SPMBuildCore BinaryTarget+Extensions.swift BuildParameters/BuildParameters.swift + BuildParameters/BuildParameters+APIDigester.swift BuildParameters/BuildParameters+Debugging.swift BuildParameters/BuildParameters+Driver.swift BuildParameters/BuildParameters+Linking.swift @@ -17,6 +18,7 @@ add_library(SPMBuildCore BuildSystem/BuildSystem.swift BuildSystem/BuildSystemCommand.swift BuildSystem/BuildSystemDelegate.swift + BuildSystem/DiagnosticsCapturingBuildSystemDelegate.swift BuiltTestProduct.swift Plugins/DefaultPluginScriptRunner.swift Plugins/PluginContextSerializer.swift diff --git a/Sources/SourceKitLSPAPI/BuildDescription.swift b/Sources/SourceKitLSPAPI/BuildDescription.swift index 8873b3bf500..aa7dd5f6ca5 100644 --- a/Sources/SourceKitLSPAPI/BuildDescription.swift +++ b/Sources/SourceKitLSPAPI/BuildDescription.swift @@ -244,7 +244,8 @@ public struct BuildDescription { outputStream: threadSafeOutput, logLevel: .error, fileSystem: fileSystem, - observabilityScope: observabilityScope + observabilityScope: observabilityScope, + delegate: nil ) let plan = try await operation.generatePlan() diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index c55d26eeaa6..82ad6587078 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -248,6 +248,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { } } + public var hasIntegratedAPIDigesterSupport: Bool { true } + public init( buildParameters: BuildParameters, packageGraphLoader: @escaping () async throws -> ModulesGraph, @@ -257,7 +259,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { logLevel: Basics.Diagnostic.Severity, fileSystem: FileSystem, observabilityScope: ObservabilityScope, - pluginConfiguration: PluginConfiguration + pluginConfiguration: PluginConfiguration, + delegate: BuildSystemDelegate? ) throws { self.buildParameters = buildParameters self.packageGraphLoader = packageGraphLoader @@ -268,6 +271,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { self.fileSystem = fileSystem self.observabilityScope = observabilityScope.makeChildScope(description: "Swift Build System") self.pluginConfiguration = pluginConfiguration + self.delegate = delegate } private func supportedSwiftVersions() throws -> [SwiftLanguageVersion] { @@ -338,6 +342,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { let request = try self.makeBuildRequest(configuredTargets: configuredTargets, derivedDataPath: derivedDataPath) struct BuildState { + private var targetsByID: [Int: SwiftBuild.SwiftBuildMessage.TargetStartedInfo] = [:] private var activeTasks: [Int: SwiftBuild.SwiftBuildMessage.TaskStartedInfo] = [:] mutating func started(task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws { @@ -353,12 +358,34 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { } return task } + + mutating func started(target: SwiftBuild.SwiftBuildMessage.TargetStartedInfo) throws { + if targetsByID[target.targetID] != nil { + throw Diagnostics.fatalError + } + targetsByID[target.targetID] = target + } + + mutating func target(for task: SwiftBuild.SwiftBuildMessage.TaskStartedInfo) throws -> SwiftBuild.SwiftBuildMessage.TargetStartedInfo? { + guard let id = task.targetID else { + return nil + } + guard let target = targetsByID[id] else { + throw Diagnostics.fatalError + } + return target + } } func emitEvent(_ message: SwiftBuild.SwiftBuildMessage, buildState: inout BuildState) throws { switch message { case .buildCompleted(let info): progressAnimation.complete(success: info.result == .ok) + if info.result == .cancelled { + self.delegate?.buildSystemDidCancel(self) + } else { + self.delegate?.buildSystem(self, didFinishWithResult: info.result == .ok) + } case .didUpdateProgress(let progressInfo): var step = Int(progressInfo.percentComplete) if step < 0 { step = 0 } @@ -368,6 +395,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { "\(progressInfo.message)" } progressAnimation.update(step: step, total: 100, text: message) + self.delegate?.buildSystem(self, didUpdateTaskProgress: message) case .diagnostic(let info): func emitInfoAsDiagnostic(info: SwiftBuildMessage.DiagnosticInfo) { let fixItsDescription = if info.fixIts.hasContent { @@ -412,12 +440,19 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { self.outputStream.send("\(info.executionDescription)") } } + let targetInfo = try buildState.target(for: info) + self.delegate?.buildSystem(self, willStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) + self.delegate?.buildSystem(self, didStartCommand: BuildSystemCommand(info, targetInfo: targetInfo)) case .taskComplete(let info): let startedInfo = try buildState.completed(task: info) if info.result != .success { self.observabilityScope.emit(severity: .error, message: "\(startedInfo.ruleInfo) failed with a nonzero exit code") } - case .planningOperationStarted, .planningOperationCompleted, .reportBuildDescription, .reportPathMap, .preparedForIndex, .backtraceFrame, .buildStarted, .preparationComplete, .targetUpToDate, .targetStarted, .targetComplete, .taskUpToDate: + let targetInfo = try buildState.target(for: startedInfo) + self.delegate?.buildSystem(self, didFinishCommand: BuildSystemCommand(startedInfo, targetInfo: targetInfo)) + case .targetStarted(let info): + try buildState.started(target: info) + case .planningOperationStarted, .planningOperationCompleted, .reportBuildDescription, .reportPathMap, .preparedForIndex, .backtraceFrame, .buildStarted, .preparationComplete, .targetUpToDate, .targetComplete, .taskUpToDate: break case .buildDiagnostic, .targetDiagnostic, .taskDiagnostic: break // deprecated @@ -569,6 +604,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { try settings.merge(Self.constructDriverSettingsOverrides(from: buildParameters.driverParameters), uniquingKeysWith: reportConflict) try settings.merge(Self.constructLinkerSettingsOverrides(from: buildParameters.linkingParameters), uniquingKeysWith: reportConflict) try settings.merge(Self.constructTestingSettingsOverrides(from: buildParameters.testingParameters), uniquingKeysWith: reportConflict) + try settings.merge(Self.constructAPIDigesterSettingsOverrides(from: buildParameters.apiDigesterMode), uniquingKeysWith: reportConflict) // Generate the build parameters. var params = SwiftBuild.SWBBuildParameters() @@ -697,6 +733,33 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { return settings } + private static func constructAPIDigesterSettingsOverrides(from digesterMode: BuildParameters.APIDigesterMode?) -> [String: String] { + var settings: [String: String] = [:] + switch digesterMode { + case .generateBaselines(let baselinesDirectory, let modulesRequestingBaselines): + settings["SWIFT_API_DIGESTER_MODE"] = "api" + for module in modulesRequestingBaselines { + settings["RUN_SWIFT_ABI_GENERATION_TOOL_MODULE_\(module)"] = "YES" + } + settings["RUN_SWIFT_ABI_GENERATION_TOOL"] = "$(RUN_SWIFT_ABI_GENERATION_TOOL_MODULE_$(PRODUCT_MODULE_NAME))" + settings["SWIFT_ABI_GENERATION_TOOL_OUTPUT_DIR"] = baselinesDirectory.appending(components: ["$(PRODUCT_MODULE_NAME)", "ABI"]).pathString + case .compareToBaselines(let baselinesDirectory, let modulesToCompare, let breakageAllowListPath): + settings["SWIFT_API_DIGESTER_MODE"] = "api" + settings["SWIFT_ABI_CHECKER_DOWNGRADE_ERRORS"] = "YES" + for module in modulesToCompare { + settings["RUN_SWIFT_ABI_CHECKER_TOOL_MODULE_\(module)"] = "YES" + } + settings["RUN_SWIFT_ABI_CHECKER_TOOL"] = "$(RUN_SWIFT_ABI_CHECKER_TOOL_MODULE_$(PRODUCT_MODULE_NAME))" + settings["SWIFT_ABI_CHECKER_BASELINE_DIR"] = baselinesDirectory.appending(component: "$(PRODUCT_MODULE_NAME)").pathString + if let breakageAllowListPath { + settings["SWIFT_ABI_CHECKER_EXCEPTIONS_FILE"] = breakageAllowListPath.pathString + } + case nil: + break + } + return settings + } + private func getPIFBuilder() async throws -> PIFBuilder { try await pifBuilder.memoize { let graph = try await getPackageGraph() @@ -786,6 +849,19 @@ fileprivate extension SwiftBuild.SwiftBuildMessage.DiagnosticInfo.Location { } } +fileprivate extension BuildSystemCommand { + init(_ taskStartedInfo: SwiftBuildMessage.TaskStartedInfo, targetInfo: SwiftBuildMessage.TargetStartedInfo?) { + self = .init( + name: taskStartedInfo.executionDescription, + targetName: targetInfo?.targetName, + description: taskStartedInfo.commandLineDisplayString ?? "", + serializedDiagnosticPaths: taskStartedInfo.serializedDiagnosticsPaths.compactMap { + try? Basics.AbsolutePath(validating: $0.pathString) + } + ) + } +} + fileprivate extension Triple { var deploymentTargetSettingName: String? { switch (self.os, self.environment) { diff --git a/Sources/XCBuildSupport/XcodeBuildSystem.swift b/Sources/XCBuildSupport/XcodeBuildSystem.swift index c6467c87bcf..b1ff26bd8d2 100644 --- a/Sources/XCBuildSupport/XcodeBuildSystem.swift +++ b/Sources/XCBuildSupport/XcodeBuildSystem.swift @@ -80,13 +80,16 @@ public final class XcodeBuildSystem: SPMBuildCore.BuildSystem { } } + public var hasIntegratedAPIDigesterSupport: Bool { false } + public init( buildParameters: BuildParameters, packageGraphLoader: @escaping () async throws -> ModulesGraph, outputStream: OutputByteStream, logLevel: Basics.Diagnostic.Severity, fileSystem: FileSystem, - observabilityScope: ObservabilityScope + observabilityScope: ObservabilityScope, + delegate: BuildSystemDelegate? ) throws { self.buildParameters = buildParameters self.packageGraphLoader = packageGraphLoader @@ -94,6 +97,7 @@ public final class XcodeBuildSystem: SPMBuildCore.BuildSystem { self.logLevel = logLevel self.fileSystem = fileSystem self.observabilityScope = observabilityScope.makeChildScope(description: "Xcode Build System") + self.delegate = delegate self.isColorized = buildParameters.outputParameters.isColorized if let xcbuildTool = Environment.current["XCBUILD_TOOL"] { xcbuildPath = try AbsolutePath(validating: xcbuildTool) diff --git a/Sources/_InternalTestSupport/XCTAssertHelpers.swift b/Sources/_InternalTestSupport/XCTAssertHelpers.swift index 6aca165bb89..d8dd24534f7 100644 --- a/Sources/_InternalTestSupport/XCTAssertHelpers.swift +++ b/Sources/_InternalTestSupport/XCTAssertHelpers.swift @@ -323,6 +323,12 @@ public struct CommandExecutionError: Error { package let result: AsyncProcessResult public let stdout: String public let stderr: String + + package init(result: AsyncProcessResult, stdout: String, stderr: String) { + self.result = result + self.stdout = stdout + self.stderr = stderr + } } diff --git a/Sources/swift-bootstrap/main.swift b/Sources/swift-bootstrap/main.swift index dfae051c72e..0911ddee21c 100644 --- a/Sources/swift-bootstrap/main.swift +++ b/Sources/swift-bootstrap/main.swift @@ -346,7 +346,8 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { outputStream: TSCBasic.stdoutStream, logLevel: logLevel, fileSystem: self.fileSystem, - observabilityScope: self.observabilityScope + observabilityScope: self.observabilityScope, + delegate: nil ) case .xcode: return try XcodeBuildSystem( @@ -355,7 +356,8 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { outputStream: TSCBasic.stdoutStream, logLevel: logLevel, fileSystem: self.fileSystem, - observabilityScope: self.observabilityScope + observabilityScope: self.observabilityScope, + delegate: nil ) case .swiftbuild: let pluginScriptRunner = DefaultPluginScriptRunner( @@ -381,6 +383,7 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { workDirectory: scratchDirectory.appending(component: "plugin-working-directory"), disableSandbox: false ), + delegate: nil, ) } } diff --git a/Tests/CommandsTests/APIDiffTests.swift b/Tests/CommandsTests/APIDiffTests.swift index 5e965db9cb4..e3271bc2f96 100644 --- a/Tests/CommandsTests/APIDiffTests.swift +++ b/Tests/CommandsTests/APIDiffTests.swift @@ -23,18 +23,43 @@ import PackageModel import SourceControl import _InternalTestSupport import Workspace -import XCTest +import Testing + +fileprivate func expectThrowsCommandExecutionError( + _ expression: @autoclosure () async throws -> T, + sourceLocation: SourceLocation = #_sourceLocation, + _ errorHandler: (_ error: CommandExecutionError) throws -> Void = { _ in } +) async rethrows { + let error = await #expect(throws: SwiftPMError.self, sourceLocation: sourceLocation) { + try await expression() + } -class APIDiffTestCase: CommandsBuildProviderTestCase { - override func setUpWithError() throws { - try XCTSkipIf(type(of: self) == APIDiffTestCase.self, "Skipping this test since it will be run in subclasses that will provide different build systems to test.") + guard case .executionFailure(let processError, let stdout, let stderr) = error, + case AsyncProcessResult.Error.nonZeroExit(let processResult) = processError, + processResult.exitStatus != .terminated(code: 0) else { + Issue.record("Unexpected error type: \(error?.interpolationDescription)", sourceLocation: sourceLocation) + return } + try errorHandler(CommandExecutionError(result: processResult, stdout: stdout, stderr: stderr)) +} + + +extension Trait where Self == Testing.ConditionTrait { + public static var requiresAPIDigester: Self { + enabled("This test requires a toolchain with swift-api-digester") { + (try? UserToolchain.default.getSwiftAPIDigester()) != nil && ProcessInfo.hostOperatingSystem != .windows + } + } +} +@Suite +struct APIDiffTests { @discardableResult private func execute( _ args: [String], packagePath: AbsolutePath? = nil, - env: Environment? = nil + env: Environment? = nil, + buildSystem: BuildSystemProvider.Kind ) async throws -> (stdout: String, stderr: String) { var environment = env ?? [:] // don't ignore local packages when caching @@ -43,32 +68,12 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packagePath, extraArgs: args, env: environment, - buildSystem: buildSystemProvider + buildSystem: buildSystem ) } - func skipIfApiDigesterUnsupportedOrUnset() throws { - try skipIfApiDigesterUnsupported() - // Opt out from testing the API diff if necessary. - // TODO: Cleanup after March 2025. - // The opt-in/opt-out mechanism doesn't seem to be used. - // It is kept around for abundance of caution. If "why is it needed" - // not identified by March 2025, then it is OK to remove it. - try XCTSkipIf( - Environment.current["SWIFTPM_TEST_API_DIFF_OUTPUT"] == "0", - "Env var SWIFTPM_TEST_API_DIFF_OUTPUT is set to skip the API diff tests." - ) - } - - func skipIfApiDigesterUnsupported() throws { - // swift-api-digester is required to run tests. - guard (try? UserToolchain.default.getSwiftAPIDigester()) != nil else { - throw XCTSkip("swift-api-digester unavailable") - } - } - - func testInvokeAPIDiffDigester() async throws { - try skipIfApiDigesterUnsupported() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testInvokeAPIDiffDigester(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") // Overwrite the existing decl. @@ -76,14 +81,14 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending("Foo.swift"), string: "public let foo = 42" ) - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertFalse(error.stdout.isEmpty) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(!error.stdout.isEmpty) } } } - func testSimpleAPIDiff() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testSimpleAPIDiff(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") // Overwrite the existing decl. @@ -91,15 +96,15 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending("Foo.swift"), string: "public let foo = 42" ) - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Foo")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func foo() has been removed")) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) } } } - func testMultiTargetAPIDiff() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testMultiTargetAPIDiff(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Bar") try localFileSystem.writeFileContents( @@ -110,18 +115,18 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending(components: "Sources", "Qux", "Qux.swift"), string: "public class Qux { private let x = 1 }" ) - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stdout, .contains("2 breaking changes detected in Qux")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: class Qux has generic signature change from to ")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: var Qux.x has been removed")) - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Baz")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func bar() has been removed")) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stdout.contains("2 breaking changes detected in Qux")) + #expect(error.stdout.contains("💔 API breakage: class Qux has generic signature change from to ")) + #expect(error.stdout.contains("💔 API breakage: var Qux.x has been removed")) + #expect(error.stdout.contains("1 breaking change detected in Baz")) + #expect(error.stdout.contains("💔 API breakage: func bar() has been removed")) } } } - func testBreakageAllowlist() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testBreakageAllowlist(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Bar") try localFileSystem.writeFileContents( @@ -137,22 +142,22 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { customAllowlistPath, string: "API breakage: class Qux has generic signature change from to \n" ) - await XCTAssertThrowsCommandExecutionError( + try await expectThrowsCommandExecutionError( try await execute(["diagnose-api-breaking-changes", "1.2.3", "--breakage-allowlist-path", customAllowlistPath.pathString], - packagePath: packageRoot) + packagePath: packageRoot, buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Qux")) - XCTAssertNoMatch(error.stdout, .contains("💔 API breakage: class Qux has generic signature change from to ")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: var Qux.x has been removed")) - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Baz")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func bar() has been removed")) + #expect(error.stdout.contains("1 breaking change detected in Qux")) + #expect(!error.stdout.contains("💔 API breakage: class Qux has generic signature change from to ")) + #expect(error.stdout.contains("💔 API breakage: var Qux.x has been removed")) + #expect(error.stdout.contains("1 breaking change detected in Baz")) + #expect(error.stdout.contains("💔 API breakage: func bar() has been removed")) } } } - func testCheckVendedModulesOnly() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testCheckVendedModulesOnly(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("NonAPILibraryTargets") try localFileSystem.writeFileContents( @@ -171,20 +176,21 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending(components: "Sources", "Qux", "Qux.swift"), string: "public class Qux { private let x = 1 }" ) - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stdout, .contains("💔 API breakage")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Foo")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Bar")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Baz")) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stdout.contains("💔 API breakage")) + let regex = try Regex("\\d+ breaking change(s?) detected in Foo") + #expect(error.stdout.contains(regex)) + #expect(error.stdout.contains(regex)) + #expect(error.stdout.contains(regex)) // Qux is not part of a library product, so any API changes should be ignored - XCTAssertNoMatch(error.stdout, .contains("Qux")) + #expect(!error.stdout.contains("Qux")) } } } - func testFilters() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testFilters(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("NonAPILibraryTargets") try localFileSystem.writeFileContents( @@ -203,83 +209,93 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending(components: "Sources", "Qux", "Qux.swift"), string: "public class Qux { private let x = 1 }" ) - await XCTAssertThrowsCommandExecutionError( - try await execute(["diagnose-api-breaking-changes", "1.2.3", "--products", "One", "--targets", "Bar"], packagePath: packageRoot) + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "1.2.3", "--products", "One", "--targets", "Bar"], packagePath: packageRoot, buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("💔 API breakage")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Foo")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Bar")) + #expect(error.stdout.contains("💔 API breakage")) + let regex = try Regex("\\d+ breaking change(s?) detected in Foo") + #expect(error.stdout.contains(regex)) + #expect(error.stdout.contains(regex)) // Baz and Qux are not included in the filter, so any API changes should be ignored. - XCTAssertNoMatch(error.stdout, .contains("Baz")) - XCTAssertNoMatch(error.stdout, .contains("Qux")) + #expect(!error.stdout.contains("Baz")) + #expect(!error.stdout.contains("Qux")) } // Diff a target which didn't have a baseline generated as part of the first invocation - await XCTAssertThrowsCommandExecutionError( - try await execute(["diagnose-api-breaking-changes", "1.2.3", "--targets", "Baz"], packagePath: packageRoot) + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "1.2.3", "--targets", "Baz"], packagePath: packageRoot, buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("💔 API breakage")) - XCTAssertMatch(error.stdout, .regex("\\d+ breaking change(s?) detected in Baz")) + #expect(error.stdout.contains("💔 API breakage")) + let regex = try Regex("\\d+ breaking change(s?) detected in Baz") + #expect(error.stdout.contains(regex)) // Only Baz is included, we should not see any other API changes. - XCTAssertNoMatch(error.stdout, .contains("Foo")) - XCTAssertNoMatch(error.stdout, .contains("Bar")) - XCTAssertNoMatch(error.stdout, .contains("Qux")) + #expect(!error.stdout.contains("Foo")) + #expect(!error.stdout.contains("Bar")) + #expect(!error.stdout.contains("Qux")) } // Test diagnostics - await XCTAssertThrowsCommandExecutionError( + try await expectThrowsCommandExecutionError( try await execute(["diagnose-api-breaking-changes", "1.2.3", "--targets", "NotATarget", "Exec", "--products", "NotAProduct", "Exec"], - packagePath: packageRoot) + packagePath: packageRoot, buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stderr, .contains("error: no such product 'NotAProduct'")) - XCTAssertMatch(error.stderr, .contains("error: no such target 'NotATarget'")) - XCTAssertMatch(error.stderr, .contains("'Exec' is not a library product")) - XCTAssertMatch(error.stderr, .contains("'Exec' is not a library target")) + #expect(error.stderr.contains("error: no such product 'NotAProduct'")) + #expect(error.stderr.contains("error: no such target 'NotATarget'")) + #expect(error.stderr.contains("'Exec' is not a library product")) + #expect(error.stderr.contains("'Exec' is not a library target")) } } } - func testAPIDiffOfModuleWithCDependency() async throws { - try skipIfApiDigesterUnsupportedOrUnset() - try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in - let packageRoot = fixturePath.appending("CTargetDep") - // Overwrite the existing decl. - try localFileSystem.writeFileContents(packageRoot.appending(components: "Sources", "Bar", "Bar.swift"), string: - """ - import Foo - - public func bar() -> String { - foo() - return "hello, world!" + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testAPIDiffOfModuleWithCDependency(buildSystem: BuildSystemProvider.Kind) async throws { + try await withKnownIssue("https://github.com/swiftlang/swift/issues/82394") { + try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in + let packageRoot = fixturePath.appending("CTargetDep") + // Overwrite the existing decl. + try localFileSystem.writeFileContents(packageRoot.appending(components: "Sources", "Bar", "Bar.swift"), string: + """ + import Foo + + public func bar() -> String { + foo() + return "hello, world!" + } + """ + ) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stdout.contains("1 breaking change detected in Bar")) + #expect(error.stdout.contains("💔 API breakage: func bar() has return type change from Swift.Int to Swift.String")) } - """ - ) - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Bar")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func bar() has return type change from Swift.Int to Swift.String")) - } - // Report an error if we explicitly ask to diff a C-family target - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3", "--targets", "Foo"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stderr, .contains("error: 'Foo' is not a Swift language target")) + // Report an error if we explicitly ask to diff a C-family target + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3", "--targets", "Foo"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stderr.contains("error: 'Foo' is not a Swift language target")) + } } + } when: { + buildSystem == .swiftbuild } } - func testAPIDiffOfVendoredCDependency() async throws { - try skipIfApiDigesterUnsupportedOrUnset() - try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in - let packageRoot = fixturePath.appending("CIncludePath") - let (output, _) = try await execute(["diagnose-api-breaking-changes", "main"], packagePath: packageRoot) + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testAPIDiffOfVendoredCDependency(buildSystem: BuildSystemProvider.Kind) async throws { + try await withKnownIssue("https://github.com/swiftlang/swift/issues/82394") { + try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in + let packageRoot = fixturePath.appending("CIncludePath") + let (output, _) = try await execute(["diagnose-api-breaking-changes", "main"], packagePath: packageRoot, buildSystem: buildSystem) - XCTAssertMatch(output, .contains("No breaking changes detected in Sample")) + #expect(output.contains("No breaking changes detected in Sample")) + } + } when: { + buildSystem == .swiftbuild } } - func testNoBreakingChanges() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testNoBreakingChanges(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Bar") // Introduce an API-compatible change @@ -287,14 +303,14 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { packageRoot.appending(components: "Sources", "Baz", "Baz.swift"), string: "public func bar() -> Int { 100 }" ) - let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot) - XCTAssertMatch(output, .contains("No breaking changes detected in Baz")) - XCTAssertMatch(output, .contains("No breaking changes detected in Qux")) + let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem) + #expect(output.contains("No breaking changes detected in Baz")) + #expect(output.contains("No breaking changes detected in Qux")) } } - func testAPIDiffAfterAddingNewTarget() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testAPIDiffAfterAddingNewTarget(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Bar") try localFileSystem.createDirectory(packageRoot.appending(components: "Sources", "Foo")) @@ -321,36 +337,34 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { ) """ ) - let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot) - XCTAssertMatch(output, .contains("No breaking changes detected in Baz")) - XCTAssertMatch(output, .contains("No breaking changes detected in Qux")) - XCTAssertMatch(output, .contains("Skipping Foo because it does not exist in the baseline")) + let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem) + #expect(output.contains("No breaking changes detected in Baz")) + #expect(output.contains("No breaking changes detected in Qux")) + #expect(output.contains("Skipping Foo because it does not exist in the baseline")) } } - - func testAPIDiffPackageWithPlugin() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testAPIDiffPackageWithPlugin(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("WithPlugin") - let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot) - XCTAssertMatch(output, .contains("No breaking changes detected in TargetLib")) + let (output, _) = try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem) + #expect(output.contains("No breaking changes detected in TargetLib")) } } - - func testBadTreeish() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testBadTreeish(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "7.8.9"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stderr, .contains("error: Couldn’t get revision")) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "7.8.9"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + #expect(error.stderr.contains("error: Couldn’t get revision")) } } } - func testBranchUpdate() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testBranchUpdate(buildSystem: BuildSystemProvider.Kind) async throws { try await withTemporaryDirectory { baselineDir in try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") @@ -363,13 +377,13 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { ) try repo.stage(file: "Foo.swift") try repo.commit(message: "Add foo") - await XCTAssertThrowsCommandExecutionError( - try await execute(["diagnose-api-breaking-changes", "main", "--baseline-dir", - baselineDir.pathString], - packagePath: packageRoot) + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "main", "--baseline-dir", baselineDir.pathString], + packagePath: packageRoot, + buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Foo")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func foo() has been removed")) + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) } // Update `main` and ensure the baseline is regenerated. @@ -382,14 +396,15 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { try repo.commit(message: "Add foo") try repo.checkout(revision: .init(identifier: "feature")) let (output, _) = try await execute(["diagnose-api-breaking-changes", "main", "--baseline-dir", baselineDir.pathString], - packagePath: packageRoot) - XCTAssertMatch(output, .contains("No breaking changes detected in Foo")) + packagePath: packageRoot, + buildSystem: buildSystem) + #expect(output.contains("No breaking changes detected in Foo")) } } } - func testBaselineDirOverride() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testBaselineDirOverride(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") // Overwrite the existing decl. @@ -402,18 +417,24 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { let repo = GitRepository(path: packageRoot) let revision = try repo.resolveRevision(identifier: "1.2.3") - await XCTAssertThrowsCommandExecutionError( - try await execute(["diagnose-api-breaking-changes", "1.2.3", "--baseline-dir", baselineDir.pathString], packagePath: packageRoot) + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "1.2.3", "--baseline-dir", baselineDir.pathString], packagePath: packageRoot, buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Foo")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func foo() has been removed")) - XCTAssertFileExists(baselineDir.appending(components: revision.identifier, "Foo.json")) + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) + let baseName: String + if buildSystem == .swiftbuild { + baseName = "Foo" + } else { + baseName = "Foo.json" + } + #expect(localFileSystem.exists(baselineDir.appending(components: revision.identifier, baseName))) } } } - func testRegenerateBaseline() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testRegenerateBaseline(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("Foo") // Overwrite the existing decl. @@ -426,65 +447,77 @@ class APIDiffTestCase: CommandsBuildProviderTestCase { let revision = try repo.resolveRevision(identifier: "1.2.3") let baselineDir = fixturePath.appending("Baselines") - let fooBaselinePath = baselineDir.appending(components: revision.identifier, "Foo.json") + let fooBaselinePath: AbsolutePath + if buildSystem == .swiftbuild { + fooBaselinePath = baselineDir.appending(components: revision.identifier, "Foo") + } else { + fooBaselinePath = baselineDir.appending(components: revision.identifier, "Foo.json") + } - try localFileSystem.createDirectory(fooBaselinePath.parentDirectory, recursive: true) - try localFileSystem.writeFileContents( - fooBaselinePath, - string: "Old Baseline" - ) + var initialTimestamp: Date? + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "1.2.3", + "--baseline-dir", baselineDir.pathString], + packagePath: packageRoot, + buildSystem: buildSystem) + ) { error in + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) + #expect(localFileSystem.exists(fooBaselinePath)) + initialTimestamp = try localFileSystem.getFileInfo(fooBaselinePath).modTime + } - await XCTAssertThrowsCommandExecutionError( + // Accomodate filesystems with low resolution timestamps + try await Task.sleep(for: .seconds(1)) + + try await expectThrowsCommandExecutionError( try await execute(["diagnose-api-breaking-changes", "1.2.3", - "--baseline-dir", baselineDir.pathString, - "--regenerate-baseline"], - packagePath: packageRoot) + "--baseline-dir", baselineDir.pathString], + packagePath: packageRoot, + buildSystem: buildSystem) ) { error in - XCTAssertMatch(error.stdout, .contains("1 breaking change detected in Foo")) - XCTAssertMatch(error.stdout, .contains("💔 API breakage: func foo() has been removed")) - XCTAssertFileExists(fooBaselinePath) - let content: String = try! localFileSystem.readFileContents(fooBaselinePath) - XCTAssertNotEqual(content, "Old Baseline") + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) + let newTimestamp = try localFileSystem.getFileInfo(fooBaselinePath).modTime + #expect(newTimestamp == initialTimestamp) + } + + // Accomodate filesystems with low resolution timestamps + try await Task.sleep(for: .seconds(1)) + + try await expectThrowsCommandExecutionError( + try await execute(["diagnose-api-breaking-changes", "1.2.3", + "--baseline-dir", baselineDir.pathString, "--regenerate-baseline"], + packagePath: packageRoot, + buildSystem: buildSystem) + ) { error in + #expect(error.stdout.contains("1 breaking change detected in Foo")) + #expect(error.stdout.contains("💔 API breakage: func foo() has been removed")) + #expect((try? localFileSystem.getFileInfo(fooBaselinePath).modTime) != initialTimestamp) } } } - func testOldName() async throws { - await XCTAssertThrowsCommandExecutionError(try await execute(["experimental-api-diff", "1.2.3", "--regenerate-baseline"], packagePath: nil)) { error in - XCTAssertMatch(error.stdout, .contains("`swift package experimental-api-diff` has been renamed to `swift package diagnose-api-breaking-changes`")) + @Test(arguments: SupportedBuildSystemOnAllPlatforms) + func testOldName(buildSystem: BuildSystemProvider.Kind) async throws { + try await expectThrowsCommandExecutionError(try await execute(["experimental-api-diff", "1.2.3", "--regenerate-baseline"], packagePath: nil, buildSystem: buildSystem)) { error in + #expect(error.stdout.contains("`swift package experimental-api-diff` has been renamed to `swift package diagnose-api-breaking-changes`")) } } - func testBrokenAPIDiff() async throws { - try skipIfApiDigesterUnsupportedOrUnset() + @Test(.requiresAPIDigester, arguments: SupportedBuildSystemOnAllPlatforms) + func testBrokenAPIDiff(buildSystem: BuildSystemProvider.Kind) async throws { try await fixture(name: "Miscellaneous/APIDiff/") { fixturePath in let packageRoot = fixturePath.appending("BrokenPkg") - await XCTAssertThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot)) { error in - XCTAssertMatch(error.stderr, .contains("baseline for Swift2 contains no symbols, swift-api-digester output")) + try await expectThrowsCommandExecutionError(try await execute(["diagnose-api-breaking-changes", "1.2.3"], packagePath: packageRoot, buildSystem: buildSystem)) { error in + let expectedError: String + if buildSystem == .swiftbuild { + expectedError = "error: Build failed" + } else { + expectedError = "baseline for Swift2 contains no symbols, swift-api-digester output" + } + #expect(error.stderr.contains(expectedError)) } } } } - -class APIDiffNativeTests: APIDiffTestCase { - - override open var buildSystemProvider: BuildSystemProvider.Kind { - return .native - } - - override func skipIfApiDigesterUnsupportedOrUnset() throws { - try super.skipIfApiDigesterUnsupportedOrUnset() - } - -} - -class APIDiffSwiftBuildTests: APIDiffTestCase { - - override open var buildSystemProvider: BuildSystemProvider.Kind { - return .swiftbuild - } - - override func skipIfApiDigesterUnsupportedOrUnset() throws { - try super.skipIfApiDigesterUnsupportedOrUnset() - } -}