Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .claude/rules/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ func foo(a:, b:, c:, d:, e:, f:) {}
// swiftlint:enable function_parameter_count
```

### multiple_closures_with_trailing_closure

When a function accepts 2+ closure parameters, trailing closure syntax triggers this rule.
Use explicit argument labels for all closures:

```swift
// BAD — trailing closure with multiple closures
withExportReport(buildStats: { ... }) {
try await export()
}

// GOOD — explicit label
withExportReport(
buildStats: { ... },
export: { try await export() }
)
```

### void_function_in_ternary False Positive

SwiftLint flags `NooraUI.format()` calls in ternary operators as `void_function_in_ternary` even though they return `String`.
Expand All @@ -155,6 +173,26 @@ let failIcon = useColors ? NooraUI.format(.danger("✗")) : "✗"
let icon = success ? successIcon : failIcon
```

### Actor vs Lock for Sync-Only State

When all operations are synchronous (array append, file read), use `Lock<T>` (NSLock wrapper)
instead of `actor`. Actor requires `await` which forces `DispatchSemaphore` bridges from sync
contexts — creating deadlock risk. See `WarningCollector`, `ManifestTracker` for the pattern.

```swift
// BAD — actor with sync-only ops forces semaphore bridge from sync callers
actor Collector {
private var items: [String] = []
func add(_ item: String) { items.append(item) }
}

// GOOD — Lock is sync, no await needed
final class Collector: Sendable {
private let storage = Lock<[String]>([])
func add(_ item: String) { storage.withLock { $0.append(item) } }
}
```

## Test Helpers for Codable Types

```swift
Expand Down
39 changes: 20 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,25 +345,26 @@ NooraUI.formatLink("url", useColors: true) // underlined primary

## Troubleshooting

| Problem | Solution |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
| Build fails | `swift package clean && swift build` |
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
| Formatting fails | Run `./bin/mise run setup` to install tools |
| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` |
| Template errors | Check Jinja2 syntax and context variables |
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` |
| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case |
| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` |
| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` |
| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` |
| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results |
| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle |
| Problem | Solution |
| --------------------------- | ------------------------------------------------------------------------------------------------------------ |
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
| Build fails | `swift package clean && swift build` |
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
| Formatting fails | Run `./bin/mise run setup` to install tools |
| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` |
| Template errors | Check Jinja2 syntax and context variables |
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` |
| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case |
| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` |
| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` |
| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` |
| Jinja trailing `\n` | `{% if false %}...{% endif %}\n` renders `"\n"`, not `""` — strip whitespace-only partial template results |
| `Bundle.module` in tests | SPM test targets without declared resources don't have `Bundle.module` — use `Bundle.main` or temp bundle |
| SwiftLint trailing closure | When function takes 2+ closures, use explicit label for last closure (`export: { ... }`) not trailing syntax |

## Additional Rules

Expand Down
14 changes: 14 additions & 0 deletions Sources/ExFigCLI/Output/FileWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,28 @@ final class FileWriter: Sendable {

private func writeFileData(_ file: FileContents) throws {
let fileURL = URL(fileURLWithPath: file.destination.url.path)
let tracker = ManifestTrackerStorage.current

if let data = file.data {
// Capture pre-state before write (zero overhead when tracker is nil)
let preState = tracker?.capturePreState(for: fileURL.path)
try data.write(to: fileURL, options: .atomic)
// Record only after successful write
if let tracker, let preState {
tracker.recordWrite(path: fileURL.path, data: data, preState: preState)
}
} else if let localFileURL = file.dataFile {
// Capture pre-state before write
let preState = tracker?.capturePreState(for: fileURL.path)
// Remove existing file if present (copyItem fails if destination exists)
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
try FileManager.default.copyItem(at: localFileURL, to: fileURL)
// Record only after successful copy
if let tracker, let preState {
tracker.recordCopy(path: fileURL.path, sourceURL: localFileURL, preState: preState)
}
} else {
fatalError("FileContents.data is nil. Use FileDownloader to download contents of the file.")
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/ExFigCLI/Report/AssetManifest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// Manifest of all files generated during an export.
struct AssetManifest: Encodable {
/// List of generated file entries.
let files: [ManifestEntry]
}

/// A single file entry in the asset manifest.
struct ManifestEntry: Encodable {
/// Relative path to the file (relative to working directory).
let path: String

/// What happened to this file during export.
let action: FileAction

/// FNV-1a 64-bit content checksum (16-char hex), `nil` for deleted files or unreadable content.
let checksum: String?

/// Type of asset (e.g., "color", "icon", "image", "typography").
let assetType: String
}

/// Classification of file write operations.
enum FileAction: String, Encodable {
/// File did not exist before write.
case created
/// File existed but content changed.
case modified
/// File existed with identical content.
case unchanged
/// File existed in previous report but is no longer generated.
case deleted
}
104 changes: 104 additions & 0 deletions Sources/ExFigCLI/Report/ExportReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import ExFigCore
import Foundation

/// Structured JSON report for a single export command.
///
/// Analogous to `BatchReport` (used by `exfig batch --report`) but tailored
/// for single-command exports (`colors`, `icons`, `images`, `typography`).
struct ExportReport: Encodable {
/// Report schema version for forward compatibility.
let version: Int

/// Command name (e.g., "colors", "icons", "images", "typography").
let command: String

/// Path to the PKL config file used.
let config: String

/// Export start time (ISO8601).
let startTime: String

/// Export end time (ISO8601).
let endTime: String

/// Duration in seconds.
let duration: TimeInterval

/// Whether the export succeeded.
let success: Bool

/// Error description if export failed, `nil` on success.
let error: String?

/// Asset counts.
let stats: ReportStats

/// Warnings collected during export.
let warnings: [String]

/// Asset manifest (present when file tracking is enabled).
let manifest: AssetManifest?

/// Current report schema version.
static let currentVersion = 1

init(
version: Int = ExportReport.currentVersion,
command: String,
config: String,
startTime: String,
endTime: String,
duration: TimeInterval,
success: Bool,
error: String?,
stats: ReportStats,
warnings: [String],
manifest: AssetManifest?
) {
self.version = version
self.command = command
self.config = config
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.success = success
self.error = error
self.stats = stats
self.warnings = warnings
self.manifest = manifest
}

/// Serializes the report to pretty-printed JSON with sorted keys.
func jsonData() throws -> Data {
try JSONCodec.encodePrettySorted(self)
}
}

/// Asset counts for a single export command report.
///
/// Separate from `ExportStats` (which contains batch-only fields like
/// `computedNodeHashes` and `granularCacheStats` that are not `Codable`).
struct ReportStats: Encodable {
let colors: Int
let icons: Int
let images: Int
let typography: Int

static let zero = ReportStats(colors: 0, icons: 0, images: 0, typography: 0)

static func colors(_ count: Int) -> ReportStats {
.init(colors: count, icons: 0, images: 0, typography: 0)
}

static func icons(_ count: Int) -> ReportStats {
.init(colors: 0, icons: count, images: 0, typography: 0)
}

static func images(_ count: Int) -> ReportStats {
.init(colors: 0, icons: 0, images: count, typography: 0)
}

static func typography(_ count: Int) -> ReportStats {
.init(colors: 0, icons: 0, images: 0, typography: count)
}
}
84 changes: 84 additions & 0 deletions Sources/ExFigCLI/Report/ExportReportHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

// swiftlint:disable function_parameter_count

/// Wraps export execution with report generation boilerplate.
///
/// Sets up `WarningCollectorStorage` and `ManifestTrackerStorage` before export,
/// captures timing and errors, builds the report, and guarantees cleanup via `defer`.
///
/// - Parameters:
/// - command: Export command name (e.g., "colors", "icons").
/// - assetType: Asset type for manifest tracking (e.g., "color", "icon").
/// - reportPath: Path to write JSON report. If `nil`, report generation is skipped entirely.
/// - configInput: PKL config path (for the report's `config` field).
/// - ui: Terminal UI for output.
/// - buildStats: Closure that converts the export count into `ReportStats`.
/// - export: The actual export operation. Returns the exported asset count.
/// - Throws: Re-throws the export error after writing the report.
func withExportReport(
command: String,
assetType: String,
reportPath: String?,
configInput: String?,
ui: TerminalUI,
buildStats: (Int) -> ReportStats,
export: () async throws -> Int
) async throws {
guard let reportPath else {
_ = try await export()
return
}

let warningCollector = WarningCollector()
let manifestTracker = ManifestTracker(assetType: assetType)
WarningCollectorStorage.current = warningCollector
ManifestTrackerStorage.current = manifestTracker
defer {
WarningCollectorStorage.current = nil
ManifestTrackerStorage.current = nil
}

let startTime = Date()
var exportCount = 0
var exportError: (any Error)?

do {
exportCount = try await export()
} catch {
exportError = error
}

let endTime = Date()
let formatter = ISO8601DateFormatter()

let exportReport = ExportReport(
version: ExportReport.currentVersion,
command: command,
config: configInput ?? "exfig.pkl",
startTime: formatter.string(from: startTime),
endTime: formatter.string(from: endTime),
duration: endTime.timeIntervalSince(startTime),
success: exportError == nil,
error: exportError.map { describeExportError($0) },
stats: buildStats(exportCount),
warnings: warningCollector.getAll(),
manifest: manifestTracker.buildManifest(previousReportPath: reportPath)
)
writeExportReport(exportReport, to: reportPath, ui: ui)

if let error = exportError {
throw error
}
}

// swiftlint:enable function_parameter_count

private func describeExportError(_ error: any Error) -> String {
if let localized = error as? LocalizedError,
let description = localized.errorDescription
{
return description
}
return String(describing: error)
}
25 changes: 25 additions & 0 deletions Sources/ExFigCLI/Report/ExportReportWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Writes an export report to disk. Failure is non-fatal — logs a warning.
///
/// Same pattern as `Batch.swift` report writing (lines 710-716):
/// wrap write in do/catch, warn on failure, never propagate the error.
func writeExportReport(_ report: ExportReport, to path: String, ui: TerminalUI) {
let data: Data
do {
data = try report.jsonData()
} catch {
ui.warning("Failed to serialize export report: \(error.localizedDescription)")
return
}

do {
let url = URL(fileURLWithPath: path)
let directory = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: url)
ui.info("Report written to: \(path)")
} catch {
ui.warning("Failed to write report to \(path): \(error.localizedDescription)")
}
}
Loading
Loading