[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:
Claude
2026-03-17 19:06:16 +00:00
parent fc622c200b
commit 554da806ce
6 changed files with 364 additions and 485 deletions

View File

@@ -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."
}
]
}
]
}
}
]
}

View File

@@ -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,

View File

@@ -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';

View File

@@ -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'),
},
};

View File

@@ -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',
},
},
);

View File

@@ -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);
}
},
};
},
};