@no-k/hermes 0.2.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 +36 -2
- package/catalog.json +49 -1
- package/dist/bin/hermes.js +1 -1
- package/dist/lib/contracts.js +1 -1
- package/docs/interactive-install.md +6 -6
- package/docs/module-versioning.md +4 -3
- package/package.json +2 -1
- 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/README.md
CHANGED
|
@@ -42,7 +42,7 @@ chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]]
|
|
|
42
42
|
`--dir`는 현재 작업 디렉터리 기준 상대 경로나 절대 경로를 받습니다.
|
|
43
43
|
공백이 있는 경로는 따옴표로 감싸세요. 그 아래에는 유틸의 카테고리/이름 구조를 유지합니다.
|
|
44
44
|
경로 입력의 초기값은 `--dir` → 현재 디렉터리의 저장된 설정 → 기본 경로 순서입니다.
|
|
45
|
-
Core 기본값은 `src/utils/hermes`, 라이브러리는 `
|
|
45
|
+
Core 기본값은 `src/utils/hermes`, 라이브러리는 `libs/hermes`이고 화면에서 각각 수정할 수 있습니다.
|
|
46
46
|
경로 입력에서 Enter는 현재 값 사용, Ctrl+U는 전체 지우기, Esc는 이전 화면입니다.
|
|
47
47
|
모노레포에서는 설치할 앱·패키지 디렉터리에서 실행합니다. 상위 디렉터리의 설정은 찾지 않습니다.
|
|
48
48
|
설정 파일이나 package.json이 없는 디렉터리에서도 파일 생성이 가능합니다.
|
|
@@ -82,7 +82,7 @@ npx @no-k/hermes@0.1.0 add array-chunk --dir "src/my utilities" --dry-run
|
|
|
82
82
|
## 항목과 의존성
|
|
83
83
|
|
|
84
84
|
항목 이름은 core 디렉터리 경로를 하이픈으로 연결한 이름입니다.
|
|
85
|
-
|
|
85
|
+
라이브러리 항목에는 tailwindcss-, lint-처럼 라이브러리 이름 접두어를 붙입니다.
|
|
86
86
|
|
|
87
87
|
| 항목 | 포함하는 소스 |
|
|
88
88
|
| --- | --- |
|
|
@@ -92,6 +92,7 @@ Tailwind 항목에는 tailwindcss- 접두어를 붙입니다.
|
|
|
92
92
|
| tailwindcss-cn | 클래스 병합과 설정 |
|
|
93
93
|
| tailwindcss-create-tailwind-css | 설정을 공유하는 cn·classVariant |
|
|
94
94
|
| string-uuid | Node.js UUID 생성 |
|
|
95
|
+
| lint-fsd | 수정 가능한 FSD ESLint 플러그인 소스 |
|
|
95
96
|
|
|
96
97
|
core 항목은 외부 런타임 npm 의존성이 없습니다.
|
|
97
98
|
Tailwind 항목은 clsx, tailwind-merge, tailwind-variants 중 해당 항목이 참조하는 패키지가 필요합니다.
|
|
@@ -109,6 +110,37 @@ string-uuid는 정적 node:crypto import 때문에 Node.js에서 사용해야
|
|
|
109
110
|
dom-* 항목에는 DOM API와 DOM TypeScript 타입이 필요합니다.
|
|
110
111
|
현재 Tailwind 헬퍼의 병합 규칙은 Tailwind CSS 4 기준입니다.
|
|
111
112
|
|
|
113
|
+
## FSD lint 소스 생성
|
|
114
|
+
|
|
115
|
+
이 항목이 포함된 Hermes 릴리스 배포 후 사용합니다.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx @no-k/hermes@latest add lint-fsd
|
|
119
|
+
pnpm add -D eslint typescript-eslint typescript jiti@^2.2.0 @types/node@^22
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`libs/hermes/lint/fsd/`에 TypeScript 플러그인 진입점, 옵션, 경로 정책과 세 규칙을 생성합니다.
|
|
123
|
+
기존 프로젝트에 저장된 라이브러리 경로가 있으면 그 값을 사용합니다.
|
|
124
|
+
이번 설치 경로를 지정하려면 `--dir libs/hermes`를 붙이세요.
|
|
125
|
+
레이어 의존 방향·slice 격리, public API와 디렉터리 구조를 검사합니다.
|
|
126
|
+
생성된 파일은 직접 수정할 수 있으며, 다시 설치할 때도 변경된 파일은 기본 유지합니다.
|
|
127
|
+
ESLint 설정과 package.json은 직접 연결합니다.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// eslint.config.ts
|
|
131
|
+
import tseslint from "typescript-eslint";
|
|
132
|
+
import fsd from "./libs/hermes/lint/fsd";
|
|
133
|
+
|
|
134
|
+
export default [...tseslint.configs.recommended, fsd.configs.recommended];
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
기존 flat config에는 `fsd.configs.recommended`를 추가하세요.
|
|
138
|
+
기본 FSD 루트는 린터 실행 위치 기준 `src`이고 alias는 `@`·`~`입니다.
|
|
139
|
+
다른 구조는 `createConfig({ roots, aliases, layers })`로 지정할 수 있습니다.
|
|
140
|
+
TypeScript 설정 로딩에 사용하는 `jiti`는 2.2 이상이 필요합니다.
|
|
141
|
+
옵션, 규칙 수정 위치와 같은 소스를 사용하는 Oxlint 설정은
|
|
142
|
+
[FSD 모듈 문서](https://github.com/no-k/hermes/tree/main/packages/lib/lint/fsd)를 참고하세요.
|
|
143
|
+
|
|
112
144
|
## 업데이트와 라이선스
|
|
113
145
|
|
|
114
146
|
새 유틸을 찾을 때는 `@latest`로 최신 CLI에 포함된 카탈로그를 탐색합니다.
|
|
@@ -161,6 +193,7 @@ CLI 소스와 관련 도구는 이 패키지 안에서 TypeScript로 관리합
|
|
|
161
193
|
| `scripts/preview-ui.ts` | 파일을 생성하지 않는 개발용 선택 화면 미리보기 |
|
|
162
194
|
| `scripts/pack-cli.ts` | npm tarball 생성 및 포함 파일 검증 |
|
|
163
195
|
| `scripts/smoke-cli.ts` | tarball을 설치해 소비 프로젝트에서 컴파일·실행 |
|
|
196
|
+
| `scripts/smoke-fsd.ts` | CLI로 lint 소스를 생성하고 ESLint·Oxlint 실행, 직접 수정과 재설치 보존 검증 |
|
|
164
197
|
| `test/*.test.ts` | CLI·catalog·패키징 회귀 테스트 |
|
|
165
198
|
|
|
166
199
|
대화형 설치는 [단계별 계약](docs/interactive-install.md)에 따라 설치 엔진·설정, 카탈로그 탐색,
|
|
@@ -209,6 +242,7 @@ pnpm run test:run
|
|
|
209
242
|
pnpm run check:cli
|
|
210
243
|
pnpm run pack:cli
|
|
211
244
|
pnpm run test:cli-install
|
|
245
|
+
pnpm run test:fsd-install
|
|
212
246
|
```
|
|
213
247
|
|
|
214
248
|
`tsconfig.json`은 런타임·도구·테스트 전체를 strict 및 `noUncheckedIndexedAccess`로 검사합니다.
|
package/catalog.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 3,
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"items": [
|
|
5
5
|
{
|
|
6
6
|
"name": "algorithm-geometry-ccw",
|
|
@@ -1726,6 +1726,54 @@
|
|
|
1726
1726
|
"http/status/status.ts"
|
|
1727
1727
|
]
|
|
1728
1728
|
},
|
|
1729
|
+
{
|
|
1730
|
+
"name": "lint-fsd",
|
|
1731
|
+
"packageName": "@hermes/lint-fsd",
|
|
1732
|
+
"version": "0.1.0",
|
|
1733
|
+
"modules": [
|
|
1734
|
+
{
|
|
1735
|
+
"name": "lint-fsd",
|
|
1736
|
+
"packageName": "@hermes/lint-fsd",
|
|
1737
|
+
"version": "0.1.0",
|
|
1738
|
+
"scope": "lib",
|
|
1739
|
+
"files": {
|
|
1740
|
+
"lint/fsd/fsd.ts": "7bca7aae487a8d2bad71f2d152b8420a2cd7a8259c514224b6df764fe7642164",
|
|
1741
|
+
"lint/fsd/import-listener.ts": "b07c96183e23f6e8902b1451fc350cd0eb5e87c0ed77626376fffa870c6434bc",
|
|
1742
|
+
"lint/fsd/index.ts": "b5a08b38570e5094eacf089ecbe5bf0ac12692671bc266c923796172b8a436a8",
|
|
1743
|
+
"lint/fsd/options.ts": "e24e3fb0cf0ab12cda93614ae19fcd1762d2b23c746a087390f612113195193b",
|
|
1744
|
+
"lint/fsd/rules/fsd-layer-imports.ts": "23cee966cf7d230e2762c70d5c115fc49a26a9ccf8ba33a2d2bedb41f244ea0d",
|
|
1745
|
+
"lint/fsd/rules/fsd-public-api.ts": "474eb3d91081db8b31a678990c19ddda346125a54c1a10a7cf7d2ed07eb41b28",
|
|
1746
|
+
"lint/fsd/rules/fsd-slice-segments.ts": "57d72120b747d4d091ae09abc99e8f821c7ebca1ee4e992d1fc9fe1ab29ad275"
|
|
1747
|
+
},
|
|
1748
|
+
"requires": [
|
|
1749
|
+
{
|
|
1750
|
+
"packageName": "eslint",
|
|
1751
|
+
"range": "^9.0.0 || ^10.0.0",
|
|
1752
|
+
"kind": "peer",
|
|
1753
|
+
"internal": false,
|
|
1754
|
+
"optional": true
|
|
1755
|
+
}
|
|
1756
|
+
]
|
|
1757
|
+
}
|
|
1758
|
+
],
|
|
1759
|
+
"scope": "lib",
|
|
1760
|
+
"category": "lib/lint",
|
|
1761
|
+
"description": "Feature-Sliced Design의 레이어 의존 방향, slice 경계, public API와 디렉터리 구조를 검사합니다. Hermes CLI가 규칙의 TypeScript 소스를 프로젝트에 생성합니다. 생성된 파일은 자유롭게 수정할 수 있습니다.",
|
|
1762
|
+
"dependencies": [],
|
|
1763
|
+
"runtime": "node",
|
|
1764
|
+
"devDependencies": [
|
|
1765
|
+
"@types/node@^22"
|
|
1766
|
+
],
|
|
1767
|
+
"files": [
|
|
1768
|
+
"lint/fsd/fsd.ts",
|
|
1769
|
+
"lint/fsd/import-listener.ts",
|
|
1770
|
+
"lint/fsd/index.ts",
|
|
1771
|
+
"lint/fsd/options.ts",
|
|
1772
|
+
"lint/fsd/rules/fsd-layer-imports.ts",
|
|
1773
|
+
"lint/fsd/rules/fsd-public-api.ts",
|
|
1774
|
+
"lint/fsd/rules/fsd-slice-segments.ts"
|
|
1775
|
+
]
|
|
1776
|
+
},
|
|
1729
1777
|
{
|
|
1730
1778
|
"name": "number-clamp",
|
|
1731
1779
|
"packageName": "@hermes/number-clamp",
|
package/dist/bin/hermes.js
CHANGED
|
@@ -25,7 +25,7 @@ const help = [
|
|
|
25
25
|
" -h, --help Show help",
|
|
26
26
|
" -v, --version Show version",
|
|
27
27
|
"",
|
|
28
|
-
"Defaults: Core → src/utils/hermes; libraries →
|
|
28
|
+
"Defaults: Core → src/utils/hermes; libraries → libs/hermes.",
|
|
29
29
|
"Saved paths are read from no-k.config.ts in the current directory.",
|
|
30
30
|
"Run in an interactive terminal; choose No to edit your selection.",
|
|
31
31
|
"External npm dependencies are reported; they are not installed automatically.",
|
package/dist/lib/contracts.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
첫 대분류는 사용자가 선택한 **Core / 라이브러리** 기준이며, 둘 다 선택할 수 있다.
|
|
12
12
|
그다음 하위 분류도 별도 단계에서 선택한다.
|
|
13
13
|
Core와 라이브러리의 설치 경로도 각각 지정할 수 있다.
|
|
14
|
-
기본 경로는 Core가 src/utils/hermes, 라이브러리가
|
|
14
|
+
기본 경로는 Core가 src/utils/hermes, 라이브러리가 libs/hermes이며,
|
|
15
15
|
사용자는 Enter로 그대로 사용하거나 각각 수정할 수 있다.
|
|
16
16
|
설치 시 no-k.config.ts를 생성해 프로젝트별 경로와 설치한 유틸 목록을 기억하고,
|
|
17
17
|
다음 실행에서 경로를 복원하고 기존 설치 항목을 표시한다.
|
|
@@ -76,7 +76,7 @@ Core / 라이브러리를 첫 화면에서 고른 후 하위 분류도 별도
|
|
|
76
76
|
경로는 요구하지 않는다.
|
|
77
77
|
- 설정이 없을 때도 경로 입력란에 기본값을 제공한다. 사용자가 입력을 바꾸지 않고 Enter를
|
|
78
78
|
누르면 표시된 경로를 사용하며, 최종 yes 후 설치가 성공하면 그 값을 설정 파일에 저장한다.
|
|
79
|
-
- 기본 경로는 Core가 src/utils/hermes, 라이브러리가
|
|
79
|
+
- 기본 경로는 Core가 src/utils/hermes, 라이브러리가 libs/hermes다.
|
|
80
80
|
코드에서는 DEFAULT_INSTALL_DIRECTORIES를 공통 정의로 사용한다.
|
|
81
81
|
- 두 대분류에 같은 경로를 지정하는 것도 가능하다. 각 목적지 아래의 기존 유틸 상대 경로는 유지한다.
|
|
82
82
|
- 단일 --dir 옵션은 양쪽 경로 입력의 공통 초기값으로 사용하고, 화면에서 각각 수정하는 것이
|
|
@@ -97,11 +97,11 @@ export default {
|
|
|
97
97
|
schemaVersion: 1,
|
|
98
98
|
directories: {
|
|
99
99
|
core: "src/utils/hermes",
|
|
100
|
-
lib: "
|
|
100
|
+
lib: "libs/hermes",
|
|
101
101
|
},
|
|
102
102
|
installed: [
|
|
103
103
|
{ name: "array-chunk", scope: "core", directory: "src/utils/hermes" },
|
|
104
|
-
{ name: "tailwindcss-cn", scope: "lib", directory: "
|
|
104
|
+
{ name: "tailwindcss-cn", scope: "lib", directory: "libs/hermes" },
|
|
105
105
|
],
|
|
106
106
|
};
|
|
107
107
|
```
|
|
@@ -121,7 +121,7 @@ export default {
|
|
|
121
121
|
기본 경로와 --dir, 화면에서 입력한 상대 경로, 설치 기록의 상대 경로에 같은 기준을 적용한다.
|
|
122
122
|
절대 경로를 직접 지정하는 것도 가능하다.
|
|
123
123
|
- **모노레포 사용**: 사용자가 설치할 앱·패키지 폴더에서 실행한다. 예를 들어 apps/web에서
|
|
124
|
-
실행하면 apps/web/no-k.config.ts와 그 아래 src/utils/hermes·
|
|
124
|
+
실행하면 apps/web/no-k.config.ts와 그 아래 src/utils/hermes·libs/hermes를 사용한다.
|
|
125
125
|
각 실행 위치의 설정과 설치 기록은 독립적이다.
|
|
126
126
|
- **기존 파일 보존**: 로드 시 원문을 보관하고 CLI가 관리하는 필드만 갱신한다. 사용자 정의 필드와
|
|
127
127
|
주석을 유지하며 경로 값, 설치 기록 추가와 버전 출처만 갱신한다. 안전하게 갱신할 수 없는 형식은 통째로 재생성하지
|
|
@@ -230,7 +230,7 @@ InstallRequest.fileDecisions에 유효한 replace 선택이 없는 충돌 파일
|
|
|
230
230
|
| InstallWizardRequest | 초기 선택 ID, --dir로 받은 공통 초기 경로, dry-run과 초기 화면에서 처리할 overwrite 옵션 |
|
|
231
231
|
| UtilityScope | 첫 대분류 ID: core 또는 lib |
|
|
232
232
|
| CatalogCategoryId | 대분류를 포함한 하위 분류 ID: core/array, core/data-structure, lib/tailwindcss 등 |
|
|
233
|
-
| DEFAULT_INSTALL_DIRECTORIES | 최초 경로 기본값: core는 src/utils/hermes, lib는
|
|
233
|
+
| DEFAULT_INSTALL_DIRECTORIES | 최초 경로 기본값: core는 src/utils/hermes, lib는 libs/hermes |
|
|
234
234
|
| InstallDirectories | 대분류별 경로. 아직 설정하지 않은 범위는 생략하며 core와 lib를 독립적으로 보관 |
|
|
235
235
|
| InstalledItem | 설치한 유틸의 ID, 대분류와 설치 당시 목적지 |
|
|
236
236
|
| ProjectConfig | no-k.config.ts의 스키마 버전, 대분류별 경로, 설치한 유틸 기록(installed) |
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# 유틸 독립 버전 관리
|
|
2
2
|
|
|
3
|
-
설치 가능한 유틸 디렉터리가 버전 단위다. 현재 Core 109
|
|
3
|
+
설치 가능한 유틸 디렉터리가 버전 단위다. 현재 Core 109개, Tailwind 4개, lint 1개가 각각
|
|
4
4
|
`package.json`과 `CHANGELOG.md`를 가진다. 소스 파일을 옮기지 않아 기존 디렉터리의 Git 이력도 유지된다.
|
|
5
5
|
같은 진입점에서 내보내는 함수·타입은 한 모듈이다. 예를 들어 heap·minHeap·maxHeap은
|
|
6
6
|
`@hermes/data-structure-heap`의 공개 API로 함께 버전 관리한다.
|
|
7
7
|
|
|
8
|
-
초기 버전은 기존 Core·Tailwind의 `1.0.0`을
|
|
8
|
+
초기 버전은 기존 Core·Tailwind의 `1.0.0`을 이어받고, `lint-fsd`는 `0.1.0`에서 시작한다.
|
|
9
|
+
유틸은 `private: true` workspace 패키지로
|
|
9
10
|
관리하고 Changesets의 `privatePackages.version`을 활성화한다. 소스를 복사해 배포하므로
|
|
10
11
|
각 유틸을 npm에 따로 publish하지 않는다. CLI `@no-k/hermes`의 버전은 별도로 관리한다.
|
|
11
12
|
|
|
@@ -90,7 +91,7 @@ codegen과 결과 확인이 성공한 뒤에만 설정을 저장한다. dry-run
|
|
|
90
91
|
5. `pnpm install --lockfile-only`, `pnpm run check`, `pnpm run test:run`, `pnpm run check:cli`,
|
|
91
92
|
`pnpm run test:cli-install`을 실행하고 결과와 변경 이력을 검토한다.
|
|
92
93
|
|
|
93
|
-
`fixed`·`linked` 그룹은 없다. Core·Tailwind의 상위 집계 패키지와 개발 설정 패키지는 Changesets 대상에서
|
|
94
|
+
`fixed`·`linked` 그룹은 없다. Core·Tailwind·lint의 상위 집계 패키지와 개발 설정 패키지는 Changesets 대상에서
|
|
94
95
|
제외한다. 내부 의존성 변경에 따른 dependent 버전 증가는 Changesets 규칙을 따른다.
|
|
95
96
|
자동으로 바뀐 의존 범위가 실제 API 호환성을 입증하지는 않으므로, major 변경 시 사용처 검증과
|
|
96
97
|
필요한 명시적 major changeset도 작성해야 한다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@no-k/hermes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Add individual TypeScript utilities to your project with the Hermes CLI.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"files": ["dist", "templates", "catalog.json", "README.md", "LICENSE", "docs"],
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"preview:ui": "tsx scripts/preview-ui.ts",
|
|
24
24
|
"pack:archive": "pnpm run build && node .build/scripts/pack-cli.js",
|
|
25
25
|
"test:install": "pnpm run build && node .build/scripts/smoke-cli.js",
|
|
26
|
+
"test:fsd-install": "pnpm run build && node .build/scripts/smoke-fsd.js",
|
|
26
27
|
"prepack": "pnpm run build"
|
|
27
28
|
},
|
|
28
29
|
"type": "module",
|
|
@@ -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;
|