From 96c243b1bd7c66ffd1a02d1060b14e02251a1f72 Mon Sep 17 00:00:00 2001 From: Kristijan Mileski Date: Tue, 28 Jul 2026 11:27:23 +0200 Subject: [PATCH] Add configurable dependency diagnostics --- .../ExplicitDependencyImportCheckPlugin.swift | 77 ++++++------ .../PluginConfiguration.swift | 110 ++++++++++++++++++ README.md | 22 ++++ 3 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 Plugins/explicitDependencyImportCheckPlugin/PluginConfiguration.swift diff --git a/Plugins/explicitDependencyImportCheckPlugin/ExplicitDependencyImportCheckPlugin.swift b/Plugins/explicitDependencyImportCheckPlugin/ExplicitDependencyImportCheckPlugin.swift index 44659b7..de78b5e 100644 --- a/Plugins/explicitDependencyImportCheckPlugin/ExplicitDependencyImportCheckPlugin.swift +++ b/Plugins/explicitDependencyImportCheckPlugin/ExplicitDependencyImportCheckPlugin.swift @@ -4,21 +4,24 @@ import PackagePlugin @main struct ExplicitDependencyImportCheckPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: any Target) async throws -> [Command] { + let configuration = try PluginConfiguration.load(from: context) let targetDependencies = findSpmTargetDependencies(fromTarget: target) let importsInCode = try findImportsUsedInCode(context: context, path: target.directory.string) let transitiveDependencies = findTransitiveDependencies(imports: importsInCode, targetDependencies: targetDependencies) let unusedTargetDependencies = findUnusedTargetDependencies(imports: importsInCode, targetDependencies: targetDependencies) - return (try exportTransitiveDependenciesAsErrors( - transitiveDeps: transitiveDependencies, - forTarget: target, - inContext: context - )) + - (try exportUnusedDependenciesAsWarnings( - unusedDependencies: unusedTargetDependencies, - forTarget: target, - inContext: context - )) + exportTransitiveDependencies( + transitiveDeps: transitiveDependencies, + severity: configuration.transitiveDependencySeverity, + forTarget: target + ) + exportUnusedDependencies( + unusedDependencies: unusedTargetDependencies, + severity: configuration.unusedDependencySeverity, + forTarget: target + ) + + return [] } func findTransitiveDependencies(imports: Set, targetDependencies: Set) -> Set { @@ -32,45 +35,37 @@ struct ExplicitDependencyImportCheckPlugin: BuildToolPlugin { } extension ExplicitDependencyImportCheckPlugin { - /// This function exports the transitive dependencies to Xcode as an error. - func exportTransitiveDependenciesAsErrors( + /// This function exports the transitive dependencies to Xcode. + func exportTransitiveDependencies( transitiveDeps: Set, - forTarget target: Target, - inContext context: PluginContext - ) throws -> [Command] { - guard !transitiveDeps.isEmpty else { return [] } + severity: DiagnosticSeverity, + forTarget target: Target + ) { + guard !transitiveDeps.isEmpty else { return } - var transitiveDependenciesErrorText = "warning: \(transitiveDeps.count) Transitive dependencies found for \(target.name) 🚨🚨🚨\n" - transitiveDependenciesErrorText += transitiveDeps.map { "👉 \($0) is a transitive dependency" } - .joined(separator: "\n") + var message = "\(transitiveDeps.count) transitive dependencies found for \(target.name):\n" + message += transitiveDeps + .sorted() + .map { "\($0) is a transitive dependency" } + .joined(separator: "\n") - let echoTool = try context.tool(named: "echo") - return [.buildCommand( - displayName: "Transitive Dependencies Report", - executable: echoTool.path, - arguments: [transitiveDependenciesErrorText], - environment: [:] - )] + severity.emit(message) } - func exportUnusedDependenciesAsWarnings( + func exportUnusedDependencies( unusedDependencies: Set, - forTarget target: Target, - inContext context: PluginContext - ) throws -> [Command] { - guard !unusedDependencies.isEmpty else { return [] } + severity: DiagnosticSeverity, + forTarget target: Target + ) { + guard !unusedDependencies.isEmpty else { return } - var warningText = "warning: \(unusedDependencies.count) Extraneous dependencies found for \(target.name) ⚠️⚠️\n" - warningText += unusedDependencies.map { "👉 \($0) is an extraneous dependency" } - .joined(separator: "\n") + var message = "\(unusedDependencies.count) extraneous dependencies found for \(target.name):\n" + message += unusedDependencies + .sorted() + .map { "\($0) is an extraneous dependency" } + .joined(separator: "\n") - let echoTool = try context.tool(named: "echo") - return [.buildCommand( - displayName: "Extraneous Dependencies Report", - executable: echoTool.path, - arguments: [warningText], - environment: [:] - )] + severity.emit(message) } } diff --git a/Plugins/explicitDependencyImportCheckPlugin/PluginConfiguration.swift b/Plugins/explicitDependencyImportCheckPlugin/PluginConfiguration.swift new file mode 100644 index 0000000..09b1205 --- /dev/null +++ b/Plugins/explicitDependencyImportCheckPlugin/PluginConfiguration.swift @@ -0,0 +1,110 @@ +import Foundation +import PackagePlugin + +struct PluginConfiguration { + var transitiveDependencySeverity: DiagnosticSeverity = .error + var unusedDependencySeverity: DiagnosticSeverity = .warning + + static func load(from context: PluginContext) throws -> PluginConfiguration { + let configPath = [ + ".explicit-dependency-import-check.yml", + ".explicit-dependency-import-check.yaml" + ] + .map { context.package.directory.appending($0).string } + .first { FileManager.default.fileExists(atPath: $0) } + + guard let configPath = configPath else { + return PluginConfiguration() + } + + let contents = try String(contentsOfFile: configPath, encoding: .utf8) + return try PluginConfiguration(contents: contents, path: configPath) + } + + init() {} + + init(contents: String, path: String) throws { + self.init() + + for (lineIndex, line) in contents.components(separatedBy: "\n").enumerated() { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedLine.isEmpty, !trimmedLine.hasPrefix("#") else { + continue + } + + let parts = trimmedLine.split(separator: ":", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespaces) + } + + guard parts.count == 2 else { + throw PluginConfigurationError.invalidLine(path: path, line: lineIndex + 1) + } + + let key = parts[0] + let rawValue = parts[1].split(separator: "#", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + ?? "" + + guard let severity = DiagnosticSeverity(rawValue: rawValue) else { + throw PluginConfigurationError.invalidSeverity( + path: path, + line: lineIndex + 1, + value: rawValue + ) + } + + switch key { + case "transitiveDependencySeverity": + transitiveDependencySeverity = severity + case "unusedDependencySeverity": + unusedDependencySeverity = severity + default: + throw PluginConfigurationError.unknownKey( + path: path, + line: lineIndex + 1, + key: key + ) + } + } + } +} + +enum DiagnosticSeverity: String { + case error + case warning + case ignore + + func emit(_ message: String) { + switch self { + case .error: + Diagnostics.error(message) + case .warning: + Diagnostics.warning(message) + case .ignore: + break + } + } +} + +enum PluginConfigurationError: Error, CustomStringConvertible, LocalizedError { + case invalidLine(path: String, line: Int) + case invalidSeverity(path: String, line: Int, value: String) + case unknownKey(path: String, line: Int, key: String) + + var description: String { + switch self { + case let .invalidLine(path, line): + return "\(path):\(line): expected `key: value`." + case let .invalidSeverity(path, line, value): + return "\(path):\(line): invalid severity `\(value)`. Expected `error`, `warning`, or `ignore`." + case let .unknownKey(path, line, key): + return "\(path):\(line): unknown configuration key `\(key)`." + } + } + + var errorDescription: String? { + description + } +} diff --git a/README.md b/README.md index b1b0efa..ee79886 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ Once added, the plugin will automatically check for transitive dependencies each ![CleanShot 2024-10-30 at 17 44 34@2x](https://github.com/user-attachments/assets/001cf46d-442a-4d6a-94d4-19cb29892e40) +## ⚙️ Configuration + +By default, transitive dependencies are reported as errors and unused dependencies are reported as warnings. You can override this behavior by adding `.explicit-dependency-import-check.yml` or `.explicit-dependency-import-check.yaml` to the root of your package: + +```yaml +transitiveDependencySeverity: error +unusedDependencySeverity: warning +``` + +Supported severities are: + +- `error`: report the finding and fail the build +- `warning`: report the finding without failing the build +- `ignore`: do not report the finding + +For example, to report transitive dependencies without failing the build: + +```yaml +transitiveDependencySeverity: warning +unusedDependencySeverity: warning +``` + ## 🤝 Contributions