@no-k/hermes 0.1.0 → 0.3.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 +210 -13
- package/catalog.json +2640 -98
- package/dist/bin/hermes.js +87 -0
- package/dist/lib/catalog-query.js +63 -0
- package/dist/lib/catalog-selection.js +199 -0
- package/dist/lib/catalog.js +144 -0
- package/dist/lib/command.js +50 -0
- package/dist/lib/config-syntax.js +210 -0
- package/dist/lib/conflict-selection.js +89 -0
- package/dist/lib/contracts.js +4 -0
- package/dist/lib/files.js +94 -0
- package/dist/lib/install-prompts.js +106 -0
- package/dist/lib/install-view.js +61 -0
- package/dist/lib/install-wizard.js +105 -0
- package/dist/lib/install.js +184 -0
- package/dist/lib/installation-status.js +80 -0
- package/dist/lib/json.js +19 -0
- package/dist/lib/picker-view.js +81 -0
- package/dist/lib/picker.js +135 -0
- package/dist/lib/project-config.js +171 -0
- package/dist/lib/report.js +89 -0
- package/dist/lib/terminal-input.js +61 -0
- package/dist/lib/terminal-style.js +15 -0
- package/dist/lib/terminal-text.js +75 -0
- package/dist/lib/terminal.js +133 -0
- package/dist/lib/versions.js +251 -0
- package/docs/interactive-install.md +405 -0
- package/docs/module-versioning.md +105 -0
- package/package.json +25 -4
- package/templates/lint/fsd/fsd.ts +180 -0
- package/templates/lint/fsd/import-listener.ts +41 -0
- package/templates/lint/fsd/index.ts +32 -0
- package/templates/lint/fsd/options.ts +74 -0
- package/templates/lint/fsd/rules/fsd-layer-imports.ts +22 -0
- package/templates/lint/fsd/rules/fsd-public-api.ts +20 -0
- package/templates/lint/fsd/rules/fsd-slice-segments.ts +33 -0
- package/bin/hermes.mjs +0 -159
- package/lib/catalog.mjs +0 -51
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DEFAULT_FSD_LAYERS, type FsdOptions } from "./options";
|
|
3
|
+
|
|
4
|
+
// This module knows only paths and FSD policy. It does not depend on ESLint, ASTs or filesystem reads.
|
|
5
|
+
export const toPosixPath = (value: string): string =>
|
|
6
|
+
value.replaceAll("\\", "/").replace(/^[a-z]:/, (drive) => drive.toUpperCase());
|
|
7
|
+
|
|
8
|
+
export const resolvePath = (base: string, value: string): string => {
|
|
9
|
+
const windows = /^[a-z]:[/\\]/i.test(base) || /^[a-z]:[/\\]/i.test(value);
|
|
10
|
+
return toPosixPath((windows ? path.win32 : path.posix).resolve(toPosixPath(base), toPosixPath(value)));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isWithin = (parent: string, child: string): boolean =>
|
|
14
|
+
child === parent || child.startsWith(parent.endsWith("/") ? parent : parent + "/");
|
|
15
|
+
const sourceExtension = /(?:\.d)?\.[cm]?[jt]sx?$/;
|
|
16
|
+
const withoutExtension = (name: string): string => name.replace(sourceExtension, "");
|
|
17
|
+
const isFile = (name: string): boolean => /\.[^/]+$/.test(name);
|
|
18
|
+
|
|
19
|
+
type Root = { path: string; aliases: [string, string][] };
|
|
20
|
+
export type FsdLocation = {
|
|
21
|
+
root: string;
|
|
22
|
+
layer: string;
|
|
23
|
+
parts: string[];
|
|
24
|
+
slice?: string;
|
|
25
|
+
scope: string[];
|
|
26
|
+
segmentless: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type FsdViolation = {
|
|
29
|
+
messageId: "invalidDirection" | "crossSliceSameLayer" | "usePublicApi" | "missingSlice" | "missingSegment";
|
|
30
|
+
data: Record<string, string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const normalizeAliases = (...groups: Record<string, string>[]): [string, string][] => {
|
|
34
|
+
const aliases = new Map<string, string>();
|
|
35
|
+
for (const group of groups) {
|
|
36
|
+
for (const [key, target] of Object.entries(group)) {
|
|
37
|
+
const prefix = key.replace(/\/\*$/, "").replace(/\/$/, "");
|
|
38
|
+
const destination = target.replace(/\/\*$/, "");
|
|
39
|
+
if (!prefix || prefix.includes("*") || destination.includes("*")) {
|
|
40
|
+
throw new Error("FSD aliases must be exact prefixes, optionally ending in /*.");
|
|
41
|
+
}
|
|
42
|
+
aliases.set(prefix, destination);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...aliases].sort(([a], [b]) => b.length - a.length);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function createFsdPolicy(cwd: string, options: FsdOptions = {}) {
|
|
49
|
+
const layers = options.layers ?? DEFAULT_FSD_LAYERS;
|
|
50
|
+
const ranks = new Map(layers.map((layer, index) => [layer, index]));
|
|
51
|
+
const segmentlessLayers = options.segmentlessLayers ?? ["app", "shared"];
|
|
52
|
+
const publicApiFiles = options.publicApiFiles ?? ["index"];
|
|
53
|
+
const sharedModuleSegments = options.sharedModuleSegments ?? ["ui", "lib"];
|
|
54
|
+
const roots: Root[] = (options.roots ?? ["src"])
|
|
55
|
+
.map((root) => {
|
|
56
|
+
const definition = typeof root === "string" ? { path: root } : root;
|
|
57
|
+
return {
|
|
58
|
+
path: resolvePath(cwd, definition.path),
|
|
59
|
+
aliases: normalizeAliases({ "@": ".", "~": "." }, options.aliases ?? {}, definition.aliases ?? {}),
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
63
|
+
if (new Set(roots.map((root) => root.path)).size !== roots.length) {
|
|
64
|
+
throw new Error("FSD roots must resolve to different directories.");
|
|
65
|
+
}
|
|
66
|
+
const ignored = (options.ignore ?? []).map((prefix) => {
|
|
67
|
+
const normalized = toPosixPath(prefix).replace(/^\.\//, "").replace(/\/$/, "");
|
|
68
|
+
if (!normalized || normalized.startsWith("/") || normalized.includes("*") || normalized.split("/").includes("..")) {
|
|
69
|
+
throw new Error("FSD ignore paths must be root-relative file/directory prefixes without globs or '..'.");
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
});
|
|
73
|
+
const isPublicFile = (name: string): boolean => publicApiFiles.includes(withoutExtension(name));
|
|
74
|
+
|
|
75
|
+
function locate(filename: string): FsdLocation | undefined {
|
|
76
|
+
const absolute = resolvePath(cwd, filename);
|
|
77
|
+
const root = roots.find((candidate) => isWithin(candidate.path, absolute));
|
|
78
|
+
if (!root) return undefined;
|
|
79
|
+
const relative = absolute.slice(root.path.length).replace(/^\//, "");
|
|
80
|
+
if (ignored.some((prefix) => isWithin(prefix, relative))) return undefined;
|
|
81
|
+
const parts = relative.split("/").filter(Boolean);
|
|
82
|
+
const layer = parts[0];
|
|
83
|
+
if (!layer || !ranks.has(layer)) return undefined;
|
|
84
|
+
const segmentless = segmentlessLayers.includes(layer);
|
|
85
|
+
const scope = parts.slice(0, 2);
|
|
86
|
+
// Shared UI/lib modules may each have an index, avoiding one large segment barrel.
|
|
87
|
+
if (
|
|
88
|
+
layer === "shared" &&
|
|
89
|
+
sharedModuleSegments.includes(parts[1] ?? "") &&
|
|
90
|
+
parts[2] &&
|
|
91
|
+
!isFile(parts[2]) &&
|
|
92
|
+
!isPublicFile(parts[2])
|
|
93
|
+
) {
|
|
94
|
+
scope.push(parts[2]);
|
|
95
|
+
}
|
|
96
|
+
return { root: root.path, layer, parts, slice: segmentless ? undefined : parts[1], scope, segmentless };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveImport(source: string, importer: FsdLocation, filename: string): FsdLocation | undefined {
|
|
100
|
+
if (source.startsWith(".")) return locate(resolvePath(resolvePath(filename, ".."), source));
|
|
101
|
+
if (path.posix.isAbsolute(source) || path.win32.isAbsolute(source)) return locate(source);
|
|
102
|
+
const root = roots.find((candidate) => candidate.path === importer.root);
|
|
103
|
+
if (!root) return undefined;
|
|
104
|
+
for (const [alias, target] of root.aliases) {
|
|
105
|
+
if (source === alias || source.startsWith(alias + "/")) {
|
|
106
|
+
return locate(resolvePath(resolvePath(root.path, target), source.slice(alias.length).replace(/^\//, "")));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Bare specifiers are npm packages unless explicitly mapped by the consumer.
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sameSlice = (from: FsdLocation, to: FsdLocation): boolean =>
|
|
114
|
+
from.root === to.root && from.layer === to.layer && from.slice !== undefined && from.slice === to.slice;
|
|
115
|
+
const sameScope = (from: FsdLocation, to: FsdLocation): boolean =>
|
|
116
|
+
from.root === to.root && from.scope.join("/") === to.scope.join("/");
|
|
117
|
+
const entityCrossApi = (from: FsdLocation, to: FsdLocation): boolean =>
|
|
118
|
+
(options.allowEntityCrossImports ?? true) &&
|
|
119
|
+
from.root === to.root &&
|
|
120
|
+
from.layer === "entities" &&
|
|
121
|
+
to.layer === "entities" &&
|
|
122
|
+
from.slice !== to.slice &&
|
|
123
|
+
to.parts.length === 4 &&
|
|
124
|
+
to.parts[2] === "@x" &&
|
|
125
|
+
withoutExtension(to.parts[3] ?? "") === from.slice;
|
|
126
|
+
|
|
127
|
+
function checkLayers(from: FsdLocation, to: FsdLocation): FsdViolation | undefined {
|
|
128
|
+
if ((ranks.get(to.layer) ?? -1) > (ranks.get(from.layer) ?? -1)) {
|
|
129
|
+
return { messageId: "invalidDirection", data: { fromLayer: from.layer, toLayer: to.layer } };
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
from.layer === to.layer &&
|
|
133
|
+
!sameSlice(from, to) &&
|
|
134
|
+
!(from.segmentless && from.root === to.root) &&
|
|
135
|
+
!entityCrossApi(from, to) &&
|
|
136
|
+
!options.allowCrossSliceSameLayer &&
|
|
137
|
+
!(options.allowCrossSliceLayers ?? []).includes(from.layer)
|
|
138
|
+
) {
|
|
139
|
+
return {
|
|
140
|
+
messageId: "crossSliceSameLayer",
|
|
141
|
+
data: {
|
|
142
|
+
fromLayer: from.layer,
|
|
143
|
+
fromSlice: from.slice ?? from.scope[1] ?? "",
|
|
144
|
+
toLayer: to.layer,
|
|
145
|
+
toSlice: to.slice ?? to.scope[1] ?? "",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkPublicApi(from: FsdLocation, to: FsdLocation): FsdViolation | undefined {
|
|
153
|
+
if (sameScope(from, to) || entityCrossApi(from, to)) return undefined;
|
|
154
|
+
const remainder = to.parts.slice(to.scope.length);
|
|
155
|
+
const publicScope = to.scope.length >= 2 && !isFile(to.scope[to.scope.length - 1] ?? "");
|
|
156
|
+
if (publicScope && (remainder.length === 0 || (remainder.length === 1 && isPublicFile(remainder[0] ?? "")))) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return { messageId: "usePublicApi", data: { layer: to.layer, publicName: to.scope.slice(1).join("/") } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function checkStructure(location: FsdLocation): FsdViolation | undefined {
|
|
163
|
+
if ((options.ignoreLayers ?? []).includes(location.layer)) return undefined;
|
|
164
|
+
const { parts, layer, segmentless } = location;
|
|
165
|
+
const filename = parts[parts.length - 1] ?? "";
|
|
166
|
+
if (segmentless) {
|
|
167
|
+
if (parts.length >= 3 || (parts.length === 2 && isPublicFile(filename))) return undefined;
|
|
168
|
+
return { messageId: "missingSegment", data: { layer, slice: "" } };
|
|
169
|
+
}
|
|
170
|
+
if (parts.length < 3) {
|
|
171
|
+
return { messageId: "missingSlice", data: { layer } };
|
|
172
|
+
}
|
|
173
|
+
if (parts.length === 3 && !isPublicFile(filename)) {
|
|
174
|
+
return { messageId: "missingSegment", data: { layer, slice: parts[1] ?? "" } };
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { locate, resolveImport, checkLayers, checkPublicApi, checkStructure };
|
|
180
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Rule } from "eslint";
|
|
2
|
+
import { type FsdViolation, createFsdPolicy } from "./fsd";
|
|
3
|
+
import type { FsdOptions } from "./options";
|
|
4
|
+
|
|
5
|
+
type SourceNode = {
|
|
6
|
+
value?: unknown;
|
|
7
|
+
literal?: SourceNode;
|
|
8
|
+
source?: SourceNode;
|
|
9
|
+
argument?: SourceNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createImportListener(
|
|
13
|
+
context: Rule.RuleContext,
|
|
14
|
+
check: "checkLayers" | "checkPublicApi",
|
|
15
|
+
): Rule.RuleListener {
|
|
16
|
+
const filename = context.filename;
|
|
17
|
+
if (!filename || filename.startsWith("<")) return {};
|
|
18
|
+
const policy = createFsdPolicy(context.cwd, context.options[0] as FsdOptions | undefined);
|
|
19
|
+
const from = policy.locate(filename);
|
|
20
|
+
if (!from) return {};
|
|
21
|
+
const visit = (node: Rule.Node): void => {
|
|
22
|
+
const candidate = node as unknown as SourceNode;
|
|
23
|
+
let source = candidate.source;
|
|
24
|
+
if (!source) {
|
|
25
|
+
const argument = candidate.argument;
|
|
26
|
+
source = argument?.literal ?? argument;
|
|
27
|
+
}
|
|
28
|
+
if (typeof source?.value !== "string") return;
|
|
29
|
+
const to = policy.resolveImport(source.value, from, filename);
|
|
30
|
+
if (!to) return;
|
|
31
|
+
const violation: FsdViolation | undefined = policy[check](from, to);
|
|
32
|
+
if (violation) context.report({ node: source as Rule.Node, ...violation });
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
ImportDeclaration: visit,
|
|
36
|
+
ExportNamedDeclaration: visit,
|
|
37
|
+
ExportAllDeclaration: visit,
|
|
38
|
+
ImportExpression: visit,
|
|
39
|
+
TSImportType: visit,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Hermes copies this plugin into the consumer project. Edit these files to customize its policy.
|
|
2
|
+
import type { Linter } from "eslint";
|
|
3
|
+
import { type FsdOptions, HERMES_OPTIONS } from "./options";
|
|
4
|
+
import fsdLayerImports from "./rules/fsd-layer-imports";
|
|
5
|
+
import fsdPublicApi from "./rules/fsd-public-api";
|
|
6
|
+
import fsdSliceSegments from "./rules/fsd-slice-segments";
|
|
7
|
+
|
|
8
|
+
export type { FsdOptions, FsdRoot } from "./options";
|
|
9
|
+
|
|
10
|
+
const plugin = {
|
|
11
|
+
meta: { name: "hermes-fsd" },
|
|
12
|
+
rules: {
|
|
13
|
+
"fsd-layer-imports": fsdLayerImports,
|
|
14
|
+
"fsd-public-api": fsdPublicApi,
|
|
15
|
+
"fsd-slice-segments": fsdSliceSegments,
|
|
16
|
+
},
|
|
17
|
+
configs: {} as { recommended: Linter.Config; hermes: Linter.Config },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function createConfig(options: FsdOptions = {}, namespace = "fsd"): Linter.Config {
|
|
21
|
+
return {
|
|
22
|
+
name: "hermes-fsd/" + namespace,
|
|
23
|
+
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
|
|
24
|
+
plugins: { [namespace]: plugin },
|
|
25
|
+
rules: Object.fromEntries(Object.keys(plugin.rules).map((rule) => [namespace + "/" + rule, ["error", options]])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
plugin.configs.recommended = createConfig();
|
|
30
|
+
plugin.configs.hermes = createConfig(HERMES_OPTIONS, "hermes");
|
|
31
|
+
|
|
32
|
+
export default plugin;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type FsdRoot = {
|
|
2
|
+
path: string;
|
|
3
|
+
/** Alias targets are relative to this FSD root, not the process cwd. */
|
|
4
|
+
aliases?: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FsdOptions = {
|
|
8
|
+
roots?: (string | FsdRoot)[];
|
|
9
|
+
aliases?: Record<string, string>;
|
|
10
|
+
/** Lowest to highest. Deprecated "processes" is opt-in. */
|
|
11
|
+
layers?: string[];
|
|
12
|
+
segmentlessLayers?: string[];
|
|
13
|
+
allowCrossSliceLayers?: string[];
|
|
14
|
+
allowCrossSliceSameLayer?: boolean;
|
|
15
|
+
allowEntityCrossImports?: boolean;
|
|
16
|
+
/** Root-relative file/directory prefixes, not globs. Applies to both ends of an import. */
|
|
17
|
+
ignore?: string[];
|
|
18
|
+
/** Only affects the structure rule. */
|
|
19
|
+
ignoreLayers?: string[];
|
|
20
|
+
/** Public entrypoint basenames, without extensions. */
|
|
21
|
+
publicApiFiles?: string[];
|
|
22
|
+
/** Shared segments whose child modules may expose their own public API. */
|
|
23
|
+
sharedModuleSegments?: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_FSD_LAYERS = ["shared", "entities", "features", "widgets", "pages", "app"];
|
|
27
|
+
|
|
28
|
+
export const HERMES_OPTIONS: FsdOptions = {
|
|
29
|
+
layers: ["shared", "entities", "features", "widgets", "pages", "processes", "app"],
|
|
30
|
+
allowCrossSliceLayers: ["widgets", "pages", "processes", "app"],
|
|
31
|
+
ignoreLayers: ["shared", "app"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const names = {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: { type: "string", pattern: "^[^/\\\\.*]+$" },
|
|
37
|
+
uniqueItems: true,
|
|
38
|
+
};
|
|
39
|
+
const aliases = {
|
|
40
|
+
type: "object",
|
|
41
|
+
additionalProperties: { type: "string", minLength: 1 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const optionSchema = {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
roots: {
|
|
48
|
+
type: "array",
|
|
49
|
+
minItems: 1,
|
|
50
|
+
items: {
|
|
51
|
+
anyOf: [
|
|
52
|
+
{ type: "string", minLength: 1 },
|
|
53
|
+
{
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: { path: { type: "string", minLength: 1 }, aliases },
|
|
56
|
+
required: ["path"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
aliases,
|
|
63
|
+
layers: { ...names, minItems: 1 },
|
|
64
|
+
segmentlessLayers: names,
|
|
65
|
+
allowCrossSliceLayers: names,
|
|
66
|
+
allowCrossSliceSameLayer: { type: "boolean" },
|
|
67
|
+
allowEntityCrossImports: { type: "boolean" },
|
|
68
|
+
ignore: { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true },
|
|
69
|
+
ignoreLayers: names,
|
|
70
|
+
publicApiFiles: { ...names, minItems: 1 },
|
|
71
|
+
sharedModuleSegments: names,
|
|
72
|
+
},
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
} as const;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Rule } from "eslint";
|
|
2
|
+
import { createImportListener } from "../import-listener";
|
|
3
|
+
import { optionSchema } from "../options";
|
|
4
|
+
|
|
5
|
+
const rule: Rule.RuleModule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Enforce FSD layer direction and slice isolation",
|
|
10
|
+
url: "https://github.com/no-k/hermes/tree/main/packages/lib/lint/fsd#fsd-layer-imports",
|
|
11
|
+
},
|
|
12
|
+
schema: [optionSchema],
|
|
13
|
+
messages: {
|
|
14
|
+
invalidDirection: "FSD layer '{{fromLayer}}' cannot import upper layer '{{toLayer}}'.",
|
|
15
|
+
crossSliceSameLayer:
|
|
16
|
+
"FSD same-layer imports must stay inside the same slice. '{{fromLayer}}/{{fromSlice}}' cannot import '{{toLayer}}/{{toSlice}}'.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create: (context) => createImportListener(context, "checkLayers"),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default rule;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Rule } from "eslint";
|
|
2
|
+
import { createImportListener } from "../import-listener";
|
|
3
|
+
import { optionSchema } from "../options";
|
|
4
|
+
|
|
5
|
+
const rule: Rule.RuleModule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Enforce public API imports across FSD scopes",
|
|
10
|
+
url: "https://github.com/no-k/hermes/tree/main/packages/lib/lint/fsd#fsd-public-api",
|
|
11
|
+
},
|
|
12
|
+
schema: [optionSchema],
|
|
13
|
+
messages: {
|
|
14
|
+
usePublicApi: "FSD cross-scope import must use the public API of '{{layer}}/{{publicName}}'.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
create: (context) => createImportListener(context, "checkPublicApi"),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default rule;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Rule } from "eslint";
|
|
2
|
+
import { createFsdPolicy } from "../fsd";
|
|
3
|
+
import { type FsdOptions, optionSchema } from "../options";
|
|
4
|
+
|
|
5
|
+
const rule: Rule.RuleModule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Enforce FSD slice/segment structure, including segment-only App and Shared",
|
|
10
|
+
url: "https://github.com/no-k/hermes/tree/main/packages/lib/lint/fsd#fsd-slice-segments",
|
|
11
|
+
},
|
|
12
|
+
schema: [optionSchema],
|
|
13
|
+
messages: {
|
|
14
|
+
missingSlice: "FSD layer '{{layer}}' must contain slice directories before implementation files.",
|
|
15
|
+
missingSegment:
|
|
16
|
+
"FSD implementation in '{{layer}}/{{slice}}' must live in a segment or be an allowed public API entrypoint.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
if (!context.filename || context.filename.startsWith("<")) return {};
|
|
21
|
+
const policy = createFsdPolicy(context.cwd, context.options[0] as FsdOptions | undefined);
|
|
22
|
+
const location = policy.locate(context.filename);
|
|
23
|
+
if (!location) return {};
|
|
24
|
+
return {
|
|
25
|
+
Program(node) {
|
|
26
|
+
const violation = policy.checkStructure(location);
|
|
27
|
+
if (violation) context.report({ node, ...violation });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default rule;
|
package/bin/hermes.mjs
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseArgs } from "node:util";
|
|
6
|
-
import { validateCatalog } from "../lib/catalog.mjs";
|
|
7
|
-
|
|
8
|
-
const packageDirectory = fileURLToPath(new URL("../", import.meta.url));
|
|
9
|
-
const manifest = JSON.parse(readFileSync(path.join(packageDirectory, "package.json"), "utf8"));
|
|
10
|
-
const help = [
|
|
11
|
-
"Hermes " + manifest.version + " — copy TypeScript utilities into your project",
|
|
12
|
-
"",
|
|
13
|
-
"Usage:",
|
|
14
|
-
" hermes list",
|
|
15
|
-
" hermes add <item...> --dir <directory> [--dry-run] [--overwrite]",
|
|
16
|
-
"",
|
|
17
|
-
"Options:",
|
|
18
|
-
" -d, --dir Destination for utility folders and LICENSE (required for add)",
|
|
19
|
-
" --dry-run Show the planned files without writing",
|
|
20
|
-
" --overwrite Replace existing files whose contents differ",
|
|
21
|
-
" -h, --help Show help",
|
|
22
|
-
" -v, --version Show version",
|
|
23
|
-
"",
|
|
24
|
-
"Example:",
|
|
25
|
-
" npx @no-k/hermes add array-chunk --dir src/lib/hermes",
|
|
26
|
-
"",
|
|
27
|
-
"External npm dependencies are reported; they are not installed automatically.",
|
|
28
|
-
].join("\n");
|
|
29
|
-
|
|
30
|
-
function stat(file) {
|
|
31
|
-
try {
|
|
32
|
-
return lstatSync(file);
|
|
33
|
-
} catch (error) {
|
|
34
|
-
if (error.code === "ENOENT") return undefined;
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function destinationStat(directory, relative) {
|
|
40
|
-
let current = directory;
|
|
41
|
-
const segments = relative.split("/");
|
|
42
|
-
for (let index = -1; index < segments.length; index += 1) {
|
|
43
|
-
if (index >= 0) current = path.join(current, segments[index]);
|
|
44
|
-
const info = stat(current);
|
|
45
|
-
if (info?.isSymbolicLink()) throw new Error("Refusing to write through a symbolic link: " + current);
|
|
46
|
-
if (index < segments.length - 1 && info && !info.isDirectory()) {
|
|
47
|
-
throw new Error("Expected a directory: " + current);
|
|
48
|
-
}
|
|
49
|
-
if (index === segments.length - 1) {
|
|
50
|
-
if (info && !info.isFile()) throw new Error("Expected a regular file: " + current);
|
|
51
|
-
return info;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function sourceContent(relative) {
|
|
57
|
-
const directory = realpathSync(path.join(packageDirectory, "templates"));
|
|
58
|
-
const file = realpathSync(path.join(directory, relative));
|
|
59
|
-
const fromRoot = path.relative(directory, file);
|
|
60
|
-
if (path.isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(".." + path.sep)) {
|
|
61
|
-
throw new Error("Source template escapes its directory: " + relative);
|
|
62
|
-
}
|
|
63
|
-
return readFileSync(file, "utf8");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function add(items, options) {
|
|
67
|
-
if (!options.dir?.trim()) throw new Error("Specify a destination with --dir <directory>.");
|
|
68
|
-
const directory = path.resolve(options.dir);
|
|
69
|
-
const files = new Map();
|
|
70
|
-
for (const item of items) {
|
|
71
|
-
for (const file of item.files) {
|
|
72
|
-
if (!files.has(file)) files.set(file, sourceContent(file));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
files.set("LICENSE", readFileSync(path.join(packageDirectory, "LICENSE"), "utf8"));
|
|
76
|
-
const plan = [...files]
|
|
77
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
78
|
-
.map(([file, content]) => {
|
|
79
|
-
const destination = path.join(directory, file);
|
|
80
|
-
const existing = destinationStat(directory, file);
|
|
81
|
-
const same = existing && readFileSync(destination, "utf8") === content;
|
|
82
|
-
return { file, content, destination, action: same ? "keep" : existing ? "replace" : "create" };
|
|
83
|
-
});
|
|
84
|
-
const conflicts = plan.filter((file) => file.action === "replace");
|
|
85
|
-
if (conflicts.length && !options.overwrite) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
"Existing files differ; no files were written:\n" +
|
|
88
|
-
conflicts.map((file) => " " + file.destination).join("\n") +
|
|
89
|
-
"\nReview the changes, then use --overwrite if you want to replace them.",
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
for (const file of plan) {
|
|
93
|
-
if (!options["dry-run"] && file.action !== "keep") {
|
|
94
|
-
mkdirSync(path.dirname(file.destination), { recursive: true });
|
|
95
|
-
writeFileSync(file.destination, file.content, { flag: file.action === "create" ? "wx" : "w" });
|
|
96
|
-
}
|
|
97
|
-
console.log((options["dry-run"] ? "[dry-run] " : "") + file.action + " " + file.destination);
|
|
98
|
-
}
|
|
99
|
-
const dependencies = [...new Set(items.flatMap((item) => item.dependencies))].sort();
|
|
100
|
-
const devDependencies = [...new Set(items.flatMap((item) => item.devDependencies ?? []))].sort();
|
|
101
|
-
if (dependencies.length || devDependencies.length) {
|
|
102
|
-
console.log("\nThese utilities need npm packages. Install them in your consuming project:");
|
|
103
|
-
if (dependencies.length)
|
|
104
|
-
console.log(" npm install " + dependencies.map((value) => JSON.stringify(value)).join(" "));
|
|
105
|
-
if (devDependencies.length) {
|
|
106
|
-
console.log(" npm install --save-dev " + devDependencies.map((value) => JSON.stringify(value)).join(" "));
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
for (const item of items) {
|
|
110
|
-
if (item.runtime === "node") console.log("\n" + item.name + ": requires Node.js (imports a Node.js builtin).");
|
|
111
|
-
if (item.runtime === "dom") console.log("\n" + item.name + ": requires DOM APIs and DOM TypeScript types.");
|
|
112
|
-
}
|
|
113
|
-
console.log("\n" + (options["dry-run"] ? "Planned" : "Added") + " " + items.map((item) => item.name).join(", "));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function main() {
|
|
117
|
-
const { values, positionals } = parseArgs({
|
|
118
|
-
options: {
|
|
119
|
-
dir: { type: "string", short: "d" },
|
|
120
|
-
"dry-run": { type: "boolean" },
|
|
121
|
-
overwrite: { type: "boolean" },
|
|
122
|
-
help: { type: "boolean", short: "h" },
|
|
123
|
-
version: { type: "boolean", short: "v" },
|
|
124
|
-
},
|
|
125
|
-
allowPositionals: true,
|
|
126
|
-
});
|
|
127
|
-
if (values.help || (!positionals.length && !values.version)) {
|
|
128
|
-
console.log(help);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
if (values.version) {
|
|
132
|
-
console.log(manifest.version);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const [command, ...names] = positionals;
|
|
136
|
-
if (!["list", "add"].includes(command)) throw new Error("Unknown command: " + command + ". Use --help.");
|
|
137
|
-
const catalog = validateCatalog(JSON.parse(readFileSync(path.join(packageDirectory, "catalog.json"), "utf8")));
|
|
138
|
-
if (catalog.version !== manifest.version)
|
|
139
|
-
throw new Error("The catalog version does not match the package. Rebuild the CLI.");
|
|
140
|
-
if (command === "list") {
|
|
141
|
-
if (names.length || values.dir || values.overwrite || values["dry-run"]) throw new Error("Usage: hermes list");
|
|
142
|
-
for (const item of catalog.items) console.log(item.name + "\t" + item.description);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (!names.length) throw new Error("Specify at least one item. Use 'hermes list' to see available items.");
|
|
146
|
-
const items = [...new Set(names)].map((name) => {
|
|
147
|
-
const item = catalog.items.find((candidate) => candidate.name === name);
|
|
148
|
-
if (!item) throw new Error("Unknown item: " + name + ". Use 'hermes list' to see available items.");
|
|
149
|
-
return item;
|
|
150
|
-
});
|
|
151
|
-
add(items, values);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
main();
|
|
156
|
-
} catch (error) {
|
|
157
|
-
console.error("Hermes: " + error.message);
|
|
158
|
-
process.exitCode = 1;
|
|
159
|
-
}
|
package/lib/catalog.mjs
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
const itemNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2
|
-
|
|
3
|
-
export function isSourcePath(value) {
|
|
4
|
-
return (
|
|
5
|
-
typeof value === "string" &&
|
|
6
|
-
value.endsWith(".ts") &&
|
|
7
|
-
!value.includes("\\") &&
|
|
8
|
-
!value.includes("\0") &&
|
|
9
|
-
!value.includes(":") &&
|
|
10
|
-
value.split("/").every((part) => part && part !== "." && part !== "..") &&
|
|
11
|
-
!/\.(test|spec|stories|config|d)\.ts$/.test(value)
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function validateCatalog(catalog) {
|
|
16
|
-
if (
|
|
17
|
-
catalog?.schemaVersion !== 1 ||
|
|
18
|
-
typeof catalog.version !== "string" ||
|
|
19
|
-
!Array.isArray(catalog.items) ||
|
|
20
|
-
catalog.items.length === 0
|
|
21
|
-
) {
|
|
22
|
-
throw new Error("Invalid Hermes catalog.");
|
|
23
|
-
}
|
|
24
|
-
const names = new Set();
|
|
25
|
-
for (const item of catalog.items) {
|
|
26
|
-
if (
|
|
27
|
-
!item ||
|
|
28
|
-
typeof item.name !== "string" ||
|
|
29
|
-
!itemNamePattern.test(item.name) ||
|
|
30
|
-
names.has(item.name) ||
|
|
31
|
-
typeof item.description !== "string" ||
|
|
32
|
-
!Array.isArray(item.files) ||
|
|
33
|
-
item.files.length === 0 ||
|
|
34
|
-
!item.files.every(isSourcePath) ||
|
|
35
|
-
new Set(item.files).size !== item.files.length ||
|
|
36
|
-
(item.runtime !== undefined && !["node", "dom"].includes(item.runtime))
|
|
37
|
-
) {
|
|
38
|
-
throw new Error("Invalid or duplicate Hermes item: " + item?.name);
|
|
39
|
-
}
|
|
40
|
-
for (const values of [item.dependencies, item.devDependencies ?? []]) {
|
|
41
|
-
if (
|
|
42
|
-
!Array.isArray(values) ||
|
|
43
|
-
!values.every((value) => typeof value === "string" && /^[^\s]+@[^\s]+$/.test(value))
|
|
44
|
-
) {
|
|
45
|
-
throw new Error("Invalid dependencies for " + item.name);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
names.add(item.name);
|
|
49
|
-
}
|
|
50
|
-
return catalog;
|
|
51
|
-
}
|