diff --git a/.changeset/mosaic-native-tooling.md b/.changeset/mosaic-native-tooling.md new file mode 100644 index 00000000000..32415a9daa8 --- /dev/null +++ b/.changeset/mosaic-native-tooling.md @@ -0,0 +1,6 @@ +--- +"@clerk/mosaic": patch +--- + +- Fixed an issue where an outgoing Flow step could show content from an update that never appeared on screen. +- Fixed an issue where adding a tag while a controlled TagInput update was still pending could use the pending tag list instead of the list shown on screen. diff --git a/eslint.config.mjs b/eslint.config.mjs index f41ac37bd92..4adb2a9be89 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import pluginEslintComments from '@eslint-community/eslint-plugin-eslint-comments'; import eslint from '@eslint/js'; import configPrettier from 'eslint-config-prettier'; import configTurbo from 'eslint-config-turbo/flat'; @@ -11,9 +10,7 @@ import pluginJsxA11y from 'eslint-plugin-jsx-a11y'; import pluginPlaywright from 'eslint-plugin-playwright'; import pluginReact from 'eslint-plugin-react'; import pluginReactHooks from 'eslint-plugin-react-hooks'; -import pluginStylex from '@stylexjs/eslint-plugin'; import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort'; -import pluginSonarjs from 'eslint-plugin-sonarjs'; import pluginTurbo from 'eslint-plugin-turbo'; import pluginUnusedImports from 'eslint-plugin-unused-imports'; import pluginYml from 'eslint-plugin-yml'; @@ -312,12 +309,6 @@ const noPhysicalCssProperties = { }, }; -const noLeakedConditionalJsx = { - selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']", - message: - 'Use a ternary (`condition ? : null`) for conditional JSX; `&&` renders a falsy left-hand value such as `0`.', -}; - export default tseslint.config([ { name: 'repo/ignores', @@ -343,6 +334,8 @@ export default tseslint.config([ 'eslint.config.mjs', 'typedoc.config.mjs', 'vitest.workspace.mjs', + 'packages/mosaic/**', + 'packages/swingset/**', // package specific ignores 'packages/astro/src/astro-components/**/*.ts', 'packages/backend/src/runtime/**/*', @@ -615,134 +608,6 @@ export default tseslint.config([ 'custom-rules/no-unstable-methods': 'error', }, }, - { - name: 'packages/mosaic', - files: ['packages/mosaic/src/**/*'], - // Tests assert on style values they receive; they are not authoring styles. - ignores: ['packages/mosaic/src/__tests__/**', 'packages/mosaic/src/**/*.test.{ts,tsx}'], - plugins: { - '@eslint-community/eslint-comments': pluginEslintComments, - '@stylexjs': pluginStylex, - sonarjs: pluginSonarjs, - }, - rules: { - '@eslint-community/eslint-comments/require-description': ['error', { ignore: ['eslint-enable'] }], - 'sonarjs/cognitive-complexity': ['error', 15], - 'sonarjs/no-collapsible-if': 'error', - 'sonarjs/no-identical-functions': 'error', - 'sonarjs/no-redundant-jump': 'error', - 'sonarjs/prefer-single-boolean-return': 'error', - '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }], - '@typescript-eslint/switch-exhaustiveness-check': 'error', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/ban-ts-comment': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - '@typescript-eslint/no-unsafe-argument': 'error', - '@typescript-eslint/no-unsafe-assignment': 'error', - '@typescript-eslint/no-unsafe-call': 'error', - '@typescript-eslint/no-unsafe-member-access': 'error', - '@typescript-eslint/no-unsafe-return': 'error', - '@typescript-eslint/only-throw-error': 'error', - '@typescript-eslint/prefer-promise-reject-errors': 'error', - '@typescript-eslint/use-unknown-in-catch-callback-variable': 'error', - 'react-hooks/exhaustive-deps': 'error', - 'react-hooks/rules-of-hooks': 'error', - 'max-lines': ['error', { max: 1000, skipBlankLines: true, skipComments: true }], - 'no-restricted-properties': [ - 'error', - { object: 'Reflect', property: 'get', message: 'Use typed property access or parse at the boundary.' }, - { object: 'Reflect', property: 'apply', message: 'Call the function directly.' }, - ], - '@stylexjs/enforce-extension': 'error', - '@stylexjs/no-legacy-contextual-styles': 'error', - '@stylexjs/no-lookahead-selectors': 'error', - '@stylexjs/no-nonstandard-styles': 'error', - '@stylexjs/no-conflicting-props': 'error', - '@stylexjs/no-unused': 'error', - '@stylexjs/sort-keys': 'error', - '@stylexjs/valid-shorthands': 'error', - '@stylexjs/valid-styles': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: "Property > Literal.key[value='&:hover']", - message: "Use hover() from mosaic/utils instead of bare '&:hover'.", - }, - noLeakedConditionalJsx, - { - selector: "JSXOpeningElement[name.name='form']", - message: 'Use `Form` from mosaic/primitives/form, which turns off the browser validation popup.', - }, - { - selector: "Property > Literal.key[value='@media (hover: hover)']", - message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.", - }, - { - selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']", - message: - "Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.", - }, - // A Mosaic part takes `xstyle`, not `className`/`style`; its prop types already reject the pair, - // this names the replacement. Native elements (lowercase) are unaffected. - { - selector: - "JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXAttribute[name.name=/^(className|style)$/]", - message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.', - }, - { - selector: - "JSXOpeningElement[name.type='JSXMemberExpression'] > JSXAttribute[name.name=/^(className|style)$/]", - message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.', - }, - // `{...stylex.props(x)}` is the same pair by another route; the types miss it because a - // spread's optional keys skip excess-property checks. - { - selector: - "JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']", - message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.', - }, - { - selector: - "JSXOpeningElement[name.type='JSXMemberExpression'] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']", - message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.', - }, - ], - }, - }, - { - name: 'packages/mosaic/jsx-a11y', - files: ['packages/mosaic/src/**/*'], - rules: { - // Mosaic renders elements through `render={p => }`, so children and controls sit on - // the outer component. Both rules only see the empty inner element and always report. - 'jsx-a11y/heading-has-content': 'off', - 'jsx-a11y/label-has-associated-control': 'off', - }, - }, - { - // StyleX `create()` files author conditions raw (`@media (hover: hover)`, `:hover`) — StyleX - // is compile-time and cannot inline a `hover()`/`motionSafe()` helper imported into `create`, - // so the media-query restrictions above (an Emotion-runtime convention) can't apply here. The - // `@stylexjs/*` rules from the mosaic block still cover these files. - name: 'packages/mosaic - stylex styles', - files: ['packages/mosaic/src/**/*.styles.ts'], - rules: { - 'no-restricted-syntax': 'off', - }, - }, - { - // Primitives are unstyled and take `className`/`style` like any other headless component; the - // `xstyle`-only rule targets styled Mosaic parts and otherwise false-positives on things like - // ``, a third-party component, not a Mosaic part. - name: 'packages/mosaic - primitives', - files: ['packages/mosaic/src/primitives/**/*'], - ignores: ['packages/mosaic/src/**/*.test.{ts,tsx}'], - rules: { - 'no-restricted-syntax': ['error', noLeakedConditionalJsx], - }, - }, { name: 'packages - vitest', files: ['packages/*/src/**/*.test.{ts,tsx}'], @@ -819,18 +684,6 @@ export default tseslint.config([ ], }, }, - { - name: 'packages/swingset', - files: ['packages/swingset/src/**/*'], - rules: { - 'turbo/no-undeclared-env-vars': [ - 'error', - { - allowList: ['SWINGSET_DASHBOARD_URL'], - }, - ], - }, - }, { name: 'packages/upgrade', files: ['packages/upgrade/src/**/*'], diff --git a/oxlint.config.mjs b/oxlint.config.mjs new file mode 100644 index 00000000000..56b1dce52b9 --- /dev/null +++ b/oxlint.config.mjs @@ -0,0 +1,707 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + $schema: './node_modules/oxlint/configuration_schema.json', + plugins: ['typescript', 'import', 'jsx-a11y', 'react', 'unicorn'], + jsPlugins: [ + { name: 'turbo', specifier: fileURLToPath(new URL('./scripts/oxlint-turbo.mjs', import.meta.url)) }, + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + 'eslint-plugin-simple-import-sort', + 'eslint-plugin-unused-imports', + 'eslint-plugin-yml', + { + name: 'eslint-compat', + specifier: fileURLToPath(new URL('./scripts/oxlint-eslint-rules.mjs', import.meta.url)), + }, + ], + categories: { + correctness: 'off', + }, + options: { + typeAware: true, + reportUnusedDisableDirectives: 'error', + }, + env: { + builtin: true, + es2021: true, + node: true, + browser: true, + }, + ignorePatterns: [ + '.cache', + '.idea', + '.next', + '.turbo', + '.vscode', + '!.*.js', + '**/.turbo/*', + '**/build/*', + '**/coverage/*', + '**/dist/*', + '**/integration/expo-native/**/*', + '**/integration/templates/**/*', + '**/node_modules/**', + '*.snap', + 'commitlint.config.ts', + 'packages/*/dist/**', + 'packages/*/examples', + 'pnpm-lock.json', + 'eslint.config.mjs', + 'typedoc.config.mjs', + 'vitest.workspace.mjs', + 'packages/astro/src/astro-components/**/*.ts', + 'packages/backend/src/runtime/**/*', + 'packages/clerk-js/rspack.config.js', + 'packages/shared/src/compiled/path-to-regexp/index.js', + 'packages/shared/tsdown.config.mjs', + 'packages/upgrade/src/__tests__/fixtures/**/*', + ], + rules: { + 'constructor-super': 'error', + 'for-direction': 'error', + 'getter-return': 'error', + 'no-async-promise-executor': 'error', + 'no-case-declarations': 'error', + 'no-class-assign': 'error', + 'no-compare-neg-zero': 'error', + 'no-cond-assign': 'error', + 'no-const-assign': 'error', + 'no-constant-binary-expression': 'error', + 'no-constant-condition': 'error', + 'no-control-regex': 'error', + 'no-debugger': 'error', + 'no-delete-var': 'error', + 'no-dupe-class-members': 'error', + 'no-dupe-else-if': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-empty': 'error', + 'no-empty-character-class': 'error', + 'no-empty-pattern': 'error', + 'no-empty-static-block': 'error', + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'error', + 'no-fallthrough': 'error', + 'no-func-assign': 'error', + 'no-global-assign': 'error', + 'no-import-assign': 'error', + 'no-invalid-regexp': 'error', + 'no-irregular-whitespace': 'error', + 'no-loss-of-precision': 'error', + 'no-misleading-character-class': 'error', + 'no-new-native-nonconstructor': 'error', + 'no-nonoctal-decimal-escape': 'error', + 'no-obj-calls': 'error', + 'no-prototype-builtins': 'error', + 'no-redeclare': 'error', + 'no-regex-spaces': 'error', + 'no-self-assign': 'error', + 'no-setter-return': 'error', + 'no-shadow-restricted-names': 'error', + 'no-sparse-arrays': 'error', + 'no-this-before-super': 'error', + 'no-undef': 'error', + 'no-unreachable': 'error', + 'no-unsafe-finally': 'error', + 'no-unsafe-negation': 'error', + 'no-unsafe-optional-chaining': 'error', + 'no-unused-labels': 'error', + 'no-unused-private-class-members': 'error', + 'no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + vars: 'all', + varsIgnorePattern: '^_', + }, + ], + 'no-useless-backreference': 'error', + 'no-useless-catch': 'error', + 'no-useless-escape': 'error', + 'no-with': 'error', + 'require-yield': 'error', + 'use-isnan': 'error', + 'valid-typeof': 'error', + 'no-array-constructor': 'error', + 'no-unused-expressions': 'error', + 'turbo/no-undeclared-env-vars': 'error', + 'import/named': 'error', + 'import/namespace': 'error', + 'import/default': 'error', + 'import/export': 'error', + 'import/no-named-as-default': 'warn', + 'import/no-named-as-default-member': 'warn', + 'import/no-duplicates': 'warn', + 'jsx-a11y/alt-text': 'warn', + 'jsx-a11y/anchor-has-content': 'error', + 'jsx-a11y/anchor-is-valid': 'error', + 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', + 'jsx-a11y/aria-props': 'error', + 'jsx-a11y/aria-proptypes': 'error', + 'jsx-a11y/aria-role': 'error', + 'jsx-a11y/aria-unsupported-elements': 'error', + 'jsx-a11y/autocomplete-valid': 'error', + 'jsx-a11y/click-events-have-key-events': 'error', + 'jsx-a11y/heading-has-content': 'error', + 'jsx-a11y/html-has-lang': 'warn', + 'jsx-a11y/iframe-has-title': 'error', + 'jsx-a11y/img-redundant-alt': 'error', + 'jsx-a11y/interactive-supports-focus': [ + 'error', + { + tabbable: ['button', 'checkbox', 'link', 'searchbox', 'spinbutton', 'switch', 'textbox'], + }, + ], + 'jsx-a11y/label-has-associated-control': 'error', + 'jsx-a11y/media-has-caption': 'error', + 'jsx-a11y/mouse-events-have-key-events': 'error', + 'jsx-a11y/no-access-key': 'error', + 'jsx-a11y/no-autofocus': 'warn', + 'jsx-a11y/no-distracting-elements': 'error', + 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ + 'error', + { + tr: ['none', 'presentation'], + canvas: ['img'], + }, + ], + 'jsx-a11y/no-noninteractive-element-interactions': [ + 'error', + { + handlers: ['onClick', 'onError', 'onLoad', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], + alert: ['onKeyUp', 'onKeyDown', 'onKeyPress'], + body: ['onError', 'onLoad'], + dialog: ['onKeyUp', 'onKeyDown', 'onKeyPress'], + iframe: ['onError', 'onLoad'], + img: ['onError', 'onLoad'], + }, + ], + 'jsx-a11y/no-noninteractive-element-to-interactive-role': [ + 'error', + { + ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], + ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], + li: ['menuitem', 'menuitemradio', 'menuitemcheckbox', 'option', 'row', 'tab', 'treeitem'], + table: ['grid'], + td: ['gridcell'], + fieldset: ['radiogroup', 'presentation'], + }, + ], + 'jsx-a11y/no-noninteractive-tabindex': [ + 'error', + { + tags: [], + roles: ['tabpanel'], + allowExpressionValues: true, + }, + ], + 'jsx-a11y/no-redundant-roles': 'error', + 'jsx-a11y/no-static-element-interactions': [ + 'error', + { + allowExpressionValues: true, + handlers: ['onClick', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], + }, + ], + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/scope': 'error', + 'jsx-a11y/tabindex-no-positive': 'error', + 'react/jsx-key': 'error', + 'react/jsx-no-comment-textnodes': 'error', + 'react/jsx-no-duplicate-props': 'error', + 'react/jsx-no-target-blank': [ + 'error', + { + allowReferrer: true, + }, + ], + 'react/jsx-no-undef': 'error', + 'react/no-children-prop': 'error', + 'react/no-danger-with-children': 'error', + 'react/no-direct-mutation-state': 'error', + 'react/no-find-dom-node': 'error', + 'react/no-is-mounted': 'error', + 'react/no-render-return-value': 'error', + 'react/no-string-refs': 'error', + 'react/no-unescaped-entities': 'error', + 'react/no-unknown-property': [ + 'error', + { + ignore: ['css'], + }, + ], + 'react/require-render-return': 'error', + 'custom-rules/no-unstable-methods': 'error', + 'custom-rules/no-raw-inert': 'error', + 'no-label-var': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + message: "Please always import from '@clerk/shared/' instead of '@clerk/shared'.", + name: '@clerk/shared', + }, + ], + patterns: [ + { + group: ['!@clerk/shared/*'], + message: 'ignore this line -- eslint matching workaround to allow all imports except @clerk/shared', + }, + { + group: ['@emotion/*'], + message: + 'Please do not import emotion directly. Import helpers from ./design-system or ./primitives instead.', + }, + ], + }, + ], + 'react/button-has-type': 'warn', + 'react/hook-use-state': 'warn', + 'react/jsx-boolean-value': 'warn', + 'react/jsx-fragments': 'warn', + 'react/jsx-no-useless-fragment': [ + 'warn', + { + allowExpressions: true, + }, + ], + 'react/jsx-pascal-case': 'warn', + 'react/no-array-index-key': 'warn', + 'react/no-unstable-nested-components': 'warn', + 'react/self-closing-comp': 'warn', + 'simple-import-sort/imports': 'error', + 'unused-imports/no-unused-imports': 'error', + 'yml/no-empty-document': 'error', + 'yml/no-empty-key': 'error', + 'yml/no-empty-mapping-value': 'error', + 'yml/no-empty-sequence-entry': 'error', + 'yml/no-irregular-whitespace': 'error', + 'yml/no-tab-indent': 'error', + 'yml/vue-custom-block/no-parsing-error': 'error', + curly: ['error', 'all'], + 'react/rules-of-hooks': 'warn', + 'react/exhaustive-deps': 'warn', + 'react/static-components': 'error', + 'react/use-memo': 'error', + 'react/preserve-manual-memoization': 'error', + 'react/incompatible-library': 'warn', + 'react/immutability': 'error', + 'react/globals': 'error', + 'react/refs': 'error', + 'react/set-state-in-effect': 'error', + 'react/error-boundaries': 'error', + 'react/purity': 'error', + 'react/set-state-in-render': 'error', + 'react/unsupported-syntax': 'warn', + 'typescript/await-thenable': 'warn', + 'typescript/ban-ts-comment': [ + 'warn', + { + 'ts-ignore': 'allow-with-description', + 'ts-expect-error': 'allow-with-description', + 'ts-check': 'allow-with-description', + }, + ], + 'typescript/no-array-delete': 'error', + 'typescript/no-base-to-string': 'error', + 'typescript/no-duplicate-enum-values': 'error', + 'typescript/no-empty-object-type': 'error', + 'typescript/no-extra-non-null-assertion': 'error', + 'typescript/no-floating-promises': [ + 'warn', + { + ignoreVoid: true, + }, + ], + 'typescript/no-for-in-array': 'error', + 'typescript/no-implied-eval': 'error', + 'typescript/no-misused-new': 'error', + 'typescript/no-misused-promises': 'warn', + 'typescript/no-namespace': 'error', + 'typescript/no-non-null-asserted-optional-chain': 'error', + 'typescript/no-redundant-type-constituents': 'warn', + 'typescript/no-require-imports': 'error', + 'typescript/no-this-alias': 'error', + 'typescript/no-unnecessary-type-assertion': 'warn', + 'typescript/no-unnecessary-type-constraint': 'error', + 'typescript/no-unsafe-enum-comparison': 'warn', + 'typescript/no-unsafe-function-type': 'error', + 'typescript/no-unsafe-unary-minus': 'error', + 'typescript/no-wrapper-object-types': 'error', + 'typescript/only-throw-error': 'error', + 'typescript/prefer-as-const': 'error', + 'typescript/prefer-namespace-keyword': 'error', + 'typescript/prefer-promise-reject-errors': 'warn', + 'typescript/require-await': 'warn', + 'typescript/restrict-plus-operands': 'error', + 'typescript/triple-slash-reference': 'error', + 'typescript/unbound-method': 'error', + 'typescript/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: true, + fixStyle: 'separate-type-imports', + }, + ], + 'eslint-compat/no-undef-init': 'warn', + 'typescript/no-deprecated': 'error', + }, + overrides: [ + { + files: ['packages/shared/src/**/*.{ts,tsx}'], + excludeFiles: ['**/__tests__/**'], + jsPlugins: [{ name: 'jsdoc-js', specifier: 'eslint-plugin-jsdoc' }], + rules: { + 'jsdoc-js/check-alignment': 'warn', + 'jsdoc-js/check-param-names': 'warn', + 'jsdoc-js/check-types': 'warn', + 'jsdoc-js/check-values': 'warn', + 'jsdoc-js/informative-docs': 'warn', + 'jsdoc-js/multiline-blocks': 'warn', + 'jsdoc-js/no-multi-asterisks': 'warn', + 'jsdoc-js/no-types': 'warn', + 'jsdoc-js/require-hyphen-before-param-description': 'warn', + 'jsdoc-js/require-jsdoc': 'off', + 'jsdoc-js/require-returns-check': 'warn', + 'jsdoc-js/require-yields-check': 'warn', + 'jsdoc-js/tag-lines': [ + 'warn', + 'always', + { count: 1, applyToEndTag: false, startLines: 1, tags: { param: { lines: 'never' } } }, + ], + 'jsdoc-js/valid-types': 'warn', + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + rules: { + 'constructor-super': 'off', + 'getter-return': 'off', + 'no-class-assign': 'off', + 'no-const-assign': 'off', + 'no-dupe-class-members': 'off', + 'no-dupe-keys': 'off', + 'no-func-assign': 'off', + 'no-import-assign': 'off', + 'no-new-native-nonconstructor': 'off', + 'no-obj-calls': 'off', + 'no-redeclare': 'off', + 'no-setter-return': 'off', + 'no-this-before-super': 'off', + 'no-undef': 'off', + 'no-unreachable': 'off', + 'no-unsafe-negation': 'off', + 'no-var': 'error', + 'no-with': 'off', + 'prefer-const': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + vars: 'all', + varsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['**/*.cjs', '**/*.js', '**/*.jsx', '**/*.mjs'], + rules: { + 'no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + vars: 'all', + varsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['**/*.cts', '**/*.mts', '**/*.ts', '**/*.tsx'], + env: { + es2018: true, + }, + rules: { + 'import/named': 'off', + 'no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + vars: 'all', + varsIgnorePattern: '^_', + }, + ], + 'typescript/no-non-null-assertion': 'error', + }, + }, + { + files: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx', '**/test/**', '**/__tests__/**'], + rules: { + 'typescript/await-thenable': 'off', + 'typescript/no-non-null-assertion': 'off', + 'typescript/unbound-method': 'off', + 'react/globals': 'off', + }, + env: { + vitest: true, + }, + }, + { + files: ['packages/clerk-js/src/ui/**/*'], + rules: { + 'custom-rules/no-navigate-useClerk': 'error', + }, + jsPlugins: [ + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + ], + }, + { + files: ['packages/ui/src/**/*'], + rules: { + 'custom-rules/no-physical-css-properties': 'error', + 'custom-rules/no-navigate-useClerk': 'error', + }, + jsPlugins: [ + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + ], + }, + { + files: ['**/*.test.{ts,tsx}'], + rules: { + 'typescript/unbound-method': 'off', + 'typescript/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: false, + fixStyle: 'separate-type-imports', + }, + ], + }, + }, + { + files: ['**/*.test-d.ts'], + rules: { + 'no-unused-vars': 'off', + }, + }, + { + files: ['packages/shared/src/**/*'], + rules: { + 'custom-rules/no-global-object': 'error', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@clerk/shared', '@clerk/shared/*'], + message: + 'Do not import from @clerk/shared package exports within the package itself. Use the @/ alias or relative imports from source files instead (e.g., import from "@/types" or "../../types").', + }, + { + group: ['../../../*'], + message: + 'Relative imports should not traverse more than 2 levels up (../../). Use the @/ path alias instead (e.g., import from "@/types").', + }, + ], + }, + ], + }, + jsPlugins: [ + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + ], + }, + { + files: ['packages/shared/src/**/__tests__/**/*', 'packages/shared/src/**/*.test.{ts,tsx}'], + rules: { + 'custom-rules/no-global-object': 'off', + }, + jsPlugins: [ + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + ], + }, + { + files: ['packages/expo-passkeys/src/**/*'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: ['node:*'], + }, + ], + }, + }, + { + files: ['packages/nextjs/src/**/*'], + rules: { + 'turbo/no-undeclared-env-vars': [ + 'error', + { + allowList: ['_NEXT_ROUTER_BASEPATH'], + }, + ], + }, + jsPlugins: [{ name: 'turbo', specifier: fileURLToPath(new URL('./scripts/oxlint-turbo.mjs', import.meta.url)) }], + }, + { + files: ['packages/upgrade/src/**/*'], + rules: { + 'custom-rules/no-unstable-methods': 'off', + 'react/no-unescaped-entities': 'off', + 'typescript/no-floating-promises': [ + 'warn', + { + ignoreVoid: true, + }, + ], + }, + jsPlugins: [ + { + name: 'custom-rules', + specifier: fileURLToPath(new URL('./scripts/oxlint-custom-rules.mjs', import.meta.url)), + }, + ], + }, + { + files: ['integration/tests/**'], + rules: { + 'no-empty-pattern': 'off', + 'playwright/consistent-spacing-between-blocks': 'warn', + 'playwright/expect-expect': 'off', + 'playwright/max-nested-describe': 'warn', + 'playwright/missing-playwright-await': 'error', + 'playwright/no-conditional-expect': 'warn', + 'playwright/no-conditional-in-test': 'warn', + 'playwright/no-duplicate-hooks': 'warn', + 'playwright/no-duplicate-slow': 'warn', + 'playwright/no-element-handle': 'warn', + 'playwright/no-eval': 'warn', + 'playwright/no-focused-test': 'error', + 'playwright/no-force-option': 'warn', + 'playwright/no-nested-step': 'warn', + 'playwright/no-networkidle': 'error', + 'playwright/no-page-pause': 'warn', + 'playwright/no-skipped-test': 'off', + 'playwright/no-standalone-expect': 'error', + 'playwright/no-unsafe-references': 'error', + 'playwright/no-unused-locators': 'error', + 'playwright/no-useless-await': 'warn', + 'playwright/no-useless-not': 'warn', + 'playwright/no-wait-for-navigation': 'error', + 'playwright/no-wait-for-selector': 'warn', + 'playwright/no-wait-for-timeout': 'warn', + 'playwright/prefer-hooks-in-order': 'warn', + 'playwright/prefer-hooks-on-top': 'warn', + 'playwright/prefer-locator': 'warn', + 'playwright/prefer-to-have-count': 'warn', + 'playwright/prefer-to-have-length': 'warn', + 'playwright/prefer-web-first-assertions': 'error', + 'playwright/valid-describe-callback': 'error', + 'playwright/valid-expect': 'error', + 'playwright/valid-expect-in-promise': 'error', + 'playwright/valid-test-tags': 'error', + 'playwright/valid-title': 'error', + }, + jsPlugins: ['eslint-plugin-playwright'], + }, + { + files: ['scripts/**/*'], + rules: { + 'turbo/no-undeclared-env-vars': 'off', + }, + jsPlugins: [{ name: 'turbo', specifier: fileURLToPath(new URL('./scripts/oxlint-turbo.mjs', import.meta.url)) }], + }, + { + files: ['packages/shared/src/**/*.{ts,tsx}'], + rules: { + 'jsdoc/check-access': 'warn', + 'jsdoc/check-property-names': 'warn', + 'jsdoc/check-tag-names': [ + 'warn', + { + definedTags: [ + 'unionReturnHeadings', + 'displayFunctionSignature', + 'paramExtension', + 'experimental', + 'hideReturns', + 'skipParametersSection', + 'extractMethods', + 'inline', + 'inlineType', + 'standalonePage', + 'generateWithEmptyComment', + 'expandProperties', + ], + typed: false, + }, + ], + 'jsdoc/empty-tags': 'warn', + 'jsdoc/implements-on-classes': 'warn', + 'jsdoc/no-blank-blocks': 'off', + 'jsdoc/no-defaults': 'warn', + 'jsdoc/require-param': [ + 'warn', + { + ignoreWhenAllParamsMissing: true, + }, + ], + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-param-name': 'warn', + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-property': 'warn', + 'jsdoc/require-property-description': 'warn', + 'jsdoc/require-property-name': 'warn', + 'jsdoc/require-property-type': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-returns-description': 'warn', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/require-yields': 'warn', + }, + plugins: ['jsdoc'], + }, + { + files: ['*.yaml', '**/*.yaml', '*.yml', '**/*.yml'], + rules: { + 'no-irregular-whitespace': 'off', + 'no-unused-vars': 'off', + }, + }, + ], + settings: { + react: { + version: '18.3.1', + }, + }, +}); diff --git a/package.json b/package.json index 5d6f89232fc..2064903d91b 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,8 @@ "json5": "2.2.3", "jsonwebtoken": "9.0.3", "lint-staged": "^14.0.1", + "oxlint": "1.90.0", + "oxlint-tsgolint": "7.0.2005", "pkglab": "0.17.1", "prettier": "3.8.3", "prettier-plugin-astro": "^0.14.1", diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 32b5f58003a..c5840e8f8a0 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -133,9 +133,9 @@ A PR that adds, renames or changes the signature of a shared helper in `src/hook ## Lint -Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries, test rules and effect rules in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. +Mosaic uses Oxlint and runs stricter lint rules than the rest of the repo. Lint with `pnpm lint` from `packages/mosaic`. -Every disable directive in `src` needs a `-- reason` (`@eslint-community/eslint-comments/require-description`). The reason is not a code comment under the root no-comments rule. Write why the rule does not apply here, not what the code does; it is what a reviewer judges. +Every disable directive in `src` needs a `-- reason`. The reason is not a code comment, so the root no-comments rule does not apply. Write why the lint rule does not apply here, not what the code does; it is what a reviewer judges. ### When `sonarjs/cognitive-complexity` fires diff --git a/packages/mosaic/docs/stylex.md b/packages/mosaic/docs/stylex.md index cefcc90ffb0..a7f9a2dd3ea 100644 --- a/packages/mosaic/docs/stylex.md +++ b/packages/mosaic/docs/stylex.md @@ -20,7 +20,7 @@ API". | `props.ts` | `themeProps` (`.cl-` + `data-`), `mergeStyleProps`, the `Mosaic*Props` types | | `styles/index.ts` | isolated-build barrel; derives `*VarName` types | -The `@stylexjs` eslint rules run on `packages/mosaic/src/**` (tests excluded). The `enforce-extension` +The `@stylexjs` Oxlint rules run on `packages/mosaic/src/**` (tests excluded). The `enforce-extension` rule reserves the `.stylex.ts` extension for StyleX define-primitives: **a `.stylex.ts` file may export nothing but `defineVars` / `defineConsts` / `defineMarker` results.** So `stylex.create(...)` lives in a plain `.styles.ts` diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json deleted file mode 100644 index d8a07c970f8..00000000000 --- a/packages/mosaic/eslint-suppressions.json +++ /dev/null @@ -1,261 +0,0 @@ -{ - "src/blocks/destructive/destructive.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/components/dialog/dialog.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/components/flow/flow.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/components/form/use-form.ts": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/components/icon/icon.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/components/panel/panel.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/components/profile/profile.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/features/api-keys/api-keys-table.model.ts": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/features/reverification/use-reverification-with-state.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/features/switcher/switcher.view.tsx": { - "react-hooks/refs": { - "count": 8 - } - }, - "src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts": { - "boundaries/dependencies": { - "count": 1 - } - }, - "src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/hooks/use-debounced-async.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/hooks/use-in-view.ts": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/hooks/use-spin-delay.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/localization/context.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/localization/messages.ts": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/machine/assign.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/machine/create-actor.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 6 - } - }, - "src/machine/create-machine.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/machine/setup.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - } - }, - "src/machine/use-machine.ts": { - "react-hooks/refs": { - "count": 11 - } - }, - "src/primitives/autocomplete/autocomplete-root.tsx": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/combobox/combobox-root.tsx": { - "react-hooks/refs": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/dialog/dialog-popup.tsx": { - "react-hooks/refs": { - "count": 2 - } - }, - "src/primitives/dialog/dialog-trigger.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/primitives/drawer/drawer-root.tsx": { - "react-hooks/refs": { - "count": 3 - } - }, - "src/primitives/drawer/use-drawer-drag.ts": { - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - }, - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/primitives/drawer/use-snap-points.ts": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/primitives/file-upload/use-object-url.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/hooks/use-data-table.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/primitives/hooks/use-focus-target.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - }, - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/menu/menu-root.tsx": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/menu/menu-trigger.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/primitives/otp/otp-input.tsx": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/primitives/popover/popover-root.tsx": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/portal/portal.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/select/select-root.tsx": { - "react-hooks/refs": { - "count": 4 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/tabs/tabs-root.tsx": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/toast/toast-provider.tsx": { - "react-hooks/refs": { - "count": 5 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/toast/toast-root.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/primitives/tooltip/tooltip-root.tsx": { - "react-hooks/refs": { - "count": 1 - } - }, - "src/primitives/utils/interaction-modality.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "src/primitives/utils/layout-animator.ts": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/primitives/utils/use-render.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 6 - }, - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, - "src/utils/feedback.tsx": { - "react-hooks/refs": { - "count": 2 - } - } -} \ No newline at end of file diff --git a/packages/mosaic/eslint.config.mjs b/packages/mosaic/eslint.config.mjs deleted file mode 100644 index c6cc71ac863..00000000000 --- a/packages/mosaic/eslint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import path from 'node:path'; - -import rootConfig from '../../eslint.config.mjs'; -import mosaicImportGuards from './eslint/config.mjs'; - -const root = path.resolve(import.meta.dirname, '../..'); - -export default [...rootConfig.map(config => ({ ...config, basePath: root })), ...mosaicImportGuards]; diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs deleted file mode 100644 index 79642a983d9..00000000000 --- a/packages/mosaic/eslint/config.mjs +++ /dev/null @@ -1,220 +0,0 @@ -import path from 'node:path'; - -import vitest from '@vitest/eslint-plugin'; -import boundaries from 'eslint-plugin-boundaries'; -import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; - -import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState, noRawInvoke } from './rules.mjs'; - -const mosaicRoot = path.resolve(import.meta.dirname, '..'); - -const SHARED = ['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization', 'root']; -const FLOW_UI = { file: { categories: { anyOf: ['view', 'controller'] } } }; -const CLERK_DATA_HOOKS = [ - 'useClerk', - 'useUser', - 'useSession', - 'useSessionList', - 'useOrganization', - 'useOrganizationList', - 'useAuth', -]; - -export default [ - { - name: 'packages/mosaic - import guard plugin', - files: ['src/**/*'], - plugins: { - mosaic: { - rules: { - 'no-model-react-state': noModelReactState, - 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, - 'forward-ref-parts': forwardRefParts, - 'no-raw-invoke': noRawInvoke, - }, - }, - }, - }, - { - name: 'packages/mosaic - type-only imports', - files: ['src/**/*.{ts,tsx}'], - rules: { - '@typescript-eslint/no-import-type-side-effects': 'error', - }, - }, - { - name: 'packages/mosaic - views and controllers', - files: ['src/**/*.{view,controller}.{ts,tsx}'], - rules: { - 'mosaic/no-dynamic-message-catalogs': 'error', - }, - }, - { - name: 'packages/mosaic - models', - files: ['src/**/*.model.{ts,tsx}'], - rules: { - 'mosaic/no-model-react-state': 'error', - }, - }, - { - name: 'packages/mosaic - parts', - files: ['src/primitives/**/*.tsx', 'src/components/**/*.tsx'], - ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}'], - rules: { - 'mosaic/forward-ref-parts': 'error', - }, - }, - { - name: 'packages/mosaic - machines', - files: ['src/**/*.{ts,tsx}'], - ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], - rules: { - 'mosaic/no-raw-invoke': 'error', - }, - }, - { - name: 'packages/mosaic - effects', - files: ['src/features/**/*.{ts,tsx}'], - ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], - plugins: noUnneededEffects.configs.strict.plugins, - rules: noUnneededEffects.configs.strict.rules, - }, - { - name: 'packages/mosaic - tests', - files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], - ignores: ['src/**/*.test-d.{ts,tsx}'], - plugins: { vitest }, - rules: { - 'vitest/expect-expect': ['error', { assertFunctionNames: ['expect', 'expect*', 'assert*'] }], - 'vitest/no-focused-tests': 'error', - 'vitest/no-identical-title': 'error', - }, - }, - { - name: 'packages/mosaic - boundaries', - files: ['src/**/*'], - ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.ts'], - plugins: { boundaries }, - settings: { - 'boundaries/root-path': mosaicRoot, - 'boundaries/flag-as-external': { outsideRootPath: true }, - 'boundaries/elements': [ - ...['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization'].map(type => ({ - type, - pattern: `src/${type}`, - partialMatch: false, - })), - { type: 'queries', pattern: 'src/queries', partialMatch: false }, - { type: 'modals', pattern: 'src/modals', partialMatch: false }, - { type: 'blocks', pattern: 'src/blocks', partialMatch: false }, - { type: 'features', pattern: 'src/features/*', partialMatch: false, capture: ['feature'] }, - { type: 'root', pattern: 'src', partialMatch: false }, - ], - 'boundaries/files': [ - { category: 'view', pattern: '**/*.view.{ts,tsx}' }, - { category: 'controller', pattern: '**/*.controller.{ts,tsx}' }, - { category: 'model', pattern: '**/*.model.{ts,tsx}' }, - { category: 'messages', pattern: '**/*.messages.ts' }, - { category: 'css-entry', pattern: 'src/styles/index.ts' }, - ], - }, - rules: { - 'boundaries/dependencies': [ - 'error', - { - default: 'disallow', - checkAllOrigins: true, - checkUnknownLocals: true, - checkInternals: true, - message: - 'Mosaic layers import down: features, then blocks, modals and queries, then components, hooks, utils and styles, then primitives. Move the shared piece down a layer, or pass it in as a prop.', - policies: [ - { allow: { to: { module: { origin: ['external', 'core'] } } } }, - { allow: { to: { element: { type: '{{ from.element.type }}' } } } }, - { from: { element: { type: 'root' } }, allow: { to: { element: { type: '*' } } } }, - { from: { file: { categories: 'css-entry' } }, allow: { to: { element: { type: '*' } } } }, - { - from: { element: { type: ['utils', 'hooks', 'styles', 'components', 'localization', 'modals'] } }, - allow: { to: { element: { type: SHARED } } }, - }, - { - from: { element: { type: 'localization' } }, - allow: { to: { file: { categories: 'messages' } } }, - }, - { - from: { element: { type: 'blocks' } }, - allow: [ - { to: { element: { type: SHARED } } }, - { to: { element: { type: 'features', captured: { feature: 'reverification' } } } }, - ], - }, - { - from: { element: { type: 'features' } }, - allow: { to: { element: { type: [...SHARED, 'queries', 'modals', 'blocks'] } } }, - }, - { - from: FLOW_UI, - disallow: { - dependency: { source: '@clerk/shared/react', kind: 'value', specifiers: CLERK_DATA_HOOKS }, - }, - message: - 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', - }, - { - from: FLOW_UI, - disallow: { - to: { file: { categories: 'messages' } }, - dependency: { kind: 'value', nodeKind: ['import', 'export'] }, - }, - message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', - }, - { - from: FLOW_UI, - disallow: { to: { element: { type: 'queries' } }, dependency: { kind: 'value' } }, - message: 'Read Clerk queries in the model and pass plain data to the controller or view.', - }, - { - from: FLOW_UI, - disallow: { - to: { element: { type: 'hooks' } }, - dependency: { kind: 'value', specifiers: 'useMosaicEnvironment' }, - }, - message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', - }, - { - from: FLOW_UI, - disallow: { - to: { element: { type: 'hooks' } }, - dependency: { kind: 'value', specifiers: 'useMosaicRouter' }, - }, - message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', - }, - { - from: FLOW_UI, - disallow: { - to: { element: { type: 'hooks' } }, - dependency: { kind: 'value', specifiers: 'useMosaicSupportEmail' }, - }, - message: 'Read the support email in the model and pass plain data to the controller or view.', - }, - { - from: { file: { categories: 'model' } }, - disallow: { dependency: { source: 'react', kind: 'value', specifiers: ['useState', 'useReducer'] } }, - message: 'Put interaction state in the controller and pass its results to the view.', - }, - { - from: { file: { categories: 'model' } }, - disallow: { to: { file: { categories: ['view', 'controller'] } }, dependency: { kind: 'value' } }, - message: 'A model answers with plain data and callbacks. Import only types from the controller or view.', - }, - { - from: { file: { categories: 'controller' } }, - disallow: { to: { file: { categories: 'view' } }, dependency: { kind: 'value' } }, - message: 'A controller hands props to the view. Import only types from the view.', - }, - ], - }, - ], - }, - }, -]; diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 491a33bc007..02e0c799693 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -1,54 +1,111 @@ +import { execFile } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { createRequire } from 'node:module'; +import { promisify } from 'node:util'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; -import { ESLint } from 'eslint'; -import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; -import { describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -const root = path.resolve(import.meta.dirname, '../../..'); -const mosaicRoot = path.resolve(import.meta.dirname, '..'); +const packageRoot = path.resolve(import.meta.dirname, '..'); +let mosaicRoot; const featureDir = 'src/features/reverification'; -const layerFixtures = [ - 'src/primitives/sample.ts', - 'src/components/sample.tsx', - 'src/hooks/sample.ts', - 'src/utils/sample.ts', - 'src/localization/sample.ts', - 'src/modals/sample.ts', - 'src/blocks/sample.tsx', - 'src/queries/sample.ts', -]; -const flowFixtures = [ - 'sample.view.ts', - 'sample.view.tsx', - 'sample.controller.ts', - 'sample.controller.tsx', - 'sample.model.ts', - 'sample.model.tsx', - 'sample.messages.ts', -].map(file => `${featureDir}/${file}`); const testFixture = 'src/hooks/__tests__/sample.test.ts'; const partFixture = 'src/primitives/sample-part.tsx'; const partTestFixture = 'src/primitives/__tests__/sample-part.test.tsx'; -const rootEslint = new ESLint({ cwd: root }); -const eslint = new ESLint({ - cwd: mosaicRoot, - overrideConfig: { - languageOptions: { - parserOptions: { - projectService: { - allowDefaultProject: [...flowFixtures, ...layerFixtures, testFixture, partFixture, partTestFixture].map( - file => `packages/mosaic/${file}`, - ), - maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, - }, - }, - }, - }, +const execute = promisify(execFile); +const require = createRequire(path.join(packageRoot, 'package.json')); +const oxlint = path.join(path.dirname(require.resolve('oxlint/package.json')), 'bin/oxlint'); + +beforeAll(async () => { + mosaicRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'mosaic-oxlint-'))); + await cp(path.join(packageRoot, 'src'), path.join(mosaicRoot, 'src'), { recursive: true }); + for (const file of ['package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.test.json']) { + await cp(path.join(packageRoot, file), path.join(mosaicRoot, file)); + } + await symlink(path.join(packageRoot, 'node_modules'), path.join(mosaicRoot, 'node_modules'), 'dir'); + const configUrl = pathToFileURL(path.join(packageRoot, 'oxlint.config.mjs')).href; + await writeFile( + path.join(mosaicRoot, 'oxlint.config.mjs'), + ` +import { createRequire } from 'node:module'; +import config from ${JSON.stringify(configUrl)}; +const require = createRequire(${JSON.stringify(path.join(packageRoot, 'package.json'))}); +const resolvePlugin = plugin => typeof plugin === 'string' + ? { name: plugin.replace(/^eslint-plugin-/, ''), specifier: require.resolve(plugin) } + : { ...plugin, specifier: require.resolve(plugin.specifier) }; +export default { + ...config, + jsPlugins: config.jsPlugins.map(resolvePlugin), + overrides: config.overrides.map(override => ({ + ...override, + ...(override.jsPlugins ? { jsPlugins: override.jsPlugins.map(resolvePlugin) } : {}), + })), + settings: { ...config.settings, 'boundaries/root-path': import.meta.dirname }, +}; +`, + ); +}, 30_000); + +afterAll(async () => { + if (mosaicRoot) { + await rm(mosaicRoot, { recursive: true, force: true }); + } }); async function lintAt(file, source) { - const [result] = await eslint.lintText(source, { filePath: path.join(mosaicRoot, file) }); - return result.messages; + const filePath = path.join(mosaicRoot, file); + await mkdir(path.dirname(filePath), { recursive: true }); + const original = await readFile(filePath).catch(error => { + if (error.code === 'ENOENT') { + return undefined; + } + throw error; + }); + await writeFile(filePath, source); + try { + const result = await execute( + process.execPath, + [ + oxlint, + '--config', + path.join(mosaicRoot, 'oxlint.config.mjs'), + '--disable-nested-config', + '--format', + 'json', + '--threads', + '1', + filePath, + ], + { + cwd: mosaicRoot, + maxBuffer: 10 * 1024 * 1024, + }, + ).catch(error => { + if (error.code === 1 && error.stdout) { + return error; + } + throw error; + }); + if (!result.stdout.trimStart().startsWith('{')) { + throw new Error(result.stdout); + } + return JSON.parse(result.stdout).diagnostics.map(diagnostic => ({ + message: diagnostic.message, + ruleId: diagnostic.code + .replace(/^([^()]+)\((.+)\)$/, '$1/$2') + .replace(/^typescript\//, '@typescript-eslint/') + .replace(/^eslint\//, ''), + fatal: !diagnostic.code, + })); + } finally { + if (original === undefined) { + await rm(filePath); + } else { + await writeFile(filePath, original); + } + } } async function lint(file, source) { @@ -198,7 +255,6 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { expect(messages.filter(message => message.ruleId === 'mosaic/no-model-react-state')).toEqual([ expect.objectContaining({ message: 'Put interaction state in the controller and pass its results to the view.', - messageId: 'controllerState', }), ]); }); @@ -229,7 +285,6 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { expect(messages.filter(message => message.ruleId === 'mosaic/no-dynamic-message-catalogs')).toEqual([ expect.objectContaining({ message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', - messageId: 'useMessages', }), ]); } @@ -436,7 +491,10 @@ describe('Mosaic parts', { timeout: 30_000 }, () => { describe('Mosaic tests', { timeout: 30_000 }, () => { async function vitestRules(source) { - const messages = await lintAt(testFixture, ["import { describe, expect, test } from 'vitest';", source].join('\n')); + const messages = await lintAt( + testFixture, + ["import { afterAll, beforeAll, describe, expect, test } from 'vitest';", source].join('\n'), + ); expect(messages.find(message => message.fatal)).toBeUndefined(); return messages.map(message => message.ruleId).filter(rule => rule?.startsWith('vitest/')); } @@ -522,32 +580,36 @@ describe('Mosaic effects', { timeout: 30_000 }, () => { }); }); -describe('Mosaic package configuration', { timeout: 30_000 }, () => { - test.each([ - 'src/features/sample.view.tsx', - 'src/features/sample.controller.ts', - 'src/features/sample.model.ts', - 'src/styles/sample.styles.ts', - 'src/primitives/sample.tsx', - ])('%s inherits repository rules without enabling guards at the root', async file => { - const filePath = path.join(mosaicRoot, file); - const rootConfig = await rootEslint.calculateConfigForFile(filePath); - const mosaicConfig = await eslint.calculateConfigForFile(filePath); - const inheritedRules = { ...mosaicConfig.rules }; - - for (const rule of [ - 'boundaries/dependencies', - 'mosaic/no-model-react-state', - 'mosaic/no-dynamic-message-catalogs', - 'mosaic/forward-ref-parts', - 'mosaic/no-raw-invoke', - '@typescript-eslint/no-import-type-side-effects', - ...Object.keys(noUnneededEffects.configs.strict.rules), - ]) { - expect(rootConfig.rules[rule]).toBeUndefined(); - delete inheritedRules[rule]; +describe('Mosaic package configuration', () => { + test('inherits repository rules and keeps Mosaic guards in the package', async () => { + const configAt = async cwd => { + const { stdout } = await execute(process.execPath, [oxlint, '-c', 'oxlint.config.mjs', '--print-config'], { + cwd, + }); + return JSON.parse(stdout); + }; + const rootConfig = await configAt(path.resolve(packageRoot, '../..')); + const mosaicConfig = await configAt(packageRoot); + expect(mosaicConfig.rules['no-restricted-imports']).toEqual(rootConfig.rules['no-restricted-imports']); + const rootRules = [rootConfig.rules, ...rootConfig.overrides.map(override => override.rules)]; + for (const rules of rootRules) { + expect(Object.keys(rules).some(rule => /^(mosaic|boundaries)\//.test(rule))).toBe(false); } + expect(mosaicConfig.overrides.some(override => override.rules['boundaries/dependencies'])).toBe(true); + }); +}); - expect(inheritedRules).toEqual(rootConfig.rules); +describe('Mosaic migration coverage', { timeout: 30_000 }, () => { + test.each([ + ['@typescript-eslint/no-floating-promises', 'export function load(fetch: () => Promise) { fetch(); }'], + ['@typescript-eslint/consistent-type-assertions', 'export const value = 1 as number;'], + ['eslint-compat/no-restricted-syntax', 'export function View() { return
; }'], + [ + '@stylexjs/enforce-extension', + "import * as stylex from '@stylexjs/stylex'; export const tokens = stylex.defineVars({ color: 'red' });", + ], + ])('preserves %s', async (rule, source) => { + const messages = await lint('sample.view.tsx', source); + expect(messages.some(message => message.ruleId === rule)).toBe(true); }); }); diff --git a/packages/mosaic/eslint/plugin.mjs b/packages/mosaic/eslint/plugin.mjs new file mode 100644 index 00000000000..d36191b24b7 --- /dev/null +++ b/packages/mosaic/eslint/plugin.mjs @@ -0,0 +1,10 @@ +import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState, noRawInvoke } from './rules.mjs'; + +export default { + rules: { + 'no-model-react-state': noModelReactState, + 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, + 'forward-ref-parts': forwardRefParts, + 'no-raw-invoke': noRawInvoke, + }, +}; diff --git a/packages/mosaic/oxlint.config.mjs b/packages/mosaic/oxlint.config.mjs new file mode 100644 index 00000000000..c8788969e32 --- /dev/null +++ b/packages/mosaic/oxlint.config.mjs @@ -0,0 +1,351 @@ +import path from 'node:path'; + +import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; +import { defineConfig } from 'oxlint'; + +import rootConfig from '../../oxlint.config.mjs'; + +const mosaicRoot = import.meta.dirname; +const SHARED = ['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization', 'root']; +const FLOW_UI = { file: { categories: { anyOf: ['view', 'controller'] } } }; +const CLERK_DATA_HOOKS = [ + 'useClerk', + 'useUser', + 'useSession', + 'useSessionList', + 'useOrganization', + 'useOrganizationList', + 'useAuth', +]; +const noLeakedConditionalJsx = { + selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']", + message: + 'Use a ternary (`condition ? : null`) for conditional JSX; `&&` renders a falsy left-hand value such as `0`.', +}; + +export default defineConfig({ + ...rootConfig, + plugins: [...rootConfig.plugins, 'vitest'], + jsPlugins: [ + ...rootConfig.jsPlugins, + { name: 'react-hooks-js', specifier: 'eslint-plugin-react-hooks' }, + { name: 'jsx-a11y-js', specifier: 'eslint-plugin-jsx-a11y' }, + { name: '@eslint-community/eslint-comments', specifier: '@eslint-community/eslint-plugin-eslint-comments' }, + { name: '@stylexjs', specifier: '@stylexjs/eslint-plugin' }, + 'eslint-plugin-sonarjs', + { name: 'mosaic', specifier: './eslint/plugin.mjs' }, + 'eslint-plugin-boundaries', + 'eslint-plugin-react-you-might-not-need-an-effect', + ], + settings: { + ...rootConfig.settings, + 'boundaries/root-path': mosaicRoot, + 'boundaries/flag-as-external': { outsideRootPath: true }, + 'boundaries/elements': [ + ...['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization'].map(type => ({ + type, + pattern: `src/${type}`, + partialMatch: false, + })), + { type: 'queries', pattern: 'src/queries', partialMatch: false }, + { type: 'modals', pattern: 'src/modals', partialMatch: false }, + { type: 'blocks', pattern: 'src/blocks', partialMatch: false }, + { type: 'features', pattern: 'src/features/*', partialMatch: false, capture: ['feature'] }, + { type: 'root', pattern: 'src', partialMatch: false }, + ], + 'boundaries/files': [ + { category: 'view', pattern: '**/*.view.{ts,tsx}' }, + { category: 'controller', pattern: '**/*.controller.{ts,tsx}' }, + { category: 'model', pattern: '**/*.model.{ts,tsx}' }, + { category: 'messages', pattern: '**/*.messages.ts' }, + { category: 'css-entry', pattern: 'src/styles/index.ts' }, + ], + 'import/resolver': { + node: true, + typescript: { + alwaysTryTypes: true, + project: [ + path.join(import.meta.dirname, 'tsconfig.json'), + path.join(import.meta.dirname, 'tsconfig.test.json'), + ], + }, + }, + }, + overrides: [ + ...rootConfig.overrides, + { + files: [ + 'src/primitives/file-upload/file-upload-dropzone.tsx', + 'src/primitives/tag-input/tag-input-root.tsx', + 'src/primitives/menu/menu-positioner.tsx', + 'src/primitives/toast/toast-viewport.tsx', + 'src/primitives/toast/toast-positioner.tsx', + 'src/primitives/select/select-positioner.tsx', + 'src/primitives/select/select-trigger.tsx', + 'src/primitives/combobox/combobox-input.tsx', + 'src/primitives/table/table-row.tsx', + 'src/primitives/autocomplete/autocomplete-input.tsx', + 'src/features/switcher/switcher.view.tsx', + ], + rules: { 'react/refs': 'off', 'react-hooks-js/refs': 'error' }, + }, + { + files: ['src/components/switch/switch.tsx'], + rules: { 'jsx-a11y/role-has-required-aria-props': 'off', 'jsx-a11y-js/role-has-required-aria-props': 'error' }, + }, + { + files: ['src/**/*'], + excludeFiles: ['src/__tests__/**', 'src/**/*.test.{ts,tsx}'], + rules: { + '@eslint-community/eslint-comments/require-description': ['error', { ignore: ['eslint-enable'] }], + 'sonarjs/cognitive-complexity': ['error', 15], + 'sonarjs/no-collapsible-if': 'error', + 'sonarjs/no-identical-functions': 'error', + 'sonarjs/no-redundant-jump': 'error', + 'sonarjs/prefer-single-boolean-return': 'error', + 'typescript/consistent-type-assertions': ['error', { assertionStyle: 'never' }], + 'typescript/switch-exhaustiveness-check': 'error', + 'typescript/await-thenable': 'error', + 'typescript/ban-ts-comment': 'error', + 'typescript/no-explicit-any': 'error', + 'typescript/no-floating-promises': 'error', + 'typescript/no-misused-promises': 'error', + 'typescript/no-unsafe-argument': 'error', + 'typescript/no-unsafe-assignment': 'error', + 'typescript/no-unsafe-call': 'error', + 'typescript/no-unsafe-member-access': 'error', + 'typescript/no-unsafe-return': 'error', + 'typescript/only-throw-error': 'error', + 'typescript/prefer-promise-reject-errors': 'error', + 'typescript/use-unknown-in-catch-callback-variable': 'error', + 'react/exhaustive-deps': 'error', + 'react/rules-of-hooks': 'error', + 'max-lines': ['error', { max: 1000, skipBlankLines: true, skipComments: true }], + 'no-restricted-properties': [ + 'error', + { object: 'Reflect', property: 'get', message: 'Use typed property access or parse at the boundary.' }, + { object: 'Reflect', property: 'apply', message: 'Call the function directly.' }, + ], + '@stylexjs/enforce-extension': 'error', + '@stylexjs/no-legacy-contextual-styles': 'error', + '@stylexjs/no-lookahead-selectors': 'error', + '@stylexjs/no-nonstandard-styles': 'error', + '@stylexjs/no-conflicting-props': 'error', + '@stylexjs/no-unused': 'error', + '@stylexjs/sort-keys': 'error', + '@stylexjs/valid-shorthands': 'error', + '@stylexjs/valid-styles': 'error', + 'eslint-compat/no-restricted-syntax': [ + 'error', + { + selector: "Property > Literal.key[value='&:hover']", + message: "Use hover() from mosaic/utils instead of bare '&:hover'.", + }, + noLeakedConditionalJsx, + { + selector: "JSXOpeningElement[name.name='form']", + message: 'Use `Form` from mosaic/primitives/form, which turns off the browser validation popup.', + }, + { + selector: "Property > Literal.key[value='@media (hover: hover)']", + message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.", + }, + { + selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']", + message: + "Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.", + }, + { + selector: + "JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXAttribute[name.name=/^(className|style)$/]", + message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.', + }, + { + selector: + "JSXOpeningElement[name.type='JSXMemberExpression'] > JSXAttribute[name.name=/^(className|style)$/]", + message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.', + }, + { + selector: + "JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']", + message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.', + }, + { + selector: + "JSXOpeningElement[name.type='JSXMemberExpression'] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']", + message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.', + }, + ], + }, + }, + { + files: ['src/**/*'], + rules: { + 'jsx-a11y/heading-has-content': 'off', + 'jsx-a11y/label-has-associated-control': 'off', + }, + }, + { + files: ['src/**/*.styles.ts'], + rules: { + 'eslint-compat/no-restricted-syntax': 'off', + }, + }, + { + files: ['src/primitives/**/*'], + excludeFiles: ['src/**/*.test.{ts,tsx}'], + rules: { + 'eslint-compat/no-restricted-syntax': ['error', noLeakedConditionalJsx], + }, + }, + { + files: ['src/**/*.{ts,tsx}'], + rules: { + 'typescript/no-import-type-side-effects': 'error', + }, + }, + { + files: ['src/**/*.{view,controller}.{ts,tsx}'], + rules: { + 'mosaic/no-dynamic-message-catalogs': 'error', + }, + }, + { + files: ['src/**/*.model.{ts,tsx}'], + rules: { + 'mosaic/no-model-react-state': 'error', + }, + }, + { + files: ['src/primitives/**/*.tsx', 'src/components/**/*.tsx'], + excludeFiles: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}'], + rules: { + 'mosaic/forward-ref-parts': 'error', + }, + }, + { + files: ['src/**/*.{ts,tsx}'], + excludeFiles: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], + rules: { + 'mosaic/no-raw-invoke': 'error', + }, + }, + { + files: ['src/features/**/*.{ts,tsx}'], + excludeFiles: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], + rules: noUnneededEffects.configs.strict.rules, + }, + { + files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], + excludeFiles: ['src/**/*.test-d.{ts,tsx}'], + rules: { + 'vitest/expect-expect': ['error', { assertFunctionNames: ['expect', 'expect*', 'assert*'] }], + 'vitest/no-focused-tests': 'error', + 'vitest/no-identical-title': 'error', + }, + }, + { + files: ['src/**/*'], + excludeFiles: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.ts'], + rules: { + 'boundaries/dependencies': [ + 'error', + { + default: 'disallow', + checkAllOrigins: true, + checkUnknownLocals: true, + checkInternals: true, + message: + 'Mosaic layers import down: features, then blocks, modals and queries, then components, hooks, utils and styles, then primitives. Move the shared piece down a layer, or pass it in as a prop.', + policies: [ + { allow: { to: { module: { origin: ['external', 'core'] } } } }, + { allow: { to: { element: { type: '{{ from.element.type }}' } } } }, + { from: { element: { type: 'root' } }, allow: { to: { element: { type: '*' } } } }, + { from: { file: { categories: 'css-entry' } }, allow: { to: { element: { type: '*' } } } }, + { + from: { element: { type: ['utils', 'hooks', 'styles', 'components', 'localization', 'modals'] } }, + allow: { to: { element: { type: SHARED } } }, + }, + { + from: { element: { type: 'localization' } }, + allow: { to: { file: { categories: 'messages' } } }, + }, + { + from: { element: { type: 'blocks' } }, + allow: [ + { to: { element: { type: SHARED } } }, + { to: { element: { type: 'features', captured: { feature: 'reverification' } } } }, + ], + }, + { + from: { element: { type: 'features' } }, + allow: { to: { element: { type: [...SHARED, 'queries', 'modals', 'blocks'] } } }, + }, + { + from: FLOW_UI, + disallow: { + dependency: { source: '@clerk/shared/react', kind: 'value', specifiers: CLERK_DATA_HOOKS }, + }, + message: + 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', + }, + { + from: FLOW_UI, + disallow: { + to: { file: { categories: 'messages' } }, + dependency: { kind: 'value', nodeKind: ['import', 'export'] }, + }, + message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', + }, + { + from: FLOW_UI, + disallow: { to: { element: { type: 'queries' } }, dependency: { kind: 'value' } }, + message: 'Read Clerk queries in the model and pass plain data to the controller or view.', + }, + { + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicEnvironment' }, + }, + message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', + }, + { + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicRouter' }, + }, + message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', + }, + { + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicSupportEmail' }, + }, + message: 'Read the support email in the model and pass plain data to the controller or view.', + }, + { + from: { file: { categories: 'model' } }, + disallow: { dependency: { source: 'react', kind: 'value', specifiers: ['useState', 'useReducer'] } }, + message: 'Put interaction state in the controller and pass its results to the view.', + }, + { + from: { file: { categories: 'model' } }, + disallow: { to: { file: { categories: ['view', 'controller'] } }, dependency: { kind: 'value' } }, + message: + 'A model answers with plain data and callbacks. Import only types from the controller or view.', + }, + { + from: { file: { categories: 'controller' } }, + disallow: { to: { file: { categories: 'view' } }, dependency: { kind: 'value' } }, + message: 'A controller hands props to the view. Import only types from the view.', + }, + ], + }, + ], + }, + }, + ], +}); diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index edae738cffe..b17cdecae8d 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -55,7 +55,7 @@ "dev": "tsdown --config tsdown.config.mts --watch", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", - "lint": "eslint src", + "lint": "oxlint -c oxlint.config.mjs src", "lint:attw": "attw --pack . --exclude-entrypoints styles.css --profile esm-only", "lint:publint": "publint", "test": "vitest run --project primitives --project mosaic", @@ -84,7 +84,6 @@ "@testing-library/user-event": "^14.6.5", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", - "@vitest/eslint-plugin": "1.6.28", "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", "eslint-plugin-boundaries": "7.3.0", @@ -96,6 +95,7 @@ "react": "catalog:react", "react-dom": "catalog:react", "tsdown": "catalog:repo", + "typescript": "7.0.2", "vitest-axe": "^0.1.0" }, "peerDependencies": { diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 31527a6de6a..808965533ca 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -137,6 +137,7 @@ export function Destructive({ fieldLabel, confirmationValue, ... useEffect(() => { if (!isOpen) { + // eslint-disable-next-line react/set-state-in-effect -- Clear the confirmation text when the dialog closes. setTypedValue(''); } }, [isOpen]); diff --git a/packages/mosaic/src/blocks/reverification/reverification.tsx b/packages/mosaic/src/blocks/reverification/reverification.tsx index 82b92c612ad..b27b3376c43 100644 --- a/packages/mosaic/src/blocks/reverification/reverification.tsx +++ b/packages/mosaic/src/blocks/reverification/reverification.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { type JSX, useRef } from 'react'; import { Card } from '../../components/card'; import type { DialogFocusTarget } from '../../components/dialog'; diff --git a/packages/mosaic/src/components/badge/badge.test.tsx b/packages/mosaic/src/components/badge/badge.test.tsx index 9e8915d9138..4eb8f04b29e 100644 --- a/packages/mosaic/src/components/badge/badge.test.tsx +++ b/packages/mosaic/src/components/badge/badge.test.tsx @@ -112,7 +112,6 @@ describe('Mosaic Badge', () => { render( } > Active diff --git a/packages/mosaic/src/components/dialog/dialog.tsx b/packages/mosaic/src/components/dialog/dialog.tsx index e142b13cc20..15d20b11af0 100644 --- a/packages/mosaic/src/components/dialog/dialog.tsx +++ b/packages/mosaic/src/components/dialog/dialog.tsx @@ -201,6 +201,7 @@ function Root({ } /** Opens the dialog. Renders a `