diff --git a/CHANGELOG.md b/CHANGELOG.md index df3ac1957c..8b4507c047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ ### Enhancements +* Add `--strict-baseline` command-line flag (and `strict_baseline` configuration + key) that fails linting when violations recorded in the baseline are no longer + detected, encouraging users to regenerate the baseline once baselined + violations have been fixed. + [swizzlr](https://github.com/swizzlr) + [#6511](https://github.com/realm/SwiftLint/issues/6511) + * Print fixed code read from stdin to stdout. [SimplyDanny](https://github.com/SimplyDanny) [#6501](https://github.com/realm/SwiftLint/issues/6501) diff --git a/README.md b/README.md index 615db35016..13baa958df 100644 --- a/README.md +++ b/README.md @@ -742,6 +742,10 @@ baseline: Baseline.json # The path to save detected violations to as a new baseline. write_baseline: Baseline.json +# If true, SwiftLint will fail when violations recorded in the baseline are no +# longer detected, encouraging the baseline to be regenerated after fixes. +strict_baseline: false + # If true, SwiftLint will check for updates after linting or analyzing. check_for_updates: true diff --git a/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift b/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift index 1546e3dbdf..3e3edb258b 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift @@ -29,6 +29,7 @@ extension Configuration { lenient: childConfiguration.lenient, baseline: childConfiguration.baseline, writeBaseline: childConfiguration.writeBaseline, + strictBaseline: childConfiguration.strictBaseline, checkForUpdates: childConfiguration.checkForUpdates ) } diff --git a/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift b/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift index bf327accdb..9358e8920f 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift @@ -18,6 +18,7 @@ extension Configuration { case lenient = "lenient" case baseline = "baseline" case writeBaseline = "write_baseline" + case strictBaseline = "strict_baseline" case checkForUpdates = "check_for_updates" case childConfig = "child_config" case parentConfig = "parent_config" @@ -108,6 +109,7 @@ extension Configuration { lenient: dict[Key.lenient.rawValue] as? Bool ?? false, baseline: dict[Key.baseline.rawValue] as? String, writeBaseline: dict[Key.writeBaseline.rawValue] as? String, + strictBaseline: dict[Key.strictBaseline.rawValue] as? Bool ?? false, checkForUpdates: dict[Key.checkForUpdates.rawValue] as? Bool ?? false ) } diff --git a/Source/SwiftLintFramework/Configuration/Configuration.swift b/Source/SwiftLintFramework/Configuration/Configuration.swift index ee3e2fa788..1c96d276dd 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration.swift @@ -47,6 +47,9 @@ public struct Configuration { /// The path to write a baseline to. public let writeBaseline: String? + /// Fail linting when violations recorded in the baseline are no longer detected. + public let strictBaseline: Bool + /// Check for updates. public let checkForUpdates: Bool @@ -89,6 +92,7 @@ public struct Configuration { lenient: Bool, baseline: String?, writeBaseline: String?, + strictBaseline: Bool, checkForUpdates: Bool ) { self.rulesWrapper = rulesWrapper @@ -104,6 +108,7 @@ public struct Configuration { self.lenient = lenient self.baseline = baseline self.writeBaseline = writeBaseline + self.strictBaseline = strictBaseline self.checkForUpdates = checkForUpdates } @@ -125,6 +130,7 @@ public struct Configuration { lenient = configuration.lenient baseline = configuration.baseline writeBaseline = configuration.writeBaseline + strictBaseline = configuration.strictBaseline checkForUpdates = configuration.checkForUpdates } @@ -170,6 +176,7 @@ public struct Configuration { lenient: Bool = false, baseline: String? = nil, writeBaseline: String? = nil, + strictBaseline: Bool = false, checkForUpdates: Bool = false ) { if let pinnedVersion, pinnedVersion != Version.current.value { @@ -200,6 +207,7 @@ public struct Configuration { lenient: lenient, baseline: baseline, writeBaseline: writeBaseline, + strictBaseline: strictBaseline, checkForUpdates: checkForUpdates ) } @@ -316,6 +324,7 @@ extension Configuration: Hashable { hasher.combine(lenient) hasher.combine(baseline) hasher.combine(writeBaseline) + hasher.combine(strictBaseline) hasher.combine(checkForUpdates) hasher.combine(basedOnCustomConfigurationFiles) hasher.combine(cachePath) @@ -338,6 +347,7 @@ extension Configuration: Hashable { lhs.lenient == rhs.lenient && lhs.baseline == rhs.baseline && lhs.writeBaseline == rhs.writeBaseline && + lhs.strictBaseline == rhs.strictBaseline && lhs.checkForUpdates == rhs.checkForUpdates && lhs.rulesMode == rhs.rulesMode } diff --git a/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift b/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift index 6444adc84e..6d357ab658 100644 --- a/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift +++ b/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift @@ -44,6 +44,7 @@ package struct LintOrAnalyzeOptions { let reporter: String? let baseline: String? let writeBaseline: String? + let strictBaseline: Bool let workingDirectory: String? let quiet: Bool let output: String? @@ -73,6 +74,7 @@ package struct LintOrAnalyzeOptions { reporter: String?, baseline: String?, writeBaseline: String?, + strictBaseline: Bool, workingDirectory: String?, quiet: Bool, output: String?, @@ -101,6 +103,7 @@ package struct LintOrAnalyzeOptions { self.reporter = reporter self.baseline = baseline self.writeBaseline = writeBaseline + self.strictBaseline = strictBaseline self.workingDirectory = workingDirectory self.quiet = quiet self.output = output @@ -150,6 +153,7 @@ package struct LintOrAnalyzeCommand { if let baselineOutputPath = options.writeBaseline ?? builder.configuration.writeBaseline { try Baseline(violations: builder.unfilteredViolations).write(toPath: baselineOutputPath) } + appendFixedBaselineViolations(builder: builder) let numberOfSeriousViolations = try Signposts.record(name: "LintOrAnalyzeCommand.PostProcessViolations") { try postProcessViolations(files: files, builder: builder) } @@ -165,6 +169,7 @@ package struct LintOrAnalyzeCommand { let options = builder.options let visitorMutationQueue = DispatchQueue(label: "io.realm.swiftlint.lintVisitorMutation") let baseline = try baseline(options, builder.configuration) + builder.baseline = baseline return try await builder.configuration.visitLintableFiles(options: options, cache: builder.cache, storage: builder.storage) { linter in let currentViolations: [StyleViolation] @@ -234,6 +239,22 @@ package struct LintOrAnalyzeCommand { return numberOfSeriousViolations } + private static func appendFixedBaselineViolations(builder: LintOrAnalyzeResultBuilder) { + let options = builder.options + let configuration = builder.configuration + guard options.strictBaseline || configuration.strictBaseline, + let baseline = builder.baseline else { + return + } + let currentBaseline = Baseline(violations: builder.unfilteredViolations) + let fixedViolations = currentBaseline.compare(baseline).map(createFixedBaselineViolation) + guard fixedViolations.isNotEmpty else { + return + } + builder.violations += fixedViolations + builder.report(violations: fixedViolations, realtimeCondition: true) + } + private static func baseline(_ options: LintOrAnalyzeOptions, _ configuration: Configuration) throws -> Baseline? { if let baselinePath = options.baseline ?? configuration.baseline { do { @@ -266,6 +287,24 @@ package struct LintOrAnalyzeCommand { return numberOfWarningViolations >= warningThreshold } + private static func createFixedBaselineViolation(fromBaseline violation: StyleViolation) -> StyleViolation { + let description = RuleDescription( + identifier: "fixed_baseline_violation", + name: "Fixed Baseline", + description: "A violation recorded in the baseline has been fixed or is no longer detected", + kind: .lint + ) + return StyleViolation( + ruleDescription: description, + severity: .error, + location: violation.location, + reason: """ + Violation previously recorded in the baseline for '\(violation.ruleIdentifier)' is no longer \ + detected. Regenerate the baseline to acknowledge this fix + """ + ) + } + private static func createThresholdViolation(threshold: Int) -> StyleViolation { let description = RuleDescription( identifier: "warning_threshold", @@ -374,6 +413,7 @@ private class LintOrAnalyzeResultBuilder { var unfilteredViolations = [StyleViolation]() /// The violations to be reported, possibly filtered by a baseline, plus any threshold violations. var violations = [StyleViolation]() + var baseline: Baseline? let storage = RuleStorage() let configuration: Configuration let reporter: any Reporter.Type diff --git a/Source/swiftlint/Commands/Analyze.swift b/Source/swiftlint/Commands/Analyze.swift index 968fb99e27..5621e444c8 100644 --- a/Source/swiftlint/Commands/Analyze.swift +++ b/Source/swiftlint/Commands/Analyze.swift @@ -34,6 +34,7 @@ extension SwiftLint { reporter: common.reporter, baseline: common.baseline, writeBaseline: common.writeBaseline, + strictBaseline: common.strictBaseline, workingDirectory: common.workingDirectory, quiet: quiet, output: common.output, diff --git a/Source/swiftlint/Commands/Lint.swift b/Source/swiftlint/Commands/Lint.swift index 56c8cca192..1412829399 100644 --- a/Source/swiftlint/Commands/Lint.swift +++ b/Source/swiftlint/Commands/Lint.swift @@ -51,6 +51,7 @@ extension SwiftLint { reporter: common.reporter, baseline: common.baseline, writeBaseline: common.writeBaseline, + strictBaseline: common.strictBaseline, workingDirectory: common.workingDirectory, quiet: quiet, output: common.output, diff --git a/Source/swiftlint/Common/LintOrAnalyzeArguments.swift b/Source/swiftlint/Common/LintOrAnalyzeArguments.swift index b47e4162a6..8963868f7e 100644 --- a/Source/swiftlint/Common/LintOrAnalyzeArguments.swift +++ b/Source/swiftlint/Common/LintOrAnalyzeArguments.swift @@ -46,6 +46,12 @@ struct LintOrAnalyzeArguments: ParsableArguments { var baseline: String? @Option(help: "The path to save detected violations to as a new baseline.") var writeBaseline: String? + @Flag(help: """ + Fail linting if violations previously recorded in the baseline are no longer \ + detected. Encourages keeping the baseline up-to-date by regenerating it \ + once baselined violations have been fixed. + """) + var strictBaseline = false @Option(help: "The working directory to use when running SwiftLint.") var workingDirectory: String? @Option(help: "The file where violations should be saved. Prints to stdout by default.") diff --git a/Tests/FileSystemAccessTests/BaselineTests.swift b/Tests/FileSystemAccessTests/BaselineTests.swift index 466976fc88..057c9a74d8 100644 --- a/Tests/FileSystemAccessTests/BaselineTests.swift +++ b/Tests/FileSystemAccessTests/BaselineTests.swift @@ -137,6 +137,20 @@ final class BaselineTests: XCTestCase { } } + func testCompareDetectsFixedViolations() throws { + try withExampleFileCreated { sourceFilePath in + let originalViolations = Self.violations(for: sourceFilePath) + let oldBaseline = Baseline(violations: originalViolations) + // Simulate some baselined violations being fixed. + let remainingViolations = Array(originalViolations.dropFirst(2)) + let newBaseline = Baseline(violations: remainingViolations) + // `newBaseline.compare(oldBaseline)` yields violations in the old + // baseline that are no longer detected — i.e., fixed violations. + let fixed = newBaseline.compare(oldBaseline) + XCTAssertEqual(fixed.count, 2) + } + } + func testCompare() throws { try withExampleFileCreated { sourceFilePath in let ruleDescriptions = Self.ruleDescriptions + Self.ruleDescriptions diff --git a/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift b/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift index 7913fc4a35..c8954bfbdd 100644 --- a/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift +++ b/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift @@ -54,6 +54,7 @@ private extension LintOrAnalyzeOptions { reporter: nil, baseline: nil, writeBaseline: nil, + strictBaseline: false, workingDirectory: nil, quiet: false, output: nil,