mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 01:58:40 -05:00
[AI] Revert verbose no-restricted-imports overrides, restore custom rule
Revert the per-package no-restricted-imports overrides approach (~485 lines of duplicated config) in favor of the custom no-cross-package-imports rule in eslint-plugin-actual (~150 lines). The custom rule auto-reads each package's package.json to determine allowed dependencies, requiring zero config duplication and zero maintenance when dependencies change. https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
This commit is contained in:
482
.oxlintrc.json
482
.oxlintrc.json
@@ -421,488 +421,6 @@
|
||||
"rules": {
|
||||
"eslint/no-empty-function": "off"
|
||||
}
|
||||
},
|
||||
|
||||
// Cross-package dependency enforcement via no-restricted-imports.
|
||||
// Each override must repeat the global paths/patterns because overrides
|
||||
// replace (not merge) the top-level rule configuration.
|
||||
|
||||
// component-library (@actual-app/components) — no @actual-app dependencies
|
||||
{
|
||||
"files": ["packages/component-library/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/core",
|
||||
"@actual-app/core/**/*",
|
||||
"@actual-app/crdt",
|
||||
"@actual-app/crdt/**/*",
|
||||
"@actual-app/web",
|
||||
"@actual-app/web/**/*",
|
||||
"@actual-app/sync-server",
|
||||
"@actual-app/sync-server/**/*"
|
||||
],
|
||||
"message": "@actual-app/components has no @actual-app dependencies. Use the package's own exports or add the dependency to package.json."
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"**/desktop-client/**",
|
||||
"**/loot-core/**",
|
||||
"**/api/**",
|
||||
"**/crdt/**",
|
||||
"**/sync-server/**",
|
||||
"**/desktop-electron/**"
|
||||
],
|
||||
"message": "Do not use relative imports to access other packages. Use the package name or add the dependency to package.json."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// crdt (@actual-app/crdt) — no @actual-app dependencies
|
||||
{
|
||||
"files": ["packages/crdt/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/components",
|
||||
"@actual-app/components/**/*",
|
||||
"@actual-app/core",
|
||||
"@actual-app/core/**/*",
|
||||
"@actual-app/web",
|
||||
"@actual-app/web/**/*",
|
||||
"@actual-app/sync-server",
|
||||
"@actual-app/sync-server/**/*"
|
||||
],
|
||||
"message": "@actual-app/crdt has no @actual-app dependencies. Use the package's own exports or add the dependency to package.json."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// loot-core (@actual-app/core) — depends on: @actual-app/crdt
|
||||
{
|
||||
"files": ["packages/loot-core/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/components",
|
||||
"@actual-app/components/**/*",
|
||||
"@actual-app/web",
|
||||
"@actual-app/web/**/*",
|
||||
"@actual-app/sync-server",
|
||||
"@actual-app/sync-server/**/*"
|
||||
],
|
||||
"message": "@actual-app/core only depends on @actual-app/crdt. Add the dependency to package.json or remove this import."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// api (@actual-app/api) — depends on: @actual-app/core, @actual-app/crdt
|
||||
{
|
||||
"files": ["packages/api/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/components",
|
||||
"@actual-app/components/**/*",
|
||||
"@actual-app/web",
|
||||
"@actual-app/web/**/*",
|
||||
"@actual-app/sync-server",
|
||||
"@actual-app/sync-server/**/*"
|
||||
],
|
||||
"message": "@actual-app/api only depends on @actual-app/core and @actual-app/crdt. Add the dependency to package.json or remove this import."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// desktop-client (@actual-app/web) — depends on: @actual-app/components, @actual-app/core
|
||||
{
|
||||
"files": ["packages/desktop-client/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/crdt",
|
||||
"@actual-app/crdt/**/*",
|
||||
"@actual-app/sync-server",
|
||||
"@actual-app/sync-server/**/*"
|
||||
],
|
||||
"message": "@actual-app/web only depends on @actual-app/components and @actual-app/core. Add the dependency to package.json or remove this import."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// sync-server (@actual-app/sync-server) — depends on: @actual-app/crdt, @actual-app/web
|
||||
{
|
||||
"files": ["packages/sync-server/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/components",
|
||||
"@actual-app/components/**/*",
|
||||
"@actual-app/core",
|
||||
"@actual-app/core/**/*"
|
||||
],
|
||||
"message": "@actual-app/sync-server only depends on @actual-app/crdt and @actual-app/web. Add the dependency to package.json or remove this import."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// desktop-electron — depends on: @actual-app/sync-server, @actual-app/core
|
||||
{
|
||||
"files": ["packages/desktop-electron/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"@actual-app/api",
|
||||
"@actual-app/api/**/*",
|
||||
"@actual-app/components",
|
||||
"@actual-app/components/**/*",
|
||||
"@actual-app/crdt",
|
||||
"@actual-app/crdt/**/*",
|
||||
"@actual-app/web",
|
||||
"@actual-app/web/**/*"
|
||||
],
|
||||
"message": "desktop-electron only depends on @actual-app/sync-server and @actual-app/core. Add the dependency to package.json or remove this import."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
// TODO: this needs refactoring
|
||||
// oxlint-disable eslint/no-restricted-imports -- intentional cross-package import, needs refactoring
|
||||
// oxlint-disable actual/no-cross-package-imports -- intentional cross-package import, needs refactoring
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
|
||||
// oxlint-enable eslint/no-restricted-imports
|
||||
// oxlint-enable actual/no-cross-package-imports
|
||||
|
||||
const THEMES = {
|
||||
light: lightTheme,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// oxlint-disable-next-line eslint/no-restricted-imports -- fix me
|
||||
// oxlint-disable-next-line eslint/no-restricted-imports, actual/no-cross-package-imports -- fix me
|
||||
import { ConfigurationPage } from '@actual-app/web/e2e/page-models/configuration-page';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ module.exports = {
|
||||
'prefer-const': require('./rules/prefer-const'),
|
||||
'no-anchor-tag': require('./rules/no-anchor-tag'),
|
||||
'no-react-default-import': require('./rules/no-react-default-import'),
|
||||
'no-cross-package-imports': require('./rules/no-cross-package-imports'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { runClassic } from 'eslint-vitest-rule-tester';
|
||||
|
||||
import * as rule from '../no-cross-package-imports';
|
||||
|
||||
void runClassic(
|
||||
'no-cross-package-imports',
|
||||
rule,
|
||||
{
|
||||
valid: [
|
||||
// @actual-app/web can import @actual-app/core (declared dep)
|
||||
{
|
||||
code: 'import { something } from "@actual-app/core";',
|
||||
filename: 'packages/desktop-client/src/components/Test.tsx',
|
||||
},
|
||||
// @actual-app/web can import @actual-app/components (declared dep)
|
||||
{
|
||||
code: 'import { Button } from "@actual-app/components";',
|
||||
filename: 'packages/desktop-client/src/components/Test.tsx',
|
||||
},
|
||||
// External packages are always allowed
|
||||
{
|
||||
code: 'import React from "react";',
|
||||
filename: 'packages/component-library/src/Button.tsx',
|
||||
},
|
||||
// Relative imports within same package are allowed
|
||||
{
|
||||
code: 'import { helper } from "./utils";',
|
||||
filename: 'packages/component-library/src/Button.tsx',
|
||||
},
|
||||
// Relative import to parent within same package is allowed
|
||||
{
|
||||
code: 'import { helper } from "../../shared/utils";',
|
||||
filename: 'packages/loot-core/src/server/deep/file.ts',
|
||||
},
|
||||
// Files outside packages/ are not checked
|
||||
{
|
||||
code: 'import { something } from "@actual-app/core";',
|
||||
filename: 'scripts/build.js',
|
||||
},
|
||||
// @actual-app/api can import @actual-app/core (declared dep)
|
||||
{
|
||||
code: 'import { something } from "@actual-app/core";',
|
||||
filename: 'packages/api/src/index.ts',
|
||||
},
|
||||
// @actual-app/api can import @actual-app/crdt (declared dep)
|
||||
{
|
||||
code: 'import { something } from "@actual-app/crdt";',
|
||||
filename: 'packages/api/src/index.ts',
|
||||
},
|
||||
// require() with declared dep is allowed
|
||||
{
|
||||
code: 'const core = require("@actual-app/core");',
|
||||
filename: 'packages/desktop-client/src/test.js',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// @actual-app/components has no internal deps — cannot import @actual-app/core
|
||||
{
|
||||
code: 'import { something } from "@actual-app/core";',
|
||||
filename: 'packages/component-library/src/Button.tsx',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/components',
|
||||
importedPackage: '@actual-app/core',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// @actual-app/components cannot import @actual-app/web
|
||||
{
|
||||
code: 'import { Page } from "@actual-app/web";',
|
||||
filename: 'packages/component-library/src/Button.tsx',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/components',
|
||||
importedPackage: '@actual-app/web',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// @actual-app/core cannot import @actual-app/web
|
||||
{
|
||||
code: 'import { Component } from "@actual-app/web";',
|
||||
filename: 'packages/loot-core/src/server/main.ts',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/core',
|
||||
importedPackage: '@actual-app/web',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// @actual-app/core cannot import @actual-app/components
|
||||
{
|
||||
code: 'import { Button } from "@actual-app/components";',
|
||||
filename: 'packages/loot-core/src/server/main.ts',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/core',
|
||||
importedPackage: '@actual-app/components',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// require() with undeclared dep is also blocked
|
||||
{
|
||||
code: 'const web = require("@actual-app/web");',
|
||||
filename: 'packages/component-library/src/test.js',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/components',
|
||||
importedPackage: '@actual-app/web',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Relative import crossing into another package is blocked
|
||||
{
|
||||
code: 'import * as theme from "../../desktop-client/src/style/themes/dark";',
|
||||
filename: 'packages/component-library/.storybook/preview.tsx',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: '@actual-app/components',
|
||||
importedPackage: '@actual-app/web',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,211 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Module-level cache: packageDir -> { name: string, allowedDeps: Set<string>, dirName: string }
|
||||
const packageCache = new Map();
|
||||
|
||||
// Reverse map: directory name -> package name (e.g. 'desktop-client' -> '@actual-app/web')
|
||||
const dirToPackageName = new Map();
|
||||
|
||||
// Find monorepo root by walking up from this file's directory
|
||||
let monorepoRoot = null;
|
||||
function findMonorepoRoot() {
|
||||
if (monorepoRoot !== null) return monorepoRoot;
|
||||
let dir = __dirname;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (
|
||||
fs.existsSync(path.join(dir, 'packages')) &&
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
monorepoRoot = dir;
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
monorepoRoot = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the package info for a given filename by locating the nearest
|
||||
* packages/<dir>/package.json in the file path.
|
||||
*/
|
||||
function getPackageInfo(filename) {
|
||||
const normalized = filename.replace(/\\/g, '/');
|
||||
const match = normalized.match(/packages\/([^/]+)\//);
|
||||
if (!match) return null;
|
||||
|
||||
const packageDir = match[1];
|
||||
if (packageCache.has(packageDir)) return packageCache.get(packageDir);
|
||||
|
||||
// Try to find package.json using the path as-is first (works for absolute paths)
|
||||
const packagesIndex = normalized.indexOf('packages/' + packageDir + '/');
|
||||
const root = normalized.substring(0, packagesIndex);
|
||||
let pkgJsonPath = path.join(root, 'packages', packageDir, 'package.json');
|
||||
|
||||
// If not found (e.g. relative path with different cwd), use monorepo root
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
const monoRoot = findMonorepoRoot();
|
||||
if (!monoRoot) return null;
|
||||
pkgJsonPath = path.join(monoRoot, 'packages', packageDir, 'package.json');
|
||||
}
|
||||
|
||||
try {
|
||||
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
||||
const allowed = new Set();
|
||||
for (const depType of [
|
||||
'dependencies',
|
||||
'devDependencies',
|
||||
'peerDependencies',
|
||||
'optionalDependencies',
|
||||
]) {
|
||||
if (pkgJson[depType]) {
|
||||
for (const dep of Object.keys(pkgJson[depType])) {
|
||||
if (dep.startsWith('@actual-app/')) {
|
||||
allowed.add(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const info = {
|
||||
name: pkgJson.name,
|
||||
allowedDeps: allowed,
|
||||
dirName: packageDir,
|
||||
};
|
||||
packageCache.set(packageDir, info);
|
||||
dirToPackageName.set(packageDir, pkgJson.name);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the @actual-app/<name> package name from an import source.
|
||||
* Returns null if the source is not an @actual-app/ import.
|
||||
*/
|
||||
function extractActualPackageName(importSource) {
|
||||
const match = importSource.match(/^(@actual-app\/[^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a relative import, resolves which packages/<dir> it lands in.
|
||||
* Returns the target directory name if it crosses into a different package, null otherwise.
|
||||
*/
|
||||
function resolveRelativeCrossPackage(importSource, filename, currentDirName) {
|
||||
if (!importSource.startsWith('.')) return null;
|
||||
|
||||
const fileDir = path.dirname(filename).replace(/\\/g, '/');
|
||||
const resolved = path.posix.normalize(path.posix.join(fileDir, importSource));
|
||||
const match = resolved.match(/packages\/([^/]+)\//);
|
||||
if (!match) return null;
|
||||
|
||||
const targetDir = match[1];
|
||||
if (targetDir === currentDirName) return null;
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the package name for a directory, loading its package.json if needed.
|
||||
*/
|
||||
function getPackageNameForDir(targetDir) {
|
||||
if (dirToPackageName.has(targetDir)) return dirToPackageName.get(targetDir);
|
||||
|
||||
// Force loading the package info which populates dirToPackageName
|
||||
const info = getPackageInfo(`packages/${targetDir}/dummy.ts`);
|
||||
return info ? info.name : null;
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow importing from other packages unless declared as a dependency in package.json',
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
noCrossPackageImport:
|
||||
'Package "{{currentPackage}}" does not declare a dependency on "{{importedPackage}}". Add it to dependencies in package.json or remove the import.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const pkgInfo = getPackageInfo(filename);
|
||||
|
||||
// Not inside a recognized package — nothing to check
|
||||
if (!pkgInfo) return {};
|
||||
|
||||
function checkImportSource(node, source) {
|
||||
if (typeof source !== 'string') return;
|
||||
|
||||
// Check @actual-app/* imports
|
||||
const importedPackage = extractActualPackageName(source);
|
||||
if (importedPackage) {
|
||||
if (importedPackage === pkgInfo.name) return;
|
||||
|
||||
if (!pkgInfo.allowedDeps.has(importedPackage)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: pkgInfo.name,
|
||||
importedPackage,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check relative imports that cross package boundaries
|
||||
const targetDir = resolveRelativeCrossPackage(
|
||||
source,
|
||||
filename,
|
||||
pkgInfo.dirName,
|
||||
);
|
||||
if (targetDir) {
|
||||
const targetPkgName = getPackageNameForDir(targetDir) || targetDir;
|
||||
if (!pkgInfo.allowedDeps.has(targetPkgName)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noCrossPackageImport',
|
||||
data: {
|
||||
currentPackage: pkgInfo.name,
|
||||
importedPackage: targetPkgName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
checkImportSource(node, node.source.value);
|
||||
},
|
||||
|
||||
// require() calls
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'require' &&
|
||||
node.arguments.length > 0 &&
|
||||
node.arguments[0].type === 'Literal'
|
||||
) {
|
||||
checkImportSource(node, node.arguments[0].value);
|
||||
}
|
||||
},
|
||||
|
||||
// Dynamic import()
|
||||
ImportExpression(node) {
|
||||
if (node.source.type === 'Literal') {
|
||||
checkImportSource(node, node.source.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user