Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions eslint-rules/corates-error-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Custom ESLint rule: corates-error-helpers
*
* Enforces using @corates/shared error helpers instead of raw Error objects.
* This ensures consistent error handling with proper error codes and types.
*
* Allowed:
* - throw createDomainError(...)
* - throw createTransportError(...)
* - throw createValidationError(...)
* - throw existingErrorVariable (re-throwing)
*
* Disallowed:
* - throw new Error('message')
* - throw Error('message')
*/

// Allowed error helper function names from @corates/shared
const ALLOWED_ERROR_HELPERS = [
'createDomainError',
'createTransportError',
'createValidationError',
'createMultiFieldValidationError',
'createUnknownError',
];

export default {
meta: {
type: 'problem',
docs: {
description: 'Enforce using @corates/shared error helpers instead of raw Error objects',
category: 'Best Practices',
recommended: true,
},
fixable: null,
schema: [
{
type: 'object',
properties: {
allowInTests: {
type: 'boolean',
default: true,
},
},
additionalProperties: false,
},
],
messages: {
useErrorHelper:
"Use error helpers from '@corates/shared' instead of 'new Error()'. Import and use createDomainError(), createTransportError(), or createValidationError().",
useErrorHelperRethrow:
"If re-throwing an error, throw the error object directly without wrapping in 'new Error()'.",
},
},

create(context) {
const options = context.options[0] || {};
const allowInTests = options.allowInTests !== false;

// Check if file is a test file
const filename = context.filename || context.getFilename();
const isTestFile =
filename.includes('__tests__') ||
filename.includes('.test.') ||
filename.includes('.spec.') ||
filename.includes('/test/');

// Skip test files if allowed
if (allowInTests && isTestFile) {
return {};
}

return {
ThrowStatement(node) {
const argument = node.argument;

if (!argument) return;

// Case 1: throw new Error(...) or throw Error(...)
if (argument.type === 'NewExpression' || argument.type === 'CallExpression') {
const callee = argument.callee;

// Check if it's Error, TypeError, RangeError, etc.
if (callee.type === 'Identifier') {
const errorConstructors = [
'Error',
'TypeError',
'RangeError',
'ReferenceError',
'SyntaxError',
'URIError',
];

if (errorConstructors.includes(callee.name)) {
// Check if we're wrapping another error (common pattern: throw new Error(error.message))
const args = argument.arguments;
const isWrappingError =
args.length > 0 &&
args[0].type === 'MemberExpression' &&
args[0].property.name === 'message';

context.report({
node: argument,
messageId: isWrappingError ? 'useErrorHelperRethrow' : 'useErrorHelper',
});
}

// Allow calls to error helper functions
if (ALLOWED_ERROR_HELPERS.includes(callee.name)) {
return;
}
}
}

// Case 2: Allow re-throwing variables (throw error, throw e, etc.)
// These are identifiers, not new Error() calls
if (argument.type === 'Identifier') {
return;
}

// Case 3: Allow throwing objects directly (rare but valid)
if (argument.type === 'ObjectExpression') {
return;
}

// Case 4: Allow throwing call expressions that are error helpers
if (argument.type === 'CallExpression') {
const callee = argument.callee;
if (callee.type === 'Identifier' && ALLOWED_ERROR_HELPERS.includes(callee.name)) {
return;
}
}
},
};
},
};
216 changes: 216 additions & 0 deletions eslint-rules/corates-ui-imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* Custom ESLint rule: corates-ui-imports
*
* Ensures correct usage of @corates/ui components:
* - Prestyled components (Dialog, Menu, etc.) should NOT be used with .Root, .Content patterns
* - Primitive components (DialogPrimitive, MenuPrimitive) SHOULD be used with .Root, .Content patterns
*
* This prevents confusion between the two component types and ensures consistent usage.
*/

// Components that have both prestyled and primitive versions
const COMPONENT_MAP = {
// Prestyled name -> Primitive name
Accordion: 'AccordionPrimitive',
Avatar: 'AvatarPrimitive',
Checkbox: 'CheckboxPrimitive',
Clipboard: 'ClipboardPrimitive',
Collapsible: 'CollapsiblePrimitive',
Combobox: 'ComboboxPrimitive',
Dialog: 'DialogPrimitive',
Drawer: 'DrawerPrimitive',
Editable: 'EditablePrimitive',
FileUpload: 'FileUploadPrimitive',
FloatingPanel: 'FloatingPanelPrimitive',
Menu: 'MenuPrimitive',
NumberInput: 'NumberInputPrimitive',
PasswordInput: 'PasswordInputPrimitive',
PinInput: 'PinInputPrimitive',
Popover: 'PopoverPrimitive',
Progress: 'ProgressPrimitive',
QRCode: 'QRCodePrimitive',
RadioGroup: 'RadioGroupPrimitive',
Select: 'SelectPrimitive',
Splitter: 'SplitterPrimitive',
Switch: 'SwitchPrimitive',
Tabs: 'TabsPrimitive',
TagsInput: 'TagsInputPrimitive',
Toast: 'ToastPrimitive',
Toaster: 'ToasterPrimitive',
ToggleGroup: 'ToggleGroupPrimitive',
Tooltip: 'TooltipPrimitive',
};

// Common primitive sub-component names (Ark UI pattern)
const PRIMITIVE_SUBCOMPONENTS = [
'Root',
'Trigger',
'Content',
'Positioner',
'Backdrop',
'CloseTrigger',
'Title',
'Description',
'Item',
'ItemText',
'ItemIndicator',
'ItemGroup',
'ItemGroupLabel',
'Control',
'Label',
'Input',
'Indicator',
'Track',
'Range',
'Thumb',
'ValueText',
'Context',
'HiddenInput',
'Arrow',
'ArrowTip',
];

export default {
meta: {
type: 'problem',
docs: {
description: 'Enforce correct usage of @corates/ui prestyled vs primitive components',
category: 'Best Practices',
recommended: true,
},
fixable: null,
schema: [],
messages: {
usePrimitive:
"Component '{{component}}' is prestyled and should not be used with '.{{subcomponent}}'. Import '{{primitive}}' instead for primitive usage: import { {{primitive}} as {{component}} } from '@corates/ui' or use the prestyled '{{component}}' without subcomponents.",
usePrestyled:
"You're importing '{{primitive}}' but not using primitive patterns (.Root, .Content, etc.). Consider using the prestyled '{{prestyled}}' component instead.",
},
},

create(context) {
// Track imports from @corates/ui
const importedComponents = new Map(); // name -> { isPrestyled, localName, node }
const usedAsPrimitive = new Set(); // local names used with .Root, .Content, etc.

return {
// Track imports
ImportDeclaration(node) {
if (node.source.value !== '@corates/ui') return;

for (const specifier of node.specifiers) {
if (specifier.type !== 'ImportSpecifier') continue;

const importedName = specifier.imported.name;
const localName = specifier.local.name;

// Check if it's a prestyled component
if (COMPONENT_MAP[importedName]) {
importedComponents.set(localName, {
isPrestyled: true,
importedName,
primitiveName: COMPONENT_MAP[importedName],
localName,
node: specifier,
});
}
// Check if it's a primitive component
else if (importedName.endsWith('Primitive')) {
const prestyledName = importedName.replace('Primitive', '');
importedComponents.set(localName, {
isPrestyled: false,
importedName,
prestyledName,
localName,
node: specifier,
});
}
}
},

// Track member expressions like Dialog.Root, Menu.Content (in non-JSX code)
MemberExpression(node) {
// Skip if this is inside a JSX context (JSXMemberExpression handles that)
if (node.parent && node.parent.type.startsWith('JSX')) return;

// Check if it's accessing a property on an identifier
if (node.object.type !== 'Identifier') return;
if (node.property.type !== 'Identifier') return;

const objectName = node.object.name;
const propertyName = node.property.name;

// Check if it's a primitive subcomponent access
if (PRIMITIVE_SUBCOMPONENTS.includes(propertyName)) {
const componentInfo = importedComponents.get(objectName);

if (componentInfo) {
usedAsPrimitive.add(objectName);

// If it's a prestyled component being used as primitive, report error
if (componentInfo.isPrestyled) {
context.report({
node,
messageId: 'usePrimitive',
data: {
component: objectName,
subcomponent: propertyName,
primitive: componentInfo.primitiveName,
},
});
}
}
}
},

// Track JSX member expressions like <Dialog.Root>, <Menu.Content>
JSXMemberExpression(node) {
// Check if it's accessing a property on an identifier
if (node.object.type !== 'JSXIdentifier') return;
if (node.property.type !== 'JSXIdentifier') return;

const objectName = node.object.name;
const propertyName = node.property.name;

// Check if it's a primitive subcomponent access
if (PRIMITIVE_SUBCOMPONENTS.includes(propertyName)) {
const componentInfo = importedComponents.get(objectName);

if (componentInfo) {
usedAsPrimitive.add(objectName);

// If it's a prestyled component being used as primitive, report error
if (componentInfo.isPrestyled) {
context.report({
node,
messageId: 'usePrimitive',
data: {
component: objectName,
subcomponent: propertyName,
primitive: componentInfo.primitiveName,
},
});
}
}
}
},

// At the end, check for primitives that weren't used as primitives
// (This is a softer warning - disabled for now as it can be noisy)
// 'Program:exit'() {
// for (const [localName, info] of importedComponents) {
// if (!info.isPrestyled && !usedAsPrimitive.has(localName)) {
// context.report({
// node: info.node,
// messageId: 'usePrestyled',
// data: {
// primitive: info.importedName,
// prestyled: info.prestyledName,
// },
// });
// }
// }
// },
};
},
};
15 changes: 15 additions & 0 deletions eslint-rules/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Custom ESLint rules for CoRATES
*
* These rules enforce project-specific patterns and best practices.
*/

import coratesUiImports from './corates-ui-imports.js';
import coratesErrorHelpers from './corates-error-helpers.js';

export default {
rules: {
'corates-ui-imports': coratesUiImports,
'corates-error-helpers': coratesErrorHelpers,
},
};
Loading