From f327e69f7d2aa0e2d6a0f6e047f1bfcf9507bb90 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 11 May 2026 15:47:07 -0700 Subject: [PATCH 1/4] Docs: rewrite prebuild-script migration guide around --combined-output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration section pointed migrants at `generate --swift-manifest`, but our own Bazel rule and Tuist plugin use `generate --combined-output --module-info-output` instead — it's strictly simpler for hand-written prebuild scripts because it produces a single statically-known output path. Rewrite the section to lead with that flow (single-module + multi-module examples), link to `bazel/safedi.bzl` and the Tuist plugin as reference implementations, and keep `--swift-manifest` as the alternative for per-root output files. Co-Authored-By: Claude Opus 4.7 (1M context) --- Documentation/Manual.md | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 5c6b7b79..0c6b21c1 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -812,29 +812,38 @@ The `SafeDIPrebuiltGenerator` plugin has been removed in SafeDI 2.x. `SafeDIGene ### Migrating prebuild scripts or custom build system integrations -If you invoke `SafeDITool` directly (not via the provided SPM plugin), the `--dependency-tree-output` flag has been replaced with `generate --swift-manifest`. The tool now takes a JSON manifest file that maps input Swift files to output files. See [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) for the expected format. +If you invoke `SafeDITool` directly (not via the provided SPM plugin), the 1.x `--dependency-tree-output` flag has been replaced. The simplest migration is `generate --combined-output`, which concatenates every dependency-tree, mock, and mock-configuration body SafeDITool would emit into one statically-known output file — a good fit for any build system that needs rule outputs declared up front. Before (1.x): ```bash safeditool input.csv --dependency-tree-output ./generated/SafeDI.swift ``` -After (2.x): +After (2.x), single module: ```bash -# Create a manifest mapping root files to outputs -cat > manifest.json << 'EOF' -{ - "dependencyTreeGeneration": [ - { - "inputFilePath": "Sources/App/Root.swift", - "outputFilePath": "generated/Root+SafeDI.swift" - } - ] -} -EOF -safeditool generate input.csv --swift-manifest manifest.json +safeditool generate input.csv --combined-output ./generated/SafeDI.swift ``` +For multi-module projects, add `--module-info-output` so downstream modules can resolve `@Instantiable` types declared upstream without re-parsing the producer's sources. Each producer emits a `.safedi` sidecar; each consumer feeds the producer's `.safedi` back in through `--dependent-module-info-file-path`: + +```bash +# Producing module (Subproject): +safeditool generate Subproject.csv \ + --combined-output Subproject/SafeDIGenerated.swift \ + --module-info-output Subproject.safedi + +# Consuming module (App), with Subproject already built: +echo "Subproject.safedi" > deps.csv +safeditool generate App.csv \ + --combined-output App/SafeDIGenerated.swift \ + --module-info-output App.safedi \ + --dependent-module-info-file-path deps.csv +``` + +For working reference implementations, see the Bazel rule at [`bazel/safedi.bzl`](../bazel/safedi.bzl) and the Tuist plugin at [`TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift`](../TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift). Both invoke `generate --combined-output --module-info-output` per module and pass dependent modules' `.safedi` artifacts via `--dependent-module-info-file-path`. + +If you need per-root output files (one generated `.swift` per `@Instantiable(isRoot: true)` type) instead of a single combined file, use `generate --swift-manifest` with a JSON manifest in the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format. `--swift-manifest` and `--combined-output` are mutually exclusive — pick whichever maps better onto your build system's output model. + ## Example applications We’ve tied everything together with an example multi-user notes application backed by SwiftUI. You can compile and run this code in [an example single-module Xcode project](../Examples/ExampleProjectIntegration). This same multi-user notes app also exists in [an example multi-module Xcode project](../Examples/ExampleMultiProjectIntegration). We have also created [an example multi-module `Package.swift` that integrates with SafeDI](../Examples/Example Package Integration). From 48700ece56460ec5c8950296b3749591b722f9c5 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 11 May 2026 15:54:32 -0700 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Dan Federman --- Documentation/Manual.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 0c6b21c1..638ca6e9 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -824,7 +824,7 @@ After (2.x), single module: safeditool generate input.csv --combined-output ./generated/SafeDI.swift ``` -For multi-module projects, add `--module-info-output` so downstream modules can resolve `@Instantiable` types declared upstream without re-parsing the producer's sources. Each producer emits a `.safedi` sidecar; each consumer feeds the producer's `.safedi` back in through `--dependent-module-info-file-path`: +For multi-module projects, you can add the `--module-info-output` flag so downstream modules can resolve `@Instantiable` types declared upstream without re-parsing the producer's sources. Each producer emits a `.safedi` sidecar; each consumer feeds the producer's `.safedi` back in through `--dependent-module-info-file-path`: ```bash # Producing module (Subproject): @@ -842,7 +842,7 @@ safeditool generate App.csv \ For working reference implementations, see the Bazel rule at [`bazel/safedi.bzl`](../bazel/safedi.bzl) and the Tuist plugin at [`TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift`](../TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift). Both invoke `generate --combined-output --module-info-output` per module and pass dependent modules' `.safedi` artifacts via `--dependent-module-info-file-path`. -If you need per-root output files (one generated `.swift` per `@Instantiable(isRoot: true)` type) instead of a single combined file, use `generate --swift-manifest` with a JSON manifest in the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format. `--swift-manifest` and `--combined-output` are mutually exclusive — pick whichever maps better onto your build system's output model. +If you need per-root output files (one generated `.swift` per `@Instantiable(isRoot: true)` type) instead of a single combined file, use `generate --swift-manifest` with a JSON manifest in the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format. `--swift-manifest` and `--combined-output` are mutually exclusive — pick whichever maps better onto your build system’s output model. ## Example applications From a1aab15eb1e85e567b799e172baba63cc4c65cda Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 11 May 2026 18:54:55 -0700 Subject: [PATCH 3/4] Tolerate whitespace and newlines in path-list CSV inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deps CSV produced by `echo "x.safedi" > deps.csv` carries a trailing newline, which previously survived `components(separatedBy: ",")` + `removingEmpty()` and ended up baked into the resolved file URL — causing the dependent module load to fail with a misleading file-not-found error. Centralize the parse in a `parsedPathListCSV()` helper that splits on commas/newlines and trims whitespace, applied to all three CSV-reading sites (Generate's deps + sources + cache-check, Scan's sources). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/SafeDITool/GenerateCommand.swift | 12 +--- Sources/SafeDITool/SafeDITool.swift | 11 ++++ Sources/SafeDITool/ScanCommand.swift | 3 +- .../SafeDIToolCombinedOutputTests.swift | 62 +++++++++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Sources/SafeDITool/GenerateCommand.swift b/Sources/SafeDITool/GenerateCommand.swift index 6b986d31..6c58e91a 100644 --- a/Sources/SafeDITool/GenerateCommand.swift +++ b/Sources/SafeDITool/GenerateCommand.swift @@ -481,8 +481,7 @@ struct Generate: AsyncParsableCommand { var swiftFiles = try await findSwiftFiles(inDirectories: additionalDirectories) if let swiftSourcesFilePath { let sourcesFromFile = try String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8) - .components(separatedBy: CharacterSet(arrayLiteral: ",")) - .removingEmpty() + .parsedPathListCSV() swiftFiles.formUnion(sourcesFromFile) } return swiftFiles @@ -551,11 +550,7 @@ struct Generate: AsyncParsableCommand { guard let currentCSVContent = try? String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8) else { return nil } - let currentCSVPaths = Set( - currentCSVContent - .components(separatedBy: CharacterSet(arrayLiteral: ",")) - .removingEmpty(), - ) + let currentCSVPaths = Set(currentCSVContent.parsedPathListCSV()) guard currentCSVPaths == Set(cached.csvInputPaths) else { return nil } @@ -602,8 +597,7 @@ struct Generate: AsyncParsableCommand { if let dependentModuleInfoFilePath { try .init( String(contentsOfFile: dependentModuleInfoFilePath, encoding: .utf8) - .components(separatedBy: CharacterSet(arrayLiteral: ",")) - .removingEmpty() + .parsedPathListCSV() .map(\.asFileURL), ) } else { diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index cd4fc297..fd415fc4 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -59,6 +59,17 @@ extension String { URL(filePath: self) #endif } + + /// Parses a path-list CSV emitted by a build system, shell script, or + /// `echo`/`printf`. Splits on commas and newlines, trims surrounding + /// whitespace from each entry, and drops empty entries — so a trailing + /// `\n` (as `echo "x" > deps.csv` writes) or CRLF line endings don't + /// produce a path that includes whitespace. + func parsedPathListCSV() -> [String] { + components(separatedBy: CharacterSet(charactersIn: ",\n\r")) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } } protocol FileFinder: Sendable { diff --git a/Sources/SafeDITool/ScanCommand.swift b/Sources/SafeDITool/ScanCommand.swift index 580e235a..a1a22d13 100644 --- a/Sources/SafeDITool/ScanCommand.swift +++ b/Sources/SafeDITool/ScanCommand.swift @@ -65,8 +65,7 @@ func performScan( // Read CSV and resolve file paths relative to project root. let inputFilePaths = try String(contentsOfFile: inputSourcesFile, encoding: .utf8) - .components(separatedBy: CharacterSet(arrayLiteral: ",")) - .removingEmpty() + .parsedPathListCSV() let directoryBaseURL = projectRootURL.appendingPathComponent("", isDirectory: true) diff --git a/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift b/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift index 0695d490..d116694b 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift @@ -441,6 +441,68 @@ struct SafeDIToolCombinedOutputTests: ~Copyable { #expect(mockConfig == emptyMockConfigurationFileOutput, "Unexpected mock configuration output: \(mockConfig)") } + @Test + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + mutating func run_combinedOutput_resolvesDependentModuleInfo_whenDepsCSVHasTrailingNewline() async throws { + // Build systems and shell scripts commonly produce the deps CSV with + // `echo "path.safedi" > deps.csv`, which writes a trailing newline. + // Generate must tolerate trailing/embedded whitespace and newlines in + // the CSV — otherwise the resolved file path includes "\n" and the + // `.safedi` load fails with a misleading file-not-found error. + let crossModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct CrossModuleService: Instantiable { + public init() {} + } + """, + ], + filesToDelete: &filesToDelete, + ) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("SafeDIToolDepsCSVNewlineTest-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + filesToDelete.append(tempDir) + + let rootURL = tempDir.appendingPathComponent("Root.swift") + try """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(crossModuleService: CrossModuleService) { + self.crossModuleService = crossModuleService + } + @Instantiated let crossModuleService: CrossModuleService + } + """.write(to: rootURL, atomically: true, encoding: .utf8) + + let csvURL = tempDir.appendingPathComponent("sources.csv") + try rootURL.path.write(to: csvURL, atomically: true, encoding: .utf8) + + let dependentModuleCSV = tempDir.appendingPathComponent("dependent-modules.csv") + // Emulate `echo "path.safedi" > deps.csv` — trailing newline included. + try "\(crossModuleOutput.moduleInfoOutputPath)\n".write(to: dependentModuleCSV, atomically: true, encoding: .utf8) + + let combinedOutputURL = tempDir.appendingPathComponent("SafeDIGenerated.swift") + let tool = try Generate.parse([ + csvURL.path, + "--combined-output", combinedOutputURL.path, + "--dependent-module-info-file-path", dependentModuleCSV.path, + ]) + try await tool.run() + + #expect( + FileManager.default.fileExists(atPath: combinedOutputURL.path), + "Combined output was not written — Generate likely failed to load the dependent .safedi file", + ) + let combinedOutput = try String(contentsOf: combinedOutputURL, encoding: .utf8) + #expect( + combinedOutput.contains("CrossModuleService()"), + "Combined output did not pick up the dependent module's type: \(combinedOutput)", + ) + } + @Test @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) mutating func run_combinedOutput_emitsAdditionalMockFromConfiguration() async throws { From f48779ab9024effdd2c99a6129817377f84bb356 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 11 May 2026 19:21:07 -0700 Subject: [PATCH 4/4] Use full == comparison in deps-CSV regression test Per CLAUDE.md: never .contains() for generator output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SafeDIToolCombinedOutputTests.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift b/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift index d116694b..a73715cd 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift @@ -492,15 +492,19 @@ struct SafeDIToolCombinedOutputTests: ~Copyable { ]) try await tool.run() - #expect( - FileManager.default.fileExists(atPath: combinedOutputURL.path), - "Combined output was not written — Generate likely failed to load the dependent .safedi file", - ) let combinedOutput = try String(contentsOf: combinedOutputURL, encoding: .utf8) - #expect( - combinedOutput.contains("CrossModuleService()"), - "Combined output did not pick up the dependent module's type: \(combinedOutput)", - ) + #expect(combinedOutput == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Root { + public init() { + let crossModuleService = CrossModuleService() + self.init(crossModuleService: crossModuleService) + } + } + """, "Unexpected combined output: \(combinedOutput)") } @Test