From 070968f9307fb92426eb434857d9e6fc9fc292c7 Mon Sep 17 00:00:00 2001 From: Thomas Catterall <1848665+swizzlr@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:56:18 -0400 Subject: [PATCH] Add --strict-baseline flag to fail lint when baselined violations are fixed Adds a new `--strict-baseline` command-line flag and matching `strict_baseline` configuration key. When enabled, SwiftLint compares the stored baseline against the current run and reports an error for each violation recorded in the baseline that is no longer detected, encouraging users to regenerate the baseline after fixes. Fixes #6511 --- CHANGELOG.md | 7 ++++ README.md | 4 ++ .../Configuration/Configuration+Merging.swift | 1 + .../Configuration/Configuration+Parsing.swift | 2 + .../Configuration/Configuration.swift | 10 +++++ .../LintOrAnalyzeCommand.swift | 40 +++++++++++++++++++ Source/swiftlint/Commands/Analyze.swift | 1 + Source/swiftlint/Commands/Lint.swift | 1 + .../Common/LintOrAnalyzeArguments.swift | 6 +++ .../FileSystemAccessTests/BaselineTests.swift | 14 +++++++ .../LintOrAnalyzeOptionsTests.swift | 1 + 11 files changed, 87 insertions(+) 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,