|
| 1 | +//===----------------------------------------------------------------------===// |
| 2 | +// |
| 3 | +// This source file is part of the Swift open source project |
| 4 | +// |
| 5 | +// Copyright (c) 2014-2022 Apple Inc. and the Swift project authors |
| 6 | +// Licensed under Apache License v2.0 with Runtime Library Exception |
| 7 | +// |
| 8 | +// See http://swift.org/LICENSE.txt for license information |
| 9 | +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors |
| 10 | +// |
| 11 | +//===----------------------------------------------------------------------===// |
| 12 | + |
| 13 | +import ArgumentParser |
| 14 | +import Basics |
| 15 | +import CoreCommands |
| 16 | +import Dispatch |
| 17 | +import PackageGraph |
| 18 | +import PackageModel |
| 19 | +import SourceControl |
| 20 | +import TSCBasic |
| 21 | + |
| 22 | +struct DeprecatedAPIDiff: ParsableCommand { |
| 23 | + static let configuration = CommandConfiguration(commandName: "experimental-api-diff", |
| 24 | + abstract: "Deprecated - use `swift package diagnose-api-breaking-changes` instead", |
| 25 | + shouldDisplay: false) |
| 26 | + |
| 27 | + @Argument(parsing: .unconditionalRemaining) |
| 28 | + var args: [String] = [] |
| 29 | + |
| 30 | + func run() throws { |
| 31 | + print("`swift package experimental-api-diff` has been renamed to `swift package diagnose-api-breaking-changes`") |
| 32 | + throw ExitCode.failure |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +struct APIDiff: SwiftCommand { |
| 37 | + static let configuration = CommandConfiguration( |
| 38 | + commandName: "diagnose-api-breaking-changes", |
| 39 | + abstract: "Diagnose API-breaking changes to Swift modules in a package", |
| 40 | + discussion: """ |
| 41 | + The diagnose-api-breaking-changes command can be used to compare the Swift API of \ |
| 42 | + a package to a baseline revision, diagnosing any breaking changes which have \ |
| 43 | + been introduced. By default, it compares every Swift module from the baseline \ |
| 44 | + revision which is part of a library product. For packages with many targets, this \ |
| 45 | + behavior may be undesirable as the comparison can be slow. \ |
| 46 | + The `--products` and `--targets` options may be used to restrict the scope of \ |
| 47 | + the comparison. |
| 48 | + """) |
| 49 | + |
| 50 | + @OptionGroup(_hiddenFromHelp: true) |
| 51 | + var globalOptions: GlobalOptions |
| 52 | + |
| 53 | + @Option(help: """ |
| 54 | + The path to a text file containing breaking changes which should be ignored by the API comparison. \ |
| 55 | + Each ignored breaking change in the file should appear on its own line and contain the exact message \ |
| 56 | + to be ignored (e.g. 'API breakage: func foo() has been removed'). |
| 57 | + """) |
| 58 | + var breakageAllowlistPath: AbsolutePath? |
| 59 | + |
| 60 | + @Argument(help: "The baseline treeish to compare to (e.g. a commit hash, branch name, tag, etc.)") |
| 61 | + var treeish: String |
| 62 | + |
| 63 | + @Option(parsing: .upToNextOption, |
| 64 | + help: "One or more products to include in the API comparison. If present, only the specified products (and any targets specified using `--targets`) will be compared.") |
| 65 | + var products: [String] = [] |
| 66 | + |
| 67 | + @Option(parsing: .upToNextOption, |
| 68 | + help: "One or more targets to include in the API comparison. If present, only the specified targets (and any products specified using `--products`) will be compared.") |
| 69 | + var targets: [String] = [] |
| 70 | + |
| 71 | + @Option(name: .customLong("baseline-dir"), |
| 72 | + help: "The path to a directory used to store API baseline files. If unspecified, a temporary directory will be used.") |
| 73 | + var overrideBaselineDir: AbsolutePath? |
| 74 | + |
| 75 | + @Flag(help: "Regenerate the API baseline, even if an existing one is available.") |
| 76 | + var regenerateBaseline: Bool = false |
| 77 | + |
| 78 | + func run(_ swiftTool: SwiftTool) throws { |
| 79 | + let apiDigesterPath = try swiftTool.getDestinationToolchain().getSwiftAPIDigester() |
| 80 | + let apiDigesterTool = SwiftAPIDigester(fileSystem: swiftTool.fileSystem, tool: apiDigesterPath) |
| 81 | + |
| 82 | + let packageRoot = try globalOptions.locations.packageDirectory ?? swiftTool.getPackageRoot() |
| 83 | + let repository = GitRepository(path: packageRoot) |
| 84 | + let baselineRevision = try repository.resolveRevision(identifier: treeish) |
| 85 | + |
| 86 | + // We turn build manifest caching off because we need the build plan. |
| 87 | + let buildSystem = try swiftTool.createBuildSystem(explicitBuildSystem: .native, cacheBuildManifest: false) |
| 88 | + |
| 89 | + let packageGraph = try buildSystem.getPackageGraph() |
| 90 | + let modulesToDiff = try determineModulesToDiff( |
| 91 | + packageGraph: packageGraph, |
| 92 | + observabilityScope: swiftTool.observabilityScope |
| 93 | + ) |
| 94 | + |
| 95 | + // Build the current package. |
| 96 | + try buildSystem.build() |
| 97 | + |
| 98 | + // Dump JSON for the baseline package. |
| 99 | + let baselineDumper = try APIDigesterBaselineDumper( |
| 100 | + baselineRevision: baselineRevision, |
| 101 | + packageRoot: swiftTool.getPackageRoot(), |
| 102 | + buildParameters: try buildSystem.buildPlan.buildParameters, |
| 103 | + apiDigesterTool: apiDigesterTool, |
| 104 | + observabilityScope: swiftTool.observabilityScope |
| 105 | + ) |
| 106 | + |
| 107 | + let baselineDir = try baselineDumper.emitAPIBaseline( |
| 108 | + for: modulesToDiff, |
| 109 | + at: overrideBaselineDir, |
| 110 | + force: regenerateBaseline, |
| 111 | + logLevel: swiftTool.logLevel, |
| 112 | + swiftTool: swiftTool |
| 113 | + ) |
| 114 | + |
| 115 | + let results = ThreadSafeArrayStore<SwiftAPIDigester.ComparisonResult>() |
| 116 | + let group = DispatchGroup() |
| 117 | + let semaphore = DispatchSemaphore(value: Int(try buildSystem.buildPlan.buildParameters.jobs)) |
| 118 | + var skippedModules: Set<String> = [] |
| 119 | + |
| 120 | + for module in modulesToDiff { |
| 121 | + let moduleBaselinePath = baselineDir.appending(component: "\(module).json") |
| 122 | + guard swiftTool.fileSystem.exists(moduleBaselinePath) else { |
| 123 | + print("\nSkipping \(module) because it does not exist in the baseline") |
| 124 | + skippedModules.insert(module) |
| 125 | + continue |
| 126 | + } |
| 127 | + semaphore.wait() |
| 128 | + DispatchQueue.sharedConcurrent.async(group: group) { |
| 129 | + do { |
| 130 | + if let comparisonResult = try apiDigesterTool.compareAPIToBaseline( |
| 131 | + at: moduleBaselinePath, |
| 132 | + for: module, |
| 133 | + buildPlan: try buildSystem.buildPlan, |
| 134 | + except: breakageAllowlistPath |
| 135 | + ) { |
| 136 | + results.append(comparisonResult) |
| 137 | + } |
| 138 | + } catch { |
| 139 | + swiftTool.observabilityScope.emit(error: "failed to compare API to baseline: \(error)") |
| 140 | + } |
| 141 | + semaphore.signal() |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + group.wait() |
| 146 | + |
| 147 | + let failedModules = modulesToDiff |
| 148 | + .subtracting(skippedModules) |
| 149 | + .subtracting(results.map(\.moduleName)) |
| 150 | + for failedModule in failedModules { |
| 151 | + swiftTool.observabilityScope.emit(error: "failed to read API digester output for \(failedModule)") |
| 152 | + } |
| 153 | + |
| 154 | + for result in results.get() { |
| 155 | + try self.printComparisonResult(result, observabilityScope: swiftTool.observabilityScope) |
| 156 | + } |
| 157 | + |
| 158 | + guard failedModules.isEmpty && results.get().allSatisfy(\.hasNoAPIBreakingChanges) else { |
| 159 | + throw ExitCode.failure |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + private func determineModulesToDiff(packageGraph: PackageGraph, observabilityScope: ObservabilityScope) throws -> Set<String> { |
| 164 | + var modulesToDiff: Set<String> = [] |
| 165 | + if products.isEmpty && targets.isEmpty { |
| 166 | + modulesToDiff.formUnion(packageGraph.apiDigesterModules) |
| 167 | + } else { |
| 168 | + for productName in products { |
| 169 | + guard let product = packageGraph |
| 170 | + .rootPackages |
| 171 | + .flatMap(\.products) |
| 172 | + .first(where: { $0.name == productName }) else { |
| 173 | + observabilityScope.emit(error: "no such product '\(productName)'") |
| 174 | + continue |
| 175 | + } |
| 176 | + guard product.type.isLibrary else { |
| 177 | + observabilityScope.emit(error: "'\(productName)' is not a library product") |
| 178 | + continue |
| 179 | + } |
| 180 | + modulesToDiff.formUnion(product.targets.filter { $0.underlyingTarget is SwiftTarget }.map(\.c99name)) |
| 181 | + } |
| 182 | + for targetName in targets { |
| 183 | + guard let target = packageGraph |
| 184 | + .rootPackages |
| 185 | + .flatMap(\.targets) |
| 186 | + .first(where: { $0.name == targetName }) else { |
| 187 | + observabilityScope.emit(error: "no such target '\(targetName)'") |
| 188 | + continue |
| 189 | + } |
| 190 | + guard target.type == .library else { |
| 191 | + observabilityScope.emit(error: "'\(targetName)' is not a library target") |
| 192 | + continue |
| 193 | + } |
| 194 | + guard target.underlyingTarget is SwiftTarget else { |
| 195 | + observabilityScope.emit(error: "'\(targetName)' is not a Swift language target") |
| 196 | + continue |
| 197 | + } |
| 198 | + modulesToDiff.insert(target.c99name) |
| 199 | + } |
| 200 | + guard !observabilityScope.errorsReported else { |
| 201 | + throw ExitCode.failure |
| 202 | + } |
| 203 | + } |
| 204 | + return modulesToDiff |
| 205 | + } |
| 206 | + |
| 207 | + private func printComparisonResult( |
| 208 | + _ comparisonResult: SwiftAPIDigester.ComparisonResult, |
| 209 | + observabilityScope: ObservabilityScope |
| 210 | + ) throws { |
| 211 | + for diagnostic in comparisonResult.otherDiagnostics { |
| 212 | + let metadata = try diagnostic.location.map { location -> ObservabilityMetadata in |
| 213 | + var metadata = ObservabilityMetadata() |
| 214 | + metadata.fileLocation = .init( |
| 215 | + try .init(validating: location.filename), |
| 216 | + line: location.line < Int.max ? Int(location.line) : .none |
| 217 | + ) |
| 218 | + return metadata |
| 219 | + } |
| 220 | + |
| 221 | + switch diagnostic.level { |
| 222 | + case .error, .fatal: |
| 223 | + observabilityScope.emit(error: diagnostic.text, metadata: metadata) |
| 224 | + case .warning: |
| 225 | + observabilityScope.emit(warning: diagnostic.text, metadata: metadata) |
| 226 | + case .note: |
| 227 | + observabilityScope.emit(info: diagnostic.text, metadata: metadata) |
| 228 | + case .remark: |
| 229 | + observabilityScope.emit(info: diagnostic.text, metadata: metadata) |
| 230 | + case .ignored: |
| 231 | + break |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + let moduleName = comparisonResult.moduleName |
| 236 | + if comparisonResult.apiBreakingChanges.isEmpty { |
| 237 | + print("\nNo breaking changes detected in \(moduleName)") |
| 238 | + } else { |
| 239 | + let count = comparisonResult.apiBreakingChanges.count |
| 240 | + print("\n\(count) breaking \(count > 1 ? "changes" : "change") detected in \(moduleName):") |
| 241 | + for change in comparisonResult.apiBreakingChanges { |
| 242 | + print(" 💔 \(change.text)") |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | +} |
0 commit comments