Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, targetDependencies: Set<String>) -> Set<String> {
Expand All @@ -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<String>,
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<String>,
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)
}
}

Expand Down
110 changes: 110 additions & 0 deletions Plugins/explicitDependencyImportCheckPlugin/PluginConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down