Skip to content

Commit 861aa0e

Browse files
committed
Implement diagnose-api-breaking-changes support with --build-system swiftbuild
1 parent 0c7ebf2 commit 861aa0e

File tree

16 files changed

+605
-237
lines changed

16 files changed

+605
-237
lines changed

Sources/Build/BuildOperation.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
198198
/// Alternative path to search for pkg-config `.pc` files.
199199
private let pkgConfigDirectories: [AbsolutePath]
200200

201+
public var hasIntegratedAPIDigesterSupport: Bool { false }
202+
201203
public convenience init(
202204
productsBuildParameters: BuildParameters,
203205
toolsBuildParameters: BuildParameters,
@@ -225,7 +227,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
225227
outputStream: outputStream,
226228
logLevel: logLevel,
227229
fileSystem: fileSystem,
228-
observabilityScope: observabilityScope
230+
observabilityScope: observabilityScope,
231+
delegate: nil
229232
)
230233
}
231234

@@ -242,7 +245,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
242245
outputStream: OutputByteStream,
243246
logLevel: Basics.Diagnostic.Severity,
244247
fileSystem: Basics.FileSystem,
245-
observabilityScope: ObservabilityScope
248+
observabilityScope: ObservabilityScope,
249+
delegate: SPMBuildCore.BuildSystemDelegate?
246250
) {
247251
/// Checks if stdout stream is tty.
248252
var productsBuildParameters = productsBuildParameters
@@ -269,6 +273,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
269273
self.additionalFileRules = additionalFileRules
270274
self.pluginConfiguration = pluginConfiguration
271275
self.pkgConfigDirectories = pkgConfigDirectories
276+
self.delegate = delegate
272277
}
273278

274279
public func getPackageGraph() async throws -> ModulesGraph {

Sources/Commands/PackageCommands/APIDiff.swift

Lines changed: 186 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import PackageGraph
1818
import PackageModel
1919
import SourceControl
2020
import SPMBuildCore
21+
import TSCBasic
2122
import TSCUtility
2223
import _Concurrency
24+
import Workspace
2325

2426
struct DeprecatedAPIDiff: ParsableCommand {
2527
static let configuration = CommandConfiguration(commandName: "experimental-api-diff",
@@ -57,7 +59,7 @@ struct APIDiff: AsyncSwiftCommand {
5759
Each ignored breaking change in the file should appear on its own line and contain the exact message \
5860
to be ignored (e.g. 'API breakage: func foo() has been removed').
5961
""")
60-
var breakageAllowlistPath: AbsolutePath?
62+
var breakageAllowlistPath: Basics.AbsolutePath?
6163

6264
@Argument(help: "The baseline treeish to compare to (for example, a commit hash, branch name, tag, and so on).")
6365
var treeish: String
@@ -75,32 +77,51 @@ struct APIDiff: AsyncSwiftCommand {
7577

7678
@Option(name: .customLong("baseline-dir"),
7779
help: "The path to a directory used to store API baseline files. If unspecified, a temporary directory will be used.")
78-
var overrideBaselineDir: AbsolutePath?
80+
var overrideBaselineDir: Basics.AbsolutePath?
7981

8082
@Flag(help: "Regenerate the API baseline, even if an existing one is available.")
8183
var regenerateBaseline: Bool = false
8284

8385
func run(_ swiftCommandState: SwiftCommandState) async throws {
84-
let apiDigesterPath = try swiftCommandState.getTargetToolchain().getSwiftAPIDigester()
85-
let apiDigesterTool = SwiftAPIDigester(fileSystem: swiftCommandState.fileSystem, tool: apiDigesterPath)
86-
8786
let packageRoot = try globalOptions.locations.packageDirectory ?? swiftCommandState.getPackageRoot()
8887
let repository = GitRepository(path: packageRoot)
8988
let baselineRevision = try repository.resolveRevision(identifier: treeish)
9089

91-
// We turn build manifest caching off because we need the build plan.
92-
let buildSystem = try await swiftCommandState.createBuildSystem(
93-
explicitBuildSystem: .native,
94-
traitConfiguration: .init(traitOptions: self.traits),
95-
cacheBuildManifest: false
96-
)
97-
98-
let packageGraph = try await buildSystem.getPackageGraph()
99-
let modulesToDiff = try determineModulesToDiff(
90+
let baselineDir = try overrideBaselineDir?.appending(component: baselineRevision.identifier) ?? swiftCommandState.productsBuildParameters.apiDiff.appending(component: "\(baselineRevision.identifier)-baselines")
91+
let packageGraph = try await swiftCommandState.loadPackageGraph()
92+
let modulesToDiff = try Self.determineModulesToDiff(
10093
packageGraph: packageGraph,
101-
observabilityScope: swiftCommandState.observabilityScope
94+
productNames: products,
95+
targetNames: targets,
96+
observabilityScope: swiftCommandState.observabilityScope,
97+
diagnoseMissingNames: true,
10298
)
10399

100+
if swiftCommandState.options.build.buildSystem == .swiftbuild {
101+
try await runWithIntegratedAPIDigesterSupport(
102+
swiftCommandState,
103+
baselineRevision: baselineRevision,
104+
baselineDir: baselineDir,
105+
modulesToDiff: modulesToDiff
106+
)
107+
} else {
108+
let buildSystem = try await swiftCommandState.createBuildSystem(
109+
traitConfiguration: .init(traitOptions: self.traits),
110+
cacheBuildManifest: false,
111+
)
112+
try await runWithSwiftPMCoordinatedDiffing(
113+
swiftCommandState,
114+
buildSystem: buildSystem,
115+
baselineRevision: baselineRevision,
116+
modulesToDiff: modulesToDiff
117+
)
118+
}
119+
}
120+
121+
private func runWithSwiftPMCoordinatedDiffing(_ swiftCommandState: SwiftCommandState, buildSystem: any BuildSystem, baselineRevision: Revision, modulesToDiff: Set<String>) async throws {
122+
let apiDigesterPath = try swiftCommandState.getTargetToolchain().getSwiftAPIDigester()
123+
let apiDigesterTool = SwiftAPIDigester(fileSystem: swiftCommandState.fileSystem, tool: apiDigesterPath)
124+
104125
// Build the current package.
105126
try await buildSystem.build()
106127

@@ -173,39 +194,180 @@ struct APIDiff: AsyncSwiftCommand {
173194
}
174195
}
175196

176-
private func determineModulesToDiff(packageGraph: ModulesGraph, observabilityScope: ObservabilityScope) throws -> Set<String> {
197+
private func runWithIntegratedAPIDigesterSupport(_ swiftCommandState: SwiftCommandState, baselineRevision: Revision, baselineDir: Basics.AbsolutePath, modulesToDiff: Set<String>) async throws {
198+
// Build the baseline revision to generate baseline files.
199+
let modulesWithBaselines = try await generateAPIBaselineUsingIntegratedAPIDigesterSupport(swiftCommandState, baselineRevision: baselineRevision, baselineDir: baselineDir, modulesNeedingBaselines: modulesToDiff)
200+
201+
// Build the package and run a comparison agains the baselines.
202+
var productsBuildParameters = try swiftCommandState.productsBuildParameters
203+
productsBuildParameters.apiDigesterMode = .compareToBaselines(
204+
baselinesDirectory: baselineDir,
205+
modulesToCompare: modulesWithBaselines,
206+
breakageAllowListPath: breakageAllowlistPath
207+
)
208+
let delegate = DiagnosticsCapturingBuildSystemDelegate()
209+
let buildSystem = try await swiftCommandState.createBuildSystem(
210+
traitConfiguration: .init(traitOptions: self.traits),
211+
cacheBuildManifest: false,
212+
productsBuildParameters: productsBuildParameters,
213+
delegate: delegate
214+
)
215+
try await buildSystem.build()
216+
217+
// Report the results of the comparison.
218+
var comparisonResults: [SwiftAPIDigester.ComparisonResult] = []
219+
for (targetName, diagnosticPaths) in delegate.serializedDiagnosticsPathsByTarget {
220+
guard let targetName, !diagnosticPaths.isEmpty else {
221+
continue
222+
}
223+
var apiBreakingChanges: [SerializedDiagnostics.Diagnostic] = []
224+
var otherDiagnostics: [SerializedDiagnostics.Diagnostic] = []
225+
for path in diagnosticPaths {
226+
let contents = try swiftCommandState.fileSystem.readFileContents(path)
227+
guard contents.count > 0 else {
228+
continue
229+
}
230+
let serializedDiagnostics = try SerializedDiagnostics(bytes: contents)
231+
let apiDigesterCategory = "api-digester-breaking-change"
232+
apiBreakingChanges.append(contentsOf: serializedDiagnostics.diagnostics.filter { $0.category == apiDigesterCategory })
233+
otherDiagnostics.append(contentsOf: serializedDiagnostics.diagnostics.filter { $0.category != apiDigesterCategory })
234+
}
235+
let result = SwiftAPIDigester.ComparisonResult(
236+
moduleName: targetName,
237+
apiBreakingChanges: apiBreakingChanges,
238+
otherDiagnostics: otherDiagnostics
239+
)
240+
comparisonResults.append(result)
241+
}
242+
243+
var detectedBreakingChange = false
244+
for result in comparisonResults.sorted(by: { $0.moduleName < $1.moduleName }) {
245+
if result.hasNoAPIBreakingChanges && !modulesToDiff.contains(result.moduleName) {
246+
continue
247+
}
248+
try printComparisonResult(result, observabilityScope: swiftCommandState.observabilityScope)
249+
detectedBreakingChange = detectedBreakingChange || !result.hasNoAPIBreakingChanges
250+
}
251+
252+
for module in modulesToDiff.subtracting(modulesWithBaselines) {
253+
print("\nSkipping \(module) because it does not exist in the baseline")
254+
}
255+
256+
if detectedBreakingChange {
257+
throw ExitCode(1)
258+
}
259+
}
260+
261+
private func generateAPIBaselineUsingIntegratedAPIDigesterSupport(_ swiftCommandState: SwiftCommandState, baselineRevision: Revision, baselineDir: Basics.AbsolutePath, modulesNeedingBaselines: Set<String>) async throws -> Set<String> {
262+
// Setup a temporary directory where we can checkout and build the baseline treeish.
263+
let baselinePackageRoot = try swiftCommandState.productsBuildParameters.apiDiff.appending("\(baselineRevision.identifier)-checkout")
264+
if swiftCommandState.fileSystem.exists(baselinePackageRoot) {
265+
try swiftCommandState.fileSystem.removeFileTree(baselinePackageRoot)
266+
}
267+
if regenerateBaseline && swiftCommandState.fileSystem.exists(baselineDir) {
268+
try swiftCommandState.fileSystem.removeFileTree(baselineDir)
269+
}
270+
271+
// Clone the current package in a sandbox and checkout the baseline revision.
272+
let repositoryProvider = GitRepositoryProvider()
273+
let specifier = RepositorySpecifier(path: baselinePackageRoot)
274+
let workingCopy = try await repositoryProvider.createWorkingCopy(
275+
repository: specifier,
276+
sourcePath: swiftCommandState.getPackageRoot(),
277+
at: baselinePackageRoot,
278+
editable: false
279+
)
280+
281+
try workingCopy.checkout(revision: baselineRevision)
282+
283+
// Create the workspace for this package.
284+
let workspace = try Workspace(
285+
forRootPackage: baselinePackageRoot,
286+
cancellator: swiftCommandState.cancellator
287+
)
288+
289+
let graph = try await workspace.loadPackageGraph(
290+
rootPath: baselinePackageRoot,
291+
observabilityScope: swiftCommandState.observabilityScope
292+
)
293+
294+
let baselineModules = try Self.determineModulesToDiff(
295+
packageGraph: graph,
296+
productNames: products,
297+
targetNames: targets,
298+
observabilityScope: swiftCommandState.observabilityScope,
299+
diagnoseMissingNames: false
300+
)
301+
302+
// Don't emit a baseline for a module that didn't exist yet in this revision.
303+
var modulesNeedingBaselines = modulesNeedingBaselines
304+
modulesNeedingBaselines.formIntersection(graph.apiDigesterModules)
305+
306+
// Abort if we weren't able to load the package graph.
307+
if swiftCommandState.observabilityScope.errorsReported {
308+
throw Diagnostics.fatalError
309+
}
310+
311+
// Update the data path input build parameters so it's built in the sandbox.
312+
var productsBuildParameters = try swiftCommandState.productsBuildParameters
313+
productsBuildParameters.dataPath = workspace.location.scratchDirectory
314+
productsBuildParameters.apiDigesterMode = .generateBaselines(baselinesDirectory: baselineDir, modulesRequestingBaselines: modulesNeedingBaselines)
315+
316+
// Build the baseline module.
317+
// FIXME: We need to implement the build tool invocation closure here so that build tool plugins work with the APIDigester. rdar://86112934
318+
let buildSystem = try await swiftCommandState.createBuildSystem(
319+
traitConfiguration: .init(),
320+
cacheBuildManifest: false,
321+
productsBuildParameters: productsBuildParameters,
322+
packageGraphLoader: { graph }
323+
)
324+
try await buildSystem.build()
325+
return baselineModules
326+
}
327+
328+
private static func determineModulesToDiff(packageGraph: ModulesGraph, productNames: [String], targetNames: [String], observabilityScope: ObservabilityScope, diagnoseMissingNames: Bool) throws -> Set<String> {
177329
var modulesToDiff: Set<String> = []
178-
if products.isEmpty && targets.isEmpty {
330+
if productNames.isEmpty && targetNames.isEmpty {
179331
modulesToDiff.formUnion(packageGraph.apiDigesterModules)
180332
} else {
181-
for productName in products {
333+
for productName in productNames {
182334
guard let product = packageGraph
183335
.rootPackages
184336
.flatMap(\.products)
185337
.first(where: { $0.name == productName }) else {
186-
observabilityScope.emit(error: "no such product '\(productName)'")
338+
if diagnoseMissingNames {
339+
observabilityScope.emit(error: "no such product '\(productName)'")
340+
}
187341
continue
188342
}
189343
guard product.type.isLibrary else {
190-
observabilityScope.emit(error: "'\(productName)' is not a library product")
344+
if diagnoseMissingNames {
345+
observabilityScope.emit(error: "'\(productName)' is not a library product")
346+
}
191347
continue
192348
}
193349
modulesToDiff.formUnion(product.modules.filter { $0.underlying is SwiftModule }.map(\.c99name))
194350
}
195-
for targetName in targets {
351+
for targetName in targetNames {
196352
guard let target = packageGraph
197353
.rootPackages
198354
.flatMap(\.modules)
199355
.first(where: { $0.name == targetName }) else {
200-
observabilityScope.emit(error: "no such target '\(targetName)'")
356+
if diagnoseMissingNames {
357+
observabilityScope.emit(error: "no such target '\(targetName)'")
358+
}
201359
continue
202360
}
203361
guard target.type == .library else {
204-
observabilityScope.emit(error: "'\(targetName)' is not a library target")
362+
if diagnoseMissingNames {
363+
observabilityScope.emit(error: "'\(targetName)' is not a library target")
364+
}
205365
continue
206366
}
207367
guard target.underlying is SwiftModule else {
208-
observabilityScope.emit(error: "'\(targetName)' is not a Swift language target")
368+
if diagnoseMissingNames {
369+
observabilityScope.emit(error: "'\(targetName)' is not a Swift language target")
370+
}
209371
continue
210372
}
211373
modulesToDiff.insert(target.c99name)

Sources/CoreCommands/BuildSystemSupport.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory {
3636
packageGraphLoader: (() async throws -> ModulesGraph)?,
3737
outputStream: OutputByteStream?,
3838
logLevel: Diagnostic.Severity?,
39-
observabilityScope: ObservabilityScope?
39+
observabilityScope: ObservabilityScope?,
40+
delegate: BuildSystemDelegate?
4041
) async throws -> any BuildSystem {
4142
_ = try await swiftCommandState.getRootPackageInformation(traitConfiguration: traitConfiguration)
4243
let testEntryPointPath = productsBuildParameters?.testProductStyle.explicitlySpecifiedEntryPointPath
@@ -68,7 +69,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory {
6869
outputStream: outputStream ?? self.swiftCommandState.outputStream,
6970
logLevel: logLevel ?? self.swiftCommandState.logLevel,
7071
fileSystem: self.swiftCommandState.fileSystem,
71-
observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope)
72+
observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope,
73+
delegate: delegate)
7274
}
7375
}
7476

@@ -84,7 +86,8 @@ private struct XcodeBuildSystemFactory: BuildSystemFactory {
8486
packageGraphLoader: (() async throws -> ModulesGraph)?,
8587
outputStream: OutputByteStream?,
8688
logLevel: Diagnostic.Severity?,
87-
observabilityScope: ObservabilityScope?
89+
observabilityScope: ObservabilityScope?,
90+
delegate: BuildSystemDelegate?
8891
) throws -> any BuildSystem {
8992
return try XcodeBuildSystem(
9093
buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters,
@@ -97,7 +100,8 @@ private struct XcodeBuildSystemFactory: BuildSystemFactory {
97100
outputStream: outputStream ?? self.swiftCommandState.outputStream,
98101
logLevel: logLevel ?? self.swiftCommandState.logLevel,
99102
fileSystem: self.swiftCommandState.fileSystem,
100-
observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope
103+
observabilityScope: observabilityScope ?? self.swiftCommandState.observabilityScope,
104+
delegate: delegate
101105
)
102106
}
103107
}
@@ -115,6 +119,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory {
115119
outputStream: OutputByteStream?,
116120
logLevel: Diagnostic.Severity?,
117121
observabilityScope: ObservabilityScope?,
122+
delegate: BuildSystemDelegate?
118123
) throws -> any BuildSystem {
119124
return try SwiftBuildSystem(
120125
buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters,
@@ -135,6 +140,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory {
135140
workDirectory: try self.swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory,
136141
disableSandbox: self.swiftCommandState.shouldDisableSandbox
137142
),
143+
delegate: delegate
138144
)
139145
}
140146
}

Sources/CoreCommands/SwiftCommandState.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,8 @@ public final class SwiftCommandState {
787787
packageGraphLoader: (() async throws -> ModulesGraph)? = .none,
788788
outputStream: OutputByteStream? = .none,
789789
logLevel: Basics.Diagnostic.Severity? = nil,
790-
observabilityScope: ObservabilityScope? = .none
790+
observabilityScope: ObservabilityScope? = .none,
791+
delegate: BuildSystemDelegate? = nil
791792
) async throws -> BuildSystem {
792793
guard let buildSystemProvider else {
793794
fatalError("build system provider not initialized")
@@ -806,7 +807,8 @@ public final class SwiftCommandState {
806807
packageGraphLoader: packageGraphLoader,
807808
outputStream: outputStream,
808809
logLevel: logLevel ?? self.logLevel,
809-
observabilityScope: observabilityScope
810+
observabilityScope: observabilityScope,
811+
delegate: delegate
810812
)
811813

812814
// register the build system with the cancellation handler

0 commit comments

Comments
 (0)