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
24 changes: 24 additions & 0 deletions .claude/rules/cache-granular.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ This pattern ensures:
- Multiple configs referencing the same Figma file all benefit from granular cache tracking
- Single atomic save at the end contains both file versions AND nodeHashes

## Light/Dark Pairing (mandatory in single-file mode)

In `useSingleFile` + `suffixDarkMode` mode, filtering to changed nodes alone breaks
`ImagesProcessor.process(light:dark:)` — it requires matching pairs and throws
`AssetsValidatorError.countMismatch` (`Asset count mismatch: light=0, dark=1`) when only one side changed.

Every single-file granular path MUST use the pairing-aware variant so a changed dark node drags its
light sibling into the export:

```toon
paths[3]{caller,method,pairing}:
IconsLoader.loadFromSingleFileWithGranularCache,loadVectorImagesWithGranularCacheAndPairing,required
ImagesLoader.loadFromSingleFileWithGranularCache (PNG),loadPNGImagesWithGranularCache(darkModeSuffix:),required
ImagesLoader.loadFromSingleFileWithGranularCache (SVG),loadVectorImagesWithGranularCacheAndPairing,required
```

`loadFromLightAndDarkFileWithGranularCache` must NOT pair (`darkModeSuffix: nil`) — light and dark live
in separate files and are filtered independently.

Pairing matches on `baseName(for: component.iconName, darkModeSuffix:)`, so `darkSuffix` must be the
same value later passed to `splitByDarkMode` (both default to `"_dark"` when `suffixDarkMode` is unset).

Regression tests: `IconsLoaderGranularCachePairingTests`, `ImagesLoaderGranularCachePairingTests`.

## Known Limitations

- Config changes (output path, format, scale) are not detected - use `--force` when config changes
Expand Down
25 changes: 25 additions & 0 deletions .claude/rules/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,31 @@ final class Collector: Sendable {
}
```

## ParsableArguments: never construct with bare `init()` and then read it

`ParsableArguments.init()` satisfies the protocol but leaves every `@Option`/`@Flag` **undecoded**.
Assigning is fine; the first *read* traps and kills the whole test process:

```
Can't read a value from a parsable argument definition.
```

Under `swift test --parallel` this shows up as a suite that logs `started` with no `passed` and a
non-zero exit — no `XCTAssert` failure line, so it is easy to mistake for a green run.

```swift
// BAD — traps on the first read of .rateLimit
cmd.faultToleranceOptions = FaultToleranceOptions()
_ = cmd.faultToleranceOptions.effectiveRateLimit(configValue: nil)

// GOOD — "no CLI flag given", all defaults decoded
cmd.faultToleranceOptions = try FaultToleranceOptions.parse([])
```

Applies to `filter`, `strictPathValidation`, and every `@OptionGroup` on manually built subcommands
(the `BatchConfigRunner.make*` pattern). Production is safe — `BatchConfigRunner` receives
already-parsed option groups; only hand-rolled test instances hit this.

## Test Helpers for Codable Types

```swift
Expand Down
20 changes: 20 additions & 0 deletions .claude/rules/modification-checklists.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ When adding fields to `FrameSource` (PKL) / `SourceInput` (ExFigCore), also upda
8. `DownloadAll.swift` — pass filter value to both `exportIcons` and `exportImages`
9. Error/warning types with context (`ExFigError`, `ExFigWarning`) — add associated values if needed

## Modifying IconsLoader or ImagesLoader (always both)

`IconsLoader` and `ImagesLoader` are siblings over `ImageLoaderBase` with near-identical
`loadFromSingleFileWithGranularCache` / `loadFromLightAndDarkFileWithGranularCache` structure.
A fix applied to one is usually a bug left in the other. Put shared logic in `ImageLoaderBase` and
check ALL granular call sites:

```toon
call_sites[4]{loader,mode,branches}:
IconsLoader,single-file,vector only
IconsLoader,light+dark files,vector only
ImagesLoader,single-file,PNG + SVG (two branches!)
ImagesLoader,light+dark files,PNG + SVG (two branches!)
```

`ImagesLoader` has a raster branch that `IconsLoader` does not (`isRasterFormat && !useSVGSource`) —
any helper added for icons needs a PNG counterpart. Example: `f124526` added light/dark pairing to
`IconsLoader` only; `ImagesLoader` kept exporting orphan dark assets until `loadPNGImagesWithGranularCache`
gained `darkModeSuffix:`. See `.claude/rules/cache-granular.md` → Light/Dark Pairing.

## Adding a New Filter Level (e.g., page filtering)

Filter predicate sites that ALL need updating:
Expand Down
20 changes: 16 additions & 4 deletions Sources/ExFigCLI/Loaders/ImageLoaderBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,20 +572,32 @@ class ImageLoaderBase: @unchecked Sendable {
/// 2. Computes content hashes for granular change detection
/// 3. Filters to only changed components (if cache exists)
/// 4. Returns computed hashes for cache update after export
///
/// Pass `darkModeSuffix` in `useSingleFile` mode so a changed dark node drags its light sibling
/// into the export — otherwise `ImagesProcessor` validation fails with a count mismatch.
/// Leave it `nil` in light/dark-file mode, where each file is filtered independently.
func loadPNGImagesWithGranularCache(
fileId: String,
frameName: String,
pageName: String? = nil,
filter: String? = nil,
scales: [Double],
darkModeSuffix: String? = nil,
rtlProperty: String? = Component.defaultRTLProperty,
rtlActiveValues: [String]? = nil,
onBatchProgress: @escaping BatchProgressCallback = { _, _ in }
) async throws -> ImagesWithHashesResult {
let filterResult = try await fetchImageComponentsWithGranularCache(
fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues
)
let filterResult = if let darkModeSuffix {
try await fetchImageComponentsWithGranularCacheAndPairing(
fileId: fileId, frameName: frameName, pageName: pageName, filter: filter,
darkModeSuffix: darkModeSuffix, rtlProperty: rtlProperty, rtlActiveValues: rtlActiveValues
)
} else {
try await fetchImageComponentsWithGranularCache(
fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues
)
}

if filterResult.allSkipped {
return ImagesWithHashesResult(
Expand Down
6 changes: 5 additions & 1 deletion Sources/ExFigCLI/Loaders/ImagesLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -427,12 +427,14 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di
// PNG source: Raster images (PNG/WebP) with granular cache
let scales = getScales(customScales: configScales)

// Use pairing-aware method to ensure light/dark pairs are exported together
let result = try await loadPNGImagesWithGranularCache(
fileId: fileId,
frameName: frameName,
pageName: pageName,
filter: filter,
scales: scales,
darkModeSuffix: darkSuffix,
rtlProperty: config.rtlProperty,
rtlActiveValues: config.rtlActiveValues,
onBatchProgress: onBatchProgress
Expand Down Expand Up @@ -461,12 +463,14 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di
)
} else {
// SVG source or vector output: fetch SVG with granular cache
let result = try await loadVectorImagesWithGranularCache(
// Use pairing-aware method to ensure light/dark pairs are exported together
let result = try await loadVectorImagesWithGranularCacheAndPairing(
fileId: fileId,
frameName: frameName,
pageName: pageName,
params: SVGParams(),
filter: filter,
darkModeSuffix: darkSuffix,
rtlProperty: config.rtlProperty,
rtlActiveValues: config.rtlActiveValues,
onBatchProgress: onBatchProgress
Expand Down
13 changes: 13 additions & 0 deletions Sources/ExFigCLI/Subcommands/Batch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ extension ExFigCommand {
resolved: resolved,
ui: ui
)

// A reported failure must fail the process — otherwise CI treats a broken export as green.
if result.failureCount > 0 {
throw ExitCode.failure
}
}

// MARK: - Run Helpers
Expand Down Expand Up @@ -868,6 +873,14 @@ extension ExFigCommand {
}

NooraUI.shared.table(headers: headers, rows: rows)

// The table truncates both the config name and the error, which makes CI logs useless
// for identifying what failed. Repeat failures in full underneath it.
guard !result.failures.isEmpty else { return }
ui.info("")
for failure in result.failures {
ui.error("\(failure.config.name): \(failure.error.localizedDescription)")
}
}

private func displayRateLimitStatus(status: RateLimiterStatus, ui: TerminalUI) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import XCTest
///
/// Without these, a regression that swaps `??` direction or stops passing `figma?.rateLimit`
/// in any subcommand would slip through unit tests on `FaultToleranceOptions` alone.
///
/// "No CLI flag given" MUST be spelled `try FaultToleranceOptions.parse([])`, never the bare
/// `FaultToleranceOptions()` initializer: `ParsableArguments.init()` leaves every `@Option`
/// undecoded, so the first read of `.rateLimit` / `.timeout` traps with
/// "Can't read a value from a parsable argument definition" and kills the test process.
final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {
private var tempFiles: [URL] = []

Expand Down Expand Up @@ -49,7 +54,7 @@ final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {
cmd.globalOptions = GlobalOptions()
cmd.options = options
cmd.cacheOptions = CacheOptions()
cmd.faultToleranceOptions = FaultToleranceOptions()
cmd.faultToleranceOptions = try FaultToleranceOptions.parse([])
cmd.filter = nil

let figma = cmd.options.params.figma
Expand All @@ -63,7 +68,7 @@ final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {
cmd.globalOptions = GlobalOptions()
cmd.options = options
cmd.cacheOptions = CacheOptions()
cmd.faultToleranceOptions = FaultToleranceOptions()
cmd.faultToleranceOptions = try FaultToleranceOptions.parse([])
cmd.filter = nil

let figma = cmd.options.params.figma
Expand Down Expand Up @@ -98,7 +103,7 @@ final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {
cmd.globalOptions = GlobalOptions()
cmd.options = options
cmd.cacheOptions = CacheOptions()
cmd.faultToleranceOptions = HeavyFaultToleranceOptions()
cmd.faultToleranceOptions = try HeavyFaultToleranceOptions.parse([])
cmd.filter = nil

let figma = cmd.options.params.figma
Expand All @@ -114,7 +119,7 @@ final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {
cmd.globalOptions = GlobalOptions()
cmd.options = options
cmd.cacheOptions = CacheOptions()
cmd.faultToleranceOptions = HeavyFaultToleranceOptions()
cmd.faultToleranceOptions = try HeavyFaultToleranceOptions.parse([])
cmd.filter = nil
cmd.strictPathValidation = false

Expand Down Expand Up @@ -143,7 +148,7 @@ final class SubcommandFaultTolerancePrecedenceTests: XCTestCase {

func testPKLTimeoutUsedWhenCLITimeoutAbsent() throws {
let options = try makeOptions(figmaBlock: "timeout = 60.0")
let cliOpts = FaultToleranceOptions()
let cliOpts = try FaultToleranceOptions.parse([])

let configTimeout = options.params.figma?.timeout
let effective: TimeInterval? = cliOpts.timeout.map { TimeInterval($0) } ?? configTimeout
Expand Down
136 changes: 136 additions & 0 deletions Tests/ExFigTests/Loaders/ImagesLoaderGranularCachePairingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
@testable import ExFigCLI
import ExFigCore
@testable import FigmaAPI
import Logging
import XCTest

/// Tests for granular cache light/dark pairing logic in ImagesLoader.
///
/// Mirrors `IconsLoaderGranularCachePairingTests`: in single-file + `suffixDarkMode` mode a changed
/// dark node must drag its light sibling into the export, otherwise `ImagesProcessor.process(light:dark:)`
/// rejects the unpaired asset with `countMismatch(light:dark:)` and the whole config fails.
final class ImagesLoaderGranularCachePairingTests: XCTestCase {
var mockClient: MockClient!
var logger: Logger!

override func setUp() {
super.setUp()
mockClient = MockClient()
logger = Logger(label: "test")
}

override func tearDown() {
mockClient = nil
super.tearDown()
}

/// iOS illustrations — the PNG branch of `loadFromSingleFileWithGranularCache`.
func testOnlyDarkChanged_pngBranch_includesBothVersions() async throws {
let secondResult = try await runOnlyDarkChangedScenario(platform: .ios)

XCTAssertFalse(secondResult.allSkipped)
XCTAssertEqual(
secondResult.light.count, 1,
"light sibling must be re-exported together with the changed dark node"
)
XCTAssertEqual(secondResult.dark?.count, 1)
XCTAssertEqual(secondResult.light.first?.name, "illuHome")

assertProcessorAccepts(secondResult, platform: .ios)
}

/// Web illustrations — the SVG/vector branch of `loadFromSingleFileWithGranularCache`.
func testOnlyDarkChanged_vectorBranch_includesBothVersions() async throws {
let secondResult = try await runOnlyDarkChangedScenario(platform: .web)

XCTAssertFalse(secondResult.allSkipped)
XCTAssertEqual(
secondResult.light.count, 1,
"light sibling must be re-exported together with the changed dark node"
)
XCTAssertEqual(secondResult.dark?.count, 1)

assertProcessorAccepts(secondResult, platform: .web)
}

// MARK: - Scenario

/// Exports twice: cold cache, then with only the dark node of `illuHome` modified.
private func runOnlyDarkChangedScenario(platform: Platform) async throws -> ImagesLoaderResultWithHashes {
let components = [
Component.make(nodeId: "1:1", name: "illuHome", frameName: "InDrive"),
Component.make(nodeId: "1:2", name: "illuHome-dark", frameName: "InDrive"),
Component.make(nodeId: "1:3", name: "illuCar", frameName: "InDrive"),
Component.make(nodeId: "1:4", name: "illuCar-dark", frameName: "InDrive"),
]

let nodes = makeNodeResponse(for: components)
mockClient.setResponse(nodes, for: NodesEndpoint.self)
mockClient.setResponse(components, for: ComponentsEndpoint.self)
mockClient.setResponse(makeImageURLs(for: components), for: ImageEndpoint.self)

var cache = ImageTrackingCache()
cache.updateFileVersion(fileId: "file123", version: "v1")

let params = PKLConfig.make(
lightFileId: "file123", imagesFrameName: "InDrive",
imagesSuffixDarkMode: "-dark"
)

let loader = ImagesLoader(client: mockClient, params: params, platform: platform, logger: logger)
loader.granularCacheManager = GranularCacheManager(client: mockClient, cache: cache)

let firstResult = try await loader.loadWithGranularCache()
XCTAssertFalse(firstResult.allSkipped)
XCTAssertEqual(firstResult.light.count, 2)
XCTAssertEqual(firstResult.dark?.count, 2)

for (fileId, hashes) in firstResult.computedHashes {
cache.updateNodeHashes(fileId: fileId, hashes: hashes)
}

// Modify ONLY the dark version of illuHome — a designer tweak to the dark variant.
var modifiedNodes = nodes
modifiedNodes["1:2"] = Node.makeWithFill(
id: "1:2", name: "illuHome-dark",
fillColor: PaintColor(r: 1.0, g: 0.0, b: 0.0, a: 1.0)
)
mockClient.setResponse(modifiedNodes, for: NodesEndpoint.self)

let loader2 = ImagesLoader(client: mockClient, params: params, platform: platform, logger: logger)
loader2.granularCacheManager = GranularCacheManager(client: mockClient, cache: cache)

return try await loader2.loadWithGranularCache()
}

// MARK: - Helpers

/// The downstream consequence of missing pairing: `ImagesProcessor` rejects an unpaired dark asset.
private func assertProcessorAccepts(_ result: ImagesLoaderResultWithHashes, platform: Platform) {
let processor = ImagesProcessor(platform: platform, nameStyle: .camelCase)
let processed = processor.process(light: result.light, dark: result.dark)
if case let .failure(error) = processed.result {
XCTFail("ImagesProcessor validation failed: \(error.localizedDescription)")
}
}

private func makeNodeResponse(for components: [Component]) -> [NodeId: Node] {
var nodes: [NodeId: Node] = [:]
for component in components {
nodes[component.nodeId] = Node.makeWithFill(
id: component.nodeId,
name: component.name,
fillColor: PaintColor(r: 0.5, g: 0.5, b: 0.5, a: 1.0)
)
}
return nodes
}

private func makeImageURLs(for components: [Component]) -> [NodeId: ImagePath?] {
var urls: [NodeId: ImagePath?] = [:]
for component in components {
urls[component.nodeId] = "https://figma.com/\(component.name).png"
}
return urls
}
}
Loading