-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Implement diagnose-api-breaking-changes support with --build-system swiftbuild #8857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<String>) 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<String> { | ||
private func runWithIntegratedAPIDigesterSupport(_ swiftCommandState: SwiftCommandState, baselineRevision: Revision, baselineDir: Basics.AbsolutePath, modulesToDiff: Set<String>) async throws { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Consider created automated tests that validate this function in isolation with happy path, and also fault injection, and more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: consider type aliasing, or creating more specific type, for the |
||
// 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<String>) async throws -> Set<String> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Consider created automated tests that validate this function in isolation with happy path, and also fault injection, and more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: consider type aliasing, or creating more specific type, for the return value to aid in code readability. For example, instead of |
||
// 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<String> { | ||
var modulesToDiff: Set<String> = [] | ||
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) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: What are we using in TSCBasic here? Can we replace it with a symbol that is not in Swift Tool Support Core?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ByteString type is defined there, so this is just formalizing a dependency that already existed now that a couple extension methods trigger MemberImportVisibility requirements