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
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,29 @@ Changing `load()` return type affects:

**`withSpinner` gotcha:** Closure is `@Sendable` — cannot capture mutable vars. Return full result from closure.

### Lint Command

`exfig lint -i exfig.pkl` validates Figma file structure against PKL config.
Rules in `Sources/ExFigCLI/Lint/Rules/`, engine in `LintEngine.swift`.
Each rule implements `LintRule` protocol with `check(context: LintContext) -> [LintDiagnostic]`.
`LintRule` extension provides `diagnostic()` factory pre-filled with rule metadata — use instead of raw `LintDiagnostic` init.
Uses `FigmaAPI.Client.request(SomeEndpoint(...))` directly (no convenience methods on Client).
`ComponentsEndpoint` returns `[Component]`, `VariablesEndpoint` returns `VariablesMeta`,
`NodesEndpoint` returns `[NodeId: Node]`.

**Lint rule development patterns:**

- Every rule must filter components by BOTH `figmaFrameName` AND `figmaPageName` from config entries
- Skip RTL variants: `comp.containingFrame.containingComponentSet != nil && comp.name.contains("RTL=")`
- Skip root frame fills when checking boundVariables — root `Document` fills are backgrounds, check only children
- Cross-file variable IDs (32+ char hex hash before `/`) are valid external library refs, not broken aliases
- `LintDataCache` actor caches Components/Variables API responses — use `context.cache.components(for:client:)`
- Rules MUST emit diagnostics on API failure — never `catch { continue }` silently. Follow `FramePageMatchRule` pattern
- Empty `fileId` guards must return a diagnostic, not silently `return []`
- `LintSeverity` is `Comparable` — use `>=` directly, no `severityRank()` helpers
- `LintOutputFormat` and `LintSeverity` conform to `ExpressibleByArgument` — use as `@Option` types directly
- Adding error handling to `check()` increases cyclomatic complexity — extract per-entry logic into private methods

### Adding a CLI Command

See `ExFigCLI/CLAUDE.md` (Adding a New Subcommand).
Expand Down Expand Up @@ -502,6 +525,11 @@ NooraUI.formatLink("url", useColors: true) // underlined primary
| Figma variable IDs file-scoped | Variable IDs differ between files — alias targets from file A can't be found by ID in file B. Use name-based matching (`resolveViaLibrary`) + mode name matching (not modeId) for cross-file resolution |
| `assertionFailure` in release | `assertionFailure` is stripped in release builds — add `FileHandle.standardError.write()` as production fallback for truly-impossible-but-must-not-be-silent error paths |
| Components API called N times | `ComponentPreFetcher` only works in batch mode — use `ComponentsCache` via `SourceFactory(componentsCache:)` for standalone multi-entry dedup |
| Config type reference | `ExFigOptions.params` is `ExFig.ModuleImpl!` — `PKLConfig` is a typealias in `PKLConfigCompat.swift`, both names work |
| `Paint.visible` doesn't exist | FigmaAPI `Paint` has no `visible` field — use `opacity` to check visibility |
| `variablesColors` location | On `Common.CommonConfig` (`config.common?.variablesColors`), NOT on `config.common?.colors?.variablesColors` |
| `cp` prompts overwrite | macOS `trash` alias intercepts `cp`; use `/bin/cp -f` to force overwrite without prompt |
| SwiftFormat `///` before nested `func` | SwiftFormat converts `//` to `///` before `func` inside method bodies — this is expected, don't fight it |

## Additional Rules

Expand Down
8 changes: 8 additions & 0 deletions Sources/ExFigCLI/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ Penpot sources create `BasePenpotClient` internally from `PENPOT_ACCESS_TOKEN` e
`FileDownloader.fetch()` treats `file://` URLs as local files (skips HTTP download). Do NOT weaken
`validateDownloadURL()` — it must remain HTTPS-only. Filter file URLs in `fetch()` before download loop.

### Lint Subcommand

`Lint.swift` validates Figma file against config without exporting.
Uses `ExFigOptions` for config loading (same as export commands).
Rules in `Lint/Rules/`, each uses `client.request(SomeEndpoint(fileId:))` for Figma API.
Generic entry collection: `func addEntries(_ icons: [some Common_FrameSource]?)` to iterate
all platform entries without platform-specific code.

### Adding a New Subcommand

1. Create `Subcommands/NewCommand.swift` implementing `AsyncParsableCommand`
Expand Down
14 changes: 14 additions & 0 deletions Sources/ExFigCLI/ExFig.docc/CICDIntegration.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ In CI, use `--quiet` to keep logs clean. Pair with `--report` for structured out
| 0 | Success |
| 1 | Export error (API failure, etc.) |

## Linting in CI

Run `exfig lint` as a pre-export validation step. It checks that your Figma file
matches the conventions expected by your PKL config (naming, frame structure, variable
bindings, dark mode setup).

```yaml
- name: Lint Figma structure
run: exfig lint -i exfig.pkl --format json --severity error
```

The command exits with code 1 if any errors are found. Use `--format json` for
machine-readable output and `--severity error` to ignore warnings.

## Version Tracking in CI

Enable `--cache` to skip unchanged exports. ExFig compares the Figma file version against a local
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFigCLI/ExFig.docc/ExFig.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ automatic retries with exponential backoff, checkpoint/resume for interrupted ex
file version tracking, and experimental per-node granular cache.

**Developer Experience**
`exfig lint` validates Figma file structure against your config before export (naming, variables, dark mode).
CI/CD ready (quiet mode, exit codes, JSON reports), GitHub Action for automated exports,
MCP server for AI assistant integration,
[Claude Code plugins](https://github.com/DesignPipe/exfig-plugins) for setup wizards and slash commands,
Expand Down
5 changes: 4 additions & 1 deletion Sources/ExFigCLI/ExFig.docc/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,12 @@ ios = new iOS.iOSConfig {
}
```

### 4. Export Resources
### 4. Validate & Export

```bash
# Validate Figma file structure before exporting
exfig lint

# Export individual resource types
exfig colors
exfig icons
Expand Down
28 changes: 28 additions & 0 deletions Sources/ExFigCLI/ExFig.docc/Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,34 @@ exfig fetch -f abc123 -r "Images" -o ./images \
| `--webp-encoding` | - | WebP encoding: lossy, lossless | lossy |
| `--webp-quality` | - | WebP quality (0-100) | 80 |

## Linting

Validate your Figma file structure against your PKL config before exporting:

```bash
# Lint with default rules
exfig lint -i exfig.pkl

# Only check specific rules
exfig lint -i exfig.pkl --rules naming-convention,deleted-variables

# JSON output for CI (exit code 1 on errors)
exfig lint -i exfig.pkl --format json --severity error
```

### Available Rules

| Rule | Severity | Description |
| -------------------------- | -------- | ------------------------------------------------------ |
| `frame-page-match` | error | Frame/page names in config exist in Figma file |
| `naming-convention` | error | Component names match `nameValidateRegexp` patterns |
| `component-not-frame` | error | Configured frames contain published components |
| `duplicate-component-names`| error | No duplicate component names in configured frames |
| `deleted-variables` | warning | No `deletedButReferenced` variables in collections |
| `alias-chain-integrity` | warning | Variable alias chains resolve without broken refs |
| `dark-mode-variables` | error | With `variablesDarkMode`, fills bound to Variables |
| `dark-mode-suffix` | warning | With `suffixDarkMode`, light components have dark pair |

## Help and Version

```bash
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFigCLI/ExFigCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ extension ExFigCommand {
Download.self,
Tokens.self,
Batch.self,
Lint.self,
]
#if canImport(MCP)
commands.append(MCPServe.self)
Expand Down
65 changes: 65 additions & 0 deletions Sources/ExFigCLI/Lint/LintEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

/// Callback for reporting lint progress with a displayable message string.
typealias LintProgressCallback = @Sendable (String) -> Void

/// Engine that runs lint rules against a PKL configuration.
struct LintEngine {
/// All registered lint rules.
let rules: [any LintRule]

/// Run all applicable rules (or filtered subset) and return diagnostics.
func run(
context: LintContext,
ruleFilter: Set<String>? = nil,
minSeverity: LintSeverity = .info,
onProgress: LintProgressCallback? = nil
) async throws -> [LintDiagnostic] {
let applicableRules = rules.filter { rule in
if let filter = ruleFilter, !filter.contains(rule.id) {
return false
}
return rule.severity >= minSeverity
}

var allDiagnostics: [LintDiagnostic] = []
let total = applicableRules.count

for (index, rule) in applicableRules.enumerated() {
onProgress?("Checking \(rule.name)... (\(index + 1)/\(total))")

do {
let diagnostics = try await rule.check(context: context)
allDiagnostics.append(contentsOf: diagnostics)
} catch is CancellationError {
throw CancellationError()
} catch {
allDiagnostics.append(LintDiagnostic(
ruleId: rule.id,
ruleName: rule.name,
severity: .error,
message: "Rule check failed: \(error.localizedDescription)",
componentName: nil,
nodeId: nil,
suggestion: "Check FIGMA_PERSONAL_TOKEN and network connectivity"
))
}
}

return allDiagnostics
}
}

extension LintEngine {
/// Default engine with all built-in rules.
static let `default` = LintEngine(rules: [
FramePageMatchRule(),
NamingConventionRule(),
ComponentNotFrameRule(),
DeletedVariablesRule(),
DuplicateComponentNamesRule(),
AliasChainIntegrityRule(),
DarkModeVariablesRule(),
DarkModeSuffixRule(),
])
}
118 changes: 118 additions & 0 deletions Sources/ExFigCLI/Lint/LintReporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import ExFigCore
import Foundation
import Noora

/// Formats and outputs lint results.
struct LintReporter {
let format: LintOutputFormat
let useColors: Bool

func report(diagnostics: [LintDiagnostic], ui: TerminalUI) throws {
switch format {
case .text:
reportText(diagnostics: diagnostics, ui: ui)
case .json:
try reportJSON(diagnostics: diagnostics, ui: ui)
}
}

// MARK: - Text Output

// swiftlint:disable function_body_length
private func reportText(diagnostics: [LintDiagnostic], ui: TerminalUI) {
if diagnostics.isEmpty {
ui.success("All lint checks passed")
return
}

let errors = diagnostics.filter { $0.severity == .error }
let warnings = diagnostics.filter { $0.severity == .warning }

// Summary header
ui.info("")
var summaryParts: [String] = []
if !errors.isEmpty {
summaryParts.append(NooraUI.format(.danger("\(errors.count) error(s)")))
}
if !warnings.isEmpty {
summaryParts.append(NooraUI.format(.accent("\(warnings.count) warning(s)")))
}
ui.info(" \(summaryParts.joined(separator: " "))")
ui.info("")

let grouped = Dictionary(grouping: diagnostics) { $0.ruleId }
let sortedGroups = grouped.sorted { lhs, rhs in
let lSev = lhs.value[0].severity
let rSev = rhs.value[0].severity
if lSev != rSev { return lSev > rSev }
return lhs.key < rhs.key
}

for (_, items) in sortedGroups {
let first = items[0]
let icon = severityIcon(first.severity)
let countStr = useColors
? NooraUI.format(.muted("(\(items.count))"))
: "(\(items.count))"

ui.info(" \(icon) \(first.ruleName) \(countStr)")

let tableItems = items.prefix(8)
let maxNameLen = min(
tableItems.compactMap(\.componentName).map(\.count).max() ?? 10,
30
)

for diag in tableItems {
let name = diag.componentName ?? diag.nodeId ?? "?"
let truncated = name.count > 30 ? String(name.prefix(27)) + "..." : name
let padded = truncated.padding(toLength: max(maxNameLen, truncated.count), withPad: " ", startingAt: 0)
let nameStr = useColors ? NooraUI.format(.primary(padded)) : padded
let msgStr = useColors ? NooraUI.format(.muted(diag.message)) : diag.message
ui.info(" \(nameStr) \(msgStr)")
}

if items.count > 8 {
let moreStr = useColors
? NooraUI.format(.muted("... +\(items.count - 8) more"))
: "... +\(items.count - 8) more"
ui.info(" \(moreStr)")
}
ui.info("")
}
}

// swiftlint:enable function_body_length

private func severityIcon(_ severity: LintSeverity) -> String {
switch severity {
case .error: useColors ? NooraUI.format(.danger("✗")) : "✗"
case .warning: useColors ? NooraUI.format(.accent("⚠")) : "⚠"
case .info: useColors ? NooraUI.format(.muted("ℹ")) : "ℹ"
}
}

// MARK: - JSON Output

private func reportJSON(diagnostics: [LintDiagnostic], ui: TerminalUI) throws {
let report = LintReport(
diagnosticsCount: diagnostics.count,
errorsCount: diagnostics.filter { $0.severity == .error }.count,
warningsCount: diagnostics.filter { $0.severity == .warning }.count,
diagnostics: diagnostics
)
let data = try JSONCodec.encode(report)
guard let jsonString = String(data: data, encoding: .utf8) else {
throw ExFigError.custom(errorString: "Failed to encode lint report as UTF-8")
}
ui.info(jsonString)
}
}

/// Top-level JSON report structure.
private struct LintReport: Codable {
let diagnosticsCount: Int
let errorsCount: Int
let warningsCount: Int
let diagnostics: [LintDiagnostic]
}
Loading
Loading