From 0f0694058ac93a552a61d785294674a3d724875b Mon Sep 17 00:00:00 2001 From: Thomas Catterall <1848665+swizzlr@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:11:54 -0400 Subject: [PATCH] Add opt-in --pretty-baseline flag for reviewable baseline diffs Introduces a `--pretty-baseline` flag (plus `pretty_baseline` configuration key) that writes baseline files as pretty-printed JSON with sorted keys and unescaped slashes. Violations have always been sorted by file and location, so the complete opt-in output is deterministic across runs and produces stable, human-reviewable diffs whenever the baseline is regenerated. The default output is unchanged, so existing baselines remain byte-identical. `--pretty-baseline` without `--write-baseline` is rejected at argument-parse time. --- CHANGELOG.md | 7 ++ README.md | 4 + Source/SwiftLintCore/Models/Baseline.swift | 13 ++- .../Configuration/Configuration+Merging.swift | 1 + .../Configuration/Configuration+Parsing.swift | 2 + .../Configuration/Configuration.swift | 10 ++ .../LintOrAnalyzeCommand.swift | 6 +- Source/swiftlint/Commands/Analyze.swift | 1 + Source/swiftlint/Commands/Lint.swift | 1 + .../Common/LintOrAnalyzeArguments.swift | 11 +++ .../FileSystemAccessTests/BaselineTests.swift | 95 +++++++++++++++++++ .../LintOrAnalyzeOptionsTests.swift | 1 + 12 files changed, 149 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3ac1957c..fdb4d73354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ ### Enhancements +* Add `--pretty-baseline` flag (and matching `pretty_baseline` + configuration key) that writes baseline files as pretty-printed JSON + with sorted keys, producing stable, reviewable diffs when the baseline + is regenerated. Default behaviour is unchanged so existing baselines + remain byte-identical. + [swizzlr](https://github.com/swizzlr) + * 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..e6dfb65034 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, the baseline produced by write_baseline is pretty-printed with +# sorted keys, producing stable diffs when regenerated. Defaults to false. +pretty_baseline: false + # If true, SwiftLint will check for updates after linting or analyzing. check_for_updates: true diff --git a/Source/SwiftLintCore/Models/Baseline.swift b/Source/SwiftLintCore/Models/Baseline.swift index 4e850db5ea..a12c9de89d 100644 --- a/Source/SwiftLintCore/Models/Baseline.swift +++ b/Source/SwiftLintCore/Models/Baseline.swift @@ -63,8 +63,17 @@ public struct Baseline: Equatable { /// Writes a `Baseline` to disk in JSON format. /// /// - parameter toPath: The path to write to. - public func write(toPath path: String) throws { - let data = try JSONEncoder().encode(sortedBaselineViolations) + /// - parameter pretty: If `true`, output is pretty-printed with sorted keys + /// and unescaped slashes, producing stable, reviewable + /// diffs. Defaults to `false` for backwards + /// compatibility with baselines written by older + /// versions of SwiftLint. + public func write(toPath path: String, pretty: Bool = false) throws { + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + } + let data = try encoder.encode(sortedBaselineViolations) try data.write(to: URL(fileURLWithPath: path)) } diff --git a/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift b/Source/SwiftLintFramework/Configuration/Configuration+Merging.swift index 1546e3dbdf..fcf020a93c 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, + prettyBaseline: childConfiguration.prettyBaseline, checkForUpdates: childConfiguration.checkForUpdates ) } diff --git a/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift b/Source/SwiftLintFramework/Configuration/Configuration+Parsing.swift index bf327accdb..8355deff06 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 prettyBaseline = "pretty_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, + prettyBaseline: dict[Key.prettyBaseline.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..99a8d0ffd9 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? + /// Pretty-print the baseline output with sorted keys. + public let prettyBaseline: Bool + /// Check for updates. public let checkForUpdates: Bool @@ -89,6 +92,7 @@ public struct Configuration { lenient: Bool, baseline: String?, writeBaseline: String?, + prettyBaseline: Bool, checkForUpdates: Bool ) { self.rulesWrapper = rulesWrapper @@ -104,6 +108,7 @@ public struct Configuration { self.lenient = lenient self.baseline = baseline self.writeBaseline = writeBaseline + self.prettyBaseline = prettyBaseline self.checkForUpdates = checkForUpdates } @@ -125,6 +130,7 @@ public struct Configuration { lenient = configuration.lenient baseline = configuration.baseline writeBaseline = configuration.writeBaseline + prettyBaseline = configuration.prettyBaseline checkForUpdates = configuration.checkForUpdates } @@ -170,6 +176,7 @@ public struct Configuration { lenient: Bool = false, baseline: String? = nil, writeBaseline: String? = nil, + prettyBaseline: 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, + prettyBaseline: prettyBaseline, checkForUpdates: checkForUpdates ) } @@ -316,6 +324,7 @@ extension Configuration: Hashable { hasher.combine(lenient) hasher.combine(baseline) hasher.combine(writeBaseline) + hasher.combine(prettyBaseline) 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.prettyBaseline == rhs.prettyBaseline && lhs.checkForUpdates == rhs.checkForUpdates && lhs.rulesMode == rhs.rulesMode } diff --git a/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift b/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift index 6444adc84e..0fc6a7d77c 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 prettyBaseline: Bool let workingDirectory: String? let quiet: Bool let output: String? @@ -73,6 +74,7 @@ package struct LintOrAnalyzeOptions { reporter: String?, baseline: String?, writeBaseline: String?, + prettyBaseline: Bool, workingDirectory: String?, quiet: Bool, output: String?, @@ -101,6 +103,7 @@ package struct LintOrAnalyzeOptions { self.reporter = reporter self.baseline = baseline self.writeBaseline = writeBaseline + self.prettyBaseline = prettyBaseline self.workingDirectory = workingDirectory self.quiet = quiet self.output = output @@ -148,7 +151,8 @@ package struct LintOrAnalyzeCommand { let builder = LintOrAnalyzeResultBuilder(options) let files = try await collectViolations(builder: builder) if let baselineOutputPath = options.writeBaseline ?? builder.configuration.writeBaseline { - try Baseline(violations: builder.unfilteredViolations).write(toPath: baselineOutputPath) + let pretty = options.prettyBaseline || builder.configuration.prettyBaseline + try Baseline(violations: builder.unfilteredViolations).write(toPath: baselineOutputPath, pretty: pretty) } let numberOfSeriousViolations = try Signposts.record(name: "LintOrAnalyzeCommand.PostProcessViolations") { try postProcessViolations(files: files, builder: builder) diff --git a/Source/swiftlint/Commands/Analyze.swift b/Source/swiftlint/Commands/Analyze.swift index 968fb99e27..a314756a92 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, + prettyBaseline: common.prettyBaseline, workingDirectory: common.workingDirectory, quiet: quiet, output: common.output, diff --git a/Source/swiftlint/Commands/Lint.swift b/Source/swiftlint/Commands/Lint.swift index 56c8cca192..8c0554f424 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, + prettyBaseline: common.prettyBaseline, workingDirectory: common.workingDirectory, quiet: quiet, output: common.output, diff --git a/Source/swiftlint/Common/LintOrAnalyzeArguments.swift b/Source/swiftlint/Common/LintOrAnalyzeArguments.swift index b47e4162a6..4013d098c6 100644 --- a/Source/swiftlint/Common/LintOrAnalyzeArguments.swift +++ b/Source/swiftlint/Common/LintOrAnalyzeArguments.swift @@ -46,6 +46,11 @@ struct LintOrAnalyzeArguments: ParsableArguments { var baseline: String? @Option(help: "The path to save detected violations to as a new baseline.") var writeBaseline: String? + @Flag(help: """ + Pretty-print the baseline output with sorted keys and unescaped slashes, \ + producing stable, reviewable diffs. Only applies when writing a baseline. + """) + var prettyBaseline = 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.") @@ -62,6 +67,12 @@ struct LintOrAnalyzeArguments: ParsableArguments { """ ) var onlyRule: [String] = [] + + func validate() throws { + if prettyBaseline, writeBaseline == nil { + throw ValidationError("'--pretty-baseline' requires '--write-baseline '.") + } + } } // MARK: - Common Argument Help diff --git a/Tests/FileSystemAccessTests/BaselineTests.swift b/Tests/FileSystemAccessTests/BaselineTests.swift index 466976fc88..9ad127fcfc 100644 --- a/Tests/FileSystemAccessTests/BaselineTests.swift +++ b/Tests/FileSystemAccessTests/BaselineTests.swift @@ -54,6 +54,23 @@ final class BaselineTests: XCTestCase { ruleDescriptions.violations(for: filePath) } + /// Violations across two synthetic files, intentionally returned in + /// reverse order to prove the baseline serializer sorts by file and + /// location before writing. + private static func twoFileViolations(for filePath: String) -> [StyleViolation] { + let other = "other" + filePath.bridge().lastPathComponent + return [ + StyleViolation( + ruleDescription: BlockBasedKVORule.description, + location: Location(file: other, line: 4, character: 1) + ), + StyleViolation( + ruleDescription: ArrayInitRule.description, + location: Location(file: filePath, line: 2, character: 1) + ), + ] + } + private static func baseline(for filePath: String) -> Baseline { Baseline(violations: ruleDescriptions.violations(for: filePath)) } @@ -83,6 +100,84 @@ final class BaselineTests: XCTestCase { } } + func testBaselineFileIsCompactByDefault() throws { + // The default output must stay backwards-compatible: a single minified + // line (no newlines, no indentation, no blank lines). A byte-level + // snapshot isn't viable because `JSONEncoder`'s compact output has + // non-deterministic key order — that's one of the reasons the opt-in + // pretty mode uses `.sortedKeys`. This test asserts the format + // properties; `testBaselineFileIsPrettyPrintedWhenRequested` asserts + // the exact serialized output for the opt-in mode. + try withExampleFileCreated { sourceFilePath in + let baselinePath = temporaryDirectoryPath.stringByAppendingPathComponent(UUID().uuidString) + try Baseline(violations: Self.twoFileViolations(for: sourceFilePath)) + .write(toPath: baselinePath) + defer { try? FileManager.default.removeItem(atPath: baselinePath) } + let contents = try String(contentsOf: URL(fileURLWithPath: baselinePath), encoding: .utf8) + let fileName = sourceFilePath.bridge().lastPathComponent + + XCTAssertFalse(contents.contains("\n"), "Default baseline output must be a single line") + XCTAssertFalse(contents.contains(" "), "Default baseline output must not be indented") + + // Structurally equivalent to the pretty output, minus formatting. + let decoded = try JSONSerialization.jsonObject(with: Data(contents.utf8)) as? [[String: Any]] + let files = decoded?.compactMap { ($0["violation"] as? [String: Any])?["location"] as? [String: Any] } + .compactMap { $0["file"] as? String } + XCTAssertEqual(files, [fileName, "other\(fileName)"]) + } + } + + func testBaselineFileIsPrettyPrintedWhenRequested() throws { + try withExampleFileCreated { sourceFilePath in + let baselinePath = temporaryDirectoryPath.stringByAppendingPathComponent(UUID().uuidString) + try Baseline(violations: Self.twoFileViolations(for: sourceFilePath)) + .write(toPath: baselinePath, pretty: true) + defer { try? FileManager.default.removeItem(atPath: baselinePath) } + let contents = try String(contentsOf: URL(fileURLWithPath: baselinePath), encoding: .utf8) + let fileName = sourceFilePath.bridge().lastPathComponent + + // Pretty output: sorted keys, two-space indent, sorted by file and + // then by location, unescaped slashes. + // swiftlint:disable line_length + let expected = """ + [ + { + "text" : "import SwiftLintFramework", + "violation" : { + "location" : { + "character" : 1, + "file" : "\(fileName)", + "line" : 2 + }, + "reason" : "Prefer using `Array(seq)` over `seq.map { $0 }` to convert a sequence into an Array", + "ruleDescription" : "Prefer using `Array(seq)` over `seq.map { $0 }` to convert a sequence into an Array", + "ruleIdentifier" : "array_init", + "ruleName" : "Array Init", + "severity" : "warning" + } + }, + { + "text" : "", + "violation" : { + "location" : { + "character" : 1, + "file" : "other\(fileName)", + "line" : 4 + }, + "reason" : "Prefer the new block based KVO API with keypaths when using Swift 3.2 or later", + "ruleDescription" : "Prefer the new block based KVO API with keypaths when using Swift 3.2 or later", + "ruleIdentifier" : "block_based_kvo", + "ruleName" : "Block Based KVO", + "severity" : "warning" + } + } + ] + """ + // swiftlint:enable line_length + XCTAssertEqual(contents, expected) + } + } + func testUnchangedViolations() throws { try withExampleFileCreated { sourceFilePath in XCTAssertEqual(Self.baseline(for: sourceFilePath).filter(Self.violations(for: sourceFilePath)), []) diff --git a/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift b/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift index 7913fc4a35..583d15b7c6 100644 --- a/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift +++ b/Tests/FrameworkTests/LintOrAnalyzeOptionsTests.swift @@ -54,6 +54,7 @@ private extension LintOrAnalyzeOptions { reporter: nil, baseline: nil, writeBaseline: nil, + prettyBaseline: false, workingDirectory: nil, quiet: false, output: nil,