@enhance-eng/archangel 0.0.1 → 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/presets/fsd.js ADDED
@@ -0,0 +1,285 @@
1
+ // The Feature-Sliced Design preset: Steiger's 21 rules, fourteen as built-in sentences and seven
2
+ // as conditions of the `fsd` plugin. Names and paths are written relative to the preset; archangel
3
+ // gives them the preset's name as a prefix and its root as the folder they are in.
4
+ import { fsdPlugin } from './fsd-conditions.js';
5
+
6
+ const LAYERS = ['shared', 'entities', 'features', 'widgets', 'pages', 'app'];
7
+ const SLICED = ['entities', 'features', 'widgets', 'pages'];
8
+ const SEGMENTS = ['ui', 'api', 'lib', 'model', 'config'];
9
+ const RESERVED = [...SEGMENTS, '@x'];
10
+ const PURPOSELESS = [
11
+ 'component', 'components', 'helper', 'helpers', 'util', 'utils', 'constant', 'constants', 'const',
12
+ 'consts', 'type', 'types', 'store', 'stores', 'modal', 'modals', 'service', 'services', 'function',
13
+ 'functions', 'class', 'classes', 'enum', 'enums', 'interface', 'interfaces', 'decorator',
14
+ 'decorators', 'schema', 'schemas', 'handler', 'handlers', 'fixture', 'fixtures', 'middleware',
15
+ 'middlewares', 'validator', 'validators', 'validation', 'validations', 'resolver', 'resolvers',
16
+ 'mutation', 'mutations', 'asset', 'assets', 'hook', 'hooks', 'context', 'provider', 'providers',
17
+ 'composable', 'composables', 'directive', 'directives', 'action', 'actions', 'reducer',
18
+ 'reducers', 'selector', 'selectors', 'effect', 'effects', 'saga', 'sagas', 'thunk', 'thunks',
19
+ 'pipe', 'pipes',
20
+ ];
21
+ const OPT_IN = ['no-cross-imports', 'no-higher-level-imports', 'import-locality', 'no-wildcard-exports'];
22
+
23
+ /** Alternatives in a pattern: `{a,b}`, or the one name itself, since `{a}` would be a capture. */
24
+ const either = (names) => (names.length === 1 ? names[0] : `{${names.join(',')}}`);
25
+
26
+ const higher = (layer) => LAYERS.slice(LAYERS.indexOf(layer) + 1);
27
+
28
+ const crossImports = SLICED.map((layer) => ({
29
+ files: layer,
30
+ mayNotImport: { all: [layer, { not: `${layer}/*/@x/**` }] },
31
+ instance: 'different',
32
+ }));
33
+
34
+ const higherImports = LAYERS.filter((layer) => higher(layer).length > 0).map((layer) => ({
35
+ files: layer,
36
+ mayNotImport: higher(layer),
37
+ }));
38
+
39
+ const docs = (rule) => `https://feature-sliced.github.io/steiger/rules/${rule}`;
40
+
41
+ /** An example of `count` files, each made by `file` from a two-digit number. */
42
+ const many = (count, file) => Object.fromEntries(Array.from({ length: count }, (_, i) => file(String(i + 1).padStart(2, '0'))));
43
+
44
+ /** The rules about imports, each with the examples that prove it. */
45
+ const IMPORT_RULES = [
46
+ {
47
+ name: 'forbidden-imports',
48
+ reason: 'A module may import only from layers below its own, and a slice not from another slice of its layer, except through @x.',
49
+ link: docs('forbidden-imports'),
50
+ sentences: [...higherImports, ...crossImports],
51
+ examples: {
52
+ rejected: [
53
+ { 'shared/example-lib/index.ts': "import { x } from '../../features/example-login';\n", 'features/example-login/index.ts': 'export const x = 1;\n' },
54
+ { 'features/example-a/model/a.ts': "import { b } from '../../example-b';\n", 'features/example-b/index.ts': 'export const b = 1;\n' },
55
+ ],
56
+ accepted: [
57
+ { 'features/example-login/model/a.ts': "import { y } from '../../../entities/example-user';\n", 'entities/example-user/index.ts': 'export const y = 1;\n' },
58
+ { 'entities/example-a/model/a.ts': "import { b } from '../../example-b/@x/example-a';\n", 'entities/example-b/@x/example-a.ts': 'export const b = 1;\n' },
59
+ ],
60
+ },
61
+ },
62
+ {
63
+ name: 'no-higher-level-imports',
64
+ reason: 'A module may import only from layers below its own.',
65
+ link: docs('no-higher-level-imports'),
66
+ sentences: higherImports,
67
+ examples: {
68
+ rejected: [{ 'shared/example-lib/index.ts': "import { x } from '../../features/example-login';\n", 'features/example-login/index.ts': 'export const x = 1;\n' }],
69
+ accepted: [{ 'features/example-login/model/a.ts': "import { y } from '../../../entities/example-user';\n", 'entities/example-user/index.ts': 'export const y = 1;\n' }],
70
+ },
71
+ },
72
+ {
73
+ name: 'no-cross-imports',
74
+ reason: 'A slice may not import from another slice of its layer, except through its @x public API.',
75
+ link: docs('no-cross-imports'),
76
+ sentences: crossImports,
77
+ examples: {
78
+ rejected: [{ 'features/example-a/model/a.ts': "import { b } from '../../example-b';\n", 'features/example-b/index.ts': 'export const b = 1;\n' }],
79
+ accepted: [{ 'entities/example-a/model/a.ts': "import { b } from '../../example-b/@x/example-a';\n", 'entities/example-b/@x/example-a.ts': 'export const b = 1;\n' }],
80
+ },
81
+ },
82
+ {
83
+ name: 'no-public-api-sidestep',
84
+ reason: "Code outside a slice or segment imports it through its public API, the index file, not its inner files.",
85
+ link: docs('no-public-api-sidestep'),
86
+ sentences: [
87
+ ...SLICED.map((layer) => ({ kind: 'entry', element: layer, files: ['index.*', '@x/**'] })),
88
+ {
89
+ files: { all: ['shared', { not: ['shared/*', 'shared/*/index.*', 'shared/{ui,lib}/*', 'shared/{ui,lib}/*/index.*'] }] },
90
+ mayOnlyBeImportedBy: 'shared',
91
+ },
92
+ { files: { all: ['app', { not: ['app/*', 'app/*/index.*'] }] }, mayOnlyBeImportedBy: 'app' },
93
+ ],
94
+ examples: {
95
+ rejected: [
96
+ { 'pages/example-home/ui/a.ts': "import { x } from '../../../features/example-login/model/x';\n", 'features/example-login/model/x.ts': 'export const x = 1;\n' },
97
+ { 'features/example-login/model/a.ts': "import { x } from '../../../shared/example-api/client';\n", 'shared/example-api/client.ts': 'export const x = 1;\n', 'shared/example-api/index.ts': "export { x } from './client';\n" },
98
+ ],
99
+ accepted: [
100
+ { 'pages/example-home/ui/a.ts': "import { x } from '../../../features/example-login';\n", 'features/example-login/index.ts': 'export const x = 1;\n' },
101
+ ],
102
+ },
103
+ },
104
+ ];
105
+
106
+ /** The rules about public APIs and where files and folders are, each with its examples. */
107
+ const STRUCTURE_RULES = [
108
+ {
109
+ name: 'public-api',
110
+ reason: 'Each slice, each segment of Shared, and each folder in shared/ui and shared/lib has an index file, its public API.',
111
+ link: docs('public-api'),
112
+ folders: [`${either(SLICED)}/*`, 'shared/*', '!shared/{ui,lib}', 'shared/{ui,lib}/*'],
113
+ mustContain: 'index.*',
114
+ examples: {
115
+ rejected: [{ 'features/example-login/model/x.ts': 'export const x = 1;\n' }],
116
+ accepted: [{ 'features/example-login/index.ts': "export { x } from './model/x';\n", 'features/example-login/model/x.ts': 'export const x = 1;\n' }],
117
+ },
118
+ },
119
+ {
120
+ name: 'no-layer-public-api',
121
+ reason: 'A layer other than App has no index file; its slices and segments have them instead.',
122
+ link: docs('no-layer-public-api'),
123
+ files: `${either(LAYERS.filter((layer) => layer !== 'app'))}/index.*`,
124
+ mustNotExist: true,
125
+ examples: { rejected: [{ 'entities/index.ts': 'export {};\n' }] },
126
+ },
127
+ {
128
+ name: 'no-reserved-folder-names',
129
+ reason: 'A folder inside a segment may not have a name that a segment or @x has, which would be confusing.',
130
+ link: docs('no-reserved-folder-names'),
131
+ folders: [`{shared,app}/*/**/${either(RESERVED)}`, `${either(SLICED)}/*/*/**/${either(RESERVED)}`],
132
+ mustNotExist: true,
133
+ examples: { rejected: [{ 'shared/example-kit/ui/button.ts': 'export {};\n' }] },
134
+ },
135
+ {
136
+ name: 'no-segmentless-slices',
137
+ reason: 'Each slice divides its code into segments, such as ui, api, lib, model and config.',
138
+ link: docs('no-segmentless-slices'),
139
+ folders: `${either(SLICED)}/*`,
140
+ mustContain: [`${either(SEGMENTS)}.*`, `${either(SEGMENTS)}/**`],
141
+ examples: {
142
+ rejected: [{ 'features/example-login/index.ts': 'export {};\n', 'features/example-login/login.ts': 'export {};\n' }],
143
+ accepted: [{ 'features/example-login/index.ts': 'export {};\n', 'features/example-login/model/login.ts': 'export {};\n' }],
144
+ },
145
+ },
146
+ {
147
+ name: 'no-segments-on-sliced-layers',
148
+ reason: 'A segment belongs inside a slice, not directly in a sliced layer.',
149
+ link: docs('no-segments-on-sliced-layers'),
150
+ folders: `${either(SLICED)}/${either(SEGMENTS)}`,
151
+ mustNotExist: true,
152
+ examples: { rejected: [{ 'features/ui/button.ts': 'export {};\n' }] },
153
+ },
154
+ {
155
+ name: 'no-ui-in-app',
156
+ reason: 'The App layer has no ui segment; UI belongs in the layers below it.',
157
+ link: docs('no-ui-in-app'),
158
+ sentences: [
159
+ { folders: 'app/ui', mustNotExist: true },
160
+ { files: 'app/ui.*', mustNotExist: true },
161
+ ],
162
+ examples: { rejected: [{ 'app/ui/root.tsx': 'export {};\n' }, { 'app/ui.tsx': 'export {};\n' }] },
163
+ },
164
+ {
165
+ name: 'segments-by-purpose',
166
+ reason: "A segment's name says what its code is for, not what it is, so not utils, components or hooks.",
167
+ link: docs('segments-by-purpose'),
168
+ sentences: [
169
+ { folders: [`{shared,app}/${either(PURPOSELESS)}`, `${either(SLICED)}/*/${either(PURPOSELESS)}`], mustNotExist: true },
170
+ { files: [`{shared,app}/${either(PURPOSELESS)}.*`, `${either(SLICED)}/*/${either(PURPOSELESS)}.*`], mustNotExist: true },
171
+ ],
172
+ examples: { rejected: [{ 'features/example-login/utils/format.ts': 'export {};\n' }, { 'shared/helpers.ts': 'export {};\n' }] },
173
+ },
174
+ {
175
+ name: 'no-processes',
176
+ reason: 'The Processes layer is deprecated; move its code to Features and App.',
177
+ link: docs('no-processes'),
178
+ folders: 'processes',
179
+ mustNotExist: true,
180
+ examples: { rejected: [{ 'processes/example-checkout/index.ts': 'export {};\n' }] },
181
+ },
182
+ ];
183
+
184
+ /** Steiger's opt-in rules about how imports and exports are written, each with its examples. */
185
+ const WRITING_RULES = [
186
+ {
187
+ name: 'import-locality',
188
+ reason: 'An import inside a slice is relative, and an import from another slice or layer goes through an alias.',
189
+ link: docs('import-locality'),
190
+ sentences: [
191
+ ...LAYERS.map((layer) => ({ files: layer, mayNotImport: LAYERS.filter((l) => l !== layer), specifier: 'relative' })),
192
+ ...SLICED.map((layer) => ({ files: layer, mayNotImport: layer, instance: 'different', specifier: 'relative' })),
193
+ ...SLICED.map((layer) => ({ files: layer, mayNotImport: layer, instance: 'same', specifier: 'alias' })),
194
+ ...['shared', 'app'].map((layer) => ({ files: layer, mayNotImport: layer, specifier: 'alias' })),
195
+ ],
196
+ examples: {
197
+ rejected: [{ 'features/example-a/model/a.ts': "import { b } from '../../../entities/example-b';\n", 'entities/example-b/index.ts': 'export const b = 1;\n' }],
198
+ accepted: [{ 'features/example-a/model/a.ts': "import { c } from '../lib/c';\n", 'features/example-a/lib/c.ts': 'export const c = 1;\n' }],
199
+ },
200
+ },
201
+ {
202
+ name: 'no-wildcard-exports',
203
+ reason: 'An index file names what it exports, so the public API is explicit.',
204
+ link: docs('no-wildcard-exports'),
205
+ files: `${either(LAYERS)}/**/index.*`,
206
+ mayNotExport: 'star',
207
+ examples: {
208
+ rejected: [{ 'features/example-a/index.ts': "export * from './model/a';\n", 'features/example-a/model/a.ts': 'export const a = 1;\n' }],
209
+ accepted: [{ 'features/example-a/index.ts': "export { a } from './model/a';\n", 'features/example-a/model/a.ts': 'export const a = 1;\n' }],
210
+ },
211
+ },
212
+ ];
213
+
214
+ /** The rules, before `strict` chooses which are on. */
215
+ const rules = (root) => [...IMPORT_RULES, ...STRUCTURE_RULES, ...WRITING_RULES, ...pluginRules(root)];
216
+
217
+ const pluginRule = (root, name, reason, examples) => ({
218
+ name,
219
+ reason,
220
+ link: docs(name),
221
+ files: '**',
222
+ plugin: `fsd/${name}`,
223
+ options: { root },
224
+ examples,
225
+ });
226
+
227
+ const pluginRules = (root) => [
228
+ pluginRule(root, 'typo-in-layer-name', 'A folder in the root whose name is close to a layer name is probably that layer, misspelt.', {
229
+ rejected: [{ 'featurez/example.ts': 'export {};\n' }],
230
+ accepted: [{ 'features/example-login/index.ts': 'export {};\n' }],
231
+ }),
232
+ pluginRule(root, 'ambiguous-slice-names', 'A slice named like a segment of Shared could be mistaken for it.', {
233
+ rejected: [{ 'shared/example-kit/index.ts': 'export {};\n', 'features/example-kit/model/a.ts': 'export {};\n' }],
234
+ accepted: [{ 'features/example-login/model/a.ts': 'export {};\n' }],
235
+ }),
236
+ pluginRule(root, 'inconsistent-naming', 'The slices of Entities are all plural or all singular.', {
237
+ rejected: [{ 'entities/example-group/orders/model/a.ts': 'export {};\n', 'entities/example-group/order-line/model/a.ts': 'export {};\n' }],
238
+ accepted: [{ 'entities/example-group/apples/model/a.ts': 'export {};\n', 'entities/example-group/pears/model/a.ts': 'export {};\n' }],
239
+ }),
240
+ pluginRule(root, 'insignificant-slice', 'A slice that at most one other slice uses belongs in that slice.', {
241
+ rejected: [{ 'entities/example-lonely/index.ts': 'export {};\n', 'entities/example-lonely/model/a.ts': 'export {};\n' }],
242
+ accepted: [
243
+ {
244
+ 'entities/example-used/index.ts': 'export const used = 1;\n',
245
+ 'entities/example-used/model/a.ts': 'export {};\n',
246
+ 'pages/example-one/ui/a.ts': "import { used } from '../../../entities/example-used';\nexport const a = used;\n",
247
+ 'pages/example-two/ui/b.ts': "import { used } from '../../../entities/example-used';\nexport const b = used;\n",
248
+ },
249
+ ],
250
+ }),
251
+ pluginRule(root, 'repetitive-naming', 'Slices that all repeat one word should be a slice group named for it.', {
252
+ rejected: [{ 'features/example-group/item-save/model/a.ts': 'export {};\n', 'features/example-group/item-load/model/a.ts': 'export {};\n', 'features/example-group/item-drop/model/a.ts': 'export {};\n' }],
253
+ accepted: [{ 'features/example-group/save/model/a.ts': 'export {};\n', 'features/example-group/load/model/a.ts': 'export {};\n', 'features/example-group/drop/model/a.ts': 'export {};\n' }],
254
+ }),
255
+ pluginRule(root, 'excessive-slicing', 'More than 20 slices in a layer or group are hard to find your way in.', {
256
+ rejected: [many(21, (i) => [`widgets/example-group/w${i}/ui/a.ts`, 'export {};\n'])],
257
+ accepted: [{ 'widgets/example-group/header/ui/a.ts': 'export {};\n', 'widgets/example-group/footer/ui/a.ts': 'export {};\n' }],
258
+ }),
259
+ pluginRule(root, 'shared-lib-grouping', 'More than 15 modules in shared/lib are hard to find your way in.', {
260
+ rejected: [many(16, (i) => [`shared/lib/example-${i}.ts`, 'export {};\n'])],
261
+ accepted: [{ 'shared/lib/example-dates/index.ts': 'export {};\n' }],
262
+ }),
263
+ ];
264
+
265
+ /**
266
+ * The Feature-Sliced Design preset for the code under `root`. It turns on the rules Steiger
267
+ * recommends, and with `strict` its four opt-in rules too. A second use in one repository takes
268
+ * its own `name`.
269
+ */
270
+ export const fsd = ({ root, strict = false, name = 'fsd' }) => ({
271
+ name,
272
+ package: '@enhance-eng/archangel',
273
+ root,
274
+ elements: {
275
+ shared: 'shared/**',
276
+ entities: 'entities/{slice}/**',
277
+ features: 'features/{slice}/**',
278
+ widgets: 'widgets/{slice}/**',
279
+ pages: 'pages/{slice}/**',
280
+ app: 'app/**',
281
+ processes: 'processes/**',
282
+ },
283
+ plugins: { fsd: fsdPlugin },
284
+ rules: rules(root).filter((rule) => strict || !OPT_IN.includes(rule.name)),
285
+ });
@@ -0,0 +1,45 @@
1
+ // The Next.js preset: code that a client component reaches stays out of server-only code and
2
+ // Node's built-in modules.
3
+
4
+ const page = (text) => `'use client';\n${text}export default function Page() { return null; }\n`;
5
+
6
+ /**
7
+ * The Next.js preset. A file whose first statement is `'use client'`, and every file it reaches
8
+ * through runtime imports, may not import `server-only` or a Node built-in module; a chain stops
9
+ * at a file that starts with `'use server'`, whose import Next.js turns into a network call.
10
+ */
11
+ export const nextjs = ({ name = 'nextjs', root = '' } = {}) => ({
12
+ name,
13
+ package: '@enhance-eng/archangel',
14
+ root,
15
+ selectors: {
16
+ client: { directive: 'use client' },
17
+ server: { directive: 'use server' },
18
+ },
19
+ rules: [
20
+ {
21
+ name: 'client-stays-client',
22
+ reason: 'Code that runs in the browser cannot use server-only code or Node built-in modules.',
23
+ link: 'https://nextjs.org/docs/app/getting-started/server-and-client-components',
24
+ files: 'client',
25
+ mayNotImport: { packages: ['server-only', 'node:*'] },
26
+ transitively: true,
27
+ stopAt: 'server',
28
+ imports: 'runtime',
29
+ examples: {
30
+ rejected: [
31
+ {
32
+ 'app/example-page.tsx': page("import { read } from './example-helper';\n"),
33
+ 'app/example-helper.ts': "import { readFile } from 'node:fs';\nexport const read = readFile;\n",
34
+ },
35
+ ],
36
+ accepted: [
37
+ {
38
+ 'app/example-page.tsx': page("import { save } from './example-actions';\n"),
39
+ 'app/example-actions.ts': "'use server';\nimport { readFile } from 'node:fs';\nexport async function save() { return readFile; }\n",
40
+ },
41
+ ],
42
+ },
43
+ },
44
+ ],
45
+ });