mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
8 Commits
claude/fix
...
claude/enf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f79560c57c | ||
|
|
e30225ed42 | ||
|
|
8876299f71 | ||
|
|
554da806ce | ||
|
|
fc622c200b | ||
|
|
d059d57cc7 | ||
|
|
8eb096957e | ||
|
|
9d9f76d7df |
@@ -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,
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
6
upcoming-release-notes/7233.md
Normal file
6
upcoming-release-notes/7233.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add `no-cross-package-imports` ESLint rule to enforce package dependency boundaries in the monorepo.
|
||||
Reference in New Issue
Block a user