Skip to content

Commit d81b1cb

Browse files
authored
fix: filter out deleted but referenced variables (#57)
* fix: filter out deleted but referenced variables Add logic in ColorsVariablesLoader to skip variables marked as deletedButReferenced in Figma responses. Extend VariableValue with a deletedButReferenced property to track this status. Update test helpers and add a test case to ensure that deleted but referenced variables are not included in loader results. The change prevents errors from attempting to load variables that are no longer active. * chore: update doc * fix: after review
1 parent 1e0eb66 commit d81b1cb

12 files changed

Lines changed: 247 additions & 68 deletions

File tree

CLAUDE.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ When changing how node IDs are resolved (e.g., `codeConnectNodeId`), update ALL
260260
3. `ImagePack` primaryNodeId in `loadVectorImages` (vector/SVG path)
261261
4. `ImagePack` primaryNodeId in `loadPNGImages` (raster path)
262262

263+
### Modifying ColorsVariablesLoader Return Type
264+
265+
`ColorsLoaderOutput` is a tuple typealias used by both `ColorsLoader` and `ColorsVariablesLoader`.
266+
Changing `load()` return type affects:
267+
268+
1. `ColorsExportContextImpl.loadColors()` — main export flow
269+
2. `Download.Colors.exportW3C()` — download command (inside `@Sendable withSpinner` closure)
270+
3. `DownloadAll.exportColors()` — download all command (inside `@Sendable withSpinner` closure)
271+
4. ALL assertions in `ColorsVariablesLoaderTests``result.light``result.output.light` etc.
272+
273+
**`withSpinner` gotcha:** Closure is `@Sendable` — cannot capture mutable vars. Return full result from closure.
274+
263275
### Adding a CLI Command
264276

265277
See `ExFigCLI/CLAUDE.md` (Adding a New Subcommand).
@@ -334,22 +346,23 @@ NooraUI.formatLink("url", useColors: true) // underlined primary
334346

335347
## Troubleshooting
336348

337-
| Problem | Solution |
338-
| ------------------------- | -------------------------------------------------------------------------------------------- |
339-
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
340-
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
341-
| Build fails | `swift package clean && swift build` |
342-
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
343-
| Formatting fails | Run `./bin/mise run setup` to install tools |
344-
| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` |
345-
| Template errors | Check Stencil syntax and context variables |
346-
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
347-
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
348-
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
349-
| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` |
350-
| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case |
351-
| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` |
352-
| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` |
349+
| Problem | Solution |
350+
| --------------------------- | ---------------------------------------------------------------------------------------------- |
351+
| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` |
352+
| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) |
353+
| Build fails | `swift package clean && swift build` |
354+
| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set |
355+
| Formatting fails | Run `./bin/mise run setup` to install tools |
356+
| test:filter no matches | SPM converts hyphens→underscores: use `ExFig_FlutterTests` not `ExFig-FlutterTests` |
357+
| Template errors | Check Stencil syntax and context variables |
358+
| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` |
359+
| Android pathData long | Simplify in Figma or use `--strict-path-validation` |
360+
| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` |
361+
| Test target won't compile | Broken test files block entire target; use `swift test --filter Target.Class` after `build` |
362+
| Test helper JSON decode | `ContainingFrame` uses default Codable (camelCase: `nodeId`, `pageName`), NOT snake_case |
363+
| Web entry test fails | Web entry types use `outputDirectory` field, while Android/Flutter use `output` |
364+
| Logger concatenation err | `Logger.Message` (swift-log) requires interpolation `"\(a) \(b)"`, not concatenation `a + b` |
365+
| Deleted variables in output | Filter `VariableValue.deletedButReferenced != true` in variable loaders AND `CodeSyntaxSyncer` |
353366

354367
## Additional Rules
355368

Sources/ExFigCLI/Context/ColorsExportContextImpl.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,15 @@ struct ColorsExportContextImpl: ColorsExportContext {
7676

7777
let result = try await loader.load()
7878

79+
for warning in result.warnings {
80+
ui.warning(warning)
81+
}
82+
7983
return ColorsLoadOutput(
80-
light: result.light,
81-
dark: result.dark ?? [],
82-
lightHC: result.lightHC ?? [],
83-
darkHC: result.darkHC ?? []
84+
light: result.output.light,
85+
dark: result.output.dark ?? [],
86+
lightHC: result.output.lightHC ?? [],
87+
darkHC: result.output.darkHC ?? []
8488
)
8589
}
8690

Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ final class ColorsVariablesLoader: Sendable {
1717
self.filter = filter
1818
}
1919

20-
func load() async throws -> ColorsLoaderOutput {
20+
struct LoadResult: Sendable {
21+
let output: ColorsLoaderOutput
22+
let warnings: [ExFigWarning]
23+
}
24+
25+
func load() async throws -> LoadResult {
2126
guard
2227
let tokensFileId = variableParams?.tokensFileId,
2328
let tokensCollectionName = variableParams?.tokensCollectionName
@@ -30,13 +35,16 @@ final class ColorsVariablesLoader: Sendable {
3035

3136
let variables: [Variable] = tokenCollection.value.variableIds.compactMap { tokenId in
3237
guard let variableMeta = meta.variables[tokenId] else { return nil }
38+
guard variableMeta.deletedButReferenced != true else { return nil }
3339
return mapVariableMetaToVariable(
3440
variableMeta: variableMeta,
3541
modeIds: extractModeIds(from: tokenCollection.value)
3642
)
3743
}
3844

39-
return mapVariablesToColorOutput(variables: variables, meta: meta)
45+
var warnings: [ExFigWarning] = []
46+
let output = mapVariablesToColorOutput(variables: variables, meta: meta, warnings: &warnings)
47+
return LoadResult(output: output, warnings: warnings)
4048
}
4149

4250
private func loadVariables(fileId: String) async throws -> VariablesEndpoint.Content {
@@ -78,7 +86,8 @@ final class ColorsVariablesLoader: Sendable {
7886

7987
private func mapVariablesToColorOutput(
8088
variables: [Variable],
81-
meta: VariablesEndpoint.Content
89+
meta: VariablesEndpoint.Content,
90+
warnings: inout [ExFigWarning]
8291
) -> ColorsLoaderOutput {
8392
var colorOutput = Colors()
8493
for variable in variables {
@@ -87,46 +96,59 @@ final class ColorsVariablesLoader: Sendable {
8796
mode: variable.valuesByMode.light,
8897
colorsArray: &colorOutput.lightColors,
8998
filter: filter,
90-
meta: meta
99+
meta: meta,
100+
warnings: &warnings
91101
)
92102
handleColorMode(
93103
variable: variable,
94104
mode: variable.valuesByMode.dark,
95105
colorsArray: &colorOutput.darkColors,
96106
filter: filter,
97-
meta: meta
107+
meta: meta,
108+
warnings: &warnings
98109
)
99110
handleColorMode(
100111
variable: variable,
101112
mode: variable.valuesByMode.lightHC,
102113
colorsArray: &colorOutput.lightHCColors,
103114
filter: filter,
104-
meta: meta
115+
meta: meta,
116+
warnings: &warnings
105117
)
106118
handleColorMode(
107119
variable: variable,
108120
mode: variable.valuesByMode.darkHC,
109121
colorsArray: &colorOutput.darkHCColors,
110122
filter: filter,
111-
meta: meta
123+
meta: meta,
124+
warnings: &warnings
112125
)
113126
}
114127
return (colorOutput.lightColors, colorOutput.darkColors, colorOutput.lightHCColors, colorOutput.darkHCColors)
115128
}
116129

130+
// swiftlint:disable:next function_parameter_count
117131
private func handleColorMode(
118132
variable: Variable,
119133
mode: ValuesByMode?,
120134
colorsArray: inout [Color],
121135
filter: String?,
122-
meta: VariablesEndpoint.Content
136+
meta: VariablesEndpoint.Content,
137+
warnings: inout [ExFigWarning]
123138
) {
124139
if case let .color(color) = mode, doesColorMatchFilter(from: variable) {
125140
colorsArray.append(createColor(from: variable, color: color))
126141
} else if case let .variableAlias(variableAlias) = mode,
127142
let variableMeta = meta.variables[variableAlias.id],
128143
let variableCollectionId = meta.variableCollections[variableMeta.variableCollectionId]
129144
{
145+
if variableMeta.deletedButReferenced == true {
146+
warnings.append(.deletedVariableAlias(
147+
tokenName: variable.name,
148+
referencedName: variableMeta.name
149+
))
150+
return
151+
}
130152
let modeId = variableCollectionId.modes.first(where: {
131153
$0.name == variableParams?.primitivesModeName
132154
})?.modeId ?? variableCollectionId.defaultModeId
@@ -135,7 +157,8 @@ final class ColorsVariablesLoader: Sendable {
135157
mode: variableMeta.valuesByMode[modeId],
136158
colorsArray: &colorsArray,
137159
filter: filter,
138-
meta: meta
160+
meta: meta,
161+
warnings: &warnings
139162
)
140163
}
141164
}

Sources/ExFigCLI/Subcommands/Download.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ extension ExFigCommand.Download {
151151
) async throws {
152152
let filterValue = filter
153153

154-
let colors = try await ui.withSpinner("Fetching colors...") {
154+
let colorsResult = try await ui.withSpinner("Fetching colors...") {
155155
if let variableParams = commonParams?.variablesColors {
156156
let loader = ColorsVariablesLoader(
157157
client: client,
@@ -172,12 +172,17 @@ extension ExFigCommand.Download {
172172
colorParams: commonParams?.colors,
173173
filter: filterValue
174174
)
175-
return try await loader.load()
175+
let output = try await loader.load()
176+
return ColorsVariablesLoader.LoadResult(output: output, warnings: [])
176177
}
177178
}
178179

180+
for warning in colorsResult.warnings {
181+
ui.warning(warning)
182+
}
183+
179184
try ColorExportHelper.exportW3C(
180-
colors: colors,
185+
colors: colorsResult.output,
181186
outputURL: outputURL,
182187
compact: jsonOptions.compact
183188
)

Sources/ExFigCLI/Subcommands/DownloadAll.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ extension ExFigCommand.Download {
6464
let figmaParams = options.params.figma
6565
let commonParams = options.params.common
6666

67-
let colors = try await ui.withSpinner("Fetching colors...") {
67+
let colorsResult = try await ui.withSpinner("Fetching colors...") {
6868
if let variableParams = commonParams?.variablesColors {
6969
let loader = ColorsVariablesLoader(
7070
client: client,
@@ -85,10 +85,16 @@ extension ExFigCommand.Download {
8585
colorParams: commonParams?.colors,
8686
filter: nil
8787
)
88-
return try await loader.load()
88+
let output = try await loader.load()
89+
return ColorsVariablesLoader.LoadResult(output: output, warnings: [])
8990
}
9091
}
9192

93+
for warning in colorsResult.warnings {
94+
ui.warning(warning)
95+
}
96+
97+
let colors = colorsResult.output
9298
let outputURL = outputDir.appendingPathComponent("colors.json")
9399

94100
switch jsonOptions.format {

Sources/ExFigCLI/Sync/CodeSyntaxSyncer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public struct CodeSyntaxSyncer: Sendable {
4343
let variableIds = collection.value.variableIds
4444
let variables: [(id: String, name: String)] = variableIds.compactMap { id in
4545
guard let variable = meta.variables[id] else { return nil }
46+
guard variable.deletedButReferenced != true else { return nil }
4647
return (id: id, name: variable.name)
4748
}
4849

Sources/ExFigCLI/TerminalUI/ExFigWarning.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,9 @@ enum ExFigWarning: Sendable, Equatable {
8888

8989
/// HEIC encoding is not available on this platform, falling back to PNG.
9090
case heicUnavailableFallingBackToPng
91+
92+
// MARK: - Variables Warnings
93+
94+
/// A color token references a deleted-but-referenced variable via alias.
95+
case deletedVariableAlias(tokenName: String, referencedName: String)
9196
}

Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ struct ExFigWarningFormatter {
1818
.preFetchComponentsPartialFailure, .preFetchNodesPartialFailure,
1919
.granularCacheWithoutCache, .themeAttributesFileNotFound,
2020
.themeAttributesMarkerNotFound, .themeAttributesNameCollision,
21-
.heicUnavailableFallingBackToPng:
21+
.heicUnavailableFallingBackToPng, .deletedVariableAlias:
2222
formatCompact(warning)
2323

2424
// Multiline format warnings
@@ -89,6 +89,9 @@ struct ExFigWarningFormatter {
8989
case .heicUnavailableFallingBackToPng:
9090
"HEIC encoding unavailable on this platform, using PNG format instead"
9191

92+
case let .deletedVariableAlias(tokenName, referencedName):
93+
"Skipped deleted variable alias: token=\(tokenName), referenced=\(referencedName)"
94+
9295
// Multiline cases handled in main format() method
9396
case .noAssetsFound, .invalidConfigsSkipped, .webIconsMissingSVGData, .webIconsConversionFailed:
9497
fatalError("Multiline warnings should not reach formatCompact")

Sources/FigmaAPI/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ FigmaClientError (Figma's own error JSON: {"status": 404, "err": "Not found"})
105105

106106
`ValuesByMode` is a tagged union decoded via try-chain: `VariableAlias``PaintColor``String``Double``Bool`.
107107

108+
`VariableValue.deletedButReferenced: Bool?` — Figma marks deleted-but-still-referenced variables. Filter in loaders with `guard meta.deletedButReferenced != true`.
109+
108110
## Testing
109111

110112
Tests use `MockClient` (thread-safe via DispatchQueue) and JSON fixtures in `Tests/FigmaAPITests/Fixtures/`.

Sources/FigmaAPI/Model/Variables.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public struct VariableValue: Codable, Sendable {
6565
public var variableCollectionId: String
6666
public var valuesByMode: [String: ValuesByMode]
6767
public var description: String
68+
public var deletedButReferenced: Bool?
6869
}
6970

7071
public typealias VariableId = String

0 commit comments

Comments
 (0)