mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
feat(eslint-config): Add module-boundary lint rule (spike, no-changelog)
Generalizes the existing homegrown import-policing rules (misplaced-n8n-typeorm-import, no-import-enterprise-edition) into one tag-driven rule enforcing type (layer) and scope (domain) boundaries between workspace packages. Tags passed as options; not yet wired into any package config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de0389d02d
commit
8740da1128
|
|
@ -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' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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<Record<BoundaryType, BoundaryType[]>>;
|
||||
/** scopes any package may import regardless of its own scope. */
|
||||
sharedScopes?: string[];
|
||||
/** importer scope -> extra scopes it has opted into importing. */
|
||||
allowedScopeDependencies?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// 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<BoundaryType, BoundaryType[]> = {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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<string, AnyRuleModule>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user