diff --git a/.claude/rules/lint-patterns.md b/.claude/rules/lint-patterns.md index 79de841d..a6a67de1 100644 --- a/.claude/rules/lint-patterns.md +++ b/.claude/rules/lint-patterns.md @@ -27,3 +27,5 @@ Uses `FigmaAPI.Client.request(SomeEndpoint(...))` directly (no convenience metho - `NodesEndpoint` supports `geometry: .paths` parameter — returns `fillGeometry`/`strokeGeometry` with SVG path data on vector nodes. **Not suitable for pathData validation** — Figma's SVG export flattens masks/booleans into different paths than raw geometry - `PathDataLengthRule` checks ALL platform icon entries (iOS/Android/Flutter/Web), deduplicates by fileId+frame+page. Downloads SVGs via `ImageEndpoint` + `URLSession`, parses with `SVGParser`, validates with `PathDataValidator`. Only reports critical >32,767 byte errors (800-char threshold removed as too noisy). Groups by fileId, batches ImageEndpoint by 50, parallelizes SVG downloads (max 10 concurrent) and fileIds - `InvalidRTLVariantValueRule` validates RTL variant property values against configured `rtlActiveValues` (default `["On"]`) and their known counterpart pairs (On↔Off, true↔false, True↔False, Yes↔No, 0↔1). Uses Components API only (no ImageEndpoint). Collects icon entries from all platforms with `rtlProperty` and `rtlActiveValues`, deduplicates by fileId+frame+page+rtlProperty. `validateRTLValues` and `validValues(for:)` are internal for testability. Suggests either renaming in Figma or adding value to `rtlActiveValues` config +- `MockClient` keys responses by endpoint TYPE by default — multi-file rule tests need the fileId-aware overloads `setResponse(_:for:fileId:)` / `setError(_:for:fileId:)` (file-specific takes precedence over file-agnostic; fileId extracted from the `…/v1/files/{fileId}/…` request path) +- `IconColorVariablesLintRule` resolves expected variables by MERGING name indices from ALL candidate files (not requiring one file to hold all) — expected-but-absent variables emit `.warning` (config drift) while binding validation still runs against the merged index. Selector / empty-allow-list / no-component-match mismatches are `.warning` (config mistakes), not `.error`. `IconColorPolicy` (failable init rejects empty allow-list) and `PaintRole` are internal for testability; `variableLookupKeys` parses `VariableID:` prefix + `library-key/localId` cross-file refs. Samples `prefix(50)` like `DarkModeVariablesRule` diff --git a/.claude/rules/pkl-codegen.md b/.claude/rules/pkl-codegen.md index a96ad813..2f753af8 100644 --- a/.claude/rules/pkl-codegen.md +++ b/.claude/rules/pkl-codegen.md @@ -15,6 +15,14 @@ If `PklProject.deps.json` is missing, run: `cd Sources/ExFigCLI/Resources/Schema Schemas: `Sources/ExFigCLI/Resources/Schemas/{ExFig,Common,Figma,iOS,Android,Flutter,Web}.pkl` Output: `Sources/ExFigConfig/Generated/*.pkl.swift` (committed to repo) +## Codegen Side Effects + +`codegen:pkl` rewrites ALL 9 `Generated/*.pkl.swift` even when one schema changed, and the +generator omits the trailing newline. This (a) breaks the `hk` `newlines` (end-of-file-fixer) +hook and (b) leaves 8 unrelated files with a 1-line newline-only diff. +Fix: `git checkout --` the files whose schema you didn't touch, then +`./bin/mise exec -- hk util end-of-file-fixer --fix Sources/ExFigConfig/Generated/.pkl.swift`. + ## Type Mapping | PKL | Swift Generated | diff --git a/.claude/rules/troubleshooting.md b/.claude/rules/troubleshooting.md index 1d7bec97..7a83f8dc 100644 --- a/.claude/rules/troubleshooting.md +++ b/.claude/rules/troubleshooting.md @@ -58,3 +58,5 @@ Common problems and solutions when working with ExFig. | DocC `+=` infinite redirect | `--hosting-base-path` adds prefix — omit for local preview, use only for GitHub Pages | | DocC preview port 8080 | `widget-simulator-service` occupies 8080 on macOS — use port 9090 or kill it | | DocC icon invisible dark mode | `currentColor` in SVG doesn't work in `` — use `~dark.svg` with `#ffffff` stroke | +| hk newlines hook fails on commit | `codegen:pkl` strips trailing newline from `Generated/*.pkl.swift`. Fix: `hk util end-of-file-fixer --fix `; verify with `hk check --all` (exit 0). Hooks run via `bin/mise x -- hk run pre-commit` | +| `Document`/`Paint` has no `visible` | swift-figma-api `Document`/`Paint` expose only `opacity` (not `visible`); only `Effect` has `visible`. Detect hidden layers via `opacity == 0` | diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index 93a06936..59b67c6c 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -298,6 +298,9 @@ Validate your Figma file structure against your PKL config before exporting: # Lint with default rules exfig lint -i exfig.pkl +# Add lint-only policies from a separate config +exfig lint -i exfig.pkl --lint-config lint.pkl + # Only check specific rules exfig lint -i exfig.pkl --rules naming-convention,deleted-variables @@ -307,17 +310,18 @@ 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 | -| `path-data-length` | error | Icon SVG pathData within 32,767-byte AAPT limit | +| 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 | +| `path-data-length` | error | Icon SVG pathData within 32,767-byte AAPT limit | +| `icon-color-variables` | error | Icon paints use configured Figma Variables | ## Help and Version diff --git a/Sources/ExFigCLI/Lint/LintConfigLoader.swift b/Sources/ExFigCLI/Lint/LintConfigLoader.swift new file mode 100644 index 00000000..d19fee27 --- /dev/null +++ b/Sources/ExFigCLI/Lint/LintConfigLoader.swift @@ -0,0 +1,29 @@ +import ExFigConfig +import Foundation + +/// Loads optional lint-only overlay configuration for `exfig lint`. +enum LintConfigLoader { + static let defaultFileName = "lint.pkl" + + static func resolvePath(explicitPath: String?, mainConfigPath: String?) -> URL? { + if let explicitPath, !explicitPath.isEmpty { + return URL(fileURLWithPath: explicitPath) + } + + let mainPath = mainConfigPath ?? ExFigOptions.defaultConfigFilename + let mainURL = URL(fileURLWithPath: mainPath) + let lintURL = mainURL.deletingLastPathComponent().appendingPathComponent(defaultFileName) + + guard FileManager.default.fileExists(atPath: lintURL.path) else { + return nil + } + return lintURL + } + + static func load(explicitPath: String?, mainConfigPath: String?) async throws -> ExFigConfig.Lint.ModuleImpl? { + guard let path = resolvePath(explicitPath: explicitPath, mainConfigPath: mainConfigPath) else { + return nil + } + return try await PKLEvaluator.evaluateLintConfig(configPath: path) + } +} diff --git a/Sources/ExFigCLI/Lint/LintEngine.swift b/Sources/ExFigCLI/Lint/LintEngine.swift index 3e199bf4..88d3eb3e 100644 --- a/Sources/ExFigCLI/Lint/LintEngine.swift +++ b/Sources/ExFigCLI/Lint/LintEngine.swift @@ -63,5 +63,6 @@ extension LintEngine { DarkModeSuffixRule(), PathDataLengthRule(), InvalidRTLVariantValueRule(), + IconColorVariablesLintRule(), ]) } diff --git a/Sources/ExFigCLI/Lint/LintTypes.swift b/Sources/ExFigCLI/Lint/LintTypes.swift index 3aa95c23..421b1880 100644 --- a/Sources/ExFigCLI/Lint/LintTypes.swift +++ b/Sources/ExFigCLI/Lint/LintTypes.swift @@ -64,6 +64,8 @@ actor LintDataCache { struct LintContext { /// The resolved PKL configuration. let config: ExFig.ModuleImpl + /// Optional lint-only overlay configuration. + let lintConfig: ExFigConfig.Lint.ModuleImpl? /// Figma API client. let client: any FigmaAPI.Client /// Shared cache for Figma API responses across rules. diff --git a/Sources/ExFigCLI/Lint/Rules/IconColorVariablesLintRule.swift b/Sources/ExFigCLI/Lint/Rules/IconColorVariablesLintRule.swift new file mode 100644 index 00000000..6fd553a0 --- /dev/null +++ b/Sources/ExFigCLI/Lint/Rules/IconColorVariablesLintRule.swift @@ -0,0 +1,578 @@ +// swiftlint:disable file_length type_body_length +import ExFigConfig +import ExFigCore +import FigmaAPI +import Foundation + +/// Checks that configured icon groups use the expected Figma Variables. +/// +/// For every opaque, non-image fill and stroke, the rule verifies that — if the paint is +/// bound to a Figma Variable — the bound variable is one of the configured allowed names. +/// Binding itself is opt-in: unbound paints are only flagged when the policy sets +/// `requireBound = true`. +struct IconColorVariablesLintRule: LintRule { + let id = "icon-color-variables" + let name = "Icon color variables" + let description = "Icon fills and strokes use the configured Figma Variables (binding optional via requireBound)" + let severity: LintSeverity = .error + + /// Maximum number of components to fetch per entry. NodesEndpoint is rate-limited, + /// so large frames are sampled (mirrors `DarkModeVariablesRule`). + static let sampleLimit = 50 + + func check(context: LintContext) async throws -> [LintDiagnostic] { + guard let rawPolicies = context.lintConfig?.iconColorVariables, !rawPolicies.isEmpty else { + return [] + } + + let defaultFileId = context.config.figma?.lightFileId ?? "" + let iconEntries = collectIconEntries(from: context.config, defaultFileId: defaultFileId) + var diagnostics: [LintDiagnostic] = [] + + for rawPolicy in rawPolicies { + let matchedEntries = iconEntries.filter { $0.matches(rawPolicy.selector) } + guard !matchedEntries.isEmpty else { + // A selector that matches nothing is a lint-config mistake, not a design + // violation — warn rather than fail the run with an error. + diagnostics.append(diagnostic( + severity: .warning, + message: "No icon entries match icon-color-variables selector", + suggestion: "Check figmaFileId, figmaPageName, figmaFrameName, or assetsFolder in lint config" + )) + continue + } + + guard let policy = IconColorPolicy(rawPolicy) else { + // An empty allow-list is a config mistake, not a design violation. + diagnostics.append(diagnostic( + severity: .warning, + message: "icon-color-variables policy has no expected variables", + suggestion: "Set paints, fills, or strokes in lint config" + )) + continue + } + + try await checkPolicy( + policy, + entries: matchedEntries, + context: context, + diagnostics: &diagnostics + ) + } + + return diagnostics + } + + // MARK: - Policy Check + + private func checkPolicy( + _ policy: IconColorPolicy, + entries: [IconEntry], + context: LintContext, + diagnostics: inout [LintDiagnostic] + ) async throws { + // Surface empty-fileId entries up front with a precise, actionable message so the + // failure is not masked by variable-source resolution failing later. + let usableEntries = entries.filter { entry in + guard entry.fileId.isEmpty else { return true } + diagnostics.append(diagnostic( + message: "No Figma file ID configured for icon entry", + suggestion: "Set figma.lightFileId or entry figmaFileId in the main config" + )) + return false + } + guard !usableEntries.isEmpty else { return } + + // Build the variable-name index from every reachable candidate file. Binding + // validation runs against this merged index regardless of whether the expected + // variables were found, so a degraded fetch never silently skips the check. + let resolution = await resolveVariableNames( + for: policy, + entries: usableEntries, + context: context, + diagnostics: &diagnostics + ) + + // Warn (do not block) when the expected variables are absent everywhere reachable — + // this is config drift (e.g. a renamed variable), distinct from a design violation. + if !resolution.expectedNamesFound, resolution.reachedAtLeastOneFile { + let expected = policy.expectedNames.sorted().joined(separator: ", ") + diagnostics.append(diagnostic( + severity: .warning, + message: "Expected icon color variables not found in any reachable file: \(expected)", + suggestion: """ + Ensure these variables are present in common.variablesColors, platform colors, \ + variablesDarkMode, or set variablesFileId in lint config + """ + )) + } + + for entry in usableEntries { + try await checkEntry( + entry, + policy: policy, + variableNamesById: resolution.namesById, + context: context, + diagnostics: &diagnostics + ) + } + } + + private func checkEntry( + _ entry: IconEntry, + policy: IconColorPolicy, + variableNamesById: [String: String], + context: LintContext, + diagnostics: inout [LintDiagnostic] + ) async throws { + let components: [Component] + do { + components = try await context.cache.components(for: entry.fileId, client: context.client) + } catch { + diagnostics.append(diagnostic( + message: "Cannot fetch components for file '\(entry.fileId)': \(error.localizedDescription)", + suggestion: "Check FIGMA_PERSONAL_TOKEN and file permissions" + )) + return + } + + let relevant = components.filter { component in + if let pageName = entry.pageName, component.containingFrame.pageName != pageName { return false } + if let frameName = entry.frameName, component.containingFrame.name != frameName { return false } + if component.containingFrame.containingComponentSet != nil, component.name.contains("RTL=") { return false } + return true + } + // A selector-matched entry whose page/frame filter matches no component means the + // configured frame/page does not exist (or was renamed). Surface it instead of + // silently reporting clean — this rule can be run in isolation via --rules. + guard !relevant.isEmpty else { + diagnostics.append(diagnostic( + severity: .warning, + message: "No components matched page/frame for icon entry in file '\(entry.fileId)'", + suggestion: "Check figmaPageName/figmaFrameName in the main config — nothing was validated" + )) + return + } + + // Sample to avoid excessive API calls (NodesEndpoint is rate-limited). + let sampled = Array(relevant.prefix(Self.sampleLimit)) + if relevant.count > Self.sampleLimit { + diagnostics.append(diagnostic( + severity: .info, + message: "Checked \(Self.sampleLimit) of \(relevant.count) components (sampling for API limits)" + )) + } + + let nodeIds = sampled.map(\.nodeId) + let nodes: [NodeId: Node] + do { + nodes = try await context.client.request(NodesEndpoint(fileId: entry.fileId, nodeIds: nodeIds)) + } catch { + diagnostics.append(diagnostic( + message: "Cannot fetch nodes for file '\(entry.fileId)': \(error.localizedDescription)", + suggestion: "Check FIGMA_PERSONAL_TOKEN and file permissions" + )) + return + } + + let iconNamesByNodeId = Dictionary( + sampled.map { ($0.nodeId, $0.iconName) }, + uniquingKeysWith: { first, _ in first } + ) + for (nodeId, node) in nodes { + let componentName = iconNamesByNodeId[nodeId] ?? node.document.name + checkChildren( + node.document.children ?? [], + componentName: componentName, + policy: policy, + variableNamesById: variableNamesById, + diagnostics: &diagnostics + ) + } + } + + // MARK: - Paint Validation + + private struct PaintCheckContext { + let componentName: String + let policy: IconColorPolicy + let variableNamesById: [String: String] + } + + private func checkChildren( + _ children: [Document], + componentName: String, + policy: IconColorPolicy, + variableNamesById: [String: String], + diagnostics: inout [LintDiagnostic] + ) { + for child in children { + checkNode( + child, + componentName: componentName, + policy: policy, + variableNamesById: variableNamesById, + diagnostics: &diagnostics + ) + } + } + + private func checkNode( + _ node: Document, + componentName: String, + policy: IconColorPolicy, + variableNamesById: [String: String], + diagnostics: inout [LintDiagnostic] + ) { + // Skip fully transparent layers and their subtrees — they are not visible output. + guard node.opacity != 0 else { return } + + for fill in node.fills { + checkPaint( + fill, + role: .fill, + node: node, + context: PaintCheckContext( + componentName: componentName, + policy: policy, + variableNamesById: variableNamesById + ), + diagnostics: &diagnostics + ) + } + + if let strokes = node.strokes { + for stroke in strokes { + checkPaint( + stroke, + role: .stroke, + node: node, + context: PaintCheckContext( + componentName: componentName, + policy: policy, + variableNamesById: variableNamesById + ), + diagnostics: &diagnostics + ) + } + } + + for child in node.children ?? [] { + checkNode( + child, + componentName: componentName, + policy: policy, + variableNamesById: variableNamesById, + diagnostics: &diagnostics + ) + } + } + + private func checkPaint( + _ paint: Paint, + role: PaintRole, + node: Document, + context: PaintCheckContext, + diagnostics: inout [LintDiagnostic] + ) { + guard shouldCheck(paint) else { return } + + let allowed = context.policy.allowed(for: role) + guard !allowed.isEmpty else { return } + + guard let variableId = paint.boundVariables?["color"]?.id else { + guard context.policy.requireBound else { return } + diagnostics.append(diagnostic( + message: "\(role.displayName) in '\(context.componentName)' not bound to Variable", + componentName: context.componentName, + nodeId: node.id, + suggestion: "Bind this \(role.rawValue) to one of: \(allowed.sorted().joined(separator: ", "))" + )) + return + } + + // When the bound variable's name cannot be resolved (metadata unavailable for its + // source file), report it as inconclusive rather than comparing a raw ID against + // human-readable names and producing a confusing false mismatch. + guard let variableName = variableName(for: variableId, in: context.variableNamesById) else { + diagnostics.append(diagnostic( + severity: .warning, + message: "\(role.displayName) in '\(context.componentName)' bound to a variable with unresolved name", + componentName: context.componentName, + nodeId: node.id, + suggestion: "Ensure the variable's source file is reachable (set variablesFileId in lint config)" + )) + return + } + + guard allowed.contains(variableName) else { + let expected = allowed.sorted().joined(separator: ", ") + let message = """ + \(role.displayName) in '\(context.componentName)' uses '\(variableName)', \ + expected one of: \(expected) + """ + diagnostics.append(diagnostic( + message: message, + componentName: context.componentName, + nodeId: node.id, + suggestion: "Bind this \(role.rawValue) to the configured icon color Variable" + )) + return + } + } + + private func shouldCheck(_ paint: Paint) -> Bool { + if paint.type == .image { return false } + if paint.opacity == 0 { return false } + return true + } + + // MARK: - Variable Source Resolution + + /// Result of merging variable names across every reachable candidate file. + private struct VariableResolution { + /// Combined `variableId → name` index from all successfully fetched files. + let namesById: [String: String] + /// Whether the full set of expected names was found in at least one file. + let expectedNamesFound: Bool + /// Whether at least one candidate file was fetched successfully. + let reachedAtLeastOneFile: Bool + } + + private func resolveVariableNames( + for policy: IconColorPolicy, + entries: [IconEntry], + context: LintContext, + diagnostics: inout [LintDiagnostic] + ) async -> VariableResolution { + // Candidates: explicit config sources, plus every icon file (cross-file library refs). + var candidateFileIds = variableCandidateFileIds(for: policy, entries: entries, config: context.config) + for fileId in entries.map(\.fileId) where !fileId.isEmpty && !candidateFileIds.contains(fileId) { + candidateFileIds.append(fileId) + } + + var namesById: [String: String] = [:] + var expectedNamesFound = false + var reachedAtLeastOneFile = false + let expectedNames = policy.expectedNames + + for fileId in candidateFileIds { + do { + let meta = try await context.cache.variables(for: fileId, client: context.client) + reachedAtLeastOneFile = true + let index = variableNameIndex(from: meta) + // Prefer names already discovered (first reachable source wins on conflict). + namesById.merge(index, uniquingKeysWith: { current, _ in current }) + if expectedNames.isSubset(of: Set(index.values)) { + expectedNamesFound = true + } + } catch { + diagnostics.append(diagnostic( + message: "Cannot fetch variables for file '\(fileId)': \(error.localizedDescription)", + suggestion: "Check FIGMA_PERSONAL_TOKEN and file permissions" + )) + } + } + + return VariableResolution( + namesById: namesById, + expectedNamesFound: expectedNamesFound, + reachedAtLeastOneFile: reachedAtLeastOneFile + ) + } + + private func variableNameIndex(from meta: VariablesMeta) -> [String: String] { + Dictionary(meta.variables.map { id, variable in (id, variable.name) }, uniquingKeysWith: { first, _ in first }) + } + + private func variableName(for variableId: String, in index: [String: String]) -> String? { + for key in variableLookupKeys(for: variableId) { + if let name = index[key] { + return name + } + } + return nil + } + + /// Derives the set of keys a bound variable ID may appear under in the name index. + /// + /// Figma variable IDs come in several shapes: a bare `123:45`, a prefixed + /// `VariableID:123:45`, or a cross-file library ref `VariableID:library-key/123:45`. + /// `internal` (not `private`) so the parsing branches can be unit-tested directly. + func variableLookupKeys(for variableId: String) -> [String] { + var keys = [variableId] + let rawId = variableId.hasPrefix("VariableID:") + ? String(variableId.dropFirst("VariableID:".count)) + : variableId + + if let slashIndex = rawId.lastIndex(of: "/") { + let localId = String(rawId[rawId.index(after: slashIndex)...]) + keys.append("VariableID:\(localId)") + keys.append(localId) + } else if variableId.hasPrefix("VariableID:") { + keys.append(rawId) + } else { + keys.append("VariableID:\(variableId)") + } + + return keys + } + + private func variableCandidateFileIds( + for policy: IconColorPolicy, + entries: [IconEntry], + config: ExFig.ModuleImpl + ) -> [String] { + var ids: [String] = [] + + func append(_ id: String?) { + guard let id, !id.isEmpty, !ids.contains(id) else { return } + ids.append(id) + } + func append(_ values: [String?]?) { + for value in values ?? [] { + append(value) + } + } + + append(policy.variablesFileId) + append(config.common?.variablesColors?.tokensFileId) + append(config.ios?.colors?.map(\.tokensFileId)) + append(config.android?.colors?.map(\.tokensFileId)) + append(config.flutter?.colors?.map(\.tokensFileId)) + append(config.web?.colors?.map(\.tokensFileId)) + + for entry in entries { + append(entry.variablesDarkMode?.variablesFileId) + } + + return ids + } + + // MARK: - Entry Collection + + private struct IconEntry { + let fileId: String + let pageName: String? + let frameName: String? + let assetsFolder: String? + let variablesDarkMode: Common.VariablesDarkMode? + + func matches(_ selector: ExFigConfig.Lint.IconSelector) -> Bool { + if let figmaFileId = selector.figmaFileId, fileId != figmaFileId { return false } + if let figmaPageName = selector.figmaPageName, pageName != figmaPageName { return false } + if let figmaFrameName = selector.figmaFrameName, frameName != figmaFrameName { return false } + if let assetsFolder = selector.assetsFolder, self.assetsFolder != assetsFolder { return false } + return true + } + } + + private func collectIconEntries(from config: ExFig.ModuleImpl, defaultFileId: String) -> [IconEntry] { + var entries: [IconEntry] = [] + + entries.append(contentsOf: config.ios?.icons?.map { + IconEntry( + fileId: $0.figmaFileId ?? defaultFileId, + pageName: $0.figmaPageName, + frameName: $0.figmaFrameName, + assetsFolder: $0.assetsFolder, + variablesDarkMode: $0.variablesDarkMode + ) + } ?? []) + + entries.append(contentsOf: config.android?.icons?.map { + IconEntry( + fileId: $0.figmaFileId ?? defaultFileId, + pageName: $0.figmaPageName, + frameName: $0.figmaFrameName, + assetsFolder: $0.output, + variablesDarkMode: $0.variablesDarkMode + ) + } ?? []) + + entries.append(contentsOf: config.flutter?.icons?.map { + IconEntry( + fileId: $0.figmaFileId ?? defaultFileId, + pageName: $0.figmaPageName, + frameName: $0.figmaFrameName, + assetsFolder: $0.output, + variablesDarkMode: $0.variablesDarkMode + ) + } ?? []) + + entries.append(contentsOf: config.web?.icons?.map { + IconEntry( + fileId: $0.figmaFileId ?? defaultFileId, + pageName: $0.figmaPageName, + frameName: $0.figmaFrameName, + assetsFolder: $0.outputDirectory, + variablesDarkMode: $0.variablesDarkMode + ) + } ?? []) + + return entries + } +} + +// MARK: - Paint Role + +/// Whether a paint is applied as a fill or a stroke. +/// `internal` (not `private`) so policy logic can be unit-tested without network calls. +enum PaintRole: String { + case fill + case stroke + + /// Capitalized label for user-facing diagnostics ("Fill" / "Stroke"). + var displayName: String { + rawValue.capitalized + } +} + +// MARK: - Icon Color Policy + +/// A validated icon-color policy. +/// +/// Wraps the generated `Lint.IconColorVariablesRule` so the "at least one allow-list is set" +/// invariant is enforced once at construction (failable init) rather than re-checked at every +/// call site, and so the `paints` / `fills` / `strokes` overlap rule lives in one place. +/// +/// `internal` (not `private`) so the failable init and `allowed(for:)` overlap logic can be +/// unit-tested directly without network calls. +struct IconColorPolicy { + let selector: ExFigConfig.Lint.IconSelector + let variablesFileId: String? + let requireBound: Bool + + private let paints: Set + private let fills: Set + private let strokes: Set + + /// Fails when the policy declares no allowed variable names at all. + init?(_ rule: ExFigConfig.Lint.IconColorVariablesRule) { + let paints = Set(rule.paints ?? []) + let fills = Set(rule.fills ?? []) + let strokes = Set(rule.strokes ?? []) + guard !(paints.isEmpty && fills.isEmpty && strokes.isEmpty) else { return nil } + + selector = rule.selector + variablesFileId = rule.variablesFileId + requireBound = rule.requireBound + self.paints = paints + self.fills = fills + self.strokes = strokes + } + + /// Allowed variable names for a paint role. `paints` applies to both roles and is merged + /// with the role-specific list. + func allowed(for role: PaintRole) -> Set { + switch role { + case .fill: paints.union(fills) + case .stroke: paints.union(strokes) + } + } + + /// Every variable name the policy expects to exist (union of all three lists). + var expectedNames: Set { + paints.union(fills).union(strokes) + } +} diff --git a/Sources/ExFigCLI/MCP/MCPResources.swift b/Sources/ExFigCLI/MCP/MCPResources.swift index 96a8482c..0b99581d 100644 --- a/Sources/ExFigCLI/MCP/MCPResources.swift +++ b/Sources/ExFigCLI/MCP/MCPResources.swift @@ -14,6 +14,7 @@ "Android.pkl", "Flutter.pkl", "Web.pkl", + "Lint.pkl", ] // MARK: - Template entries diff --git a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift index 6d112e2d..5c178b8a 100644 --- a/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift +++ b/Sources/ExFigCLI/MCP/MCPToolDefinitions.swift @@ -57,7 +57,8 @@ "Comma-separated rule IDs to run. " + "Available: frame-page-match, naming-convention, component-not-frame, " + "deleted-variables, duplicate-component-names, dark-mode-variables, " - + "dark-mode-suffix, path-data-length" + + "dark-mode-suffix, path-data-length, invalid-rtl-variant-value, " + + "icon-color-variables" ), ]), "severity": .object([ diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 117d30fd..dbf49d17 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -150,7 +150,8 @@ let client = try await state.getClient() let cache = LintDataCache() let ui = TerminalUI(outputMode: .quiet) - let context = LintContext(config: config, client: client, cache: cache, ui: ui) + let lintConfig = try await LintConfigLoader.load(explicitPath: nil, mainConfigPath: configPath) + let context = LintContext(config: config, lintConfig: lintConfig, client: client, cache: cache, ui: ui) let engine = LintEngine.default let diagnostics = try await engine.run( diff --git a/Sources/ExFigCLI/Resources/Schemas/Lint.pkl b/Sources/ExFigCLI/Resources/Schemas/Lint.pkl new file mode 100644 index 00000000..2a696ce6 --- /dev/null +++ b/Sources/ExFigCLI/Resources/Schemas/Lint.pkl @@ -0,0 +1,51 @@ +/// Lint-only configuration schema. +/// +/// This module is intentionally separate from ExFig export configuration. +/// It describes additional design policies for `exfig lint` without changing +/// what ExFig exports. +open module Lint + +/// Selects icon entries from the main ExFig config. +/// +/// All fields are optional and combined with AND. An empty selector (the default, +/// `new IconSelector {}`) matches every icon entry. +class IconSelector { + /// Figma file ID to match. + figmaFileId: String? + + /// Figma page name to match. + figmaPageName: String? + + /// Figma frame name to match. + figmaFrameName: String? + + /// Export assets folder to match. + assetsFolder: String? +} + +/// Expected Figma Variables for icon paints. +class IconColorVariablesRule { + /// Selects existing icon entries from the main ExFig config. + /// The default empty selector matches all icon entries. + selector: IconSelector = new IconSelector {} + + /// Optional variable file override. Normally inferred from the main config. + variablesFileId: String? + + /// Whether matching paints must be bound to a Figma Variable. + /// Only enforced for a role (fill/stroke) that has at least one allowed variable name configured. + requireBound: Boolean = false + + /// Allowed variable names applied to both fills and strokes. + /// Merged with the role-specific `fills`/`strokes` lists (not mutually exclusive with them). + paints: Listing? + + /// Allowed variable names for fills (in addition to `paints`). + fills: Listing? + + /// Allowed variable names for strokes (in addition to `paints`). + strokes: Listing? +} + +/// Icon color variable lint policies. +iconColorVariables: Listing? diff --git a/Sources/ExFigCLI/Shared/SchemaExtractor.swift b/Sources/ExFigCLI/Shared/SchemaExtractor.swift index 4437a375..b9242486 100644 --- a/Sources/ExFigCLI/Shared/SchemaExtractor.swift +++ b/Sources/ExFigCLI/Shared/SchemaExtractor.swift @@ -7,7 +7,7 @@ import Foundation enum SchemaExtractor { static let schemaFiles = [ "ExFig.pkl", "Figma.pkl", "Common.pkl", - "iOS.pkl", "Android.pkl", "Flutter.pkl", "Web.pkl", + "iOS.pkl", "Android.pkl", "Flutter.pkl", "Web.pkl", "Lint.pkl", "PklProject", ] diff --git a/Sources/ExFigCLI/Subcommands/Lint.swift b/Sources/ExFigCLI/Subcommands/Lint.swift index 426d56f2..359970b2 100644 --- a/Sources/ExFigCLI/Subcommands/Lint.swift +++ b/Sources/ExFigCLI/Subcommands/Lint.swift @@ -15,6 +15,7 @@ extension ExFigCommand { Examples: exfig lint -i exfig.pkl # Lint one config + exfig lint -i exfig.pkl --lint-config lint.pkl # Add lint-only policies exfig lint -i exfig.pkl --rules naming-convention # Filter rules exfig lint -i exfig.pkl --format json # JSON output for CI exfig lint -i exfig.pkl --severity error # Only errors @@ -39,6 +40,12 @@ extension ExFigCommand { @Option(name: .long, help: "Minimum severity: error, warning, or info") var severity: LintSeverity = .info + @Option( + name: .long, + help: "Path to optional lint-only PKL config. Defaults to lint.pkl next to the input config." + ) + var lintConfig: String? + func run() async throws { // JSON mode: force quiet to keep stdout clean for machine parsing let effectiveVerbose = globalOptions.verbose @@ -65,7 +72,17 @@ extension ExFigCommand { ) let cache = LintDataCache() - let context = LintContext(config: options.params, client: client, cache: cache, ui: ui) + let overlayConfig = try await LintConfigLoader.load( + explicitPath: lintConfig, + mainConfigPath: options.input + ) + let context = LintContext( + config: options.params, + lintConfig: overlayConfig, + client: client, + cache: cache, + ui: ui + ) let engine = LintEngine.default let diagnostics = try await ui.withSpinnerMessage( diff --git a/Sources/ExFigConfig/Generated/Lint.pkl.swift b/Sources/ExFigConfig/Generated/Lint.pkl.swift new file mode 100644 index 00000000..cbe9d9a5 --- /dev/null +++ b/Sources/ExFigConfig/Generated/Lint.pkl.swift @@ -0,0 +1,102 @@ +// Code generated from Pkl module `Lint`. DO NOT EDIT. +import PklSwift + +public enum Lint {} + +public protocol Lint_Module: PklRegisteredType, DynamicallyEquatable, Hashable, Sendable { + var iconColorVariables: [Lint.IconColorVariablesRule]? { get } +} + +extension Lint { + public typealias Module = Lint_Module + + /// Lint-only configuration schema. + /// + /// This module is intentionally separate from ExFig export configuration. + /// It describes additional design policies for `exfig lint` without changing + /// what ExFig exports. + public struct ModuleImpl: Module { + public static let registeredIdentifier: String = "Lint" + + /// Icon color variable lint policies. + public var iconColorVariables: [IconColorVariablesRule]? + + public init(iconColorVariables: [IconColorVariablesRule]?) { + self.iconColorVariables = iconColorVariables + } + } + + /// Expected Figma Variables for icon paints. + public struct IconColorVariablesRule: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Lint#IconColorVariablesRule" + + /// Selects existing icon entries from the main ExFig config. + /// The default empty selector matches all icon entries. + public var selector: IconSelector + + /// Optional variable file override. Normally inferred from the main config. + public var variablesFileId: String? + + /// Whether matching paints must be bound to a Figma Variable. + /// Only enforced for a role (fill/stroke) that has at least one allowed variable name configured. + public var requireBound: Bool + + /// Allowed variable names applied to both fills and strokes. + /// Merged with the role-specific `fills`/`strokes` lists (not mutually exclusive with them). + public var paints: [String]? + + /// Allowed variable names for fills (in addition to `paints`). + public var fills: [String]? + + /// Allowed variable names for strokes (in addition to `paints`). + public var strokes: [String]? + + public init( + selector: IconSelector, + variablesFileId: String?, + requireBound: Bool, + paints: [String]?, + fills: [String]?, + strokes: [String]? + ) { + self.selector = selector + self.variablesFileId = variablesFileId + self.requireBound = requireBound + self.paints = paints + self.fills = fills + self.strokes = strokes + } + } + + /// Selects icon entries from the main ExFig config. + /// + /// All fields are optional and combined with AND. An empty selector (the default, + /// `new IconSelector {}`) matches every icon entry. + public struct IconSelector: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Lint#IconSelector" + + /// Figma file ID to match. + public var figmaFileId: String? + + /// Figma page name to match. + public var figmaPageName: String? + + /// Figma frame name to match. + public var figmaFrameName: String? + + /// Export assets folder to match. + public var assetsFolder: String? + + public init( + figmaFileId: String?, + figmaPageName: String?, + figmaFrameName: String?, + assetsFolder: String? + ) { + self.figmaFileId = figmaFileId + self.figmaPageName = figmaPageName + self.figmaFrameName = figmaFrameName + self.assetsFolder = assetsFolder + } + } +} diff --git a/Sources/ExFigConfig/PKL/PKLEvaluator.swift b/Sources/ExFigConfig/PKL/PKLEvaluator.swift index adf2a39f..ae98801b 100644 --- a/Sources/ExFigConfig/PKL/PKLEvaluator.swift +++ b/Sources/ExFigConfig/PKL/PKLEvaluator.swift @@ -61,6 +61,10 @@ public enum PKLEvaluator { // Batch Batch.Module.self, Batch.BatchConfig.self, + // Lint + Lint.ModuleImpl.self, + Lint.IconColorVariablesRule.self, + Lint.IconSelector.self, // iOS iOS.Module.self, iOS.HeicOptions.self, @@ -120,4 +124,28 @@ public enum PKLEvaluator { ) } } + + /// Evaluates a lint-only PKL configuration file. + /// - Parameter configPath: Path to the lint .pkl configuration file + /// - Returns: Evaluated lint module + /// - Throws: `PKLError.configNotFound` if file doesn't exist, + /// or PklSwift evaluation errors on syntax/type issues + public static func evaluateLintConfig(configPath: URL) async throws -> Lint.ModuleImpl { + guard FileManager.default.fileExists(atPath: configPath.path) else { + throw PKLError.configNotFound(path: configPath.path) + } + + _ = _typeRegistration + + var options = EvaluatorOptions.preconfigured + options.allowedModules = allowedModules + options.allowedResources = allowedResources + + return try await PklSwift.withEvaluator(options: options) { evaluator in + try await evaluator.evaluateModule( + source: .path(configPath.path), + as: Lint.ModuleImpl.self + ) + } + } } diff --git a/Tests/ExFigTests/ExtractSchemasTests.swift b/Tests/ExFigTests/ExtractSchemasTests.swift index 4ee5c81d..c150f537 100644 --- a/Tests/ExFigTests/ExtractSchemasTests.swift +++ b/Tests/ExFigTests/ExtractSchemasTests.swift @@ -98,6 +98,7 @@ final class ExtractSchemasTests: XCTestCase { "Android.pkl", "Flutter.pkl", "Web.pkl", + "Lint.pkl", "PklProject", ] XCTAssertEqual(SchemaExtractor.schemaFiles.sorted(), expected.sorted()) diff --git a/Tests/ExFigTests/Fixtures/PKL/valid-lint-config.pkl b/Tests/ExFigTests/Fixtures/PKL/valid-lint-config.pkl new file mode 100644 index 00000000..d553bb99 --- /dev/null +++ b/Tests/ExFigTests/Fixtures/PKL/valid-lint-config.pkl @@ -0,0 +1,19 @@ +/// Test fixture: valid lint-only ExFig configuration. +amends "../../../../Sources/ExFigCLI/Resources/Schemas/Lint.pkl" + +iconColorVariables = new Listing { + new IconColorVariablesRule { + selector = new IconSelector { + figmaPageName = "Outlined" + } + paints = new { "textandicon/Primary" } + } + + new IconColorVariablesRule { + selector = new IconSelector { + figmaPageName = "Double color" + } + fills = new { "doublecolor/Bg" } + strokes = new { "doublecolor/Outline" } + } +} diff --git a/Tests/ExFigTests/Helpers/MockClient.swift b/Tests/ExFigTests/Helpers/MockClient.swift index 34b6757f..6ebe7214 100644 --- a/Tests/ExFigTests/Helpers/MockClient.swift +++ b/Tests/ExFigTests/Helpers/MockClient.swift @@ -17,20 +17,63 @@ public final class MockClient: Client, @unchecked Sendable { public init() {} + // MARK: - Keys + + /// Per-endpoint-type key (file-agnostic). Used as the fallback when no file-specific + /// response is configured. + private func key(forType endpointType: Any.Type) -> String { + String(describing: endpointType) + } + + /// File-specific key. Lets a single mock return different responses for different file IDs + /// (e.g. cross-file variable resolution), which the type-only key cannot express. + private func key(forType endpointType: Any.Type, fileId: String) -> String { + "\(endpointType)#\(fileId)" + } + + /// Extracts the `{fileId}` path segment from a Figma `…/v1/files/{fileId}/…` request URL. + private func fileId(from request: URLRequest) -> String? { + guard let segments = request.url?.pathComponents, + let filesIndex = segments.firstIndex(of: "files"), + segments.index(after: filesIndex) < segments.endIndex + else { return nil } + return segments[segments.index(after: filesIndex)] + } + // MARK: - Configuration - /// Sets a successful response for a specific endpoint type. + /// Sets a successful response for a specific endpoint type (any file ID). public func setResponse(_ response: T.Content, for endpointType: T.Type) { - let key = String(describing: endpointType) + let key = key(forType: endpointType) queue.sync { self._responses[key] = response self._errors.removeValue(forKey: key) } } - /// Sets an error to throw for a specific endpoint type. + /// Sets a successful response for a specific endpoint type scoped to a single file ID. + /// Takes precedence over the file-agnostic response when the request targets `fileId`. + public func setResponse(_ response: T.Content, for endpointType: T.Type, fileId: String) { + let key = key(forType: endpointType, fileId: fileId) + queue.sync { + self._responses[key] = response + self._errors.removeValue(forKey: key) + } + } + + /// Sets an error to throw for a specific endpoint type (any file ID). public func setError(_ error: any Error, for endpointType: (some Endpoint).Type) { - let key = String(describing: endpointType) + let key = key(forType: endpointType) + queue.sync { + self._errors[key] = error + self._responses.removeValue(forKey: key) + } + } + + /// Sets an error to throw for a specific endpoint type scoped to a single file ID. + /// Takes precedence over the file-agnostic configuration when the request targets `fileId`. + public func setError(_ error: any Error, for endpointType: (some Endpoint).Type, fileId: String) { + let key = key(forType: endpointType, fileId: fileId) queue.sync { self._errors[key] = error self._responses.removeValue(forKey: key) @@ -56,9 +99,11 @@ public final class MockClient: Client, @unchecked Sendable { // MARK: - Client Protocol public func request(_ endpoint: T) async throws -> T.Content { - let key = String(describing: type(of: endpoint)) + let typeKey = key(forType: type(of: endpoint)) let baseURL = FigmaClient.baseURL let request = try endpoint.makeRequest(baseURL: baseURL) + // File-specific configuration (if any) takes precedence over the file-agnostic one. + let fileKey = fileId(from: request).map { key(forType: type(of: endpoint), fileId: $0) } // Record timestamp and get delay (thread-safe) let delay = queue.sync { () -> TimeInterval in @@ -73,14 +118,12 @@ public final class MockClient: Client, @unchecked Sendable { } return try queue.sync { - if let error = self._errors[key] { - throw error - } - - guard let response = self._responses[key] as? T.Content else { - throw MockClientError.noResponseConfigured(endpoint: key) + // Resolve in precedence order: file-specific, then file-agnostic. + for candidate in [fileKey, typeKey].compactMap({ $0 }) { + if let error = self._errors[candidate] { throw error } + if let response = self._responses[candidate] as? T.Content { return response } } - return response + throw MockClientError.noResponseConfigured(endpoint: typeKey) } } diff --git a/Tests/ExFigTests/Lint/LintConfigLoaderTests.swift b/Tests/ExFigTests/Lint/LintConfigLoaderTests.swift new file mode 100644 index 00000000..e2f28667 --- /dev/null +++ b/Tests/ExFigTests/Lint/LintConfigLoaderTests.swift @@ -0,0 +1,47 @@ +@testable import ExFigCLI +import Foundation +import Testing + +struct LintConfigLoaderTests { + @Test("returns nil when autodetected lint config is absent") + func returnsNilWhenAutodetectedLintConfigAbsent() { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let mainConfig = tempDir.appendingPathComponent("exfig.pkl") + + let resolved = LintConfigLoader.resolvePath( + explicitPath: nil, + mainConfigPath: mainConfig.path + ) + + #expect(resolved == nil) + } + + @Test("autodetects lint config next to main config") + func autodetectsLintConfigNextToMainConfig() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let lintConfig = tempDir.appendingPathComponent("lint.pkl") + try Data().write(to: lintConfig) + + let resolved = LintConfigLoader.resolvePath( + explicitPath: nil, + mainConfigPath: tempDir.appendingPathComponent("exfig.pkl").path + ) + + #expect(resolved == lintConfig) + } + + @Test("explicit lint config path wins over autodetect") + func explicitLintConfigPathWins() { + let explicit = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("custom-lint.pkl") + + let resolved = LintConfigLoader.resolvePath( + explicitPath: explicit.path, + mainConfigPath: nil + ) + + #expect(resolved == explicit) + } +} diff --git a/Tests/ExFigTests/Lint/LintRulesTests.swift b/Tests/ExFigTests/Lint/LintRulesTests.swift index 31c8f7f2..ee515154 100644 --- a/Tests/ExFigTests/Lint/LintRulesTests.swift +++ b/Tests/ExFigTests/Lint/LintRulesTests.swift @@ -11,15 +11,17 @@ import Testing private func makeLintContext( config: PKLConfig, - client: MockClient + client: MockClient, + lintConfig: ExFigConfig.Lint.ModuleImpl? = nil ) -> LintContext { let ui = TerminalUI(outputMode: .quiet) - return LintContext(config: config, client: client, cache: LintDataCache(), ui: ui) + return LintContext(config: config, lintConfig: lintConfig, client: client, cache: LintDataCache(), ui: ui) } /// Creates a PKLConfig with iOS icons entries for lint testing. private func makeIOSIconsConfig( lightFileId: String = "abc123", + commonVariablesFileId: String? = nil, frameName: String? = nil, pageName: String? = nil, nameValidateRegexp: String? = nil, @@ -42,6 +44,16 @@ private func makeIOSIconsConfig( } var commonParts: [String] = [] + if let commonVariablesFileId { + commonParts.append(""" + "variablesColors": { + "tokensFileId": "\(commonVariablesFileId)", + "tokensCollectionName": "Oymyakon", + "lightModeName": "Light", + "darkModeName": "Dark" + } + """) + } if let suffix = suffixDarkMode { commonParts.append("\"icons\": { \"suffixDarkMode\": { \"suffix\": \"\(suffix)\" } }") } @@ -604,6 +616,171 @@ private func makeVariantComponent( return try! JSONCodec.decode(Component.self, from: Data(json.utf8)) } +private func makeLintConfig( + pageName: String? = nil, + frameName: String? = nil, + variablesFileId: String? = nil, + requireBound: Bool = false, + paints: [String]? = nil, + fills: [String]? = nil, + strokes: [String]? = nil +) -> ExFigConfig.Lint.ModuleImpl { + var selectorParts: [String] = [] + if let pageName { selectorParts.append("\"figmaPageName\": \"\(pageName)\"") } + if let frameName { selectorParts.append("\"figmaFrameName\": \"\(frameName)\"") } + + var ruleParts = [ + "\"selector\": { \(selectorParts.joined(separator: ", ")) }", + "\"requireBound\": \(requireBound)", + ] + if let variablesFileId { + ruleParts.append("\"variablesFileId\": \"\(variablesFileId)\"") + } + if let paints { + ruleParts.append("\"paints\": [\(paints.map { "\"\($0)\"" }.joined(separator: ", "))]") + } + if let fills { + ruleParts.append("\"fills\": [\(fills.map { "\"\($0)\"" }.joined(separator: ", "))]") + } + if let strokes { + ruleParts.append("\"strokes\": [\(strokes.map { "\"\($0)\"" }.joined(separator: ", "))]") + } + + let json = """ + { + "iconColorVariables": [ + { \(ruleParts.joined(separator: ", ")) } + ] + } + """ + // swiftlint:disable:next force_try + return try! JSONCodec.decode(ExFigConfig.Lint.ModuleImpl.self, from: Data(json.utf8)) +} + +private func makeIconNode( + rootFills: [String] = [], + childFills: [String] = [], + childStrokes: [String] = [] +) -> Node { + let rootFillsJson = rootFills.joined(separator: ", ") + let childFillsJson = childFills.joined(separator: ", ") + let childStrokesJson = childStrokes.joined(separator: ", ") + let json = """ + { + "document": { + "id": "root", + "name": "icon", + "fills": [\(rootFillsJson)], + "children": [ + { + "id": "shape", + "name": "shape", + "fills": [\(childFillsJson)], + "strokes": [\(childStrokesJson)] + } + ] + } + } + """ + // swiftlint:disable:next force_try + return try! JSONCodec.decode(Node.self, from: Data(json.utf8)) +} + +private func boundPaint(variableId: String) -> String { + """ + { + "type": "SOLID", + "color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "\(variableId)", "type": "VARIABLE_ALIAS" } + } + } + """ +} + +private func unboundPaint() -> String { + """ + { + "type": "SOLID", + "color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } + } + """ +} + +private func invisiblePaint() -> String { + """ + { + "type": "SOLID", + "opacity": 0, + "color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } + } + """ +} + +private func imagePaint() -> String { + """ + { + "type": "IMAGE", + "scaleMode": "FILL", + "imageRef": "image-ref" + } + """ +} + +/// Icon node whose paint-bearing shape is nested two levels below the root, for testing +/// that `checkChildren` recurses through the full subtree. +private func makeDeeplyNestedIconNode(childFills: [String]) -> Node { + let json = """ + { + "document": { + "id": "root", + "name": "icon", + "fills": [], + "children": [ + { + "id": "group", + "name": "group", + "fills": [], + "children": [ + { + "id": "shape", + "name": "shape", + "fills": [\(childFills.joined(separator: ", "))] + } + ] + } + ] + } + } + """ + // swiftlint:disable:next force_try + return try! JSONCodec.decode(Node.self, from: Data(json.utf8)) +} + +/// Icon node whose only paint-bearing child is fully transparent (`opacity: 0`), +/// for testing that hidden subtrees are skipped. +private func makeTransparentChildIconNode(childFills: [String]) -> Node { + let json = """ + { + "document": { + "id": "root", + "name": "icon", + "fills": [], + "children": [ + { + "id": "shape", + "name": "shape", + "opacity": 0, + "fills": [\(childFills.joined(separator: ", "))] + } + ] + } + } + """ + // swiftlint:disable:next force_try + return try! JSONCodec.decode(Node.self, from: Data(json.utf8)) +} + // MARK: - LintEngine Tests struct LintEngineTests { @@ -637,7 +814,7 @@ struct LintEngineTests { #expect(!diagnostics.contains { $0.ruleId == "component-not-frame" }) } - @Test("default engine registers all 10 rules") + @Test("default engine registers all 11 rules") func defaultEngineHasAllRules() { let ruleIds = Set(LintEngine.default.rules.map(\.id)) let expected: Set = [ @@ -651,6 +828,7 @@ struct LintEngineTests { "dark-mode-suffix", "path-data-length", "invalid-rtl-variant-value", + "icon-color-variables", ] #expect(ruleIds == expected) } @@ -690,6 +868,694 @@ struct LintEngineTests { } } +// MARK: - IconColorVariablesLintRule Tests + +struct IconColorVariablesLintRuleTests { + let rule = IconColorVariablesLintRule() + + @Test("skips when lint config is absent") + func skipsWhenLintConfigAbsent() async throws { + let client = MockClient() + let config = makeIOSIconsConfig(frameName: "Icons") + let context = makeLintContext(config: config, client: client) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("paints allowlist applies to fills and strokes") + func paintsAllowlistAppliesToFillsAndStrokes() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_car", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode( + childFills: [boundPaint(variableId: "VariableID:text")], + childStrokes: [boundPaint(variableId: "VariableID:text")] + ), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "text", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("external library variable IDs resolve to local variable names") + func externalLibraryVariableIdsResolveToLocalVariableNames() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_car", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode( + childFills: [boundPaint(variableId: "VariableID:library-key/2092:51")] + ), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "2092:51", name: "TextAndIcon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["TextAndIcon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("fills and strokes are checked separately") + func fillsAndStrokesAreCheckedSeparately() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_wallet", frameName: "Finance", pageName: "Double color"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode( + childFills: [boundPaint(variableId: "VariableID:bg")], + childStrokes: [boundPaint(variableId: "VariableID:outline")] + ), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "bg", name: "doublecolor/Bg", valuesByMode: [:]), + (id: "outline", name: "doublecolor/Outline", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Double color" + ) + let lintConfig = makeLintConfig( + pageName: "Double color", + fills: ["doublecolor/Bg"], + strokes: ["doublecolor/Outline"] + ) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("wrong variable emits diagnostic") + func wrongVariableEmitsDiagnostic() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childFills: [boundPaint(variableId: "VariableID:wrong")]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "expected", name: "textandicon/Primary", valuesByMode: [:]), + (id: "wrong", name: "doublecolor/Bg", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.ruleId == "icon-color-variables") + #expect(diagnostics.first?.message.contains("doublecolor/Bg") == true) + #expect(diagnostics.first?.message.contains("textandicon/Primary") == true) + } + + @Test("unbound paint is ignored by default") + func unboundPaintIsIgnoredByDefault() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childFills: [unboundPaint()]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "expected", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("requireBound emits diagnostic for unbound paint") + func requireBoundEmitsDiagnosticForUnboundPaint() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childFills: [unboundPaint()]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "expected", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", requireBound: true, paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("not bound") == true) + } + + @Test("expected variables absent everywhere emits config warning, not a hard error") + func expectedVariablesAbsentEmitsConfigWarning() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + // Reachable file exists, but none of the expected variables are present (e.g. renamed). + client.setResponse([ + "1:1": makeIconNode(childFills: [unboundPaint()]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "other", name: "other/Variable", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + // Config drift is surfaced as a warning, not an error that blocks CI. + let warning = diagnostics.first { $0.message.contains("Expected icon color variables not found") } + #expect(warning != nil) + #expect(warning?.severity == .warning) + // The unbound paint is NOT flagged (requireBound is false) — validation still ran. + #expect(!diagnostics.contains { $0.message.contains("not bound") }) + } + + @Test("binding validation still runs when expected variables are missing") + func bindingValidationRunsDespiteMissingExpectedVariables() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + // The bound variable resolves to a name, but it is the WRONG one. Even though the + // expected variable is absent from the file, the binding check must still fire — + // this is the C2 regression guard (a degraded variable set must not silence validation). + client.setResponse([ + "1:1": makeIconNode(childFills: [boundPaint(variableId: "VariableID:wrong")]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "wrong", name: "doublecolor/Bg", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + // The mismatch is reported even though "textandicon/Primary" was never found. + #expect(diagnostics.contains { $0.message.contains("uses 'doublecolor/Bg'") }) + } + + @Test("cross-file: expected variable in a different file than the bound icon") + func crossFileExpectedVariableResolution() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self, fileId: "icons-file") + client.setResponse([ + "1:1": makeIconNode(childFills: [boundPaint(variableId: "VariableID:text")]), + ], for: NodesEndpoint.self, fileId: "icons-file") + // The icons file holds NO variables; the library file holds the expected one. + // Only a fileId-aware mock can express this difference. + client.setResponse(VariablesMeta.make(variables: []), for: VariablesEndpoint.self, fileId: "icons-file") + client.setResponse(VariablesMeta.make( + variables: [ + (id: "text", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self, fileId: "variables-file") + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("ignores root background invisible and image paints") + func ignoresRootBackgroundInvisibleAndImagePaints() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode( + rootFills: [unboundPaint()], + childFills: [invisiblePaint(), imagePaint(), boundPaint(variableId: "VariableID:text")] + ), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "text", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } +} + +// MARK: - IconColorVariablesLintRule: Failure & Coverage Tests + +/// Split from `IconColorVariablesLintRuleTests` to stay under SwiftLint's type_body_length limit. +struct IconColorVariablesLintRuleFailureTests { + let rule = IconColorVariablesLintRule() + + // MARK: - API Failure Paths (must emit diagnostics, never silently pass) + + @Test("components fetch failure emits diagnostic") + func componentsFetchFailureEmitsDiagnostic() async throws { + let client = MockClient() + client.setError(URLError(.timedOut), for: ComponentsEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [(id: "text", name: "textandicon/Primary", valuesByMode: [:])] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + let failure = diagnostics.first { $0.message.contains("Cannot fetch components") } + #expect(failure != nil) + #expect(failure?.severity == .error) + } + + @Test("nodes fetch failure emits diagnostic") + func nodesFetchFailureEmitsDiagnostic() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setError(URLError(.timedOut), for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [(id: "text", name: "textandicon/Primary", valuesByMode: [:])] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + let failure = diagnostics.first { $0.message.contains("Cannot fetch nodes") } + #expect(failure != nil) + #expect(failure?.severity == .error) + } + + @Test("variables fetch failure emits diagnostic") + func variablesFetchFailureEmitsDiagnostic() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childFills: [boundPaint(variableId: "VariableID:text")]), + ], for: NodesEndpoint.self) + client.setError(URLError(.timedOut), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + let failure = diagnostics.first { $0.message.contains("Cannot fetch variables") } + #expect(failure != nil) + #expect(failure?.severity == .error) + } + + @Test("empty file ID emits a precise diagnostic") + func emptyFileIdEmitsDiagnostic() async throws { + let client = MockClient() + // No figma.lightFileId and no entry figmaFileId → entry fileId resolves to "". + let config = makeIOSIconsConfig(lightFileId: "", pageName: "Outlined") + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + let failure = diagnostics.first { $0.message.contains("No Figma file ID configured") } + #expect(failure != nil) + #expect(failure?.suggestion?.contains("figma.lightFileId") == true) + } + + // MARK: - Selector / Policy Config Diagnostics + + @Test("selector matching no entries emits a warning") + func selectorMatchingNoEntriesEmitsWarning() async throws { + let client = MockClient() + let config = makeIOSIconsConfig(lightFileId: "icons-file", pageName: "Outlined") + // Selector targets a page that no entry uses. + let lintConfig = makeLintConfig(pageName: "Nonexistent", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("No icon entries match") == true) + #expect(diagnostics.first?.severity == .warning) + } + + @Test("policy with no expected variables emits a warning") + func emptyExpectedVariablesEmitsWarning() async throws { + let client = MockClient() + let config = makeIOSIconsConfig(lightFileId: "icons-file", pageName: "Outlined") + // No paints/fills/strokes set at all. + let lintConfig = makeLintConfig(pageName: "Outlined") + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("no expected variables") == true) + #expect(diagnostics.first?.severity == .warning) + } + + @Test("frame/page matching no component emits a warning") + func noComponentMatchEmitsWarning() async throws { + let client = MockClient() + // Component exists, but on a different page than the entry selects. + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Filled"), + ], for: ComponentsEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [(id: "text", name: "textandicon/Primary", valuesByMode: [:])] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + let warning = diagnostics.first { $0.message.contains("No components matched page/frame") } + #expect(warning != nil) + #expect(warning?.severity == .warning) + } + + // MARK: - Node Traversal Coverage + + @Test("flags one wrong fill among multiple fills on a node") + func flagsWrongFillAmongMultiple() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childFills: [ + boundPaint(variableId: "VariableID:ok"), + boundPaint(variableId: "VariableID:wrong"), + ]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "ok", name: "textandicon/Primary", valuesByMode: [:]), + (id: "wrong", name: "doublecolor/Bg", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("doublecolor/Bg") == true) + } + + @Test("recurses into deeply nested children") + func recursesIntoNestedChildren() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeDeeplyNestedIconNode(childFills: [boundPaint(variableId: "VariableID:wrong")]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "wrong", name: "doublecolor/Bg", valuesByMode: [:]), + (id: "ok", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + // The wrong fill is two levels below root — recursion must reach it. + #expect(diagnostics.contains { $0.message.contains("doublecolor/Bg") }) + } + + @Test("skips fully transparent subtrees") + func skipsTransparentSubtrees() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + // The only fill (wrong) lives on a node with opacity 0 → must be skipped. + client.setResponse([ + "1:1": makeTransparentChildIconNode(childFills: [boundPaint(variableId: "VariableID:wrong")]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [ + (id: "wrong", name: "doublecolor/Bg", valuesByMode: [:]), + (id: "ok", name: "textandicon/Primary", valuesByMode: [:]), + ] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("requireBound flags an unbound stroke") + func requireBoundFlagsUnboundStroke() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "ic_home", frameName: "Actions", pageName: "Outlined"), + ], for: ComponentsEndpoint.self) + client.setResponse([ + "1:1": makeIconNode(childStrokes: [unboundPaint()]), + ], for: NodesEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [(id: "outline", name: "doublecolor/Outline", valuesByMode: [:])] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig( + pageName: "Outlined", + requireBound: true, + strokes: ["doublecolor/Outline"] + ) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("Stroke") == true) + #expect(diagnostics.first?.message.contains("not bound") == true) + } + + @Test("skips RTL variants") + func skipsRTLVariants() async throws { + let client = MockClient() + // An RTL variant inside a component set: it must NOT be checked. + client.setResponse([ + makeVariantComponent( + nodeId: "1:1", + name: "RTL=On", + frameName: "Actions", + pageName: "Outlined", + componentSetName: "ic_home" + ), + ], for: ComponentsEndpoint.self) + client.setResponse(VariablesMeta.make( + variables: [(id: "text", name: "textandicon/Primary", valuesByMode: [:])] + ), for: VariablesEndpoint.self) + + let config = makeIOSIconsConfig( + lightFileId: "icons-file", + commonVariablesFileId: "variables-file", + pageName: "Outlined" + ) + let lintConfig = makeLintConfig(pageName: "Outlined", paints: ["textandicon/Primary"]) + let context = makeLintContext(config: config, client: client, lintConfig: lintConfig) + + let diagnostics = try await rule.check(context: context) + + // Only the RTL variant exists, so after filtering nothing remains → "no components matched". + #expect(!diagnostics.contains { $0.message.contains("uses") }) + } + + // MARK: - Pure Logic (no network) + + @Test("variableLookupKeys derives all candidate keys") + func variableLookupKeysDerivesCandidates() { + // Cross-file library ref: prefix + library-key/localId. + let libraryKeys = rule.variableLookupKeys(for: "VariableID:library-key/2092:51") + #expect(libraryKeys.contains("VariableID:library-key/2092:51")) + #expect(libraryKeys.contains("VariableID:2092:51")) + #expect(libraryKeys.contains("2092:51")) + + // Prefixed local ID, no slash. + let prefixedKeys = rule.variableLookupKeys(for: "VariableID:123:45") + #expect(prefixedKeys.contains("VariableID:123:45")) + #expect(prefixedKeys.contains("123:45")) + + // Bare ID, no prefix and no slash. + let bareKeys = rule.variableLookupKeys(for: "123:45") + #expect(bareKeys.contains("123:45")) + #expect(bareKeys.contains("VariableID:123:45")) + } + + @Test("IconColorPolicy fails when no allow-list is set") + func iconColorPolicyRejectsEmptyAllowList() throws { + let emptyRule = try #require(makeLintConfig(pageName: "Outlined").iconColorVariables?.first) + #expect(IconColorPolicy(emptyRule) == nil) + } + + @Test("IconColorPolicy merges paints into both roles") + func iconColorPolicyMergesPaints() throws { + let raw = try #require(makeLintConfig( + pageName: "Outlined", + paints: ["shared/Color"], + fills: ["fill/Only"], + strokes: ["stroke/Only"] + ).iconColorVariables?.first) + let policy = try #require(IconColorPolicy(raw)) + + #expect(policy.allowed(for: .fill) == ["shared/Color", "fill/Only"]) + #expect(policy.allowed(for: .stroke) == ["shared/Color", "stroke/Only"]) + #expect(policy.expectedNames == ["shared/Color", "fill/Only", "stroke/Only"]) + } +} + /// A rule that always throws, for testing engine error handling. private struct FailingLintRule: LintRule { let id = "failing-rule" diff --git a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift index f1301460..c278a575 100644 --- a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift +++ b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift @@ -76,6 +76,20 @@ struct PKLEvaluatorTests { #expect(darkMode.variablesFileId == "lib-file-123") } + @Test("Evaluates lint-only PKL config") + func evaluatesLintOnlyConfig() async throws { + let configPath = Self.fixturesPath.appendingPathComponent("valid-lint-config.pkl") + + let module = try await PKLEvaluator.evaluateLintConfig(configPath: configPath) + + let policies = try #require(module.iconColorVariables) + #expect(policies.count == 2) + #expect(policies[0].selector.figmaPageName == "Outlined") + #expect(policies[0].paints == ["textandicon/Primary"]) + #expect(policies[1].fills == ["doublecolor/Bg"]) + #expect(policies[1].strokes == ["doublecolor/Outline"]) + } + @Test("All generated PKL types are registered") func allGeneratedPklTypesRegistered() { // Every registeredIdentifier in Generated/*.pkl.swift must be listed here AND @@ -105,6 +119,10 @@ struct PKLEvaluatorTests { // Batch Batch.Module.registeredIdentifier, Batch.BatchConfig.registeredIdentifier, + // Lint + Lint.ModuleImpl.registeredIdentifier, + Lint.IconColorVariablesRule.registeredIdentifier, + Lint.IconSelector.registeredIdentifier, // iOS iOS.Module.registeredIdentifier, iOS.HeicOptions.registeredIdentifier, @@ -137,7 +155,7 @@ struct PKLEvaluatorTests { ] #expect( - expectedIdentifiers.count == 43, + expectedIdentifiers.count == 46, """ Generated PKL type count changed! After running codegen:pkl: 1. Update registerPklTypes(_:) in PKLEvaluator.swift with new types diff --git a/llms-full.txt b/llms-full.txt index d960ad60..2fd41974 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -574,6 +574,9 @@ Validate your Figma file structure against your PKL config before exporting: # Lint with default rules exfig lint -i exfig.pkl +# Add lint-only policies from a separate config +exfig lint -i exfig.pkl --lint-config lint.pkl + # Only check specific rules exfig lint -i exfig.pkl --rules naming-convention,deleted-variables @@ -583,17 +586,18 @@ 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 | -| `path-data-length` | error | Icon SVG pathData within 32,767-byte AAPT limit | +| 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 | +| `path-data-length` | error | Icon SVG pathData within 32,767-byte AAPT limit | +| `icon-color-variables` | error | Icon paints use configured Figma Variables | ## Help and Version diff --git a/mise.toml b/mise.toml index 1c79f7c3..d93dc168 100644 --- a/mise.toml +++ b/mise.toml @@ -199,6 +199,7 @@ pkl run \ "$SCHEMAS_DIR/Android.pkl" \ "$SCHEMAS_DIR/Flutter.pkl" \ "$SCHEMAS_DIR/Web.pkl" \ + "$SCHEMAS_DIR/Lint.pkl" \ -o "$TEMP_DIR/" # Only replace files on success