diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..66b1de1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,109 @@ +# PackageGeneratorPlugin — Copilot Instructions + +## What This Project Is + +A Swift Package Manager **Command Plugin** that auto-generates `Package.swift` for heavily modularized SPM projects. It reads source files, extracts imports via a companion binary (`package-generator-cli`), resolves dependencies, and writes the output. + +It is **not a library**. There is no test target. Testing is done via dry-run in consumer projects. + +## Build & Run + +```bash +# Build the package (plugin + yaml-converter tool) +swift build + +# Run the plugin in a consumer project +swift package plugin --allow-writing-to-package-directory package-generator + +# With explicit config file +swift package plugin --allow-writing-to-package-directory package-generator --confFile myconfig.yaml + +# Dry run (writes Package_generated.swift instead of Package.swift) +# Set dryRun: true in config, then: +swift package plugin --allow-writing-to-package-directory package-generator +diff Package.swift Package_generated.swift +``` + +## Architecture + +### Targets + +- **`Package Generator`** plugin — `Plugins/PackageGenerator/` — the SPM command plugin +- **`yaml-converter`** executable — `Tools/YamlConverter/` — converts YAML→JSON using Yams; invoked as a subprocess by the plugin +- **`package-generator-cli`** binary — ARM64-only artifact bundle from GitHub releases; does the actual Swift import extraction from source files + +### Plugin Pipeline (`PackageGeneratorV2.swift` orchestrates) + +| Phase | File | What it does | +|-------|------|-------------| +| Config load | `ConfigLoader.swift` | Finds `packageGenerator.{yaml,yml,json}`, converts YAML via `yaml-converter` subprocess, decodes into `ConfigurationV2` | +| 2 — Discovery | `TargetResolution.swift` | `resolveTargetPaths` (shortest-path logic), `linkTestTargets` (strips "Tests" suffix), `discoverExternalDeps` (reads SPM graph), `runCLI` (invokes `package-generator-cli`) | +| 3 — Import Analysis | `ImportAnalysis.swift` | `filterImports` — drops self-imports and explicit exclusions; Apple SDK filtering is intentionally **deferred** to render phase | +| 4 — Code Generation | `CodeGeneration.swift` | `renderSingleTarget`, `generateProductsSection`, `generateTargetsSection`, `writeOutput` | +| 5 — Advanced Features | `AdvancedFeatures.swift` | `generateExportedFiles`, `detectUnusedTargets`, `computeDependencyWeight` | + +Entry point: `Plugin.swift` → `PackageGeneratorV2.generate(config:context:)` + +### Configuration (`ConfigurationV2.swift`) + +Config is decoded from JSON (YAML is converted first). The decoder merges the legacy `targetsParameters` dict into inline `parameters` on each target **in memory only** — the on-disk config file is never modified. + +## Key Conventions + +### PackagePlugin API Usage +- Always use `context.package.directoryURL` (not the deprecated `directory`) +- Always use `context.pluginWorkDirectoryURL` for temp files +- Always use `URL`-based APIs with `appendingPathComponent` / `deletingLastPathComponent` + +### External Dependency Identity +Use `dependency.package.id` (URL-derived identity, e.g. `"vitamin-play-apple-releases"`) **not** `dependency.package.displayName` (self-declared name, may differ). This is what SPM requires in `.product(name:package:)`. + +```swift +// Correct +let packageIdentity = dependency.package.id + +// Wrong — displayName can differ from the URL-derived identity +let packageIdentity = dependency.package.displayName +``` + +### Apple SDK Filtering is Deferred +`filterImports` (Phase 3) does **not** filter Apple SDKs. This is intentional: filtering happens in `renderSingleTarget` (Phase 4), after checking `externalDeps` first. This prevents silently dropping external products whose names collide with Apple SDK names (e.g., `Charts`). + +### Parameter Injection is Verbatim +`parameters` strings and `additionalDependencies` strings from config are written directly into `Package.swift` without parsing or validation. + +`parameters` are appended after `path:` as extra target arguments: +```swift +for param in target.parameters ?? [] { + extraParams += ",\n\(s2)\(param.trimmingCharacters(in: .whitespacesAndNewlines))" +} +``` + +`additionalDependencies` are appended to the `dependencies:` array after the auto-resolved imports (which are sorted). Use when a target needs a dep that isn't imported in source (macros, plugins, transitive deps): +```yaml +additionalDependencies: + - '"SomeLocalTarget"' + - '.product(name: "PreviewSnapshots", package: "swiftui-preview-snapshots")' +``` + +### Dependency Resolution Priority (in `renderSingleTarget`) +1. `externalDeps` map → `.product(name:package:)` reference +2. `allLocalTargetNames` set → `"TargetName"` string literal +3. Apple SDK built-in list (`AppleSDKs.swift`) → silently skipped +4. Otherwise → warning emitted, dep skipped +5. `additionalDependencies` → appended verbatim **after** the sorted resolved deps, bypassing all resolution + +### Swift 6 Strict Concurrency +The package uses `swiftLanguageModes: [.v6]`. All new code must comply with Swift 6 strict concurrency rules. + +### Code Structure +Top-level functions at file scope (not in types) is the pattern for all phase functions. `PackageGeneratorV2` is the only struct, with `static func generate`. + +### YAML Temp Files +YAML configs are converted to a temp file named `.packageGenerator_temp_.json` in the package directory. Cleaned up automatically unless `keepTempFiles: true`. + +### Config Search Order +`--confFile` arg → `packageGenerator.yaml` → `packageGenerator.yml` → `packageGenerator.json` + +### Binary Target +`package-generator-cli` is ARM64-only (`arm64-apple-macosx`). To test against a local build, swap the `.binaryTarget` in `Package.swift` to use a local `path:` (the commented-out block is already there). diff --git a/Plugins/PackageGenerator/CodeGeneration.swift b/Plugins/PackageGenerator/CodeGeneration.swift index 307853b..19b1a11 100644 --- a/Plugins/PackageGenerator/CodeGeneration.swift +++ b/Plugins/PackageGenerator/CodeGeneration.swift @@ -61,7 +61,8 @@ func renderSingleTarget( // Build multi-line dependencies section using three-way resolution. var depsStr = "" - if !target.dependencies.isEmpty { + let extraDeps = target.additionalDependencies ?? [] + if !target.dependencies.isEmpty || !extraDeps.isEmpty { var resolvedLines: [String] = [] for dep in target.dependencies { if let extDep = externalDeps[dep] { @@ -74,9 +75,10 @@ func renderSingleTarget( Diagnostics.emit(.warning, "Dropped unresolved import '\(dep)'. If it's a system framework, ignore this.") } } - if !resolvedLines.isEmpty { - let lines = resolvedLines.sorted().map { "\(s3)\($0)" } - depsStr = "\n\(s2)dependencies: [\n" + lines.joined(separator: ",\n") + "\n\(s2)]," + let allLines = resolvedLines.sorted().map { "\(s3)\($0)" } + + extraDeps.map { "\(s3)\($0)" } + if !allLines.isEmpty { + depsStr = "\n\(s2)dependencies: [\n" + allLines.joined(separator: ",\n") + "\n\(s2)]," } } diff --git a/Plugins/PackageGenerator/ConfigurationV2.swift b/Plugins/PackageGenerator/ConfigurationV2.swift index e4330b2..8d20b1f 100644 --- a/Plugins/PackageGenerator/ConfigurationV2.swift +++ b/Plugins/PackageGenerator/ConfigurationV2.swift @@ -84,6 +84,9 @@ struct ConfigurationV2: Codable { let exclude: [String]? let parameters: [String]? let regularTargetName: String? + /// Verbatim dependency strings injected into the target's `dependencies:` array as-is. + /// Accepts plain target names (`"Core"`) or full product references (`.product(name: "Foo", package: "bar")`). + let additionalDependencies: [String]? enum TargetType: String, Codable { case regular @@ -98,13 +101,14 @@ struct ConfigurationV2: Codable { } } - init(name: String, type: TargetType = .regular, path: String? = nil, exclude: [String]? = nil, parameters: [String]? = nil, regularTargetName: String? = nil) { + init(name: String, type: TargetType = .regular, path: String? = nil, exclude: [String]? = nil, parameters: [String]? = nil, regularTargetName: String? = nil, additionalDependencies: [String]? = nil) { self.name = name self.type = type self.path = path self.exclude = exclude self.parameters = parameters self.regularTargetName = regularTargetName + self.additionalDependencies = additionalDependencies } init(from decoder: Decoder) throws { @@ -115,6 +119,7 @@ struct ConfigurationV2: Codable { exclude = try c.decodeIfPresent([String].self, forKey: .exclude) parameters = try c.decodeIfPresent([String].self, forKey: .parameters) regularTargetName = try c.decodeIfPresent(String.self, forKey: .regularTargetName) + additionalDependencies = try c.decodeIfPresent([String].self, forKey: .additionalDependencies) } } diff --git a/Plugins/PackageGenerator/PackageGeneratorV2.swift b/Plugins/PackageGenerator/PackageGeneratorV2.swift index a4502a0..9a5ea28 100644 --- a/Plugins/PackageGenerator/PackageGeneratorV2.swift +++ b/Plugins/PackageGenerator/PackageGeneratorV2.swift @@ -63,7 +63,8 @@ struct PackageGeneratorV2 { path: resolvedPath, fullPath: context.package.directoryURL.appendingPathComponent(resolvedPath).path, exclude: targetSpec.exclude ?? [], - parameters: targetSpec.parameters + parameters: targetSpec.parameters, + additionalDependencies: targetSpec.additionalDependencies ) parsedPackages.append(parsed) diff --git a/Plugins/PackageGenerator/ParsedPackage.swift b/Plugins/PackageGenerator/ParsedPackage.swift index bb3b7cd..1c335fb 100644 --- a/Plugins/PackageGenerator/ParsedPackage.swift +++ b/Plugins/PackageGenerator/ParsedPackage.swift @@ -13,6 +13,8 @@ public struct ParsedPackage: Codable, CustomStringConvertible { public var hasBiggestNumberOfDependencies: Bool = false /// Extra target parameters (resources:, swiftSettings:, etc.) rendered verbatim after `path:`. public var parameters: [String]? + /// Verbatim dependency strings injected into the `dependencies:` array as-is, bypassing import resolution. + public var additionalDependencies: [String]? enum CodingKeys: String, CodingKey { case name @@ -40,6 +42,7 @@ public struct ParsedPackage: Codable, CustomStringConvertible { self.localDependencies = try container.decodeIfPresent(Int.self, forKey: .localDependencies) ?? 0 self.hasBiggestNumberOfDependencies = try container.decodeIfPresent(Bool.self, forKey: .hasBiggestNumberOfDependencies) ?? false self.parameters = nil // Set by plugin from config; not from CLI JSON + self.additionalDependencies = nil // Set by plugin from config; not from CLI JSON } public var hasResources: Bool { @@ -50,7 +53,7 @@ public struct ParsedPackage: Codable, CustomStringConvertible { return "[\(dependencies.count)|\(localDependencies)] \(name) \(hasResources == false ? "" : "/ hasResources")" } - public init(name: String, isTest: Bool, isMacro: Bool = false, dependencies: [String], path: String, fullPath: String, resources: String? = nil, localDependencies: Int = 0, hasBiggestNumberOfDependencies: Bool = false, exclude: [String] = [], parameters: [String]? = nil) { + public init(name: String, isTest: Bool, isMacro: Bool = false, dependencies: [String], path: String, fullPath: String, resources: String? = nil, localDependencies: Int = 0, hasBiggestNumberOfDependencies: Bool = false, exclude: [String] = [], parameters: [String]? = nil, additionalDependencies: [String]? = nil) { self.name = name self.isTest = isTest self.isMacro = isMacro @@ -62,5 +65,6 @@ public struct ParsedPackage: Codable, CustomStringConvertible { self.hasBiggestNumberOfDependencies = hasBiggestNumberOfDependencies self.exclude = exclude self.parameters = parameters + self.additionalDependencies = additionalDependencies } } diff --git a/README.md b/README.md index aad3e0c..2ae4eea 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,13 @@ packageDirectoryTargets: - 'swiftSettings: [...]' - 'resources: [.process("Files")]' regularTargetName: null # for tests: explicit link to regular target + additionalDependencies: # verbatim entries added to dependencies: [] + - '"AnotherTarget"' + - '.product(name: "Foo", package: "foo-package")' ``` +`additionalDependencies` injects strings verbatim into the target's `dependencies:` array, bypassing import analysis. Use it when a target needs a dependency that isn't imported in source (common with macros, plugins, or transitive requirements). Auto-resolved imports are emitted first (sorted), then `additionalDependencies` in declaration order. + **Path Resolution** (shortest-path logic): - Regular target: `/Sources/` or custom `path` - Test target: `/Tests/` or custom `path`