@no-k/hermes 0.2.0 → 0.3.2

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.
@@ -0,0 +1,176 @@
1
+ # Hermes 개발 가이드
2
+
3
+ Hermes의 코드를 수정하거나 새 항목을 추가하는 기여자를 위한 문서입니다.
4
+ 설치와 사용 방법은 [사용 가이드](../README.md)를 참고하세요.
5
+
6
+ ## CLI 구조
7
+
8
+ CLI 소스와 관련 도구는 `packages/cli` 안에서 TypeScript로 관리합니다.
9
+ 터미널 UI는 Ink·React, 버전 범위 검사는 `semver`를 사용합니다.
10
+
11
+ | 경로 | 역할 |
12
+ | --- | --- |
13
+ | `src/bin/hermes.ts` | 대화형 설치 진입, 조회 명령과 결과 출력 |
14
+ | `src/lib/install-wizard.ts` | 선택·경로·충돌·최종 확인 연결과 변경된 계획 재검토 |
15
+ | `src/lib/install-prompts.ts`, `install-view.tsx` | 경로 입력과 스크롤 가능한 최종 Yes/No 화면 |
16
+ | `src/lib/install.ts` | 쓰기 없는 설치 계획 계산, 파일별 충돌 결정, 설정 저장을 포함한 실행 |
17
+ | `src/lib/barrel.ts` | 설치 루트별 배럴 생성, 기존 export 보존과 누락된 export 추가 |
18
+ | `src/lib/files.ts` | 목적지 검사, 계획 이후 변경 감지와 파일 쓰기 |
19
+ | `src/lib/project-config.ts`, `config-syntax.ts` | 설정 읽기·검증, 원문 보존 갱신과 설치 목록 병합 |
20
+ | `src/lib/report.ts` | 파일 결과·외부 의존성·실행 환경 안내 |
21
+ | `src/lib/catalog.ts`, `json.ts` | catalog 타입과 JSON 검증 |
22
+ | `src/lib/catalog-query.ts` | 분류 계층 조회, 이름·전체 설명 검색, 분류 필터와 표시용 요약 |
23
+ | `src/lib/catalog-selection.ts` | 대분류·하위 분류·유틸 선택과 이전 선택 복원 |
24
+ | `src/lib/picker.ts`, `picker-view.tsx`, `terminal.ts`, `terminal-text.ts` | 선택 상태, Ink 화면, 터미널 입력과 화면 복원 |
25
+ | `src/lib/installation-status.ts` | 설치 기록과 실제 파일을 대조한 상태·위치 표시 |
26
+ | `src/lib/conflict-selection.ts` | 기존 파일과 생성 후보의 차이 검토, 파일별 유지·교체 선택 |
27
+ | `src/lib/versions.ts` | 실제 파일 해시와 설치 버전 대조, 내부 의존성·외부 peer 호환성 검사 |
28
+ | `scripts/catalog-lib.ts` | 유틸 탐색, 의존성 추적과 README 설명 추출 |
29
+ | `scripts/build-cli.ts` | catalog와 템플릿 생성 |
30
+ | `scripts/version-modules.ts` | Changesets 버전 적용 시 유틸 변경을 CLI 배포 버전에 연결 |
31
+ | `scripts/preview-ui.ts` | 파일을 생성하지 않는 개발용 선택 화면 미리보기 |
32
+ | `scripts/pack-cli.ts` | npm tarball 생성 및 포함 파일 검증 |
33
+ | `scripts/smoke-cli.ts` | tarball을 설치해 소비 프로젝트에서 컴파일·실행 |
34
+ | `scripts/smoke-fsd.ts` | CLI로 lint 소스를 생성하고 ESLint·Oxlint 실행, 직접 수정과 재설치 보존 검증 |
35
+ | `test/*.test.ts` | CLI·catalog·패키징 회귀 테스트 |
36
+
37
+ 대화형 설치는 [단계별 계약](interactive-install.md)에 따라 설치 엔진·설정, 카탈로그 탐색,
38
+ 독립 모듈 버전 관리와 터미널 선택 화면을 연결합니다.
39
+ `hermes`와 `hermes add` 모두 대화형 선택 후 최종 Yes에서 설치하고 설정과 버전을 저장합니다.
40
+
41
+ ## 화면 개발
42
+
43
+ 개발용 화면은 다음 명령으로 확인할 수 있습니다. 선택 결과를 표시하고 파일을 생성하지 않습니다.
44
+ 매번 전체 빌드하지 않고 `tsx`로 화면 소스를 실행합니다. 기존 catalog.json을 읽으며,
45
+ 아직 빌드하지 않았다면 소스에서 카탈로그를 메모리에 구성합니다.
46
+
47
+ ```bash
48
+ pnpm --filter @no-k/hermes run preview:ui
49
+ # 초기 선택값을 넣어 탐색할 수도 있습니다.
50
+ pnpm --filter @no-k/hermes run preview:ui number-lcm tailwindcss-cn
51
+ # 결과와 재진입 상태를 JSON으로 확인합니다.
52
+ pnpm --filter @no-k/hermes run preview:ui --json number-lcm
53
+ ```
54
+
55
+ 현재 단계, 포커스, 체크 수를 색상으로 구분합니다. 101열 이상에서는 목록 옆에 상세 패널이
56
+ 표시되고, 좁거나 낮은 터미널에서는 한 열로 전환합니다. 키 입력을 즉시 선택 상태에 반영하고
57
+ 연속 입력의 화면 갱신을 합칩니다. 바뀐 줄만 최대 60fps로 출력해 전체 화면 지우기를 줄입니다.
58
+
59
+ 대분류 → 각 대분류의 하위 분류 → 각 하위 분류의 유틸 순서로 선택합니다.
60
+ ↑↓로 이동하고 Space로 체크하며 Enter로 다음 화면에 갑니다. `/`로 검색을 시작하고
61
+ Enter로 검색 입력을 끝냅니다. Tab은 상세 보기, Esc는 상세·검색 닫기 또는 이전 화면,
62
+ `a`는 검색 결과 전체 선택·해제, Ctrl+L은 검색 초기화, `q`는 취소입니다.
63
+ 목록에서는 `j`/`k`로도 이동할 수 있습니다. 한국어 검색과 bracketed paste를 지원합니다.
64
+ 분류를 해제하면 그 안의 선택은 설치 대상에서 제외되고, 다시 선택하면 체크가 복원됩니다.
65
+ 상세 화면에서 함께 생성되는 의존 모듈, npm·peer 의존성, 실행 환경과 기존 설치 위치를 확인할 수 있습니다.
66
+
67
+ 미리보기는 실행 디렉터리의 설정을 읽습니다. 위 pnpm 명령의 실행 디렉터리는 `packages/cli`입니다.
68
+ 다른 앱의 설치 기록을 보려면 CLI를 빌드한 뒤 그 앱 디렉터리에서
69
+ `node /저장소/절대경로/packages/cli/.build/scripts/preview-ui.js`를 실행합니다.
70
+
71
+ 설정 모듈은 `export default { ... }`의 데이터 리터럴, 주석, 마지막 쉼표, 최상위 `as const`를
72
+ 지원합니다. 사용자 필드와 주석을 보존하기 위해 필요한 값만 수정하며, import·함수·변수 참조 등
73
+ 지원하지 않는 구문은 위치를 안내하고 원본을 유지합니다. 설정 파일의 코드를 실행하지 않습니다.
74
+
75
+ ## 검증 명령
76
+
77
+ 저장소 루트에서 실행합니다.
78
+
79
+ ```bash
80
+ pnpm install --frozen-lockfile
81
+ pnpm run build:cli
82
+ pnpm run check
83
+ pnpm run test:run
84
+ pnpm run check:cli
85
+ pnpm run pack:cli
86
+ pnpm run test:cli-install
87
+ pnpm run test:fsd-install
88
+ ```
89
+
90
+ ## 빌드와 카탈로그
91
+
92
+ `tsconfig.json`은 런타임·도구·테스트 전체를 strict 및 `noUncheckedIndexedAccess`로 검사합니다.
93
+ NodeNext 모듈 해석과 ES2022 출력을 사용하며, 상대 import에는 `.js` 확장자를 씁니다.
94
+ `tsconfig.build.json`은 런타임을 `dist/`에, `tsconfig.tools.json`은 도구·테스트와 참조하는
95
+ 공유 모듈을 `.build/`에 컴파일합니다. 타입 오류가 있으면 해당 컴파일의 출력은 생성되지 않습니다.
96
+
97
+ 빌드는 이전 `dist/`, `.build/`, `templates/`, `catalog.json`을 지우고 컴파일한 다음
98
+ catalog와 템플릿을 생성합니다. 항목 설명은 각 유틸 README의 첫 문단 전체에서 가져옵니다.
99
+ 생성된 catalog를 직접 수정하지 말고 해당 README를 수정하세요.
100
+
101
+ 카탈로그 스키마 3은 각 항목의 `scope`(core/lib)와 `category`(core/array, lib/tailwindcss 등)를
102
+ 원본 진입 경로에서 생성합니다. Core의 하위 분류와 `packages/lib`의 라이브러리 패키지를 빌드 시
103
+ 탐색하므로 새 유틸이나 분류가 자동 반영됩니다. 유틸 ID나 보조 파일 순서로 소속을 추측하지 않습니다.
104
+ 각 모듈의 manifest에서 패키지 이름·버전·의존 범위를 읽고, 함께 복사하는 모듈과 파일 해시를 기록합니다.
105
+ 프로젝트 설정인 no-k.config.ts의 스키마는 1을 유지하며 기존 위치 기록도 읽을 수 있습니다.
106
+
107
+ `filterCatalog`는 이름·소개 문단 전체 검색과 대분류·하위 분류 필터를 조합합니다.
108
+ 검색어를 공백으로 나누어 모두 일치하는 항목을 찾으며, 한글 Unicode 표현과 영문 대소문자를
109
+ 정규화합니다. `getCatalogGroups`는 같은 결과를 대분류별 하위 분류와 유틸 목록으로 구성합니다.
110
+ 필터 생략은 전체 조회, 빈 배열은 선택 없음입니다. `summarizeDescription`의 화면용 요약은
111
+ 전체 설명이나 검색 결과를 바꾸지 않습니다. 선택 화면은 이 조회 함수들을 재사용하며,
112
+ 검색 결과 밖에 있는 체크도 유지하고 전체 선택 수를 표시합니다.
113
+
114
+ ## 배포 파일 검증
115
+
116
+ `check:cli`, `pack:cli`, `test:cli-install`은 빌드부터 실행합니다.
117
+ 직접 `npm pack` 또는 `pnpm pack`을 실행해도 `prepack`이 같은 빌드를 수행합니다.
118
+ 검증 스크립트 내부의 `npm pack --ignore-scripts`는 이미 빌드한 결과를 포장해 중복 빌드를 피합니다.
119
+
120
+ npm 패키지에는 `dist/`의 ESM JavaScript, `catalog.json`, `templates/`의 TypeScript 유틸,
121
+ README·LICENSE·package.json과 `docs/` 문서가 포함됩니다. CLI TypeScript 소스, 개발 도구와 테스트는 포함하지 않습니다.
122
+ TypeScript·타입 패키지·tsx는 개발 의존성이며, 배포된 CLI의 직접 런타임 의존성은
123
+ `ink`, `react`, `semver`입니다. 소비 프로젝트에 생성되는 유틸에는 UI 의존성을 추가하지 않습니다.
124
+ `pack:cli`는 `artifacts/`에 tarball을 만들고 파일 목록·실행 권한을 검사합니다.
125
+ `test:cli-install`은 그 tarball을 별도 소비 프로젝트에서 검증하며 npm에 배포하지 않습니다.
126
+ 첫 `npx` 실행은 런타임 의존성을 내려받고, 다음 실행은 같은 캐시에서 오프라인으로 검증합니다.
127
+ PTY 통합 검증은 macOS·Linux의 `script` 명령으로 실제 터미널 입력을 전달합니다.
128
+ 한국어 탐색과 여러 분류 선택, 별도 경로 저장·재사용, No 이후 파일별 결정 유지,
129
+ 최종 확인 중 파일 변경, 화면별 취소, 입력·출력 각각의 리다이렉트를 검사합니다.
130
+ 최종 Yes 이전에는 파일이 생기거나 기존 파일이 바뀌지 않는지도 실제 실행 중 확인합니다.
131
+ Windows에서는 이 PTY 테스트를 건너뛰며 `test:cli-install`은 macOS·Linux에서 실행합니다.
132
+
133
+ ## FSD lint 개발
134
+
135
+ ### 저장소 호환 preset
136
+
137
+ `configs/eslint`는 새 플러그인을 재노출하는 내부 패키지로 유지합니다.
138
+ 규칙 이름 세 개는 동일하고 저장소는 `plugin.configs.hermes`를 사용합니다.
139
+
140
+ ```js
141
+ // eslint.config.mjs — 저장소 루트
142
+ import hermes from "./configs/eslint/dist/index.js";
143
+
144
+ export default [hermes.configs.hermes];
145
+ ```
146
+
147
+ 이 preset은 `hermes/` namespace와 기존 `widgets` 이상 cross-slice 예외,
148
+ 선택적인 `processes` 레이어를 유지합니다.
149
+ 공개 `recommended` preset은 이 예외를 기본으로 켜지 않습니다.
150
+ 이전의 임의 경로에서 레이어 이름을 찾던 동작은 명시적인 `roots`로 바뀌었습니다.
151
+ `aliases: ["@"]` 형태는 `aliases: { "@": "." }`로 이전하세요.
152
+ `app`의 slice 없는 구조, Shared 하위 모듈의 public API와 `@x`는 공식 구조에 맞춰 보완했습니다.
153
+
154
+ ### FSD 검증과 버전 관리
155
+
156
+ 저장소 루트에서 실행합니다.
157
+
158
+ ```bash
159
+ pnpm run check:fsd
160
+ pnpm run pack:cli
161
+ pnpm run test:fsd-install
162
+ ```
163
+
164
+ 규칙은 `packages/lib/lint/fsd/rules`, 린터 API와 무관한 경로·정책 판정은 `packages/lib/lint/fsd/fsd.ts`,
165
+ AST 방문 연결은 `packages/lib/lint/fsd/import-listener.ts`에 있습니다.
166
+ 공통 fixture를 ESLint RuleTester와 실제 oxlint 프로세스에서 실행합니다.
167
+ 설치 검증은 Hermes CLI tarball로 임시 소비 프로젝트에 소스를 생성한 뒤
168
+ ESLint 9·10과 oxlint에서 실행하고, 생성 파일을 수정한 결과도 확인합니다. npm에 publish하지 않습니다.
169
+
170
+ `@hermes/lint-fsd`는 카탈로그의 버전·의존성 추적용 private 모듈 이름입니다.
171
+ 별도로 npm에 publish하지 않고 `@no-k/hermes`의 templates에 포함합니다.
172
+ 이후 규칙 변경은 `pnpm changeset`에서 이 모듈을 선택합니다.
173
+ `pnpm run version:modules`는 다른 유틸처럼 모듈 변경을 CLI 배포 버전에 연결합니다.
174
+ 저장소 내부 lint용 `packages/lib/lint/dist`는 개발 빌드 산출물이며 사용자에게 복사하지 않습니다.
175
+
176
+ 릴리스 준비와 Changesets 적용 절차는 [모듈 버전 관리](module-versioning.md)를 참고하세요.
@@ -11,7 +11,7 @@
11
11
  첫 대분류는 사용자가 선택한 **Core / 라이브러리** 기준이며, 둘 다 선택할 수 있다.
12
12
  그다음 하위 분류도 별도 단계에서 선택한다.
13
13
  Core와 라이브러리의 설치 경로도 각각 지정할 수 있다.
14
- 기본 경로는 Core가 src/utils/hermes, 라이브러리가 src/lib/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, 라이브러리가 src/lib/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: "src/lib/hermes",
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: "src/lib/hermes" },
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·src/lib/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는 src/lib/hermes |
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개와 Tailwind 4개가 각각
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`을 이어받는다. 유틸은 `private: true` workspace 패키지로
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
 
@@ -79,26 +80,41 @@ codegen과 결과 확인이 성공한 뒤에만 설정을 저장한다. dry-run
79
80
  현재 카탈로그에는 모듈당 한 버전만 있다. 이전 버전을 다운로드하거나 서로 다른 버전을 같은
80
81
  설치 경로에 공존시키는 기능은 제공하지 않는다.
81
82
 
82
- ## 개발자의 버전 갱신 절차
83
+ ## 버전 갱신과 npm 배포
83
84
 
84
- 1. 모듈의 공개 API 변경에 맞춰 patch·minor·major를 결정하고 관련 사용처를 수정·검증한다.
85
- 2. `pnpm changeset`으로 변경한 모듈과 설명을 기록한다. 새 유틸에는 manifest와 changelog도 추가한다.
85
+ 1. CLI 또는 모듈의 공개 API 변경에 맞춰 patch·minor·major를 결정하고 관련 사용처를 수정·검증한다.
86
+ 2. `pnpm changeset`으로 변경한 패키지와 설명을 기록한다. CLI 변경은 `@no-k/hermes`,
87
+ 유틸 변경은 해당 모듈을 선택한다. 새 유틸에는 manifest와 changelog도 추가한다.
86
88
  3. `pnpm run version:status`로 예정된 버전과 영향받는 의존 모듈을 확인한다.
87
- 4. 릴리스를 준비할 때 `pnpm run version:modules`로 버전·내부 의존 범위·changelog를 갱신한다.
89
+ 4. 릴리스를 준비할 때 `pnpm run release:version`으로 버전·내부 의존 범위·changelog를 갱신한다.
88
90
  유틸만 변경한 경우에도 CLI patch changeset을 자동 추가해 번들 카탈로그 갱신을 포함한다.
89
- 기존 CLI changeset이 있으면 그 변경 수준을 사용한다.
90
- 5. `pnpm install --lockfile-only`, `pnpm run check`, `pnpm run test:run`, `pnpm run check:cli`,
91
+ 기존 CLI changeset이 있으면 그 변경 수준을 사용한다. lockfile 갱신과 CLI 재빌드까지 실행해
92
+ package.json과 catalog.json의 버전을 맞춘다. 적용할 changeset이 없으면 오류로 중단한다.
93
+ 기존 `pnpm run version:modules`도 같은 명령을 실행한다.
94
+ 5. `pnpm run check`, `pnpm run test:run`, `pnpm run check:cli`,
91
95
  `pnpm run test:cli-install`을 실행하고 결과와 변경 이력을 검토한다.
96
+ 6. `pnpm run release:plan`으로 npm에 아직 배포되지 않은 버전을 확인한다.
97
+ 버전 변경을 커밋한 뒤 `pnpm run release:publish`로 배포한다.
92
98
 
93
- `fixed`·`linked` 그룹은 없다. Core·Tailwind의 상위 집계 패키지와 개발 설정 패키지는 Changesets 대상에서
99
+ `release:publish`는 CLI를 빌드·테스트하고 `changeset publish`를 실행한다.
100
+ Changesets는 현재 package.json 버전의 npm 등록 여부를 확인해 미배포 버전만 배포한다.
101
+ 배포에 성공하면 `@no-k/hermes@N.N.N` 형태의 로컬 Git 태그를 생성한다.
102
+ commit·push는 자동 실행하지 않는다. `release:version`과 `release:plan`은 npm 배포와 태그 생성을 하지 않는다.
103
+
104
+ `ERR_PNPM_FAILED_TO_PUBLISH`와 `You cannot publish over the previously published versions`가
105
+ 나오면 오류에 표시된 버전을 확인한다. `artifacts/`에는 과거 버전의 tarball도 남으므로,
106
+ 예전 파일을 다시 배포해도 그 안의 package.json 버전은 바뀌지 않는다.
107
+ 새 changeset을 적용한 뒤 `release:plan`과 `release:publish`를 사용하면 현재 소스의 버전으로 배포한다.
108
+
109
+ `fixed`·`linked` 그룹은 없다. Core·Tailwind·lint의 상위 집계 패키지와 개발 설정 패키지는 Changesets 대상에서
94
110
  제외한다. 내부 의존성 변경에 따른 dependent 버전 증가는 Changesets 규칙을 따른다.
95
111
  자동으로 바뀐 의존 범위가 실제 API 호환성을 입증하지는 않으므로, major 변경 시 사용처 검증과
96
112
  필요한 명시적 major changeset도 작성해야 한다.
97
113
 
98
114
  GitHub changelog 생성기는 프로젝트 설정을 유지하며 `@changesets/changelog-github`를 사용한다.
99
- 릴리스 환경에는 해당 도구가 요구하는 `GITHUB_TOKEN`을 제공한다. 위 명령들은 publish·push·tag를
100
- 실행하지 않는다. 이번 도입에서는 유틸 초기 버전만 등록하고 실제 릴리스 버전 증가는 하지 않는다.
115
+ 릴리스 환경에는 해당 도구가 요구하는 `GITHUB_TOKEN`을 제공한다.
101
116
 
102
117
  참고: [Changesets의 private 패키지 버전 관리](https://changesets.dev/guide/beyond-npm),
118
+ [Changesets CLI](https://changesets.dev/guide/cli),
103
119
  [pnpm workspace 범위](https://pnpm.io/workspaces),
104
120
  [npm peerDependencies](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/#peerdependencies).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@no-k/hermes",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
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;