@enhance-eng/archangel 0.0.1 → 0.1.0
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/README.md +25 -3
- package/archangel.schema.json +1192 -0
- package/bin/archangel.js +28 -0
- package/index.d.ts +374 -0
- package/index.js +9 -0
- package/lib/platform.js +41 -0
- package/package.json +53 -7
- package/plugin.d.ts +121 -0
- package/plugin.js +8 -0
- package/presets/fsd-conditions.js +360 -0
- package/presets/fsd.js +285 -0
- package/presets/nextjs.js +45 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The `fsd` plugin: the seven Feature-Sliced Design rules that are guesses about names or counts,
|
|
2
|
+
// which serve this one preset and so stay out of archangel's built-in conditions. Each follows
|
|
3
|
+
// the rule of the same name in Steiger 0.8.1's Feature-Sliced Design plugin, over a folder tree
|
|
4
|
+
// built from the paths in the view, and reports the folder Steiger reports.
|
|
5
|
+
import pluralize from 'pluralize';
|
|
6
|
+
|
|
7
|
+
import { defineCondition, definePlugin } from '../plugin.js';
|
|
8
|
+
|
|
9
|
+
const LAYERS = ['shared', 'entities', 'features', 'widgets', 'pages', 'app'];
|
|
10
|
+
const UNSLICED = ['shared', 'app'];
|
|
11
|
+
const SEGMENTS = ['ui', 'api', 'lib', 'model', 'config'];
|
|
12
|
+
const SOURCE = ['.tsx', '.jsx', '.ts', '.js', '.cjs', '.mjs'];
|
|
13
|
+
const DOCS = 'https://feature-sliced.github.io/steiger/rules/';
|
|
14
|
+
|
|
15
|
+
const baseName = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
16
|
+
|
|
17
|
+
/** A name without its last extension, as Node's `path.parse` gives it. */
|
|
18
|
+
const stem = (path) => {
|
|
19
|
+
const base = baseName(path);
|
|
20
|
+
const dot = base.lastIndexOf('.');
|
|
21
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Two paths joined with a slash, either of which may be empty. */
|
|
25
|
+
const join = (first, second) => (first === '' ? second : second === '' ? first : `${first}/${second}`);
|
|
26
|
+
|
|
27
|
+
/** The folder tree below `root`, from the paths of the view's files. */
|
|
28
|
+
const tree = (view, root) => {
|
|
29
|
+
const top = { type: 'folder', path: root, children: [] };
|
|
30
|
+
const folders = new Map([[root, top]]);
|
|
31
|
+
const prefix = root === '' ? '' : `${root}/`;
|
|
32
|
+
for (const file of view.files) {
|
|
33
|
+
if (!file.path.startsWith(prefix)) continue;
|
|
34
|
+
const parts = file.path.slice(prefix.length).split('/');
|
|
35
|
+
let folder = top;
|
|
36
|
+
for (const part of parts.slice(0, -1)) {
|
|
37
|
+
const path = join(folder.path, part);
|
|
38
|
+
let next = folders.get(path);
|
|
39
|
+
if (next === undefined) {
|
|
40
|
+
next = { type: 'folder', path, children: [] };
|
|
41
|
+
folders.set(path, next);
|
|
42
|
+
folder.children.push(next);
|
|
43
|
+
}
|
|
44
|
+
folder = next;
|
|
45
|
+
}
|
|
46
|
+
folder.children.push({ type: 'file', path: file.path });
|
|
47
|
+
}
|
|
48
|
+
return top;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const hasPrefix = (name) => /^([0-9]_|_)/.test(name);
|
|
52
|
+
const removePrefix = (name) => name.replace(/^([0-9]_|_)/, '');
|
|
53
|
+
|
|
54
|
+
const layersOf = (root) => {
|
|
55
|
+
const found = [];
|
|
56
|
+
for (const child of root.children) {
|
|
57
|
+
if (child.type !== 'folder') continue;
|
|
58
|
+
const name = baseName(child.path);
|
|
59
|
+
const layer = LAYERS.find((l) => l === removePrefix(name));
|
|
60
|
+
if (layer === undefined) continue;
|
|
61
|
+
const existing = found.find(([l]) => l === layer);
|
|
62
|
+
if (existing === undefined) found.push([layer, child]);
|
|
63
|
+
else if (hasPrefix(name) && !hasPrefix(baseName(existing[1].path))) existing[1] = child;
|
|
64
|
+
}
|
|
65
|
+
return Object.fromEntries(found);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isSliced = (layer) => !UNSLICED.includes(removePrefix(baseName(typeof layer === 'string' ? layer : layer.path)));
|
|
69
|
+
const isIndex = (node) => node.type === 'file' && stem(node.path).split('.')[0] === 'index';
|
|
70
|
+
const isSlice = (folder) => folder.children.some((child) => SEGMENTS.includes(stem(child.path)));
|
|
71
|
+
const indexesOf = (node) => (node.type === 'file' ? [node] : node.children.filter(isIndex));
|
|
72
|
+
const segmentsOf = (folder) =>
|
|
73
|
+
Object.fromEntries(folder.children.filter((child) => !isIndex(child)).map((child) => [stem(child.path), child]));
|
|
74
|
+
|
|
75
|
+
const slicesOf = (layer) => {
|
|
76
|
+
const slices = {};
|
|
77
|
+
const traverse = (folder, prefix) => {
|
|
78
|
+
if (isSlice(folder)) {
|
|
79
|
+
slices[join(prefix, baseName(folder.path))] = folder;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const child of folder.children) {
|
|
83
|
+
if (child.type === 'folder') traverse(child, join(prefix, baseName(folder.path)));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
for (const child of layer.children) if (child.type === 'folder') traverse(child, '');
|
|
87
|
+
return slices;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const allSlices = (root) => {
|
|
91
|
+
const found = {};
|
|
92
|
+
for (const [layerName, layer] of Object.entries(layersOf(root))) {
|
|
93
|
+
if (!isSliced(layer)) continue;
|
|
94
|
+
for (const [name, slice] of Object.entries(slicesOf(layer))) found[name] = { ...slice, layerName };
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const groupSlices = (names) => {
|
|
100
|
+
const groups = {};
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const parts = name.split('/');
|
|
103
|
+
const last = parts.pop();
|
|
104
|
+
const group = parts.join('/');
|
|
105
|
+
(groups[group] ??= []).push(last);
|
|
106
|
+
}
|
|
107
|
+
return groups;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Each source file in a layer, with its layer and slice, as Steiger indexes them. */
|
|
111
|
+
const indexSourceFiles = (root) => {
|
|
112
|
+
const index = {};
|
|
113
|
+
const walk = (node, place) => {
|
|
114
|
+
if (node.type === 'file') index[node.path] = place;
|
|
115
|
+
else for (const child of node.children) walk(child, place);
|
|
116
|
+
};
|
|
117
|
+
for (const [layerName, layer] of Object.entries(layersOf(root))) {
|
|
118
|
+
for (const child of layer.children) {
|
|
119
|
+
if (child.type === 'file') walk(child, { layerName, sliceName: null });
|
|
120
|
+
}
|
|
121
|
+
if (!isSliced(layer)) {
|
|
122
|
+
for (const segment of Object.values(segmentsOf(layer))) walk(segment, { layerName, sliceName: null });
|
|
123
|
+
} else {
|
|
124
|
+
for (const [sliceName, slice] of Object.entries(slicesOf(layer))) {
|
|
125
|
+
for (const segment of Object.values(segmentsOf(slice))) walk(segment, { layerName, sliceName });
|
|
126
|
+
for (const index2 of indexesOf(slice)) walk(index2, { layerName, sliceName });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return index;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** The Levenshtein distance between two names. */
|
|
134
|
+
const distance = (a, b) => {
|
|
135
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
136
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
137
|
+
const current = [i];
|
|
138
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
139
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
140
|
+
current.push(Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost));
|
|
141
|
+
}
|
|
142
|
+
previous = current;
|
|
143
|
+
}
|
|
144
|
+
return previous[b.length];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const rootOption = {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: { root: { type: 'string', description: 'The Feature-Sliced Design root.' } },
|
|
150
|
+
required: ['root'],
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const condition = (name, description, reads, check) =>
|
|
155
|
+
defineCondition({ name, description, docs: `${DOCS}${name}`, optionsSchema: rootOption, reads, check });
|
|
156
|
+
|
|
157
|
+
const typoInLayerName = condition(
|
|
158
|
+
'typo-in-layer-name',
|
|
159
|
+
'A folder in the root whose name is one to three edits from a layer name is probably that layer, misspelt.',
|
|
160
|
+
[],
|
|
161
|
+
(view, { root }) => {
|
|
162
|
+
const violations = [];
|
|
163
|
+
for (const child of tree(view, root).children) {
|
|
164
|
+
if (child.type !== 'folder') continue;
|
|
165
|
+
const name = baseName(child.path);
|
|
166
|
+
if (LAYERS.includes(name)) continue;
|
|
167
|
+
const near = LAYERS.map((layer) => [distance(name, layer), layer])
|
|
168
|
+
.filter(([d]) => d <= 3)
|
|
169
|
+
.sort((a, b) => a[0] - b[0])[0];
|
|
170
|
+
if (near !== undefined) {
|
|
171
|
+
violations.push({ file: child.path, message: `Layer "${name}" potentially contains a typo. Did you mean "${near[1]}"?` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const ambiguousSliceNames = condition(
|
|
179
|
+
'ambiguous-slice-names',
|
|
180
|
+
'A slice or slice group may not be named like a segment of Shared.',
|
|
181
|
+
[],
|
|
182
|
+
(view, { root }) => {
|
|
183
|
+
const top = tree(view, root);
|
|
184
|
+
const layers = layersOf(top);
|
|
185
|
+
if (layers.shared === undefined) return [];
|
|
186
|
+
const segmentNames = Object.keys(segmentsOf(layers.shared));
|
|
187
|
+
const violations = [];
|
|
188
|
+
for (const [sliceName, slice] of Object.entries(allSlices(top))) {
|
|
189
|
+
const parts = sliceName.split('/');
|
|
190
|
+
const matching = segmentNames.find((segment) => parts.includes(segment));
|
|
191
|
+
if (matching === undefined) continue;
|
|
192
|
+
if (matching === sliceName) {
|
|
193
|
+
violations.push({ file: slice.path, message: `Slice "${sliceName}" could be confused with a segment from Shared with the same name` });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const at = parts.indexOf(matching);
|
|
197
|
+
if (at === parts.length - 1) {
|
|
198
|
+
violations.push({ file: slice.path, message: `Slice "${sliceName}" could be confused with a segment "${matching}" from Shared` });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const toGroup = parts.slice(0, at + 1);
|
|
202
|
+
const group = toGroup.reduce(
|
|
203
|
+
(folder, part) => folder?.children.find((child) => child.type === 'folder' && baseName(child.path) === part),
|
|
204
|
+
layers[slice.layerName],
|
|
205
|
+
);
|
|
206
|
+
if (group !== undefined) {
|
|
207
|
+
violations.push({ file: group.path, message: `Slice group "${toGroup.join('/')}" could be confused with a segment "${matching}" from Shared` });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return violations;
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const NEUTRAL = new Set(['k8s', 'kubernetes', 'media']);
|
|
215
|
+
|
|
216
|
+
const inconsistentNaming = condition(
|
|
217
|
+
'inconsistent-naming',
|
|
218
|
+
'The slices of Entities in one group are all plural or all singular.',
|
|
219
|
+
[],
|
|
220
|
+
(view, { root }) => {
|
|
221
|
+
const { entities } = layersOf(tree(view, root));
|
|
222
|
+
if (entities === undefined) return [];
|
|
223
|
+
const violations = [];
|
|
224
|
+
for (const [group, names] of Object.entries(groupSlices(Object.keys(slicesOf(entities))))) {
|
|
225
|
+
const checked = names.filter((name) => !NEUTRAL.has(name.toLowerCase()));
|
|
226
|
+
const plural = checked.filter((name) => pluralize.isPlural(name));
|
|
227
|
+
const singular = checked.filter((name) => !pluralize.isPlural(name));
|
|
228
|
+
if (plural.length > 0 && singular.length > 0) {
|
|
229
|
+
const prefer = plural.length >= singular.length ? 'plural' : 'singular';
|
|
230
|
+
violations.push({
|
|
231
|
+
file: join(entities.path, group),
|
|
232
|
+
message: `Inconsistent pluralization of slice names. Prefer all ${prefer} names`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return violations;
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const insignificantSlice = condition(
|
|
241
|
+
'insignificant-slice',
|
|
242
|
+
'A slice outside Pages is used by at least two other slices or layers.',
|
|
243
|
+
['imports'],
|
|
244
|
+
(view, { root }) => {
|
|
245
|
+
const top = tree(view, root);
|
|
246
|
+
const index = indexSourceFiles(top);
|
|
247
|
+
const imports = new Map(view.files.map((file) => [file.path, file.imports]));
|
|
248
|
+
const references = new Map();
|
|
249
|
+
const referenced = (key) => {
|
|
250
|
+
if (!references.has(key)) references.set(key, new Set());
|
|
251
|
+
return references.get(key);
|
|
252
|
+
};
|
|
253
|
+
for (const [path, place] of Object.entries(index)) {
|
|
254
|
+
const key = join(place.layerName, place.sliceName ?? '');
|
|
255
|
+
const extension = path.slice(path.lastIndexOf('.'));
|
|
256
|
+
const found = SOURCE.includes(extension) ? (imports.get(path) ?? []) : [];
|
|
257
|
+
for (const target of found.map((i) => (i.file === null ? undefined : index[i.file]))) {
|
|
258
|
+
if (target === undefined || target.layerName === place.layerName) continue;
|
|
259
|
+
const targetKey = join(target.layerName, target.sliceName ?? '');
|
|
260
|
+
if (targetKey !== key) referenced(targetKey).add(key);
|
|
261
|
+
}
|
|
262
|
+
referenced(key);
|
|
263
|
+
}
|
|
264
|
+
const violations = [];
|
|
265
|
+
for (const [key, users] of references) {
|
|
266
|
+
const layer = key.split('/')[0];
|
|
267
|
+
if (!isSliced(layer) || layer === 'pages') continue;
|
|
268
|
+
const file = join(top.path, key);
|
|
269
|
+
if (users.size === 1) {
|
|
270
|
+
const [user] = users;
|
|
271
|
+
if (UNSLICED.includes(user)) {
|
|
272
|
+
if (user !== 'app') {
|
|
273
|
+
violations.push({ file, message: `This slice has only one reference on layer "${user}". Consider moving this code to "${user}".` });
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
violations.push({ file, message: `This slice has only one reference in slice "${user}". Consider merging them.` });
|
|
277
|
+
}
|
|
278
|
+
} else if (users.size === 0) {
|
|
279
|
+
violations.push({ file, message: 'This slice has no references. Consider removing it.' });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return violations;
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const repetitiveNaming = condition(
|
|
287
|
+
'repetitive-naming',
|
|
288
|
+
'The slices of a layer or group do not all repeat one word.',
|
|
289
|
+
[],
|
|
290
|
+
(view, { root }) => {
|
|
291
|
+
const violations = [];
|
|
292
|
+
for (const layer of Object.values(layersOf(tree(view, root)))) {
|
|
293
|
+
if (!isSliced(layer)) continue;
|
|
294
|
+
for (const [group, names] of Object.entries(groupSlices(Object.keys(slicesOf(layer))))) {
|
|
295
|
+
const words = names.map((name) => (name.match(/(?:[A-Z]+|[a-z]+)[a-z]*/g) ?? []).map((w) => w.toLowerCase()));
|
|
296
|
+
const counts = new Map();
|
|
297
|
+
for (const word of words.flat()) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
298
|
+
for (const [word, count] of counts) {
|
|
299
|
+
if (names.length > 2 && count >= names.length && words.every((w) => w.includes(word))) {
|
|
300
|
+
violations.push({ file: join(layer.path, group), message: `Repetitive word "${word}" in slice names.` });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return violations;
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const excessiveSlicing = condition(
|
|
310
|
+
'excessive-slicing',
|
|
311
|
+
'A sliced layer, or a group in one, has at most 20 slices.',
|
|
312
|
+
[],
|
|
313
|
+
(view, { root }) => {
|
|
314
|
+
const violations = [];
|
|
315
|
+
for (const [layerName, layer] of Object.entries(layersOf(tree(view, root)))) {
|
|
316
|
+
if (!isSliced(layer)) continue;
|
|
317
|
+
for (const [group, names] of Object.entries(groupSlices(Object.keys(slicesOf(layer))))) {
|
|
318
|
+
if (names.length <= 20) continue;
|
|
319
|
+
const where = group === '' ? `Layer "${layerName}" has ${names.length} ungrouped slices` : `Slice group "${group}" has ${names.length} slices`;
|
|
320
|
+
violations.push({
|
|
321
|
+
file: join(layer.path, group),
|
|
322
|
+
message: `${where}, which is above the recommended threshold of 20. Consider grouping them or moving the code inside to the layer where it's used.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return violations;
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const sharedLibGrouping = condition(
|
|
331
|
+
'shared-lib-grouping',
|
|
332
|
+
'Shared/lib holds at most 15 modules.',
|
|
333
|
+
[],
|
|
334
|
+
(view, { root }) => {
|
|
335
|
+
const { shared } = layersOf(tree(view, root));
|
|
336
|
+
const lib = shared === undefined ? undefined : segmentsOf(shared).lib;
|
|
337
|
+
if (lib?.type !== 'folder' || lib.children.length <= 15) return [];
|
|
338
|
+
return [
|
|
339
|
+
{
|
|
340
|
+
file: lib.path,
|
|
341
|
+
message: `Shared/lib has ${lib.children.length} modules, which is above the recommended threshold of 15. Consider grouping them.`,
|
|
342
|
+
},
|
|
343
|
+
];
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
/** The plugin the `fsd` preset brings, which its rules use as `fsd/<condition>`. */
|
|
348
|
+
export const fsdPlugin = definePlugin({
|
|
349
|
+
name: 'fsd',
|
|
350
|
+
schemaVersion: 1,
|
|
351
|
+
conditions: [
|
|
352
|
+
typoInLayerName,
|
|
353
|
+
ambiguousSliceNames,
|
|
354
|
+
inconsistentNaming,
|
|
355
|
+
insignificantSlice,
|
|
356
|
+
repetitiveNaming,
|
|
357
|
+
excessiveSlicing,
|
|
358
|
+
sharedLibGrouping,
|
|
359
|
+
],
|
|
360
|
+
});
|
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
|
+
});
|