From 4aa10d1b45366025eb35e69a0e049a56624a1427 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 11:03:47 -0600 Subject: [PATCH 1/8] chore(mosaic): enforce model and view import boundaries --- .changeset/mosaic-import-guards.md | 2 + .github/workflows/ci.yml | 3 + eslint.config.mjs | 72 ++++++++++++++ scripts/mosaic-import-guards.test.mjs | 133 ++++++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 .changeset/mosaic-import-guards.md create mode 100644 scripts/mosaic-import-guards.test.mjs diff --git a/.changeset/mosaic-import-guards.md b/.changeset/mosaic-import-guards.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-import-guards.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee010ea28b4..1f84f270897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,6 +236,9 @@ jobs: - name: Run lint run: pnpm turbo lint $TURBO_ARGS + - name: Test Mosaic import guards + run: pnpm exec vitest run --config scripts/vitest.config.mjs scripts/mosaic-import-guards.test.mjs + - name: Upload Turbo Summary uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ env.TURBO_SUMMARIZE == 'true' }} diff --git a/eslint.config.mjs b/eslint.config.mjs index d915da10caa..c3c2a6131ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -692,6 +692,78 @@ export default tseslint.config([ ], }, }, + { + name: 'packages/mosaic - views and controllers', + files: ['packages/mosaic/src/**/*.{view,controller}.{ts,tsx}'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@clerk/shared/react', + importNames: [ + 'useClerk', + 'useUser', + 'useSession', + 'useSessionList', + 'useOrganization', + 'useOrganizationList', + 'useAuth', + ], + message: + 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', + allowTypeImports: true, + }, + ], + patterns: [ + { + regex: '[.]messages(?:[.]ts)?$', + message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-environment(?:[.]ts)?$', + importNames: ['useMosaicEnvironment'], + message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-router(?:[.]ts)?$', + importNames: ['useMosaicRouter'], + message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-support-email(?:[.]ts)?$', + importNames: ['useMosaicSupportEmail'], + message: 'Read the support email in the model and pass plain data to the controller or view.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, + { + name: 'packages/mosaic - models', + files: ['packages/mosaic/src/**/*.model.{ts,tsx}'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react', + importNames: ['useState', 'useReducer'], + message: 'Put interaction state in the controller and pass its results to the view.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, { name: 'packages/mosaic/jsx-a11y', files: ['packages/mosaic/src/**/*'], diff --git a/scripts/mosaic-import-guards.test.mjs b/scripts/mosaic-import-guards.test.mjs new file mode 100644 index 00000000000..ade592a9d27 --- /dev/null +++ b/scripts/mosaic-import-guards.test.mjs @@ -0,0 +1,133 @@ +import path from 'node:path'; + +import { ESLint } from 'eslint'; +import { describe, expect, test } from 'vitest'; + +const root = path.resolve(import.meta.dirname, '..'); +const eslint = new ESLint({ + cwd: root, + overrideConfig: { + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: [ + 'packages/mosaic/src/features/sample.view.ts', + 'packages/mosaic/src/features/sample.view.tsx', + 'packages/mosaic/src/features/sample.controller.ts', + 'packages/mosaic/src/features/sample.controller.tsx', + 'packages/mosaic/src/features/sample.model.ts', + 'packages/mosaic/src/features/sample.model.tsx', + 'packages/mosaic/src/features/sample.messages.ts', + ], + }, + }, + }, + }, +}); + +async function lint(file, source) { + const [result] = await eslint.lintText(source, { filePath: path.join(root, 'packages/mosaic/src/features', file) }); + return result.messages; +} + +async function restrictedImports(file, source) { + const messages = await lint(file, source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + return messages + .filter(message => message.ruleId === '@typescript-eslint/no-restricted-imports') + .map(message => message.message); +} + +describe('Mosaic import boundaries', () => { + test.each(['sample.view.ts', 'sample.view.tsx', 'sample.controller.ts', 'sample.controller.tsx'])( + '%s rejects catalog values and Clerk hooks', + async file => { + const messages = await restrictedImports( + file, + [ + "import { messages } from './sample.messages';", + "import { useUser as user } from '@clerk/shared/react';", + "import { useMosaicRouter as router } from '@/hooks/use-mosaic-router';", + ].join('\n'), + ); + + expect(messages).toHaveLength(3); + expect(messages[0]).toContain('useMessages()'); + expect(messages[1]).toContain('model'); + expect(messages[2]).toContain('model'); + }, + ); + + test('blocks both guards in the same view and accepts type-only catalog imports', async () => { + const messages = await restrictedImports( + 'sample.view.tsx', + [ + "import type { MessageKey } from '@/features/sample.messages.ts';", + "import { type OtherMessageKey } from './sample.messages';", + "import { messages } from '@/features/sample.messages.ts';", + "import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment.ts';", + "import { useMosaicSupportEmail } from '../../hooks/use-mosaic-support-email';", + ].join('\n'), + ); + + expect(messages).toHaveLength(3); + expect(messages[0]).toContain('useMessages()'); + }); + + test.each([ + 'useClerk', + 'useUser', + 'useSession', + 'useSessionList', + 'useOrganization', + 'useOrganizationList', + 'useAuth', + ])('rejects aliased %s from shared React in controllers', async name => { + const messages = await restrictedImports( + 'sample.controller.ts', + `import { ${name} as readClerk } from '@clerk/shared/react';`, + ); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('model'); + }); + + test.each(['sample.model.ts', 'sample.model.tsx'])( + '%s sends React interaction state to the controller', + async file => { + const messages = await restrictedImports(file, "import { useState as state, useReducer } from 'react';"); + + expect(messages).toHaveLength(2); + expect(messages.every(message => message.includes('controller'))).toBe(true); + }, + ); + + test('allows model Clerk hooks, controller state, catalog registration and shared utilities', async () => { + expect(await restrictedImports('sample.model.ts', "import { useUser } from '@clerk/shared/react';")).toEqual([]); + expect(await restrictedImports('sample.model.ts', "import { messages } from './sample.messages';")).toEqual([]); + expect(await restrictedImports('sample.controller.ts', "import { useState } from 'react';")).toEqual([]); + expect( + await restrictedImports('sample.controller.ts', "import type { useUser } from '@clerk/shared/react';"), + ).toEqual([]); + expect(await restrictedImports('sample.messages.ts', "import { messages } from './other.messages';")).toEqual([]); + expect( + await restrictedImports('sample.view.tsx', "import { isClerkAPIResponseError } from '@clerk/shared/error';"), + ).toEqual([]); + expect(await restrictedImports('sample.view.tsx', "import { useController } from './sample.controller';")).toEqual( + [], + ); + expect( + await restrictedImports('sample.view.tsx', "import type { CountryIso } from '@clerk/shared/phone';"), + ).toEqual([]); + expect( + await restrictedImports('sample.view.tsx', "import type { ClerkAPIError } from '@clerk/shared/types';"), + ).toEqual([]); + }); + + test('keeps the repository restriction on the shared package root', async () => { + const messages = await lint( + 'sample.view.tsx', + "import { useUser } from '@clerk/shared';\nimport { css } from '@emotion/react';", + ); + expect(messages.filter(message => message.ruleId === 'no-restricted-imports')).toHaveLength(2); + }); +}); From a769c6f9e23ba15b289ddf80a9e0143e9cd91282 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 11:28:50 -0600 Subject: [PATCH 2/8] test(mosaic): cover import guard bypasses --- scripts/mosaic-import-guards.test.mjs | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/scripts/mosaic-import-guards.test.mjs b/scripts/mosaic-import-guards.test.mjs index ade592a9d27..af66770ce0b 100644 --- a/scripts/mosaic-import-guards.test.mjs +++ b/scripts/mosaic-import-guards.test.mjs @@ -101,6 +101,72 @@ describe('Mosaic import boundaries', () => { }, ); + test.each([ + "import React from 'react'; React.useState(0);", + "import R from 'react'; R.useReducer(value => value, 0);", + "import React from 'react'; React['useState'](0);", + "import React from 'react'; const { useState: state } = React; state(0);", + "import React from 'react'; const { ['useReducer']: reducer } = React;", + ])('rejects state hooks through the React default binding: %s', async source => { + const messages = await lint('sample.model.ts', source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + expect(messages.filter(message => message.ruleId === 'custom-rules/no-model-react-state')).toEqual([ + expect.objectContaining({ + message: 'Put interaction state in the controller and pass its results to the view.', + messageId: 'controllerState', + }), + ]); + }); + + test.each([ + "import React from 'react'; React.useMemo(() => 0, []);", + "import React from 'react'; export type Node = React.ReactNode;", + "import React from 'react'; export type StateHook = typeof React.useState;", + "import type React from 'react'; export type StateHook = typeof React.useState;", + "import React from 'react'; function read(React) { return React.useState(0); }", + 'const React = { useState: value => value }; React.useState(0);', + "import React from './other'; React.useState(0);", + "import React from 'react'; const useState = 'useMemo'; React[useState](() => 0, []);", + "import React from 'react'; const useState = 'useMemo'; const { [useState]: memo } = React;", + ])('preserves unrelated React usage: %s', async source => { + const messages = await lint('sample.model.ts', source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + expect(messages.filter(message => message.ruleId === 'custom-rules/no-model-react-state')).toEqual([]); + }); + + test.each(['sample.view.ts', 'sample.view.tsx', 'sample.controller.ts', 'sample.controller.tsx'])( + '%s rejects dynamic catalog imports', + async file => { + for (const source of ["import('./sample.messages');", 'import(`@/features/sample.messages.ts`);']) { + const messages = await lint(file, source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + expect(messages.filter(message => message.ruleId === 'custom-rules/no-dynamic-message-catalogs')).toEqual([ + expect.objectContaining({ + message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', + messageId: 'useMessages', + }), + ]); + } + }, + ); + + test.each([ + ['sample.view.ts', "import('./sample.controller');"], + ['sample.controller.ts', 'import(`./sample.model`);'], + ['sample.view.ts', "export type Messages = typeof import('./sample.messages');"], + ['sample.model.ts', "import('./sample.messages');"], + ['sample.messages.ts', "import('./other.messages');"], + ['sample.controller.ts', "import React from 'react'; React.useState(0);"], + ])('preserves allowed imports in %s: %s', async (file, source) => { + const messages = await lint(file, source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + expect( + messages.filter(message => + ['custom-rules/no-model-react-state', 'custom-rules/no-dynamic-message-catalogs'].includes(message.ruleId), + ), + ).toEqual([]); + }); + test('allows model Clerk hooks, controller state, catalog registration and shared utilities', async () => { expect(await restrictedImports('sample.model.ts', "import { useUser } from '@clerk/shared/react';")).toEqual([]); expect(await restrictedImports('sample.model.ts', "import { messages } from './sample.messages';")).toEqual([]); From 0b4f36c16f91776aca1acb1c8d49ca829b884bec Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 11:29:03 -0600 Subject: [PATCH 3/8] fix(mosaic): detect import guard bypasses --- eslint.config.mjs | 5 +++ scripts/mosaic-eslint-rules.mjs | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 scripts/mosaic-eslint-rules.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index c3c2a6131ff..435f4f2706d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; import { CUSTOM_BLOCK_TAGS, CUSTOM_MODIFIER_TAGS } from './.typedoc/custom-tags.mjs'; +import { noDynamicMessageCatalogs, noModelReactState } from './scripts/mosaic-eslint-rules.mjs'; const REPO_ROOT = import.meta.dirname; const ECMA_VERSION = 2021, @@ -390,6 +391,8 @@ export default tseslint.config([ 'no-unstable-methods': noUnstableMethods, 'no-physical-css-properties': noPhysicalCssProperties, 'no-raw-inert': noRawInert, + 'no-model-react-state': noModelReactState, + 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, }, }, 'simple-import-sort': pluginSimpleImportSort, @@ -696,6 +699,7 @@ export default tseslint.config([ name: 'packages/mosaic - views and controllers', files: ['packages/mosaic/src/**/*.{view,controller}.{ts,tsx}'], rules: { + 'custom-rules/no-dynamic-message-catalogs': 'error', '@typescript-eslint/no-restricted-imports': [ 'error', { @@ -749,6 +753,7 @@ export default tseslint.config([ name: 'packages/mosaic - models', files: ['packages/mosaic/src/**/*.model.{ts,tsx}'], rules: { + 'custom-rules/no-model-react-state': 'error', '@typescript-eslint/no-restricted-imports': [ 'error', { diff --git a/scripts/mosaic-eslint-rules.mjs b/scripts/mosaic-eslint-rules.mjs new file mode 100644 index 00000000000..2b4b12a9987 --- /dev/null +++ b/scripts/mosaic-eslint-rules.mjs @@ -0,0 +1,79 @@ +function propertyName(node, computed) { + if (!computed && node.type === 'Identifier') { + return node.name; + } + return node.type === 'Literal' ? node.value : undefined; +} + +export const noModelReactState = { + meta: { + type: 'problem', + schema: [], + messages: { + controllerState: 'Put interaction state in the controller and pass its results to the view.', + }, + }, + create(context) { + const stateHooks = new Set(['useState', 'useReducer']); + + return { + ImportDeclaration(node) { + if (node.source.value !== 'react' || node.importKind === 'type') { + return; + } + for (const variable of context.sourceCode.getDeclaredVariables(node)) { + if (!variable.defs.some(definition => definition.node.type === 'ImportDefaultSpecifier')) { + continue; + } + for (const reference of variable.references) { + const usage = reference.identifier.parent; + if ( + usage.type === 'MemberExpression' && + usage.object === reference.identifier && + stateHooks.has(propertyName(usage.property, usage.computed)) + ) { + context.report({ node: usage, messageId: 'controllerState' }); + } + if ( + usage.type === 'VariableDeclarator' && + usage.init === reference.identifier && + usage.id.type === 'ObjectPattern' + ) { + for (const property of usage.id.properties) { + if (property.type === 'Property' && stateHooks.has(propertyName(property.key, property.computed))) { + context.report({ node: property, messageId: 'controllerState' }); + } + } + } + } + } + }, + }; + }, +}; + +export const noDynamicMessageCatalogs = { + meta: { + type: 'problem', + schema: [], + messages: { + useMessages: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', + }, + }, + create(context) { + return { + ImportExpression(node) { + const source = node.source; + const specifier = + source.type === 'Literal' + ? source.value + : source.type === 'TemplateLiteral' && source.expressions.length === 0 + ? source.quasis[0].value.cooked + : undefined; + if (typeof specifier === 'string' && /[.]messages(?:[.]ts)?$/.test(specifier)) { + context.report({ node, messageId: 'useMessages' }); + } + }, + }; + }, +}; From d6d530a7b75f429c4ffe4d2096754ae2c7496144 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 12:02:35 -0600 Subject: [PATCH 4/8] chore(mosaic): colocate lint guards and tests --- .github/workflows/ci.yml | 3 - eslint.config.mjs | 79 +--------------- packages/mosaic/eslint/config.mjs | 90 +++++++++++++++++++ .../mosaic/eslint/import-guards.test.mjs | 10 +-- .../mosaic/eslint/rules.mjs | 0 5 files changed, 97 insertions(+), 85 deletions(-) create mode 100644 packages/mosaic/eslint/config.mjs rename scripts/mosaic-import-guards.test.mjs => packages/mosaic/eslint/import-guards.test.mjs (94%) rename scripts/mosaic-eslint-rules.mjs => packages/mosaic/eslint/rules.mjs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f84f270897..ee010ea28b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,9 +236,6 @@ jobs: - name: Run lint run: pnpm turbo lint $TURBO_ARGS - - name: Test Mosaic import guards - run: pnpm exec vitest run --config scripts/vitest.config.mjs scripts/mosaic-import-guards.test.mjs - - name: Upload Turbo Summary uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ env.TURBO_SUMMARIZE == 'true' }} diff --git a/eslint.config.mjs b/eslint.config.mjs index 435f4f2706d..a4d68bf5800 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,7 +21,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; import { CUSTOM_BLOCK_TAGS, CUSTOM_MODIFIER_TAGS } from './.typedoc/custom-tags.mjs'; -import { noDynamicMessageCatalogs, noModelReactState } from './scripts/mosaic-eslint-rules.mjs'; +import mosaicImportGuards from './packages/mosaic/eslint/config.mjs'; const REPO_ROOT = import.meta.dirname; const ECMA_VERSION = 2021, @@ -391,8 +391,6 @@ export default tseslint.config([ 'no-unstable-methods': noUnstableMethods, 'no-physical-css-properties': noPhysicalCssProperties, 'no-raw-inert': noRawInert, - 'no-model-react-state': noModelReactState, - 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, }, }, 'simple-import-sort': pluginSimpleImportSort, @@ -695,80 +693,7 @@ export default tseslint.config([ ], }, }, - { - name: 'packages/mosaic - views and controllers', - files: ['packages/mosaic/src/**/*.{view,controller}.{ts,tsx}'], - rules: { - 'custom-rules/no-dynamic-message-catalogs': 'error', - '@typescript-eslint/no-restricted-imports': [ - 'error', - { - paths: [ - { - name: '@clerk/shared/react', - importNames: [ - 'useClerk', - 'useUser', - 'useSession', - 'useSessionList', - 'useOrganization', - 'useOrganizationList', - 'useAuth', - ], - message: - 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', - allowTypeImports: true, - }, - ], - patterns: [ - { - regex: '[.]messages(?:[.]ts)?$', - message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', - allowTypeImports: true, - }, - { - regex: '(^|/)use-mosaic-environment(?:[.]ts)?$', - importNames: ['useMosaicEnvironment'], - message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', - allowTypeImports: true, - }, - { - regex: '(^|/)use-mosaic-router(?:[.]ts)?$', - importNames: ['useMosaicRouter'], - message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', - allowTypeImports: true, - }, - { - regex: '(^|/)use-mosaic-support-email(?:[.]ts)?$', - importNames: ['useMosaicSupportEmail'], - message: 'Read the support email in the model and pass plain data to the controller or view.', - allowTypeImports: true, - }, - ], - }, - ], - }, - }, - { - name: 'packages/mosaic - models', - files: ['packages/mosaic/src/**/*.model.{ts,tsx}'], - rules: { - 'custom-rules/no-model-react-state': 'error', - '@typescript-eslint/no-restricted-imports': [ - 'error', - { - paths: [ - { - name: 'react', - importNames: ['useState', 'useReducer'], - message: 'Put interaction state in the controller and pass its results to the view.', - allowTypeImports: true, - }, - ], - }, - ], - }, - }, + ...mosaicImportGuards, { name: 'packages/mosaic/jsx-a11y', files: ['packages/mosaic/src/**/*'], diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs new file mode 100644 index 00000000000..059eb53466a --- /dev/null +++ b/packages/mosaic/eslint/config.mjs @@ -0,0 +1,90 @@ +import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; + +export default [ + { + name: 'packages/mosaic - import guard plugin', + files: ['packages/mosaic/src/**/*'], + plugins: { + mosaic: { + rules: { + 'no-model-react-state': noModelReactState, + 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, + }, + }, + }, + }, + { + name: 'packages/mosaic - views and controllers', + files: ['packages/mosaic/src/**/*.{view,controller}.{ts,tsx}'], + rules: { + 'mosaic/no-dynamic-message-catalogs': 'error', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@clerk/shared/react', + importNames: [ + 'useClerk', + 'useUser', + 'useSession', + 'useSessionList', + 'useOrganization', + 'useOrganizationList', + 'useAuth', + ], + message: + 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', + allowTypeImports: true, + }, + ], + patterns: [ + { + regex: '[.]messages(?:[.]ts)?$', + message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-environment(?:[.]ts)?$', + importNames: ['useMosaicEnvironment'], + message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-router(?:[.]ts)?$', + importNames: ['useMosaicRouter'], + message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', + allowTypeImports: true, + }, + { + regex: '(^|/)use-mosaic-support-email(?:[.]ts)?$', + importNames: ['useMosaicSupportEmail'], + message: 'Read the support email in the model and pass plain data to the controller or view.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, + { + name: 'packages/mosaic - models', + files: ['packages/mosaic/src/**/*.model.{ts,tsx}'], + rules: { + 'mosaic/no-model-react-state': 'error', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react', + importNames: ['useState', 'useReducer'], + message: 'Put interaction state in the controller and pass its results to the view.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, +]; diff --git a/scripts/mosaic-import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs similarity index 94% rename from scripts/mosaic-import-guards.test.mjs rename to packages/mosaic/eslint/import-guards.test.mjs index af66770ce0b..a3d77533fa6 100644 --- a/scripts/mosaic-import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import { ESLint } from 'eslint'; import { describe, expect, test } from 'vitest'; -const root = path.resolve(import.meta.dirname, '..'); +const root = path.resolve(import.meta.dirname, '../../..'); const eslint = new ESLint({ cwd: root, overrideConfig: { @@ -110,7 +110,7 @@ describe('Mosaic import boundaries', () => { ])('rejects state hooks through the React default binding: %s', async source => { const messages = await lint('sample.model.ts', source); expect(messages.find(message => message.fatal)).toBeUndefined(); - expect(messages.filter(message => message.ruleId === 'custom-rules/no-model-react-state')).toEqual([ + 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', @@ -131,7 +131,7 @@ describe('Mosaic import boundaries', () => { ])('preserves unrelated React usage: %s', async source => { const messages = await lint('sample.model.ts', source); expect(messages.find(message => message.fatal)).toBeUndefined(); - expect(messages.filter(message => message.ruleId === 'custom-rules/no-model-react-state')).toEqual([]); + expect(messages.filter(message => message.ruleId === 'mosaic/no-model-react-state')).toEqual([]); }); test.each(['sample.view.ts', 'sample.view.tsx', 'sample.controller.ts', 'sample.controller.tsx'])( @@ -140,7 +140,7 @@ describe('Mosaic import boundaries', () => { for (const source of ["import('./sample.messages');", 'import(`@/features/sample.messages.ts`);']) { const messages = await lint(file, source); expect(messages.find(message => message.fatal)).toBeUndefined(); - expect(messages.filter(message => message.ruleId === 'custom-rules/no-dynamic-message-catalogs')).toEqual([ + 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', @@ -162,7 +162,7 @@ describe('Mosaic import boundaries', () => { expect(messages.find(message => message.fatal)).toBeUndefined(); expect( messages.filter(message => - ['custom-rules/no-model-react-state', 'custom-rules/no-dynamic-message-catalogs'].includes(message.ruleId), + ['mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs'].includes(message.ruleId), ), ).toEqual([]); }); diff --git a/scripts/mosaic-eslint-rules.mjs b/packages/mosaic/eslint/rules.mjs similarity index 100% rename from scripts/mosaic-eslint-rules.mjs rename to packages/mosaic/eslint/rules.mjs From b5a2530ae55227b7ed02938317ba15580d738b7e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 12:58:09 -0600 Subject: [PATCH 5/8] chore(mosaic): load guards from package eslint config --- eslint.config.mjs | 2 -- packages/mosaic/eslint.config.mjs | 8 +++++ packages/mosaic/eslint/config.mjs | 6 ++-- packages/mosaic/eslint/import-guards.test.mjs | 32 +++++++++++++++++-- 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 packages/mosaic/eslint.config.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index a4d68bf5800..d915da10caa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,7 +21,6 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; import { CUSTOM_BLOCK_TAGS, CUSTOM_MODIFIER_TAGS } from './.typedoc/custom-tags.mjs'; -import mosaicImportGuards from './packages/mosaic/eslint/config.mjs'; const REPO_ROOT = import.meta.dirname; const ECMA_VERSION = 2021, @@ -693,7 +692,6 @@ export default tseslint.config([ ], }, }, - ...mosaicImportGuards, { name: 'packages/mosaic/jsx-a11y', files: ['packages/mosaic/src/**/*'], diff --git a/packages/mosaic/eslint.config.mjs b/packages/mosaic/eslint.config.mjs new file mode 100644 index 00000000000..c6cc71ac863 --- /dev/null +++ b/packages/mosaic/eslint.config.mjs @@ -0,0 +1,8 @@ +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 index 059eb53466a..346e3cd2623 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -3,7 +3,7 @@ import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; export default [ { name: 'packages/mosaic - import guard plugin', - files: ['packages/mosaic/src/**/*'], + files: ['src/**/*'], plugins: { mosaic: { rules: { @@ -15,7 +15,7 @@ export default [ }, { name: 'packages/mosaic - views and controllers', - files: ['packages/mosaic/src/**/*.{view,controller}.{ts,tsx}'], + files: ['src/**/*.{view,controller}.{ts,tsx}'], rules: { 'mosaic/no-dynamic-message-catalogs': 'error', '@typescript-eslint/no-restricted-imports': [ @@ -69,7 +69,7 @@ export default [ }, { name: 'packages/mosaic - models', - files: ['packages/mosaic/src/**/*.model.{ts,tsx}'], + files: ['src/**/*.model.{ts,tsx}'], rules: { 'mosaic/no-model-react-state': 'error', '@typescript-eslint/no-restricted-imports': [ diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index a3d77533fa6..18b407768f6 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -4,8 +4,10 @@ import { ESLint } from 'eslint'; import { describe, expect, test } from 'vitest'; const root = path.resolve(import.meta.dirname, '../../..'); +const mosaicRoot = path.resolve(import.meta.dirname, '..'); +const rootEslint = new ESLint({ cwd: root }); const eslint = new ESLint({ - cwd: root, + cwd: mosaicRoot, overrideConfig: { languageOptions: { parserOptions: { @@ -26,7 +28,7 @@ const eslint = new ESLint({ }); async function lint(file, source) { - const [result] = await eslint.lintText(source, { filePath: path.join(root, 'packages/mosaic/src/features', file) }); + const [result] = await eslint.lintText(source, { filePath: path.join(mosaicRoot, 'src/features', file) }); return result.messages; } @@ -197,3 +199,29 @@ describe('Mosaic import boundaries', () => { expect(messages.filter(message => message.ruleId === 'no-restricted-imports')).toHaveLength(2); }); }); + +describe('Mosaic package configuration', () => { + 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 [ + '@typescript-eslint/no-restricted-imports', + 'mosaic/no-model-react-state', + 'mosaic/no-dynamic-message-catalogs', + ]) { + expect(rootConfig.rules[rule]).toBeUndefined(); + delete inheritedRules[rule]; + } + + expect(inheritedRules).toEqual(rootConfig.rules); + }); +}); From 516cc128c893371243581bed142192ee07ce61a8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 13:48:28 -0600 Subject: [PATCH 6/8] docs(mosaic): note import guards only load from the package eslint config --- packages/mosaic/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 16abbc46736..ad729e297b9 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -86,7 +86,7 @@ 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 `eslint.config.mjs`). +Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer import guards 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 the import guards. 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. From a7f121e2e95a01aacff12efb0e2f6bc823ead7f7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 14:12:33 -0600 Subject: [PATCH 7/8] fix(mosaic): keep API key search state in controller --- .../src/features/api-keys/api-keys-table.controller.ts | 9 ++++++--- .../mosaic/src/features/api-keys/api-keys-table.model.ts | 8 +------- packages/mosaic/src/features/api-keys/api-keys-table.tsx | 5 ++--- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts b/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts index de537cc7bf8..842547d6a1c 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts @@ -3,13 +3,15 @@ import { useEffect, useRef, useState } from 'react'; import { useNow } from '../../hooks/use-now'; import { useLocale } from '../../localization'; import { formatDate, formatRelativeTime } from './api-keys-table.format'; -import type { APIKeysTableModel } from './api-keys-table.model'; +import { useAPIKeysTableModel } from './api-keys-table.model'; import type { APIKey, APIKeyRecord, APIKeysTableMessages } from './api-keys-table.types'; import { useCreateAPIKeyController } from './create-api-key.controller'; const SEARCH_DEBOUNCE_MS = 500; -export function useAPIKeysTableController(model: APIKeysTableModel, messages: APIKeysTableMessages) { +export function useAPIKeysTableController(subject: string, messages: APIKeysTableMessages) { + const [query, setQuery] = useState(''); + const model = useAPIKeysTableModel(subject, query); const locale = useLocale(); const now = useNow({ updateInterval: 60_000 }); const [searchValue, setSearchValue] = useState(''); @@ -34,7 +36,8 @@ export function useAPIKeysTableController(model: APIKeysTableModel, messages: AP setSearchValue(value); clearTimeout(timer.current); timer.current = setTimeout(() => { - model.search(value.trim()); + setQuery(value.trim()); + model.fetchPage(1); }, SEARCH_DEBOUNCE_MS); }, manage: model.canManage diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.model.ts b/packages/mosaic/src/features/api-keys/api-keys-table.model.ts index 98741477c56..eb4dd0593f3 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.model.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.model.ts @@ -1,7 +1,6 @@ import { isOrganizationId } from '@clerk/shared/internal/clerk-js/organization'; import { useAPIKeys, useClerk, useSession } from '@clerk/shared/react'; import type { APIKeyResource } from '@clerk/shared/types'; -import { useState } from 'react'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { save } from '../../utils/errors'; @@ -30,9 +29,8 @@ export function useAPIKeysAccess(subject: string | undefined) { return { isLoaded, isAvailable, canRead, canManage }; } -export function useAPIKeysTableModel(subject: string) { +export function useAPIKeysTableModel(subject: string, query: string) { const clerk = useClerk(); - const [query, setQuery] = useState(''); const { isLoaded, isAvailable, canRead, canManage } = useAPIKeysAccess(subject); const apiKeys = useAPIKeys({ subject, @@ -48,10 +46,6 @@ export function useAPIKeysTableModel(subject: string) { ...apiKeys, data: apiKeys.data.map(toAPIKeyRecord), canManage, - search: (nextQuery: string) => { - setQuery(nextQuery); - apiKeys.fetchPage(1); - }, create: async ({ name, expiresAt }: CreateAPIKeyInput) => { let secret = ''; await save(async () => { diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.tsx index f7fbff30ab3..80222108d6a 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react'; import { useMessages } from '../../localization'; import { useAPIKeysTableController } from './api-keys-table.controller'; import { resolveAPIKeysTableMessages } from './api-keys-table.messages'; -import { PAGE_SIZE, useAPIKeysTableModel } from './api-keys-table.model'; +import { PAGE_SIZE } from './api-keys-table.model'; import type { APIKeysTableMessages } from './api-keys-table.types'; import { APIKeysTableView } from './api-keys-table.view'; @@ -29,8 +29,7 @@ function SubjectAPIKeysTable({ subject, messages: overrides, fallback }: APIKeys isOrganizationId(subject) ? 'organization' : 'user', overrides, ); - const model = useAPIKeysTableModel(subject); - const { isLoaded, isAvailable, manage, ...controller } = useAPIKeysTableController(model, messages); + const { isLoaded, isAvailable, manage, ...controller } = useAPIKeysTableController(subject, messages); if (!isLoaded || !isAvailable) { return fallback ?? null; From 44956714af5b3a66613200e3ca62902b5290c5fc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 14:30:37 -0600 Subject: [PATCH 8/8] test(mosaic): allow time for type-aware eslint warm-up in import guard tests --- packages/mosaic/eslint/import-guards.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 18b407768f6..156bab14ef5 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -40,7 +40,7 @@ async function restrictedImports(file, source) { .map(message => message.message); } -describe('Mosaic import boundaries', () => { +describe('Mosaic import boundaries', { timeout: 30_000 }, () => { test.each(['sample.view.ts', 'sample.view.tsx', 'sample.controller.ts', 'sample.controller.tsx'])( '%s rejects catalog values and Clerk hooks', async file => { @@ -200,7 +200,7 @@ describe('Mosaic import boundaries', () => { }); }); -describe('Mosaic package configuration', () => { +describe('Mosaic package configuration', { timeout: 30_000 }, () => { test.each([ 'src/features/sample.view.tsx', 'src/features/sample.controller.ts',