diff --git a/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.test.ts b/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.test.ts new file mode 100644 index 00000000000..0e904c36d0a --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.test.ts @@ -0,0 +1,100 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { + EnforceModuleBoundariesRule, + type Options, + type PackageTag, +} from './enforce-module-boundaries.js'; + +const ruleTester = new RuleTester(); + +// A representative slice of a tagged n8n frontend monorepo. +const packages: PackageTag[] = [ + { name: 'apps-frontend', path: 'apps/frontend', type: 'app', scope: 'shell' }, + { name: '@n8n/feature-ai', path: 'features/ai', type: 'feature', scope: 'ai' }, + { + name: '@n8n/feature-credentials', + path: 'features/credentials', + type: 'feature', + scope: 'credentials', + }, + { + name: '@n8n/credentials-data', + path: 'data-access/credentials', + type: 'data-access', + scope: 'credentials', + }, + { + name: '@n8n/rest-api-client', + path: 'shared/rest-api-client', + type: 'data-access', + scope: 'shared', + }, + { name: '@n8n/design-system', path: 'shared/design-system', type: 'ui', scope: 'shared' }, + { name: '@n8n/utils', path: 'shared/utils', type: 'util', scope: 'shared' }, +]; + +const options: [Options] = [{ packages }]; + +ruleTester.run('enforce-module-boundaries', EnforceModuleBoundariesRule, { + valid: [ + { + // app may glue together features + code: 'import { AiPanel } from "@n8n/feature-ai"', + filename: '/repo/apps/frontend/src/main.ts', + options, + }, + { + // feature -> data-access (shared) is allowed + code: 'import { restApi } from "@n8n/rest-api-client"', + filename: '/repo/features/ai/src/store.ts', + options, + }, + { + // feature -> shared UI is allowed + code: 'import { N8nButton } from "@n8n/design-system"', + filename: '/repo/features/ai/src/Panel.vue', + options, + }, + { + // anyone -> util is allowed + code: 'import { deepCopy } from "@n8n/utils"', + filename: '/repo/shared/rest-api-client/src/client.ts', + options, + }, + { + // external / relative imports are ignored + code: 'import { ref } from "vue"; import { helper } from "./helper";', + filename: '/repo/features/ai/src/store.ts', + options, + }, + { + // cross-scope becomes valid once explicitly opted in + code: 'import { credData } from "@n8n/credentials-data"', + filename: '/repo/features/ai/src/store.ts', + options: [{ packages, allowedScopeDependencies: { ai: ['credentials'] } }] as [Options], + }, + ], + invalid: [ + { + // data-access must stay UI-free + code: 'import { N8nButton } from "@n8n/design-system"', + filename: '/repo/shared/rest-api-client/src/client.ts', + options, + errors: [{ messageId: 'forbiddenType' }], + }, + { + // features must not import each other (vertical slicing) + code: 'import { CredForm } from "@n8n/feature-credentials"', + filename: '/repo/features/ai/src/Panel.vue', + options, + errors: [{ messageId: 'forbiddenType' }], + }, + { + // type is allowed (feature->data-access) but scope crosses without opt-in + code: 'import { credData } from "@n8n/credentials-data"', + filename: '/repo/features/ai/src/store.ts', + options, + errors: [{ messageId: 'forbiddenScope' }], + }, + ], +}); diff --git a/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.ts b/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.ts new file mode 100644 index 00000000000..7ba6abec0fa --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/enforce-module-boundaries.ts @@ -0,0 +1,129 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +/** + * SPIKE: generalized library tagging + module-boundary enforcement. + * + * Every workspace package gets two tags: a `type` (its layer) and a `scope` + * (its domain/owner). Import edges between packages are then policed by two + * matrices: + * - type hierarchy: e.g. a `data-access` package may not import a UI package. + * - scope rules: a package may only import its own scope, a `shared` scope, + * or a scope it has explicitly opted into depending on. + * + * This is the n8n-native version of NX's enforce-module-boundaries, built on + * the same custom-rule machinery n8n already uses for `misplaced-n8n-typeorm-import` + * and `no-import-enterprise-edition` — no new dependency. + * + * ponytail: tags are passed as options here so the rule stays hermetic and + * testable. Real wiring would generate the package list from each package.json's + * `n8n.tags` field at config-build time (one fs scan, cached). Add when adopted. + */ + +export type BoundaryType = 'app' | 'feature' | 'data-access' | 'domain-ui' | 'ui' | 'util'; + +export interface PackageTag { + name: string; + path: string; + type: BoundaryType; + scope: string; +} + +export interface Options { + packages: PackageTag[]; + /** importer type -> target types it may import. Overrides the default hierarchy. */ + allowedTypes?: Partial>; + /** scopes any package may import regardless of its own scope. */ + sharedScopes?: string[]; + /** importer scope -> extra scopes it has opted into importing. */ + allowedScopeDependencies?: Record; +} + +// Konstantin's hierarchy: apps glue everything, features slice vertically and +// must not import each other, data-access stays UI-free, utils are universal. +const DEFAULT_ALLOWED_TYPES: Record = { + app: ['app', 'feature', 'data-access', 'domain-ui', 'ui', 'util'], + feature: ['data-access', 'domain-ui', 'ui', 'util'], + 'data-access': ['data-access', 'util'], + 'domain-ui': ['domain-ui', 'ui', 'util'], + ui: ['ui', 'util'], + util: ['util'], +}; + +export const EnforceModuleBoundariesRule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: + 'Enforce type (layer) and scope (domain) import boundaries between tagged workspace packages.', + }, + messages: { + forbiddenType: + 'A "{{ fromType }}" package cannot import a "{{ toType }}" package ({{ toName }}).', + forbiddenScope: + 'Scope "{{ fromScope }}" cannot import scope "{{ toScope }}" ({{ toName }}). Add it to allowedScopeDependencies or expose a shared package.', + }, + schema: [ + { + type: 'object', + properties: { + packages: { type: 'array' }, + allowedTypes: { type: 'object' }, + sharedScopes: { type: 'array', items: { type: 'string' } }, + allowedScopeDependencies: { type: 'object' }, + }, + required: ['packages'], + additionalProperties: false, + }, + ], + }, + defaultOptions: [{ packages: [] } as Options], + create(context, [options]) { + const { packages, allowedScopeDependencies = {} } = options; + const allowedTypes = { ...DEFAULT_ALLOWED_TYPES, ...options.allowedTypes }; + const sharedScopes = options.sharedScopes ?? ['shared']; + + const filename = context.filename.split('\\').join('/'); + + // Longest-matching path wins so nested packages resolve to themselves. + const fromPkg = packages + .filter((p) => filename.includes(p.path)) + .sort((a, b) => b.path.length - a.path.length)[0]; + + if (!fromPkg) return {}; + + const resolveTarget = (specifier: string) => + packages.find((p) => specifier === p.name || specifier.startsWith(`${p.name}/`)); + + return { + ImportDeclaration(node) { + if (typeof node.source.value !== 'string') return; + + const toPkg = resolveTarget(node.source.value); + if (!toPkg || toPkg.name === fromPkg.name) return; // external dep or self import + + if (!allowedTypes[fromPkg.type].includes(toPkg.type)) { + context.report({ + node: node.source, + messageId: 'forbiddenType', + data: { fromType: fromPkg.type, toType: toPkg.type, toName: toPkg.name }, + }); + return; + } + + const scopeAllowed = + fromPkg.type === 'app' || // apps glue every scope together + fromPkg.scope === toPkg.scope || + sharedScopes.includes(toPkg.scope) || + (allowedScopeDependencies[fromPkg.scope] ?? []).includes(toPkg.scope); + + if (!scopeAllowed) { + context.report({ + node: node.source, + messageId: 'forbiddenScope', + data: { fromScope: fromPkg.scope, toScope: toPkg.scope, toName: toPkg.name }, + }); + } + }, + }; + }, +}); diff --git a/packages/@n8n/eslint-config/src/rules/index.ts b/packages/@n8n/eslint-config/src/rules/index.ts index abd486c6c5c..7c416e56357 100644 --- a/packages/@n8n/eslint-config/src/rules/index.ts +++ b/packages/@n8n/eslint-config/src/rules/index.ts @@ -21,6 +21,7 @@ import { NoErrorInstanceInToThrowRule } from './no-error-instance-in-to-throw.js import { NoAwsCredentialDiscoveryImportsRule } from './no-aws-credential-discovery-imports.js'; import { NoUncentralizedHttpRule } from './no-uncentralized-http.js'; import { NoApplicationErrorRule } from './no-application-error.js'; +import { EnforceModuleBoundariesRule } from './enforce-module-boundaries.js'; export const rules = { 'no-uncaught-json-parse': NoUncaughtJsonParseRule, @@ -45,4 +46,5 @@ export const rules = { 'no-aws-credential-discovery-imports': NoAwsCredentialDiscoveryImportsRule, 'no-uncentralized-http': NoUncentralizedHttpRule, 'no-application-error': NoApplicationErrorRule, + 'enforce-module-boundaries': EnforceModuleBoundariesRule, } satisfies Record;