From 9ecc39cff2354e3727c3221c0efbcf8b9d4a4aa8 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sat, 5 Sep 2026 23:55:51 +0200 Subject: [PATCH 1/9] fix: Regenerate resources when inputs or configuration change --- .editorconfig | 14 +++ .github/workflows/ci.yml | 2 + Package.swift | 2 +- Plugins/package-resources-plugin/plugin.swift | 24 +++++- .../test_resource_inputs.py | 86 +++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 Tests/PluginIntegrationTests/test_resource_inputs.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5cfd51d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +indent_style = tab +tab_width = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{py,yml}] +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2da04c9..af1f114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v7 + - name: Check incremental resource generation + run: python3 Tests/PluginIntegrationTests/test_resource_inputs.py - name: Test uses: capturecontext/swift-package-action/build@3.0-beta.14 with: diff --git a/Package.swift b/Package.swift index b8384b0..ee6139b 100644 --- a/Package.swift +++ b/Package.swift @@ -53,7 +53,7 @@ let package = Package( ), .package( url: "https://github.com/capturecontext/swift-function-composition.git", - .upToNextMinor(from: "0.0.4"), + .upToNextMinor(from: "0.0.3"), traits: ["NominalTypes"] ), .package( diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index bd15ae8..75f71b5 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -44,7 +44,10 @@ struct PackageResourcesPlugin: BuildToolPlugin { let configArgs: [String] = configURL.map { url in ["--config", url.path()] } ?? [] - + var inputURLs = try resourceInputURLs(in: target.directoryURL) + if let configURL, !inputURLs.contains(configURL) { + inputURLs.append(configURL) + } return [ .buildCommand( @@ -54,10 +57,29 @@ struct PackageResourcesPlugin: BuildToolPlugin { + inputArgs + outputArgs + configArgs, + inputFiles: inputURLs, outputFiles: [ outputURL ] ) ] } + + /// Files track edits; directories also track additions and removals between builds. + private func resourceInputURLs(in directory: URL) throws -> [URL] { + var inputs = [directory] + let children = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ).sorted { $0.path() < $1.path() } + for child in children { + let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + if values.isDirectory == true && values.isSymbolicLink != true { + inputs += try resourceInputURLs(in: child) + } else { + inputs.append(child) + } + } + return inputs + } } diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py new file mode 100644 index 0000000..8ef7fe1 --- /dev/null +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Exercise the production plugin's incremental build inputs without remote dependencies. + +The tiny tool records fixture contents instead of invoking resource processors, isolating +SwiftPM invalidation from generator behavior and dependency resolution. +""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class ResourceInputsTests(unittest.TestCase): + def test_edits_additions_removals_and_package_configuration(self): + with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary: + root = Path(temporary) + plugin = root / "Plugins/Resources/plugin.swift" + plugin.parent.mkdir(parents=True) + shutil.copyfile(ROOT / "Plugins/package-resources-plugin/plugin.swift", plugin) + (root / "Package.swift").write_text('''// swift-tools-version: 6.1 +import PackageDescription +let package = Package( + name: "ResourceInputFixture", + platforms: [.macOS(.v15)], + targets: [ + .executableTarget(name: "package-resources-cli"), + .plugin(name: "Resources", capability: .buildTool(), dependencies: ["package-resources-cli"]), + .target(name: "Fixture", resources: [.copy("Resources")], plugins: ["Resources"]), + ] +) +''') + tool = root / "Sources/package-resources-cli/main.swift" + tool.parent.mkdir(parents=True) + tool.write_text(r'''import Foundation +let arguments = CommandLine.arguments +func argument(_ name: String) -> String { arguments[arguments.firstIndex(of: name)! + 1] } +let input = URL(fileURLWithPath: argument("--input")) +let files = FileManager.default.enumerator(at: input, includingPropertiesForKeys: nil)! +var values: [String] = [] +for case let file as URL in files where file.pathExtension == "txt" { + values.append(try String(contentsOf: file, encoding: .utf8)) +} +if arguments.contains("--config") { + values.append(try String(contentsOfFile: argument("--config"), encoding: .utf8)) +} +let snapshot = values.sorted().joined(separator: "|") +try "public let resourceSnapshot = \(String(reflecting: snapshot))\n" + .write(toFile: argument("--output"), atomically: true, encoding: .utf8) +''') + resources = root / "Sources/Fixture/Resources" + resources.mkdir(parents=True) + (resources.parent / "Fixture.swift").write_text("public enum Fixture {}\n") + original = resources / "original.txt" + original.write_text("first") + configuration = root / ".packageresources" + configuration.write_text("config-first") + + def build_snapshot(): + result = subprocess.run( + ["swift", "build", "--target", "Fixture", "--build-system", "native"], + cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + self.assertEqual(result.returncode, 0, result.stdout) + generated = list((root / ".build/plugins/outputs").rglob("Resources.generated.swift")) + self.assertEqual(len(generated), 1) + return generated[0].read_text() + + self.assertIn('"config-first|first"', build_snapshot()) + original.write_text("edited") + self.assertIn('"config-first|edited"', build_snapshot()) + added = resources / "added.txt" + added.write_text("added") + self.assertIn('"added|config-first|edited"', build_snapshot()) + original.unlink() + self.assertIn('"added|config-first"', build_snapshot()) + configuration.write_text("config-edited") + self.assertIn('"added|config-edited"', build_snapshot()) + + +if __name__ == "__main__": + unittest.main() From 057f6a595952e9d858db395df7ad96462bc8573f Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 00:02:26 +0200 Subject: [PATCH 2/9] ci: Preserve dependency resolution diagnostics --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af1f114..3cb70d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,8 @@ jobs: action: xcodebuild-test platform: ${{ matrix.platform }} scheme: package-resources-cli-Package + - name: Report unfiltered dependency resolution failure + if: failure() + run: | + swift --version + swift package resolve From 1f6285a93d0ab644f83e294879d6d19f0c3e7a28 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 00:09:20 +0200 Subject: [PATCH 3/9] ci: Include Xcode package graph diagnostics --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cb70d4..5fc1589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,4 @@ jobs: run: | swift --version swift package resolve + xcodebuild -resolvePackageDependencies -scheme package-resources-cli-Package -workspace .swiftpm/xcode/package.xcworkspace From a922dc37b95134c33681497bbbdc1df468507750 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 00:26:25 +0200 Subject: [PATCH 4/9] fix: Exclude plugin build outputs from recursive resource inputs --- Plugins/package-resources-plugin/plugin.swift | 14 +++++++++++--- .../test_resource_inputs.py | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index 75f71b5..e87e408 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -44,7 +44,10 @@ struct PackageResourcesPlugin: BuildToolPlugin { let configArgs: [String] = configURL.map { url in ["--config", url.path()] } ?? [] - var inputURLs = try resourceInputURLs(in: target.directoryURL) + var inputURLs = try resourceInputURLs( + in: target.directoryURL, + excluding: context.pluginWorkDirectoryURL.resolvingSymlinksInPath() + ) if let configURL, !inputURLs.contains(configURL) { inputURLs.append(configURL) } @@ -66,7 +69,12 @@ struct PackageResourcesPlugin: BuildToolPlugin { } /// Files track edits; directories also track additions and removals between builds. - private func resourceInputURLs(in directory: URL) throws -> [URL] { + private func resourceInputURLs(in directory: URL, excluding pluginWorkDirectory: URL) throws -> [URL] { + guard + ![".build", ".git"].contains(directory.lastPathComponent), + directory.resolvingSymlinksInPath() != pluginWorkDirectory + else { return [] } + var inputs = [directory] let children = try FileManager.default.contentsOfDirectory( at: directory, @@ -75,7 +83,7 @@ struct PackageResourcesPlugin: BuildToolPlugin { for child in children { let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) if values.isDirectory == true && values.isSymbolicLink != true { - inputs += try resourceInputURLs(in: child) + inputs += try resourceInputURLs(in: child, excluding: pluginWorkDirectory) } else { inputs.append(child) } diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py index 8ef7fe1..eb02089 100644 --- a/Tests/PluginIntegrationTests/test_resource_inputs.py +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -17,6 +17,12 @@ class ResourceInputsTests(unittest.TestCase): def test_edits_additions_removals_and_package_configuration(self): + self.check_inputs() + + def test_package_root_target_excludes_build_outputs(self): + self.check_inputs(target_root=True) + + def check_inputs(self, target_root=False): with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary: root = Path(temporary) plugin = root / "Plugins/Resources/plugin.swift" @@ -34,13 +40,21 @@ def test_edits_additions_removals_and_package_configuration(self): ] ) ''') + if target_root: + manifest = root / "Package.swift" + manifest.write_text(manifest.read_text().replace( + '.target(name: "Fixture", resources: [.copy("Resources")],', + '.target(name: "Fixture", path: ".", ' + 'exclude: ["Plugins", "Sources/package-resources-cli", "Package.swift"], ' + 'resources: [.copy("Sources/Fixture/Resources")],', + )) tool = root / "Sources/package-resources-cli/main.swift" tool.parent.mkdir(parents=True) tool.write_text(r'''import Foundation let arguments = CommandLine.arguments func argument(_ name: String) -> String { arguments[arguments.firstIndex(of: name)! + 1] } let input = URL(fileURLWithPath: argument("--input")) -let files = FileManager.default.enumerator(at: input, includingPropertiesForKeys: nil)! +let files = FileManager.default.enumerator(at: input, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! var values: [String] = [] for case let file as URL in files where file.pathExtension == "txt" { values.append(try String(contentsOf: file, encoding: .utf8)) From 1b24f1864b15f6ba0da6fa5d5b4eaea1f0f4aac1 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 01:05:28 +0200 Subject: [PATCH 5/9] fix: Pin merged Swift Snippets compiler compatibility repair --- Package.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Package.swift b/Package.swift index ee6139b..92a4cd2 100644 --- a/Package.swift +++ b/Package.swift @@ -60,6 +60,11 @@ let package = Package( url: "https://github.com/capturecontext/swift-keypaths-extensions.git", .upToNextMinor(from: "0.2.2") ), + // Keep both Swift 6.2 and 6.4 dependency graphs compatible until the next release. + .package( + url: "https://github.com/capturecontext/swift-snippets.git", + revision: "aeb54d10efb0be0a2f3b5b70d3e88e2df0649281" + ), .package( url: "https://github.com/capturecontext/swiftlang-snippets.git", .upToNextMinor(from: "0.1.1") From 024e9000f6717d8399a12a3033f26fd171a3dc77 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 01:31:21 +0200 Subject: [PATCH 6/9] fix: Exclude the active SwiftPM scratch tree from plugin inputs --- Plugins/package-resources-plugin/plugin.swift | 18 +++++++++++++++++- .../test_resource_inputs.py | 19 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index e87e408..9108819 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -46,7 +46,7 @@ struct PackageResourcesPlugin: BuildToolPlugin { } ?? [] var inputURLs = try resourceInputURLs( in: target.directoryURL, - excluding: context.pluginWorkDirectoryURL.resolvingSymlinksInPath() + excluding: buildDirectory(containing: context.pluginWorkDirectoryURL) ) if let configURL, !inputURLs.contains(configURL) { inputURLs.append(configURL) @@ -68,6 +68,22 @@ struct PackageResourcesPlugin: BuildToolPlugin { ] } + /// SwiftPM writes this state at the active scratch root before evaluating plugins. + /// Locate it from our output path so custom scratch directory names are also excluded. + private func buildDirectory(containing pluginWorkDirectory: URL) -> URL { + var directory = pluginWorkDirectory.resolvingSymlinksInPath() + while directory.path != "/" { + let state = directory.appending(component: "workspace-state.json") + let plugins = directory.appending(component: "plugins") + if FileManager.default.fileExists(atPath: state.path), + FileManager.default.fileExists(atPath: plugins.path) { + return directory + } + directory.deleteLastPathComponent() + } + return pluginWorkDirectory.resolvingSymlinksInPath() + } + /// Files track edits; directories also track additions and removals between builds. private func resourceInputURLs(in directory: URL, excluding pluginWorkDirectory: URL) throws -> [URL] { guard diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py index eb02089..f471886 100644 --- a/Tests/PluginIntegrationTests/test_resource_inputs.py +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -22,7 +22,10 @@ def test_edits_additions_removals_and_package_configuration(self): def test_package_root_target_excludes_build_outputs(self): self.check_inputs(target_root=True) - def check_inputs(self, target_root=False): + def test_package_root_target_excludes_custom_scratch_outputs(self): + self.check_inputs(target_root=True, scratch_path="build-output") + + def check_inputs(self, target_root=False, scratch_path=".build"): with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary: root = Path(temporary) plugin = root / "Plugins/Resources/plugin.swift" @@ -48,13 +51,21 @@ def check_inputs(self, target_root=False): 'exclude: ["Plugins", "Sources/package-resources-cli", "Package.swift"], ' 'resources: [.copy("Sources/Fixture/Resources")],', )) + if target_root and scratch_path != ".build": + # Keep SwiftPM source discovery valid; plugin input traversal must also exclude it. + manifest.write_text(manifest.read_text().replace( + '"Package.swift"]', f'"Package.swift", "{scratch_path}"]', + )) tool = root / "Sources/package-resources-cli/main.swift" tool.parent.mkdir(parents=True) tool.write_text(r'''import Foundation let arguments = CommandLine.arguments func argument(_ name: String) -> String { arguments[arguments.firstIndex(of: name)! + 1] } let input = URL(fileURLWithPath: argument("--input")) -let files = FileManager.default.enumerator(at: input, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! +let rootResources = input.appending(path: "Sources/Fixture/Resources") +let resources = FileManager.default.fileExists(atPath: rootResources.path) + ? rootResources : input.appending(path: "Resources") +let files = FileManager.default.enumerator(at: resources, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! var values: [String] = [] for case let file as URL in files where file.pathExtension == "txt" { values.append(try String(contentsOf: file, encoding: .utf8)) @@ -76,11 +87,11 @@ def check_inputs(self, target_root=False): def build_snapshot(): result = subprocess.run( - ["swift", "build", "--target", "Fixture", "--build-system", "native"], + ["swift", "build", "--scratch-path", str(root / scratch_path), "--target", "Fixture", "--build-system", "native"], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) self.assertEqual(result.returncode, 0, result.stdout) - generated = list((root / ".build/plugins/outputs").rglob("Resources.generated.swift")) + generated = list((root / scratch_path / "plugins/outputs").rglob("Resources.generated.swift")) self.assertEqual(len(generated), 1) return generated[0].read_text() From ccab15289b52eb9118ae3ea0e0326012584b1fb8 Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 01:31:21 +0200 Subject: [PATCH 7/9] fix: Pin merged Casification compatibility and document release constraints --- Package.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 92a4cd2..3215e01 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,12 @@ let package = Package( url: "https://github.com/capturecontext/swift-keypaths-extensions.git", .upToNextMinor(from: "0.2.2") ), - // Keep both Swift 6.2 and 6.4 dependency graphs compatible until the next release. + // These merged revisions support checkout consumers on Swift 6.2 and 6.4. + // Replace with published version requirements before a semantic CLI release. + .package( + url: "https://github.com/capturecontext/swift-casification.git", + revision: "32a8de52e24cae336065925a0e3df2bfb2a9f010" + ), .package( url: "https://github.com/capturecontext/swift-snippets.git", revision: "aeb54d10efb0be0a2f3b5b70d3e88e2df0649281" From 90f24b1a15883bbfd108015b312c9d36aa111b2f Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 01:50:32 +0200 Subject: [PATCH 8/9] fix: Track symlinked resource destinations without recursive cycles --- Plugins/package-resources-plugin/plugin.swift | 33 ++++++++++++++++--- .../test_resource_inputs.py | 22 +++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index 9108819..2dd016c 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -44,14 +44,18 @@ struct PackageResourcesPlugin: BuildToolPlugin { let configArgs: [String] = configURL.map { url in ["--config", url.path()] } ?? [] + var visitedDirectories: Set = [] var inputURLs = try resourceInputURLs( in: target.directoryURL, - excluding: buildDirectory(containing: context.pluginWorkDirectoryURL) + excluding: buildDirectory(containing: context.pluginWorkDirectoryURL), + visitedDirectories: &visitedDirectories ) if let configURL, !inputURLs.contains(configURL) { inputURLs.append(configURL) } + inputURLs = Array(Set(inputURLs)).sorted { $0.path < $1.path } + return [ .buildCommand( displayName: "Run package-resources-plugin for \(target.name)", @@ -85,10 +89,15 @@ struct PackageResourcesPlugin: BuildToolPlugin { } /// Files track edits; directories also track additions and removals between builds. - private func resourceInputURLs(in directory: URL, excluding pluginWorkDirectory: URL) throws -> [URL] { + private func resourceInputURLs( + in directory: URL, + excluding pluginWorkDirectory: URL, + visitedDirectories: inout Set + ) throws -> [URL] { guard ![".build", ".git"].contains(directory.lastPathComponent), - directory.resolvingSymlinksInPath() != pluginWorkDirectory + directory.resolvingSymlinksInPath() != pluginWorkDirectory, + visitedDirectories.insert(directory.resolvingSymlinksInPath()).inserted else { return [] } var inputs = [directory] @@ -98,8 +107,22 @@ struct PackageResourcesPlugin: BuildToolPlugin { ).sorted { $0.path() < $1.path() } for child in children { let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - if values.isDirectory == true && values.isSymbolicLink != true { - inputs += try resourceInputURLs(in: child, excluding: pluginWorkDirectory) + if values.isSymbolicLink == true { + let destination = child.resolvingSymlinksInPath() + guard destination != pluginWorkDirectory, + !destination.path.hasPrefix(pluginWorkDirectory.path + "/") else { continue } + inputs.append(child) // Retargeting the link also invalidates generation. + if let destinationValues = try? destination.resourceValues(forKeys: [.isDirectoryKey]) { + if destinationValues.isDirectory == true { + inputs += try resourceInputURLs(in: destination, excluding: pluginWorkDirectory, + visitedDirectories: &visitedDirectories) + } else { + inputs.append(destination) + } + } + } else if values.isDirectory == true { + inputs += try resourceInputURLs(in: child, excluding: pluginWorkDirectory, + visitedDirectories: &visitedDirectories) } else { inputs.append(child) } diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py index f471886..0854ebd 100644 --- a/Tests/PluginIntegrationTests/test_resource_inputs.py +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -25,7 +25,10 @@ def test_package_root_target_excludes_build_outputs(self): def test_package_root_target_excludes_custom_scratch_outputs(self): self.check_inputs(target_root=True, scratch_path="build-output") - def check_inputs(self, target_root=False, scratch_path=".build"): + def test_symlinked_resource_directory_tracks_destination_edits(self): + self.check_inputs(symlink_resources=True) + + def check_inputs(self, target_root=False, scratch_path=".build", symlink_resources=False): with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary: root = Path(temporary) plugin = root / "Plugins/Resources/plugin.swift" @@ -65,7 +68,7 @@ def check_inputs(self, target_root=False, scratch_path=".build"): let rootResources = input.appending(path: "Sources/Fixture/Resources") let resources = FileManager.default.fileExists(atPath: rootResources.path) ? rootResources : input.appending(path: "Resources") -let files = FileManager.default.enumerator(at: resources, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! +let files = FileManager.default.enumerator(at: resources.resolvingSymlinksInPath(), includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! var values: [String] = [] for case let file as URL in files where file.pathExtension == "txt" { values.append(try String(contentsOf: file, encoding: .utf8)) @@ -78,7 +81,13 @@ def check_inputs(self, target_root=False, scratch_path=".build"): .write(toFile: argument("--output"), atomically: true, encoding: .utf8) ''') resources = root / "Sources/Fixture/Resources" - resources.mkdir(parents=True) + if symlink_resources: + shared = root / "SharedResources" + shared.mkdir() + resources.parent.mkdir(parents=True) + resources.symlink_to(shared, target_is_directory=True) + else: + resources.mkdir(parents=True) (resources.parent / "Fixture.swift").write_text("public enum Fixture {}\n") original = resources / "original.txt" original.write_text("first") @@ -105,6 +114,13 @@ def build_snapshot(): self.assertIn('"added|config-first"', build_snapshot()) configuration.write_text("config-edited") self.assertIn('"added|config-edited"', build_snapshot()) + if symlink_resources: + replacement = root / "ReplacementResources" + replacement.mkdir() + (replacement / "replacement.txt").write_text("replacement") + resources.unlink() + resources.symlink_to(replacement, target_is_directory=True) + self.assertIn('"config-edited|replacement"', build_snapshot()) if __name__ == "__main__": From 906c5828068a25dccda6e51f846075c3aa04619c Mon Sep 17 00:00:00 2001 From: Maxim Krouk Date: Sun, 6 Sep 2026 02:03:28 +0200 Subject: [PATCH 9/9] Skip dangling resource symlinks in build inputs --- Plugins/package-resources-plugin/plugin.swift | 3 ++- .../PluginIntegrationTests/test_resource_inputs.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index 2dd016c..df640a3 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -109,7 +109,8 @@ struct PackageResourcesPlugin: BuildToolPlugin { let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) if values.isSymbolicLink == true { let destination = child.resolvingSymlinksInPath() - guard destination != pluginWorkDirectory, + guard FileManager.default.fileExists(atPath: destination.path), + destination != pluginWorkDirectory, !destination.path.hasPrefix(pluginWorkDirectory.path + "/") else { continue } inputs.append(child) // Retargeting the link also invalidates generation. if let destinationValues = try? destination.resourceValues(forKeys: [.isDirectoryKey]) { diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py index 0854ebd..395b505 100644 --- a/Tests/PluginIntegrationTests/test_resource_inputs.py +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -28,7 +28,10 @@ def test_package_root_target_excludes_custom_scratch_outputs(self): def test_symlinked_resource_directory_tracks_destination_edits(self): self.check_inputs(symlink_resources=True) - def check_inputs(self, target_root=False, scratch_path=".build", symlink_resources=False): + def test_dangling_resource_symlink_is_ignored_until_replaced(self): + self.check_inputs(dangling_resource=True) + + def check_inputs(self, target_root=False, scratch_path=".build", symlink_resources=False, dangling_resource=False): with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary: root = Path(temporary) plugin = root / "Plugins/Resources/plugin.swift" @@ -89,6 +92,9 @@ def check_inputs(self, target_root=False, scratch_path=".build", symlink_resourc else: resources.mkdir(parents=True) (resources.parent / "Fixture.swift").write_text("public enum Fixture {}\n") + if dangling_resource: + dangling = resources / "missing.asset" + dangling.symlink_to(root / "missing-destination") original = resources / "original.txt" original.write_text("first") configuration = root / ".packageresources" @@ -114,6 +120,11 @@ def build_snapshot(): self.assertIn('"added|config-first"', build_snapshot()) configuration.write_text("config-edited") self.assertIn('"added|config-edited"', build_snapshot()) + if dangling_resource: + dangling.unlink() + dangling.mkdir() + (dangling / "recovered.txt").write_text("recovered") + self.assertIn('"added|config-edited|recovered"', build_snapshot()) if symlink_resources: replacement = root / "ReplacementResources" replacement.mkdir()