@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/plugin.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ // The types of an archangel plugin: the view its conditions receive, and the helpers that declare a
2
+ // plugin and its conditions. The view's fields match what archangel sends; a test in the engine
3
+ // compares the two. A plugin reads only the fields it knows, so a field added to the view never
4
+ // breaks one, and `schemaVersion` changes only when a field's meaning does.
5
+
6
+ /** What a condition's `check` receives: the files in its rule's `files`. */
7
+ export interface View {
8
+ /** The version of the view, which a plugin declares it was written for. */
9
+ readonly schemaVersion: 1;
10
+ /** The files, sorted by path, each with the facts its condition reads. */
11
+ readonly files: readonly FileRecord[];
12
+ }
13
+
14
+ /** One file of the view. */
15
+ export interface FileRecord {
16
+ /** The path, relative to the config folder. */
17
+ readonly path: string;
18
+ /** The file's element, or `null` when it is in none. */
19
+ readonly element: string | null;
20
+ /** The values of the element's captures, by name. */
21
+ readonly captures: Readonly<Record<string, string>>;
22
+ /** The declared selectors the file is in. */
23
+ readonly selectors: readonly string[];
24
+ /** The file's imports, for a condition that reads `imports`. */
25
+ readonly imports?: readonly ImportRecord[];
26
+ /** The paths of the files that import it, for a condition that reads `importers`. */
27
+ readonly importers?: readonly string[];
28
+ /** What the file exports, for a condition that reads `exports`. */
29
+ readonly exports?: ExportsRecord;
30
+ }
31
+
32
+ /** One import of a file. */
33
+ export interface ImportRecord {
34
+ /** The specifier, as written. */
35
+ readonly specifier: string;
36
+ /** Which import of that specifier in the file, from 0. */
37
+ readonly occurrence: number;
38
+ /** The line, from 1. */
39
+ readonly line: number;
40
+ /** The column, from 1, in characters. */
41
+ readonly column: number;
42
+ /** `runtime`, or `type` for an import the compiler removes. */
43
+ readonly kind: 'runtime' | 'type';
44
+ /** The names it brings in: `default`, `*` or the name. */
45
+ readonly names: readonly string[];
46
+ /** The repository file it resolves to, or `null`. */
47
+ readonly file: string | null;
48
+ /** The package it resolves to, or `null`. */
49
+ readonly package: string | null;
50
+ }
51
+
52
+ /** What a file exports. */
53
+ export interface ExportsRecord {
54
+ /** The names it exports, `default` included. */
55
+ readonly names: readonly string[];
56
+ /** Whether it has a default export. */
57
+ readonly default: boolean;
58
+ /** Whether it has an `export *`. */
59
+ readonly star: boolean;
60
+ }
61
+
62
+ /** A fact of a file that a condition may read. */
63
+ export type Fact = 'imports' | 'importers' | 'exports';
64
+
65
+ /** A file of the view of a condition that reads the facts `R`, which are then always there. */
66
+ export type FileWith<R extends Fact> = Omit<FileRecord, Fact> & {
67
+ readonly [K in R]-?: NonNullable<FileRecord[K]>;
68
+ };
69
+
70
+ /** The view of a condition that reads the facts `R`. */
71
+ export type ViewWith<R extends Fact> = Omit<View, 'files'> & { readonly files: readonly FileWith<R>[] };
72
+
73
+ /** One violation a condition reports: a file of its view, the import if it is about one, and why. */
74
+ export type Violation = {
75
+ /** A file of the condition's view; any other file is a defect of the plugin. */
76
+ readonly file: string;
77
+ /** The import the violation is about, which an exception above it can permit. */
78
+ readonly import?: { readonly specifier: string; readonly occurrence?: number };
79
+ /** What is wrong, for the developer. */
80
+ readonly message: string;
81
+ };
82
+
83
+ /** A condition: what a rule's files must do, which archangel cannot say with its own conditions. */
84
+ export type Condition<N extends string = string, O = unknown, R extends Fact = Fact> = {
85
+ /** The name a rule uses after the plugin's name, as `team/<name>`. */
86
+ readonly name: N;
87
+ /** What the condition requires, in one sentence. */
88
+ readonly description: string;
89
+ /** Where to read more. */
90
+ readonly docs?: string;
91
+ /** The JSON Schema of the options, which archangel checks each rule's options against. */
92
+ readonly optionsSchema?: object | null;
93
+ /** The facts of each file the condition reads; the view holds only these. */
94
+ readonly reads: readonly R[];
95
+ /** The violations in the view, given the rule's options. */
96
+ check(view: ViewWith<R>, options: O): readonly Violation[] | Promise<readonly Violation[]>;
97
+ };
98
+
99
+ /** Any condition, whatever its options and facts. */
100
+ export type AnyCondition = Condition<string, never, Fact>;
101
+
102
+ /** A plugin: a name, the view version it was written for, and its conditions. */
103
+ export type Plugin<N extends string = string, C extends readonly AnyCondition[] = readonly AnyCondition[]> = {
104
+ /** The plugin's name, by convention its package's without `archangel-plugin-`. */
105
+ readonly name: N;
106
+ /** The version of the view the plugin was written for. */
107
+ readonly schemaVersion: 1;
108
+ /** The conditions. */
109
+ readonly conditions: C;
110
+ };
111
+
112
+ /** Declares a condition, with types for its options and for the facts it reads. */
113
+ export declare function defineCondition<const N extends string, O = unknown, const R extends Fact = never>(
114
+ condition: Condition<N, O, R>,
115
+ ): Condition<N, O, R>;
116
+
117
+ /** Declares a plugin, which a config loads under a name in `plugins`. */
118
+ export declare function definePlugin<
119
+ const N extends string,
120
+ const C extends readonly AnyCondition[],
121
+ >(plugin: Plugin<N, C>): Plugin<N, C>;
package/plugin.js ADDED
@@ -0,0 +1,8 @@
1
+ // The plugin helpers. Each returns its argument: its only work is in the types, which check a
2
+ // condition's options and the facts it reads as the plugin is written.
3
+
4
+ /** @type {import('./plugin.d.ts').defineCondition} */
5
+ export const defineCondition = (condition) => condition;
6
+
7
+ /** @type {import('./plugin.d.ts').definePlugin} */
8
+ export const definePlugin = (plugin) => plugin;
@@ -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
+ });