frontend/eslint.config.js
Rama main · réplica de lectura del repositorio original.
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',
},
],
},
],
},
},
])