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:
Charlie Kolb 2026-07-10 13:38:30 +02:00
parent de0389d02d
commit 8740da1128
No known key found for this signature in database
3 changed files with 231 additions and 0 deletions

View File

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

View File

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

View File

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