import { readdirSync } from 'node:fs' import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' // API internals must depend on concrete files so public index exports remain a feature boundary const restrictedApiIndexImports = [ '@/api/accounts', '@/api/auth', '@/api/budgets', '@/api/categories', '@/api/currency', '@/api/dashboard', '@/api/import-runs', '@/api/insights', '@/api/institutions', '@/api/merchants', '@/api/oidc', '@/api/passkeys', '@/api/provider-imports', '@/api/tags', '@/api/tax-advantaged-categories', '@/api/transaction-imports', '@/api/transactions', '@/api/two-factor', '@/api/user', '@/api/version', ] // The API layer talks to the backend and the query cache, never to the UI layers, so nothing // under src/api may reach into pages, components, or contexts const restrictedApiUiImports = ['@/pages/*', '@/components/*', '@/contexts/*'] // A path leaving its own folder is written through the alias so it reads the same wherever the // importing file sits and survives the file being moved. Same-folder './' imports stay relative const crossFolderRelativeImport = { group: ['../*', '../**'], message: 'Import through the @/ alias rather than a relative path that leaves the folder', } // A page never imports another page, and shared code never imports a page, so what two pages use lives // in shared code. One block per page folder, since a flat config block names the files it covers and a // pattern can only exempt the page's own folder by its name const pageFolders = readdirSync(new URL('./src/pages', import.meta.url), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) const PAGE_IMPORT_MESSAGE = 'A page never imports another page. Move what both use into shared code, such as @/utils or @/components' const SHARED_PAGE_IMPORT_MESSAGE = 'Shared code never imports a page. Move what the page owns into shared code, or pass it in' // Flat config replaces a rule outright rather than merging it, so a block that sets // no-restricted-imports has to restate every pattern that should still apply to its files const restrictedImportPatterns = [crossFolderRelativeImport] const STACKING_LEVEL_MESSAGE = 'Name the stacking level: a class from the scale such as z-popover, or STACKING_LEVELS from @/constants/stackingLevels where a style object or a DOM write needs the number. Where the value only orders siblings inside one container, name a module-level constant beside it instead' // Interpolating a level into an arbitrary class is worse than the bare number it replaces. Tailwind // only generates a class it finds as complete literal text, so the element ends up with no stacking // level at all rather than the wrong one, and nothing reports it const INTERPOLATED_CLASS_MESSAGE = 'Tailwind never generates a class assembled by interpolation, so this element would carry no stacking level at all. Use a class from the scale, such as z-popover' const SET_PROPERTY_MESSAGE = 'Set a stacking level through element.style.zIndex rather than through setProperty, so it reads as a level and the scale covers it' // A stacking level written as a bare number is how the app ended up with five overlays at 110 and no // statement anywhere of which belongs above which. Only the arbitrary bracket form of the class is // rejected, so a bare z-10 or z-30 stays available for a level ordering siblings inside one container. // // One form needs several selectors, because the same value reaches the tree differently depending on // how it was written. A class in a plain string is a Literal, while one built in a template literal // lives in TemplateElement.value.raw, which App.tsx does. A numeric zIndex is matched on raw rather // than value, since esquery applies a regex attribute test only to strings, and it needs a variant // for a quoted key, for a negative value, which the parser gives as a unary minus over the digits and // never as a literal carrying the sign, and for an assignment to element.style.zIndex. // // Two forms still get through: a class assembled by string concatenation, and a value chosen by a // conditional inside the property. Neither appears anywhere in the tree, and catching either costs a // false positive on legitimate code. Handwritten stylesheet z-index declarations are checked by // tests/constants/stackingLevels.test.ts, since this block matches TypeScript alone const restrictedStackingLevels = [ { selector: 'Literal[value=/z-\\[\\d+\\]/]', message: STACKING_LEVEL_MESSAGE, }, { selector: 'TemplateElement[value.raw=/z-\\[\\d+\\]/]', message: STACKING_LEVEL_MESSAGE, }, { selector: 'TemplateElement[value.raw=/z-\\[$/]', message: INTERPOLATED_CLASS_MESSAGE, }, { selector: ":matches(Property[key.name='zIndex'], Property[key.value='zIndex']) > Literal[raw=/^\\d/]", message: STACKING_LEVEL_MESSAGE, }, { selector: ":matches(Property[key.name='zIndex'], Property[key.value='zIndex']) > UnaryExpression > Literal[raw=/^\\d/]", message: STACKING_LEVEL_MESSAGE, }, { selector: ":matches(AssignmentExpression[left.property.name='zIndex'], AssignmentExpression[left.property.value='zIndex']) > Literal[raw=/^\\d/]", message: STACKING_LEVEL_MESSAGE, }, { selector: ":matches(AssignmentExpression[left.property.name='zIndex'], AssignmentExpression[left.property.value='zIndex']) > UnaryExpression > Literal[raw=/^\\d/]", message: STACKING_LEVEL_MESSAGE, }, { selector: "CallExpression[callee.property.name='setProperty'][arguments.0.value='z-index']", message: SET_PROPERTY_MESSAGE, }, ] // A busy page can hand an IntersectionObserver several changes in one call, oldest first, so a callback // that reads the first entry acts on a state that has already passed and may never be told again const LATEST_ENTRY_MESSAGE = 'Read the last entry an IntersectionObserver hands over, which is the current one' const restrictedObserverEntries = [ { selector: "NewExpression[callee.name='IntersectionObserver'] > :function > ArrayPattern", message: LATEST_ENTRY_MESSAGE, }, { selector: "NewExpression[callee.name='IntersectionObserver'] :function MemberExpression[computed=true][property.value=0]", message: LATEST_ENTRY_MESSAGE, }, ] // A load the app abandons, such as a page left before its data arrives or a search typed over, only // stops its request when the query function hands the request its cancel signal const restrictedQueryFns = [ { selector: "Property[key.name='queryFn']:not(:has(CallExpression Identifier[name='signal']))", message: 'Take the signal from the query context and pass it to the request, so an abandoned load stops its request', }, ] // crypto.randomUUID is missing on a page opened over plain HTTP, such as a self-hosted app at a LAN address const RANDOM_UUID_MESSAGE = 'crypto.randomUUID is missing on pages opened over plain HTTP. Use createRandomId from @/utils/randomId' // The ban follows the name, not the object, so an alias (const c = crypto), a longer path (window.self.crypto) // and a prototype call (Crypto.prototype.randomUUID.call) all fail too const restrictedRandomUuid = [ { selector: "Identifier[name='randomUUID']", message: RANDOM_UUID_MESSAGE }, { selector: "Literal[value='randomUUID']", message: RANDOM_UUID_MESSAGE }, { selector: "TemplateElement[value.cooked='randomUUID']", message: RANDOM_UUID_MESSAGE }, ] // A native title is the browser's tooltip, which waits, looks different on every system and never shows on // a touch or a keyboard focus. A button says what it does through HintButton or useControlHint. The ban // covers a plain button, a motion button and any component named for a button, since a title handed to // one ends on the button const BUTTON_TITLE_MESSAGE = 'A button explains itself with the app tooltip, not a native title. Use HintButton or useControlHint from @/components/tooltips, and keep aria-label for the name' const restrictedButtonTitles = [ { selector: "JSXOpeningElement[name.name=/^(button|\\w*Button)$/] > JSXAttribute[name.name='title']", message: BUTTON_TITLE_MESSAGE, }, { selector: "JSXOpeningElement[name.type='JSXMemberExpression'][name.property.name='button'] > JSXAttribute[name.name='title']", message: BUTTON_TITLE_MESSAGE, }, ] export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, rules: { 'no-restricted-imports': ['error', { patterns: restrictedImportPatterns }], 'no-restricted-syntax': ['error', ...restrictedStackingLevels, ...restrictedObserverEntries], }, }, { files: ['src/**/*.{ts,tsx}'], rules: { 'no-restricted-syntax': [ 'error', ...restrictedStackingLevels, ...restrictedObserverEntries, ...restrictedQueryFns, ...restrictedRandomUuid, ...restrictedButtonTitles, ], }, }, ...pageFolders.map((page) => ({ files: [`src/pages/${page}/**/*.{ts,tsx}`], rules: { 'no-restricted-imports': [ 'error', { patterns: [ ...restrictedImportPatterns, { group: ['@/pages/*', `!@/pages/${page}`], message: PAGE_IMPORT_MESSAGE }, ], }, ], }, })), { files: ['src/{components,hooks,utils,contexts,constants}/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { patterns: [...restrictedImportPatterns, { group: ['@/pages/*'], message: SHARED_PAGE_IMPORT_MESSAGE }] }, ], }, }, { files: ['src/api/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { paths: restrictedApiIndexImports.map((name) => ({ name, message: 'Import the specific API source file instead of the domain folder index', })), patterns: [ ...restrictedImportPatterns, { group: restrictedApiUiImports, message: 'The API layer must not import from the UI layers', }, ], }, ], }, }, ])