diff --git a/CHANGELOG.md b/CHANGELOG.md index c27560b..eb774b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.15.1] - 2025-11-22 + +- Fix [issue #7](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3) to prevent false positives for `sprinkles()`/`style()`/`recipe()` calls with non-empty object arguments while continuing to flag bare `({})` calls +- Add regression tests covering empty and non-empty call expressions in recipe base/variants to guard against future regressions + ## [1.15.0] - 2025-11-14 - Add new rule `prefer-theme-tokens`that requires theme tokens instead of hard-coded values in vanilla-extract styles diff --git a/package.json b/package.json index 41b0d10..648e93a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antebudimir/eslint-plugin-vanilla-extract", - "version": "1.15.0", + "version": "1.15.1", "description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.", "author": "Ante Budimir", "license": "MIT", @@ -78,6 +78,7 @@ "@typescript-eslint/utils": "^8.26.1", "@vanilla-extract/css": "^1.17.1", "@vanilla-extract/recipes": "^0.5.5", + "@vanilla-extract/sprinkles": "^1.6.0", "@vitest/coverage-v8": "3.0.8", "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", diff --git a/src/css-rules/no-empty-blocks/__tests__/recipe.test.ts b/src/css-rules/no-empty-blocks/__tests__/recipe.test.ts index 3b5020d..801dc51 100644 --- a/src/css-rules/no-empty-blocks/__tests__/recipe.test.ts +++ b/src/css-rules/no-empty-blocks/__tests__/recipe.test.ts @@ -89,6 +89,124 @@ run({ } }); `, + + // Recipe with sprinkles() in base - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'], flexDirection: ['row'], flexWrap: ['wrap-reverse'] } + })); + + export const columnsStyle = recipe({ + base: sprinkles({ display: 'flex', flexDirection: 'row' }), + variants: { + wrappingDirection: { + reverse: sprinkles({ flexWrap: 'wrap-reverse' }), + }, + }, + }); + `, + + // Recipe with sprinkles() in variant values - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px', '16px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + large: sprinkles({ padding: '16px' }), + }, + }, + }); + `, + + // Recipe with style() calls in variant values - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { style } from '@vanilla-extract/css'; + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + size: { + small: style({ fontSize: '12px' }), + large: style({ fontSize: '16px' }), + }, + }, + }); + `, + + // Recipe with mixed CallExpression and ObjectExpression in variants - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + variant: { + sprinkled: sprinkles({ padding: '8px' }), + regular: { padding: '8px' }, + }, + }, + }); + `, + + // Recipe with only CallExpression variants (no default) - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { flexWrap: ['wrap-reverse'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + wrappingDirection: { + reverse: sprinkles({ flexWrap: 'wrap-reverse' }), + }, + }, + }); + `, + + // Recipe with nested CallExpression in multiple variant categories - should be valid + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'], padding: ['8px', '16px'], color: ['blue', 'gray'] } + })); + + const myRecipe = recipe({ + base: sprinkles({ display: 'flex' }), + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + large: sprinkles({ padding: '16px' }), + }, + color: { + primary: sprinkles({ color: 'blue' }), + secondary: sprinkles({ color: 'gray' }), + }, + }, + }); + `, ], invalid: [ // Empty recipe @@ -280,5 +398,185 @@ run({ `, errors: [{ messageId: 'invalidPropertyType', data: { type: 'ArrowFunctionExpression' } }], }, + + // Recipe with empty variant category alongside CallExpression variants + // Should only report the empty category, not the CallExpression + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px', '16px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + large: sprinkles({ padding: '16px' }), + }, + emptyCategory: {}, + }, + }); + `, + errors: [{ messageId: 'emptyVariantCategory' }], + output: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px', '16px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + large: sprinkles({ padding: '16px' }), + }, + + }, + }); + `, + }, + + // Recipe with CallExpression in base and empty variants + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + const myRecipe = recipe({ + base: sprinkles({ display: 'flex' }), + variants: {}, + }); + `, + errors: [{ messageId: 'emptyRecipeProperty' }], + output: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + const myRecipe = recipe({ + base: sprinkles({ display: 'flex' }), + + }); + `, + }, + + // Recipe with mixed valid CallExpression and invalid literal in same category + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + invalid: 'invalid-string', + }, + }, + }); + `, + errors: [{ messageId: 'invalidPropertyType' }], + }, + + // Recipe with sprinkles({}) in base and empty variants - entire recipe is empty + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + export const myRecipe = recipe({ + base: sprinkles({}), + variants: {}, + }); + `, + errors: [{ messageId: 'emptyStyleDeclaration' }], + }, + + // Recipe with sprinkles({}) in variant value - should be flagged as empty + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + empty: sprinkles({}), + }, + }, + }); + `, + errors: [{ messageId: 'emptyVariantValue' }], + output: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + + }, + }, + }); + `, + }, + + // Recipe with both base and variants using empty CallExpressions - entire recipe becomes empty + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { style } from '@vanilla-extract/css'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + export const myRecipe = recipe({ + base: style({}), + variants: { + layout: { + flex: sprinkles({}), + }, + }, + }); + `, + errors: [{ messageId: 'emptyStyleDeclaration' }, { messageId: 'emptyVariantValue' }], + }, ], }); diff --git a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts index ca5b406..2f1d724 100644 --- a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts +++ b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts @@ -1,6 +1,6 @@ import type { Rule } from 'eslint'; import { TSESTree } from '@typescript-eslint/utils'; -import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; +import { isCallExpressionWithEmptyObject, isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js'; import { processConditionalExpression } from './conditional-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; @@ -58,7 +58,13 @@ export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpr if (propertyName === 'base') { hasBaseProperty = true; - if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) { + + // CallExpression (e.g., sprinkles(), style()) is considered non-empty unless it has an empty object argument, e.g. sprinkles({}) + if (property.value.type === 'CallExpression') { + if (!isCallExpressionWithEmptyObject(property.value)) { + isBaseEmpty = false; + } + } else if (property.value.type === 'ObjectExpression' && !isEmptyObject(property.value)) { isBaseEmpty = false; } } else if (propertyName === 'variants') { diff --git a/src/css-rules/no-empty-blocks/recipe-processor.ts b/src/css-rules/no-empty-blocks/recipe-processor.ts index 15c8470..4556409 100644 --- a/src/css-rules/no-empty-blocks/recipe-processor.ts +++ b/src/css-rules/no-empty-blocks/recipe-processor.ts @@ -1,6 +1,6 @@ import type { Rule } from 'eslint'; import { TSESTree } from '@typescript-eslint/utils'; -import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; +import { isCallExpressionWithEmptyObject, isEmptyObject } from '../shared-utils/empty-object-processor.js'; import { processEmptyNestedStyles } from './empty-nested-style-processor.js'; import { removeNodeWithComma } from './node-remover.js'; import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js'; @@ -88,8 +88,30 @@ export const processRecipeProperties = ( return; } - // Check for non-object variant values - if (variantValueProperty.value.type !== 'ObjectExpression') { + const valueType = variantValueProperty.value.type; + + // Allow CallExpression (e.g., sprinkles(), style()) as valid variant values unless it has an empty object argument, e.g. sprinkles({}) + if (valueType === 'CallExpression') { + const callExpression = variantValueProperty.value as TSESTree.CallExpression; + if (isCallExpressionWithEmptyObject(callExpression)) { + // Treat sprinkles({}) or style({}) as empty + if (!reportedNodes.has(variantValueProperty)) { + reportedNodes.add(variantValueProperty); + ruleContext.report({ + node: variantValueProperty as Rule.Node, + messageId: 'emptyVariantValue', + fix(fixer) { + return removeNodeWithComma(ruleContext, variantValueProperty, fixer); + }, + }); + } + } + // Valid CallExpressions with arguments are fine + return; + } + + // Check for non-object variant values (excluding CallExpression) + if (valueType !== 'ObjectExpression') { if (!reportedNodes.has(variantValueProperty)) { reportedNodes.add(variantValueProperty); diff --git a/src/css-rules/shared-utils/__tests__/empty-object-processor.test.ts b/src/css-rules/shared-utils/__tests__/empty-object-processor.test.ts new file mode 100644 index 0000000..7655dca --- /dev/null +++ b/src/css-rules/shared-utils/__tests__/empty-object-processor.test.ts @@ -0,0 +1,114 @@ +import tsParser from '@typescript-eslint/parser'; +import { run } from 'eslint-vitest-rule-tester'; +import noEmptyBlocksRule from '../../no-empty-blocks/rule-definition.js'; + +run({ + name: 'vanilla-extract/empty-object-processor-tests', + rule: noEmptyBlocksRule, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + valid: [ + // sprinkles() with no arguments is valid (not empty) + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + variant: { + value: sprinkles() + } + } + }); + `, + + // Test for CallExpression with non-empty object argument + ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }) + } + } + }); + `, + ], + invalid: [ + // Test for CallExpression with empty object argument - sprinkles({}) + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + empty: sprinkles({}) + } + } + }); + `, + errors: [{ messageId: 'emptyVariantValue' }], + output: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { padding: ['8px'] } + })); + + const myRecipe = recipe({ + base: { color: 'black' }, + variants: { + spacing: { + small: sprinkles({ padding: '8px' }), + + } + } + }); + `, + }, + + // Test for sprinkles({}) with empty object argument in recipe base + { + code: ` + import { recipe } from '@vanilla-extract/recipes'; + import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; + + const sprinkles = createSprinkles(defineProperties({ + properties: { display: ['flex'] } + })); + + export const myRecipe = recipe({ + base: sprinkles({}), + variants: {}, + }); + `, + errors: [{ messageId: 'emptyStyleDeclaration' }], + }, + ], +}); diff --git a/src/css-rules/shared-utils/empty-object-processor.ts b/src/css-rules/shared-utils/empty-object-processor.ts index fbee6e5..672c6a5 100644 --- a/src/css-rules/shared-utils/empty-object-processor.ts +++ b/src/css-rules/shared-utils/empty-object-processor.ts @@ -15,3 +15,16 @@ export const isObjectExpression = (node: TSESTree.Node): node is TSESTree.Object export const isEmptyObject = (node: TSESTree.Node): boolean => { return isObjectExpression(node) && node.properties.length === 0; }; + +/** + * Checks if a CallExpression has an empty object as its first argument. + * Examples: sprinkles({}), style({}), recipe({}) + */ +export const isCallExpressionWithEmptyObject = (node: TSESTree.CallExpression): boolean => { + if (node.arguments.length === 0) { + return false; + } + + const firstArgument = node.arguments[0]; + return firstArgument?.type === 'ObjectExpression' && isEmptyObject(firstArgument); +}; diff --git a/src/index.ts b/src/index.ts index cf2c070..66bc0cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import preferThemeTokensRule from './css-rules/prefer-theme-tokens/index.js'; const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.15.0', + version: '1.15.1', }, rules: { 'alphabetical-order': alphabeticalOrderRule,