@clearlydev/nx-archweave 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +265 -2
- package/dist/bin/review-verdict.js +330 -0
- package/dist/eslint/fsd-boundaries.d.ts +119 -0
- package/dist/eslint/fsd-boundaries.js +144 -0
- package/dist/eslint/index.d.ts +78 -0
- package/dist/eslint/index.js +256 -0
- package/dist/eslint/tsconfig-aliases.d.ts +18 -0
- package/dist/eslint/tsconfig-aliases.js +114 -0
- package/dist/generators/add-agent-rules/generator.js +550 -0
- package/dist/generators/add-agent-rules/render.js +46 -0
- package/dist/generators/add-backend/generator.js +784 -0
- package/dist/generators/add-backend/render.js +102 -0
- package/dist/generators/add-fsd-lite/generator.js +553 -0
- package/dist/generators/add-fsd-lite/render.js +72 -0
- package/dist/generators/add-github-templates/files/en/codeowners/.github/CODEOWNERS.template +4 -0
- package/dist/generators/add-github-templates/files/en/issue/.github/ISSUE_TEMPLATE/bug-report.yml.template +28 -0
- package/dist/generators/add-github-templates/files/en/issue/.github/ISSUE_TEMPLATE/feature-request.yml.template +17 -0
- package/dist/generators/add-github-templates/files/en/pr/.github/PULL_REQUEST_TEMPLATE.md.template +12 -0
- package/dist/generators/add-github-templates/files/ko/codeowners/.github/CODEOWNERS.template +4 -0
- package/dist/generators/add-github-templates/files/ko/issue/.github/ISSUE_TEMPLATE/bug-report.yml.template +28 -0
- package/dist/generators/add-github-templates/files/ko/issue/.github/ISSUE_TEMPLATE/feature-request.yml.template +17 -0
- package/dist/generators/add-github-templates/files/ko/pr/.github/PULL_REQUEST_TEMPLATE.md.template +12 -0
- package/dist/generators/add-github-templates/generator.js +125 -0
- package/dist/generators/add-github-templates/render.js +26 -0
- package/dist/generators/add-integration/generator.js +818 -0
- package/dist/generators/add-integration/render.js +34 -0
- package/dist/generators/add-review/generator.js +921 -0
- package/dist/generators/add-review/render.js +96 -0
- package/dist/generators/allow-builds/generator.js +436 -0
- package/dist/generators/allow-builds/render.js +82 -0
- package/dist/generators/doctor/generator.js +2431 -0
- package/dist/generators/doctor/integration-report.js +70 -0
- package/dist/generators/doctor/render.js +10 -0
- package/dist/generators/entity/generator.js +395 -0
- package/dist/generators/feature/generator.js +395 -0
- package/dist/generators/init/generator.js +3211 -0
- package/dist/generators/init/render.js +125 -0
- package/dist/generators/init/validation.js +74 -0
- package/dist/generators/install-integration/generator.js +1233 -0
- package/dist/generators/install-integration/render.js +128 -0
- package/dist/generators/page/generator.js +395 -0
- package/dist/generators/record-integration/generator.js +852 -0
- package/dist/generators/record-integration/render.js +31 -0
- package/dist/generators/shared/dry-run.js +12 -0
- package/dist/generators/shared/frontend-app.js +57 -0
- package/dist/generators/shared/generator.js +395 -0
- package/dist/generators/shared/project-type.js +39 -0
- package/dist/generators/shared/record-pipeline.js +34 -0
- package/dist/generators/shared/run-nx.js +24 -0
- package/dist/generators/shared/template-files.js +38 -0
- package/dist/generators/shared/toolkit-config-file.js +110 -0
- package/dist/generators/slice/core.js +185 -0
- package/dist/generators/slice/files/index/index.ts.template +14 -0
- package/dist/generators/slice/files/module/__name__.spec.ts.template +11 -0
- package/dist/generators/slice/files/module/__name__.ts.template +11 -0
- package/dist/generators/slice/files/segment/index.ts.template +20 -0
- package/dist/generators/slice/render.js +70 -0
- package/dist/generators/widget/generator.js +395 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +680 -0
- package/dist/lib/nx-plugin.d.ts +2 -0
- package/dist/lib/nx-plugin.js +7 -0
- package/dist/runtime/integrations-composition-root.js +20 -0
- package/dist/runtime/node-command-runner.js +81 -0
- package/generators.json +85 -0
- package/package.json +95 -4
- package/src/generators/add-agent-rules/schema.json +43 -0
- package/src/generators/add-backend/schema.json +39 -0
- package/src/generators/add-fsd-lite/schema.json +43 -0
- package/src/generators/add-github-templates/schema.json +35 -0
- package/src/generators/add-integration/schema.json +35 -0
- package/src/generators/add-review/schema.json +72 -0
- package/src/generators/allow-builds/schema.json +20 -0
- package/src/generators/doctor/schema.json +20 -0
- package/src/generators/entity/schema.json +37 -0
- package/src/generators/feature/schema.json +37 -0
- package/src/generators/init/schema.json +98 -0
- package/src/generators/install-integration/schema.json +35 -0
- package/src/generators/page/schema.json +53 -0
- package/src/generators/record-integration/schema.json +26 -0
- package/src/generators/shared/schema.json +32 -0
- package/src/generators/widget/schema.json +45 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `archweave/fsd-boundaries` — canonical FSD import rules inside one frontend app.
|
|
3
|
+
* Three findings:
|
|
4
|
+
*
|
|
5
|
+
* - upward: a layer imports a layer above it (app > pages > widgets > features > entities > shared)
|
|
6
|
+
* - cross-slice: two slices on the same sliced layer import each other — unless through the
|
|
7
|
+
* target slice's `@x/<importer-slice>` public API (FSD's cross-import notation)
|
|
8
|
+
* - sidestep: a slice's (or a shared segment's) internal file is imported past its index
|
|
9
|
+
*
|
|
10
|
+
* Only relative specifiers and the configured aliases are judged; package specifiers
|
|
11
|
+
* (`react`, `firebase/app`) are never touched — string-matching layer names in them was the
|
|
12
|
+
* measured source of false positives. Locations are computed from path strings alone: the
|
|
13
|
+
* rule never reads the file system.
|
|
14
|
+
*
|
|
15
|
+
* Self-contained on purpose: no `eslint` import, so the plugin's only runtime dependency stays
|
|
16
|
+
* @nx/devkit. The handful of ESLint shapes it touches are typed structurally below.
|
|
17
|
+
*/
|
|
18
|
+
export type FsdEnforcementSeverity = 'error' | 'warn';
|
|
19
|
+
export interface FsdBoundariesOptions {
|
|
20
|
+
/** The app's source root, relative to ESLint's cwd (Nx lints from the project root). */
|
|
21
|
+
root: string;
|
|
22
|
+
/** Import-prefix aliases → directories relative to cwd, e.g. `{ '@/': 'src' }`. */
|
|
23
|
+
aliases: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
declare const LAYER_ORDER: readonly ["app", "pages", "widgets", "features", "entities", "shared"];
|
|
26
|
+
type FsdLayer = (typeof LAYER_ORDER)[number];
|
|
27
|
+
/** Where a file sits inside the FSD tree. `unit` is the slice on a sliced layer, the segment
|
|
28
|
+
* on `shared` (its segments carry a public API too), null on `app`. `rest` is the path below
|
|
29
|
+
* the unit, with a trailing `index[.ext]` dropped. */
|
|
30
|
+
interface Location {
|
|
31
|
+
layer: FsdLayer;
|
|
32
|
+
unit: string | null;
|
|
33
|
+
rest: string[];
|
|
34
|
+
}
|
|
35
|
+
export declare function locate(rootRelativePath: string): Location | null;
|
|
36
|
+
export type FsdViolation = {
|
|
37
|
+
kind: 'upward';
|
|
38
|
+
from: Location;
|
|
39
|
+
to: Location;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'cross-slice';
|
|
42
|
+
from: Location;
|
|
43
|
+
to: Location;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'sidestep';
|
|
46
|
+
from: Location;
|
|
47
|
+
to: Location;
|
|
48
|
+
};
|
|
49
|
+
/** Pure core: the violation (if any) of `from` importing `to`. */
|
|
50
|
+
export declare function judge(from: Location, to: Location): FsdViolation | null;
|
|
51
|
+
export declare function describeLocation(location: Location): string;
|
|
52
|
+
/** Resolves an import specifier to a path relative to the source root, or null when it is
|
|
53
|
+
* a package specifier or lands outside the root. */
|
|
54
|
+
export declare function resolveSpecifier(specifier: string, importerRootRelative: string, options: {
|
|
55
|
+
rootAbsolute: string;
|
|
56
|
+
cwd: string;
|
|
57
|
+
aliases: Record<string, string>;
|
|
58
|
+
}): string | null;
|
|
59
|
+
export interface SourceNode {
|
|
60
|
+
type: string;
|
|
61
|
+
value?: unknown;
|
|
62
|
+
}
|
|
63
|
+
export interface AstNode {
|
|
64
|
+
type: string;
|
|
65
|
+
source?: SourceNode | null;
|
|
66
|
+
arguments?: AstNode[];
|
|
67
|
+
}
|
|
68
|
+
export interface RuleContext {
|
|
69
|
+
filename: string;
|
|
70
|
+
cwd: string;
|
|
71
|
+
options: unknown[];
|
|
72
|
+
report(descriptor: {
|
|
73
|
+
node: AstNode;
|
|
74
|
+
messageId: string;
|
|
75
|
+
data?: Record<string, string>;
|
|
76
|
+
}): void;
|
|
77
|
+
}
|
|
78
|
+
export declare const fsdBoundariesRule: {
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem";
|
|
81
|
+
docs: {
|
|
82
|
+
description: string;
|
|
83
|
+
url: string;
|
|
84
|
+
};
|
|
85
|
+
schema: {
|
|
86
|
+
type: string;
|
|
87
|
+
properties: {
|
|
88
|
+
root: {
|
|
89
|
+
type: string;
|
|
90
|
+
};
|
|
91
|
+
aliases: {
|
|
92
|
+
type: string;
|
|
93
|
+
additionalProperties: {
|
|
94
|
+
type: string;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
additionalProperties: boolean;
|
|
99
|
+
}[];
|
|
100
|
+
messages: {
|
|
101
|
+
upward: string;
|
|
102
|
+
crossSlice: string;
|
|
103
|
+
sidestep: string;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
create(context: RuleContext): {
|
|
107
|
+
ImportDeclaration?: undefined;
|
|
108
|
+
ExportNamedDeclaration?: undefined;
|
|
109
|
+
ExportAllDeclaration?: undefined;
|
|
110
|
+
ImportExpression?: undefined;
|
|
111
|
+
} | {
|
|
112
|
+
ImportDeclaration: (node: AstNode) => void;
|
|
113
|
+
ExportNamedDeclaration: (node: AstNode) => void;
|
|
114
|
+
ExportAllDeclaration: (node: AstNode) => void;
|
|
115
|
+
ImportExpression: (node: AstNode) => void;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
export {};
|
|
119
|
+
//# sourceMappingURL=fsd-boundaries.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { posix, relative, resolve, sep } from 'node:path';
|
|
2
|
+
const LAYER_ORDER = ['app', 'pages', 'widgets', 'features', 'entities', 'shared'];
|
|
3
|
+
/** Directory name → layer. `_app`/`_pages` are FSD's own Next.js naming (its routers own
|
|
4
|
+
* `app/` and `pages/`). */
|
|
5
|
+
const LAYER_BY_DIR = {
|
|
6
|
+
app: 'app',
|
|
7
|
+
_app: 'app',
|
|
8
|
+
pages: 'pages',
|
|
9
|
+
_pages: 'pages',
|
|
10
|
+
widgets: 'widgets',
|
|
11
|
+
features: 'features',
|
|
12
|
+
entities: 'entities',
|
|
13
|
+
shared: 'shared',
|
|
14
|
+
};
|
|
15
|
+
const SLICED_LAYERS = new Set(['pages', 'widgets', 'features', 'entities']);
|
|
16
|
+
const INDEX_FILE = /^index(\.[cm]?[jt]sx?)?$/;
|
|
17
|
+
export function locate(rootRelativePath) {
|
|
18
|
+
const segments = rootRelativePath.split('/').filter((s) => s.length > 0 && s !== '.');
|
|
19
|
+
if (segments.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const layer = LAYER_BY_DIR[segments[0]];
|
|
22
|
+
if (layer === undefined)
|
|
23
|
+
return null;
|
|
24
|
+
const hasUnit = layer !== 'app';
|
|
25
|
+
const unit = hasUnit ? (segments[1] ?? null) : null;
|
|
26
|
+
const rest = segments.slice(hasUnit ? 2 : 1);
|
|
27
|
+
if (rest.length > 0 && INDEX_FILE.test(rest[rest.length - 1]))
|
|
28
|
+
rest.pop();
|
|
29
|
+
return { layer, unit, rest };
|
|
30
|
+
}
|
|
31
|
+
/** Pure core: the violation (if any) of `from` importing `to`. */
|
|
32
|
+
export function judge(from, to) {
|
|
33
|
+
if (LAYER_ORDER.indexOf(to.layer) < LAYER_ORDER.indexOf(from.layer))
|
|
34
|
+
return { kind: 'upward', from, to };
|
|
35
|
+
if (to.unit === null)
|
|
36
|
+
return null; // the app layer, or a bare layer directory — nothing finer to guard
|
|
37
|
+
const sameUnit = from.layer === to.layer && from.unit === to.unit;
|
|
38
|
+
if (sameUnit)
|
|
39
|
+
return null; // inside one slice/segment anything goes
|
|
40
|
+
const viaCrossImportApi = to.rest[0] === '@x' && to.rest[1] === from.unit && from.layer === to.layer;
|
|
41
|
+
if (viaCrossImportApi)
|
|
42
|
+
return null;
|
|
43
|
+
if (from.layer === to.layer && SLICED_LAYERS.has(to.layer))
|
|
44
|
+
return { kind: 'cross-slice', from, to };
|
|
45
|
+
if (to.rest.length > 0)
|
|
46
|
+
return { kind: 'sidestep', from, to };
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export function describeLocation(location) {
|
|
50
|
+
return location.unit === null ? location.layer : `${location.layer}/${location.unit}`;
|
|
51
|
+
}
|
|
52
|
+
/** Resolves an import specifier to a path relative to the source root, or null when it is
|
|
53
|
+
* a package specifier or lands outside the root. */
|
|
54
|
+
export function resolveSpecifier(specifier, importerRootRelative, options) {
|
|
55
|
+
let rootRelative = null;
|
|
56
|
+
if (specifier.startsWith('./') || specifier.startsWith('../') || specifier === '.' || specifier === '..') {
|
|
57
|
+
rootRelative = posix.normalize(posix.join(posix.dirname(importerRootRelative), specifier));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
for (const [prefix, target] of Object.entries(options.aliases)) {
|
|
61
|
+
if (!specifier.startsWith(prefix))
|
|
62
|
+
continue;
|
|
63
|
+
const targetAbsolute = resolve(options.cwd, target);
|
|
64
|
+
const targetRootRelative = toPosix(relative(options.rootAbsolute, targetAbsolute));
|
|
65
|
+
rootRelative = posix.normalize(posix.join(targetRootRelative === '' ? '.' : targetRootRelative, specifier.slice(prefix.length)));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (rootRelative === null)
|
|
70
|
+
return null;
|
|
71
|
+
if (rootRelative === '..' || rootRelative.startsWith('../'))
|
|
72
|
+
return null;
|
|
73
|
+
return rootRelative;
|
|
74
|
+
}
|
|
75
|
+
function toPosix(path) {
|
|
76
|
+
return sep === '/' ? path : path.split(sep).join('/');
|
|
77
|
+
}
|
|
78
|
+
export const fsdBoundariesRule = {
|
|
79
|
+
meta: {
|
|
80
|
+
type: 'problem',
|
|
81
|
+
docs: {
|
|
82
|
+
description: 'Enforce canonical Feature-Sliced Design import rules inside a frontend app',
|
|
83
|
+
url: 'https://github.com/clearlydev/nx-archweave#fsd-lite',
|
|
84
|
+
},
|
|
85
|
+
schema: [
|
|
86
|
+
{
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
root: { type: 'string' },
|
|
90
|
+
aliases: { type: 'object', additionalProperties: { type: 'string' } },
|
|
91
|
+
},
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
messages: {
|
|
96
|
+
upward: 'FSD: "{{from}}" may not import from "{{to}}" (a higher layer).',
|
|
97
|
+
crossSlice: 'FSD: "{{from}}" may not import a sibling slice "{{to}}" — move the shared part down a layer, or expose it through "{{to}}/@x/{{fromUnit}}".',
|
|
98
|
+
sidestep: 'FSD: import "{{to}}" through its public API (index.ts), not its internal file "{{path}}".',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
create(context) {
|
|
102
|
+
const options = (context.options[0] ?? {});
|
|
103
|
+
const root = options.root ?? 'src';
|
|
104
|
+
const aliases = options.aliases ?? { '@/': root };
|
|
105
|
+
const cwd = context.cwd;
|
|
106
|
+
const rootAbsolute = resolve(cwd, root);
|
|
107
|
+
const importerRootRelative = toPosix(relative(rootAbsolute, context.filename));
|
|
108
|
+
if (importerRootRelative === '' || importerRootRelative.startsWith('..'))
|
|
109
|
+
return {};
|
|
110
|
+
const from = locate(importerRootRelative);
|
|
111
|
+
if (from === null)
|
|
112
|
+
return {};
|
|
113
|
+
const check = (node, specifier) => {
|
|
114
|
+
if (typeof specifier !== 'string')
|
|
115
|
+
return;
|
|
116
|
+
const target = resolveSpecifier(specifier, importerRootRelative, { rootAbsolute, cwd, aliases });
|
|
117
|
+
if (target === null)
|
|
118
|
+
return;
|
|
119
|
+
const to = locate(target);
|
|
120
|
+
if (to === null)
|
|
121
|
+
return;
|
|
122
|
+
const violation = judge(from, to);
|
|
123
|
+
if (violation === null)
|
|
124
|
+
return;
|
|
125
|
+
const data = { from: describeLocation(from), to: describeLocation(to), fromUnit: from.unit ?? '', path: target };
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
messageId: violation.kind === 'upward' ? 'upward' : violation.kind === 'cross-slice' ? 'crossSlice' : 'sidestep',
|
|
129
|
+
data,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
const fromSource = (node) => check(node, node.source?.value);
|
|
133
|
+
return {
|
|
134
|
+
ImportDeclaration: fromSource,
|
|
135
|
+
ExportNamedDeclaration: fromSource,
|
|
136
|
+
ExportAllDeclaration: fromSource,
|
|
137
|
+
ImportExpression: (node) => {
|
|
138
|
+
const argument = node.source;
|
|
139
|
+
if (argument && argument.type === 'Literal')
|
|
140
|
+
check(node, argument.value);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type FsdEnforcementSeverity } from './fsd-boundaries.ts';
|
|
2
|
+
export { fsdBoundariesRule, type FsdBoundariesOptions, type FsdEnforcementSeverity } from './fsd-boundaries.ts';
|
|
3
|
+
export { aliasesFromTsconfig } from './tsconfig-aliases.ts';
|
|
4
|
+
/** The ESLint plugin object — `plugins: { archweave: archweavePlugin }`. */
|
|
5
|
+
export declare const archweavePlugin: {
|
|
6
|
+
meta: {
|
|
7
|
+
name: string;
|
|
8
|
+
version: string;
|
|
9
|
+
};
|
|
10
|
+
rules: {
|
|
11
|
+
'fsd-boundaries': {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem";
|
|
14
|
+
docs: {
|
|
15
|
+
description: string;
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
schema: {
|
|
19
|
+
type: string;
|
|
20
|
+
properties: {
|
|
21
|
+
root: {
|
|
22
|
+
type: string;
|
|
23
|
+
};
|
|
24
|
+
aliases: {
|
|
25
|
+
type: string;
|
|
26
|
+
additionalProperties: {
|
|
27
|
+
type: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
additionalProperties: boolean;
|
|
32
|
+
}[];
|
|
33
|
+
messages: {
|
|
34
|
+
upward: string;
|
|
35
|
+
crossSlice: string;
|
|
36
|
+
sidestep: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
create(context: import("./fsd-boundaries.ts").RuleContext): {
|
|
40
|
+
ImportDeclaration?: undefined;
|
|
41
|
+
ExportNamedDeclaration?: undefined;
|
|
42
|
+
ExportAllDeclaration?: undefined;
|
|
43
|
+
ImportExpression?: undefined;
|
|
44
|
+
} | {
|
|
45
|
+
ImportDeclaration: (node: import("./fsd-boundaries.ts").AstNode) => void;
|
|
46
|
+
ExportNamedDeclaration: (node: import("./fsd-boundaries.ts").AstNode) => void;
|
|
47
|
+
ExportAllDeclaration: (node: import("./fsd-boundaries.ts").AstNode) => void;
|
|
48
|
+
ImportExpression: (node: import("./fsd-boundaries.ts").AstNode) => void;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
export interface FsdBoundariesConfigOptions {
|
|
54
|
+
/** `error` (Team preset) or `warn` (Solo / Quick Start). Default `error`. */
|
|
55
|
+
severity?: FsdEnforcementSeverity;
|
|
56
|
+
/** The app's source root, relative to the config file's directory. Default `src`. */
|
|
57
|
+
root?: string;
|
|
58
|
+
/** Import-prefix aliases → directories relative to the config file's directory. Default:
|
|
59
|
+
* `{ '@/': <root> }` (the alias Nx's Next.js template ships) plus whatever the app's
|
|
60
|
+
* tsconfig `paths` declare (see `tsconfig`). Passing this replaces both. */
|
|
61
|
+
aliases?: Record<string, string>;
|
|
62
|
+
/** The tsconfig whose `compilerOptions.paths` (and its `extends` chain) add to the default
|
|
63
|
+
* aliases, relative to `cwd`. Default `tsconfig.json`; `false` turns the lookup off. */
|
|
64
|
+
tsconfig?: string | false;
|
|
65
|
+
/** The directory the config is evaluated from — Nx lints from the project root. Default
|
|
66
|
+
* `process.cwd()`. */
|
|
67
|
+
cwd?: string;
|
|
68
|
+
/** Override the files the rule applies to. Default: everything under `root`. */
|
|
69
|
+
files?: string[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The one flat-config entry add-fsd-lite adds to a frontend app's eslint.config.mjs:
|
|
73
|
+
*
|
|
74
|
+
* import { fsdBoundaries } from '@clearlydev/nx-archweave/eslint';
|
|
75
|
+
* export default [...baseConfig, ...fsdBoundaries({ severity: 'error' })];
|
|
76
|
+
*/
|
|
77
|
+
export declare function fsdBoundaries(options?: FsdBoundariesConfigOptions): object[];
|
|
78
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/eslint/index.ts
|
|
2
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
3
|
+
import { dirname as dirname2, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/eslint/fsd-boundaries.ts
|
|
7
|
+
import { posix, relative, resolve, sep } from "node:path";
|
|
8
|
+
var LAYER_ORDER = ["app", "pages", "widgets", "features", "entities", "shared"];
|
|
9
|
+
var LAYER_BY_DIR = {
|
|
10
|
+
app: "app",
|
|
11
|
+
_app: "app",
|
|
12
|
+
pages: "pages",
|
|
13
|
+
_pages: "pages",
|
|
14
|
+
widgets: "widgets",
|
|
15
|
+
features: "features",
|
|
16
|
+
entities: "entities",
|
|
17
|
+
shared: "shared"
|
|
18
|
+
};
|
|
19
|
+
var SLICED_LAYERS = /* @__PURE__ */ new Set(["pages", "widgets", "features", "entities"]);
|
|
20
|
+
var INDEX_FILE = /^index(\.[cm]?[jt]sx?)?$/;
|
|
21
|
+
function locate(rootRelativePath) {
|
|
22
|
+
const segments = rootRelativePath.split("/").filter((s) => s.length > 0 && s !== ".");
|
|
23
|
+
if (segments.length === 0) return null;
|
|
24
|
+
const layer = LAYER_BY_DIR[segments[0]];
|
|
25
|
+
if (layer === void 0) return null;
|
|
26
|
+
const hasUnit = layer !== "app";
|
|
27
|
+
const unit = hasUnit ? segments[1] ?? null : null;
|
|
28
|
+
const rest = segments.slice(hasUnit ? 2 : 1);
|
|
29
|
+
if (rest.length > 0 && INDEX_FILE.test(rest[rest.length - 1])) rest.pop();
|
|
30
|
+
return { layer, unit, rest };
|
|
31
|
+
}
|
|
32
|
+
function judge(from, to) {
|
|
33
|
+
if (LAYER_ORDER.indexOf(to.layer) < LAYER_ORDER.indexOf(from.layer)) return { kind: "upward", from, to };
|
|
34
|
+
if (to.unit === null) return null;
|
|
35
|
+
const sameUnit = from.layer === to.layer && from.unit === to.unit;
|
|
36
|
+
if (sameUnit) return null;
|
|
37
|
+
const viaCrossImportApi = to.rest[0] === "@x" && to.rest[1] === from.unit && from.layer === to.layer;
|
|
38
|
+
if (viaCrossImportApi) return null;
|
|
39
|
+
if (from.layer === to.layer && SLICED_LAYERS.has(to.layer)) return { kind: "cross-slice", from, to };
|
|
40
|
+
if (to.rest.length > 0) return { kind: "sidestep", from, to };
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function describeLocation(location) {
|
|
44
|
+
return location.unit === null ? location.layer : `${location.layer}/${location.unit}`;
|
|
45
|
+
}
|
|
46
|
+
function resolveSpecifier(specifier, importerRootRelative, options) {
|
|
47
|
+
let rootRelative = null;
|
|
48
|
+
if (specifier.startsWith("./") || specifier.startsWith("../") || specifier === "." || specifier === "..") {
|
|
49
|
+
rootRelative = posix.normalize(posix.join(posix.dirname(importerRootRelative), specifier));
|
|
50
|
+
} else {
|
|
51
|
+
for (const [prefix, target] of Object.entries(options.aliases)) {
|
|
52
|
+
if (!specifier.startsWith(prefix)) continue;
|
|
53
|
+
const targetAbsolute = resolve(options.cwd, target);
|
|
54
|
+
const targetRootRelative = toPosix(relative(options.rootAbsolute, targetAbsolute));
|
|
55
|
+
rootRelative = posix.normalize(posix.join(targetRootRelative === "" ? "." : targetRootRelative, specifier.slice(prefix.length)));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (rootRelative === null) return null;
|
|
60
|
+
if (rootRelative === ".." || rootRelative.startsWith("../")) return null;
|
|
61
|
+
return rootRelative;
|
|
62
|
+
}
|
|
63
|
+
function toPosix(path) {
|
|
64
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
65
|
+
}
|
|
66
|
+
var fsdBoundariesRule = {
|
|
67
|
+
meta: {
|
|
68
|
+
type: "problem",
|
|
69
|
+
docs: {
|
|
70
|
+
description: "Enforce canonical Feature-Sliced Design import rules inside a frontend app",
|
|
71
|
+
url: "https://github.com/clearlydev/nx-archweave#fsd-lite"
|
|
72
|
+
},
|
|
73
|
+
schema: [
|
|
74
|
+
{
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
root: { type: "string" },
|
|
78
|
+
aliases: { type: "object", additionalProperties: { type: "string" } }
|
|
79
|
+
},
|
|
80
|
+
additionalProperties: false
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
messages: {
|
|
84
|
+
upward: 'FSD: "{{from}}" may not import from "{{to}}" (a higher layer).',
|
|
85
|
+
crossSlice: 'FSD: "{{from}}" may not import a sibling slice "{{to}}" \u2014 move the shared part down a layer, or expose it through "{{to}}/@x/{{fromUnit}}".',
|
|
86
|
+
sidestep: 'FSD: import "{{to}}" through its public API (index.ts), not its internal file "{{path}}".'
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
create(context) {
|
|
90
|
+
const options = context.options[0] ?? {};
|
|
91
|
+
const root = options.root ?? "src";
|
|
92
|
+
const aliases = options.aliases ?? { "@/": root };
|
|
93
|
+
const cwd = context.cwd;
|
|
94
|
+
const rootAbsolute = resolve(cwd, root);
|
|
95
|
+
const importerRootRelative = toPosix(relative(rootAbsolute, context.filename));
|
|
96
|
+
if (importerRootRelative === "" || importerRootRelative.startsWith("..")) return {};
|
|
97
|
+
const from = locate(importerRootRelative);
|
|
98
|
+
if (from === null) return {};
|
|
99
|
+
const check = (node, specifier) => {
|
|
100
|
+
if (typeof specifier !== "string") return;
|
|
101
|
+
const target = resolveSpecifier(specifier, importerRootRelative, { rootAbsolute, cwd, aliases });
|
|
102
|
+
if (target === null) return;
|
|
103
|
+
const to = locate(target);
|
|
104
|
+
if (to === null) return;
|
|
105
|
+
const violation = judge(from, to);
|
|
106
|
+
if (violation === null) return;
|
|
107
|
+
const data = { from: describeLocation(from), to: describeLocation(to), fromUnit: from.unit ?? "", path: target };
|
|
108
|
+
context.report({
|
|
109
|
+
node,
|
|
110
|
+
messageId: violation.kind === "upward" ? "upward" : violation.kind === "cross-slice" ? "crossSlice" : "sidestep",
|
|
111
|
+
data
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
const fromSource = (node) => check(node, node.source?.value);
|
|
115
|
+
return {
|
|
116
|
+
ImportDeclaration: fromSource,
|
|
117
|
+
ExportNamedDeclaration: fromSource,
|
|
118
|
+
ExportAllDeclaration: fromSource,
|
|
119
|
+
ImportExpression: (node) => {
|
|
120
|
+
const argument = node.source;
|
|
121
|
+
if (argument && argument.type === "Literal") check(node, argument.value);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/eslint/tsconfig-aliases.ts
|
|
128
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
129
|
+
import { dirname, isAbsolute, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
130
|
+
function aliasesFromTsconfig({ cwd, tsconfig = "tsconfig.json", maxDepth = 8 }) {
|
|
131
|
+
const aliases = {};
|
|
132
|
+
let current = resolve2(cwd, tsconfig);
|
|
133
|
+
const seen = /* @__PURE__ */ new Set();
|
|
134
|
+
for (let depth = 0; current !== null && depth < maxDepth && !seen.has(current); depth++) {
|
|
135
|
+
seen.add(current);
|
|
136
|
+
const config = readJsonc(current);
|
|
137
|
+
if (config === null) break;
|
|
138
|
+
const compilerOptions = asRecord(config["compilerOptions"]);
|
|
139
|
+
const paths = asRecord(compilerOptions?.["paths"]);
|
|
140
|
+
if (paths) {
|
|
141
|
+
const baseUrl = typeof compilerOptions?.["baseUrl"] === "string" ? resolve2(dirname(current), compilerOptions["baseUrl"]) : dirname(current);
|
|
142
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
143
|
+
if (!pattern.endsWith("*")) continue;
|
|
144
|
+
const prefix = pattern.slice(0, -1);
|
|
145
|
+
if (prefix in aliases) continue;
|
|
146
|
+
const target = Array.isArray(targets) ? targets.find((t) => typeof t === "string") : void 0;
|
|
147
|
+
if (target === void 0 || !target.endsWith("/*")) continue;
|
|
148
|
+
const dir = toPosix2(relative2(cwd, resolve2(baseUrl, target.slice(0, -"/*".length))));
|
|
149
|
+
aliases[prefix] = dir === "" ? "." : dir;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const parent = config["extends"];
|
|
153
|
+
const parentPath = typeof parent === "string" ? parent : Array.isArray(parent) ? parent.find((p) => typeof p === "string") : void 0;
|
|
154
|
+
current = parentPath !== void 0 && (parentPath.startsWith(".") || isAbsolute(parentPath)) ? resolveExtends(dirname(current), parentPath) : null;
|
|
155
|
+
}
|
|
156
|
+
return aliases;
|
|
157
|
+
}
|
|
158
|
+
function resolveExtends(fromDir, target) {
|
|
159
|
+
const candidates = [resolve2(fromDir, target), resolve2(fromDir, `${target}.json`)];
|
|
160
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
161
|
+
}
|
|
162
|
+
function asRecord(value) {
|
|
163
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
164
|
+
}
|
|
165
|
+
function toPosix2(path) {
|
|
166
|
+
return sep2 === "/" ? path : path.split(sep2).join("/");
|
|
167
|
+
}
|
|
168
|
+
function readJsonc(path) {
|
|
169
|
+
let text;
|
|
170
|
+
try {
|
|
171
|
+
text = readFileSync(path, "utf-8");
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
let out = "";
|
|
176
|
+
let mode = "code";
|
|
177
|
+
for (let i = 0; i < text.length; i++) {
|
|
178
|
+
const c = text[i];
|
|
179
|
+
const d = text[i + 1];
|
|
180
|
+
if (mode === "code") {
|
|
181
|
+
if (c === '"') {
|
|
182
|
+
mode = "string";
|
|
183
|
+
out += c;
|
|
184
|
+
} else if (c === "/" && d === "/") {
|
|
185
|
+
mode = "line";
|
|
186
|
+
i++;
|
|
187
|
+
} else if (c === "/" && d === "*") {
|
|
188
|
+
mode = "block";
|
|
189
|
+
i++;
|
|
190
|
+
} else {
|
|
191
|
+
out += c;
|
|
192
|
+
}
|
|
193
|
+
} else if (mode === "string") {
|
|
194
|
+
out += c;
|
|
195
|
+
if (c === "\\") {
|
|
196
|
+
out += d ?? "";
|
|
197
|
+
i++;
|
|
198
|
+
} else if (c === '"') {
|
|
199
|
+
mode = "code";
|
|
200
|
+
}
|
|
201
|
+
} else if (mode === "line") {
|
|
202
|
+
if (c === "\n") {
|
|
203
|
+
mode = "code";
|
|
204
|
+
out += c;
|
|
205
|
+
}
|
|
206
|
+
} else if (c === "*" && d === "/") {
|
|
207
|
+
mode = "code";
|
|
208
|
+
i++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
213
|
+
return asRecord(parsed);
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/eslint/index.ts
|
|
220
|
+
function ownVersion() {
|
|
221
|
+
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
222
|
+
for (; ; ) {
|
|
223
|
+
const manifest = join(dir, "package.json");
|
|
224
|
+
if (existsSync2(manifest)) {
|
|
225
|
+
const pkg = JSON.parse(readFileSync2(manifest, "utf-8"));
|
|
226
|
+
if (pkg.name === "@clearlydev/nx-archweave" && pkg.version) return pkg.version;
|
|
227
|
+
}
|
|
228
|
+
const parent = dirname2(dir);
|
|
229
|
+
if (parent === dir) throw new Error("@clearlydev/nx-archweave: its own package.json was not found");
|
|
230
|
+
dir = parent;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
var archweavePlugin = {
|
|
234
|
+
meta: { name: "@clearlydev/nx-archweave", version: ownVersion() },
|
|
235
|
+
rules: { "fsd-boundaries": fsdBoundariesRule }
|
|
236
|
+
};
|
|
237
|
+
function fsdBoundaries(options = {}) {
|
|
238
|
+
const root = options.root ?? "src";
|
|
239
|
+
const cwd = options.cwd ?? process.cwd();
|
|
240
|
+
const fromTsconfig = options.aliases === void 0 && options.tsconfig !== false ? aliasesFromTsconfig({ cwd, tsconfig: options.tsconfig }) : {};
|
|
241
|
+
const ruleOptions = { root, aliases: options.aliases ?? { "@/": root, ...fromTsconfig } };
|
|
242
|
+
return [
|
|
243
|
+
{
|
|
244
|
+
name: "nx-archweave/fsd-boundaries",
|
|
245
|
+
files: options.files ?? [`${root}/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`],
|
|
246
|
+
plugins: { archweave: archweavePlugin },
|
|
247
|
+
rules: { "archweave/fsd-boundaries": [options.severity ?? "error", ruleOptions] }
|
|
248
|
+
}
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
export {
|
|
252
|
+
aliasesFromTsconfig,
|
|
253
|
+
archweavePlugin,
|
|
254
|
+
fsdBoundaries,
|
|
255
|
+
fsdBoundariesRule
|
|
256
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import aliases from a tsconfig's `compilerOptions.paths`: every
|
|
3
|
+
* `"<prefix>*": ["<dir>/*"]` entry becomes `{ "<prefix>": "<dir>" }`, the directory relative
|
|
4
|
+
* to `cwd` (what the rule's `aliases` option takes). The `extends` chain is followed
|
|
5
|
+
* (relative paths only — a package preset is skipped), the nearest config winning per key;
|
|
6
|
+
* `baseUrl` of the config that declares the paths resolves the targets. Comments and
|
|
7
|
+
* trailing commas are tolerated, like tsc. Anything unreadable yields nothing: the rule
|
|
8
|
+
* then falls back to its default alias — a wrong alias map costs a missed report, never a
|
|
9
|
+
* false one, so this stays best-effort.
|
|
10
|
+
*/
|
|
11
|
+
export declare function aliasesFromTsconfig({ cwd, tsconfig, maxDepth }: {
|
|
12
|
+
cwd: string;
|
|
13
|
+
tsconfig?: string;
|
|
14
|
+
maxDepth?: number;
|
|
15
|
+
}): Record<string, string>;
|
|
16
|
+
/** tsconfig is JSON with comments and trailing commas; strip both outside strings. */
|
|
17
|
+
export declare function readJsonc(path: string): Record<string, unknown> | null;
|
|
18
|
+
//# sourceMappingURL=tsconfig-aliases.d.ts.map
|