Compare commits

...

8 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
f79560c57c Refactor no-cross-package-imports rule to remove hard-banned imports and streamline relative path resolution. Update tests to reflect changes in error messaging for imports not declared in dependencies. 2026-03-17 21:33:54 +00:00
Matiss Janis Aboltins
e30225ed42 Enhance no-cross-package-imports rule to support hard-banned imports and relative path resolution. Introduce checks for exports from undeclared dependencies and add tests for new behaviors, including hard bans on specific package imports. 2026-03-17 21:13:02 +00:00
github-actions[bot]
8876299f71 Add release notes for PR #7233 2026-03-17 20:31:14 +00:00
Claude
554da806ce [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
2026-03-17 19:06:16 +00:00
Claude
fc622c200b [AI] Replace custom ESLint rule with no-restricted-imports overrides
Remove the custom no-cross-package-imports rule and instead use oxlint's
built-in eslint/no-restricted-imports with per-package overrides to enforce
cross-package dependency boundaries. Each package gets an override that
restricts @actual-app/* imports to only its declared dependencies.

This also catches relative cross-package imports (e.g. ../../desktop-client/)
via glob patterns in the overrides.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 17:56:20 +00:00
Claude
d059d57cc7 [AI] Detect relative imports that cross package boundaries
Extend no-cross-package-imports rule to also catch relative imports like
`../../desktop-client/src/...` that traverse into a different package
directory. Resolves the import path against the file's directory and
checks if the target package is declared as a dependency.

Add oxlint-disable for the known intentional violation in
component-library/.storybook/preview.tsx.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 16:30:08 +00:00
Claude
8eb096957e [AI] Restore @actual-app/web no-restricted-imports rule and fix stacked disable comments
Keep the existing no-restricted-imports rule for @actual-app/web in loot-core
as-is alongside the new no-cross-package-imports rule. Fix the stacked
oxlint-disable-next-line comments in desktop-electron/e2e/onboarding.test.ts
by combining them into a single line so both suppressions apply correctly.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 12:01:14 +00:00
Claude
9d9f76d7df [AI] Add no-cross-package-imports ESLint rule to enforce package dependency boundaries
Add a custom ESLint rule that validates @actual-app/* imports are declared
as dependencies in the importing package's package.json. This replaces the
manual no-restricted-imports pattern for @actual-app/web in loot-core with
an automatic, self-maintaining rule that covers all packages.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 11:26:04 +00:00
6 changed files with 448 additions and 1 deletions

View File

@@ -4,10 +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 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 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,201 @@
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',
},
// export { foo } from declared dep is allowed
{
code: 'export { something } from "@actual-app/core";',
filename: 'packages/desktop-client/src/index.ts',
},
// export * from declared dep is allowed
{
code: 'export * from "@actual-app/core";',
filename: 'packages/desktop-client/src/index.ts',
},
],
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 (not in its deps)
{
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',
},
},
],
},
// export * from undeclared dep is blocked
{
code: 'export * from "@actual-app/web";',
filename: 'packages/component-library/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// export { foo } from undeclared dep is blocked
{
code: 'export { Page } from "@actual-app/web";',
filename: 'packages/component-library/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// export * from @actual-app/web in loot-core is blocked (not in its deps)
{
code: 'export * from "@actual-app/web";',
filename: 'packages/loot-core/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/core',
importedPackage: '@actual-app/web',
},
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
},
);

View File

@@ -0,0 +1,237 @@
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;
}
/**
* Computes a monorepo-root-relative path for reliable package detection.
* This avoids false matches when the checkout itself is under a directory named "packages/".
*/
function toMonorepoRelative(filename) {
const monoRoot = findMonorepoRoot();
if (!monoRoot) return null;
const normalized = filename.replace(/\\/g, '/');
const normalizedRoot = monoRoot.replace(/\\/g, '/');
if (normalized.startsWith(normalizedRoot + '/')) {
return normalized.substring(normalizedRoot.length + 1);
}
// Resolve relative paths (e.g. from test harnesses) against monorepo root
const resolved = path.resolve(monoRoot, filename).replace(/\\/g, '/');
if (resolved.startsWith(normalizedRoot + '/')) {
return resolved.substring(normalizedRoot.length + 1);
}
return null;
}
/**
* Finds the package info for a given filename by locating the nearest
* packages/<dir>/package.json in the file path, anchored to the monorepo root.
*/
function getPackageInfo(filename) {
const relative = toMonorepoRelative(filename);
if (!relative) return null;
const match = relative.match(/^packages\/([^/]+)\//);
if (!match) return null;
const packageDir = match[1];
if (packageCache.has(packageDir)) return packageCache.get(packageDir);
const monoRoot = findMonorepoRoot();
const 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 relative = toMonorepoRelative(filename);
if (!relative) return null;
const fileDir = path.posix.dirname(relative);
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;
// Resolve the target package name from either @actual-app/* or relative imports
let importedPackage = extractActualPackageName(source);
if (!importedPackage) {
const targetDir = resolveRelativeCrossPackage(
source,
filename,
pkgInfo.dirName,
);
if (!targetDir) return;
importedPackage = getPackageNameForDir(targetDir) || targetDir;
}
if (importedPackage === pkgInfo.name) return;
if (!pkgInfo.allowedDeps.has(importedPackage)) {
context.report({
node,
messageId: 'noCrossPackageImport',
data: {
currentPackage: pkgInfo.name,
importedPackage,
},
});
}
}
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);
}
},
// export { foo } from '...'
ExportNamedDeclaration(node) {
if (node.source) {
checkImportSource(node, node.source.value);
}
},
// export * from '...'
ExportAllDeclaration(node) {
if (node.source) {
checkImportSource(node, node.source.value);
}
},
};
},
};

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add `no-cross-package-imports` ESLint rule to enforce package dependency boundaries in the monorepo.