From 8dfe630e990a1952425e5e939529270c34a5d41b Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 10:57:11 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=95=98=EC=9C=84=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20->=20=EC=83=81=EC=9C=84=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20import=20=ED=95=98=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=ED=95=98=EB=8A=94=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rules/index.js | 4 +- lib/rules/no-cross-layer-import.js | 182 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 lib/rules/no-cross-layer-import.js diff --git a/lib/rules/index.js b/lib/rules/index.js index 85accec..2a49822 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -1,6 +1,6 @@ // Import all rules here -// const exampleRule = require('./example-rule'); +const noCrossLayerImport = require('./no-cross-layer-import'); module.exports = { - // 'example-rule': exampleRule, + 'no-cross-layer-import': noCrossLayerImport, }; diff --git a/lib/rules/no-cross-layer-import.js b/lib/rules/no-cross-layer-import.js new file mode 100644 index 0000000..07ab412 --- /dev/null +++ b/lib/rules/no-cross-layer-import.js @@ -0,0 +1,182 @@ +const { + extractLayer, + extractLayerFromImport, + isImportAllowed, + isInternalImport, + getLayerLevel, +} = require('../utils'); + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'FSD 아키텍처에서 상위 레이어가 하위 레이어를 import하는 것을 방지합니다', + category: 'FSD Architecture', + recommended: true, + url: 'https://github.com/kyh0726/fsd-eslint-plugin/blob/main/docs/rules/no-cross-layer-import.md', + }, + messages: { + crossLayerImport: + '레이어 순서 위반: "{{fromLayer}}" 레이어는 "{{toLayer}}" 레이어를 import할 수 없습니다. FSD 레이어 순서를 준수해주세요 (app → pages → widgets → features → entities → shared).', + }, + schema: [ + { + type: 'object', + properties: { + alias: { + type: 'string', + description: + 'Path alias prefix (예: "@" for "@/entities/user"). 기본값: "@"', + }, + ignorePatterns: { + type: 'array', + items: { + type: 'string', + }, + description: '체크를 무시할 파일 패턴 (정규표현식)', + }, + }, + additionalProperties: false, + }, + ], + }, + + create(context) { + const options = context.options[0] || {}; + const alias = options.alias || '@'; + const ignorePatterns = (options.ignorePatterns || []).map( + (pattern) => new RegExp(pattern) + ); + + const filename = context.getFilename(); + + // 무시 패턴에 매칭되는 경우 스킵 + if (ignorePatterns.some((pattern) => pattern.test(filename))) { + return {}; + } + + const currentLayer = extractLayer(filename); + + // 현재 파일이 FSD 레이어에 속하지 않으면 체크하지 않음 + if (!currentLayer) { + return {}; + } + + return { + ImportDeclaration(node) { + const importPath = node.source.value; + + // 내부 import가 아니면 스킵 (외부 패키지) + if (!isInternalImport(importPath)) { + return; + } + + const importedLayer = extractLayerFromImport(importPath); + + // import되는 파일이 FSD 레이어에 속하지 않으면 스킵 + if (!importedLayer) { + return; + } + + // 같은 레이어 import는 허용 + if (currentLayer === importedLayer) { + return; + } + + // 레이어 순서 체크 + if (!isImportAllowed(currentLayer, importedLayer)) { + context.report({ + node: node.source, + messageId: 'crossLayerImport', + data: { + fromLayer: currentLayer, + toLayer: importedLayer, + }, + }); + } + }, + + // dynamic import도 체크 + ImportExpression(node) { + if (node.source.type !== 'Literal') { + return; + } + + const importPath = node.source.value; + + if (!isInternalImport(importPath)) { + return; + } + + const importedLayer = extractLayerFromImport(importPath); + + if (!importedLayer) { + return; + } + + if (currentLayer === importedLayer) { + return; + } + + if (!isImportAllowed(currentLayer, importedLayer)) { + context.report({ + node: node.source, + messageId: 'crossLayerImport', + data: { + fromLayer: currentLayer, + toLayer: importedLayer, + }, + }); + } + }, + + // require() 호출도 체크 + CallExpression(node) { + if ( + node.callee.type !== 'Identifier' || + node.callee.name !== 'require' + ) { + return; + } + + if (node.arguments.length === 0) { + return; + } + + const firstArg = node.arguments[0]; + if (firstArg.type !== 'Literal') { + return; + } + + const importPath = firstArg.value; + + if (!isInternalImport(importPath)) { + return; + } + + const importedLayer = extractLayerFromImport(importPath); + + if (!importedLayer) { + return; + } + + if (currentLayer === importedLayer) { + return; + } + + if (!isImportAllowed(currentLayer, importedLayer)) { + context.report({ + node: firstArg, + messageId: 'crossLayerImport', + data: { + fromLayer: currentLayer, + toLayer: importedLayer, + }, + }); + } + }, + }; + }, +}; + From c70b187e012e82318431fa3c97b31f62c3fce86f Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 10:57:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20configs=EC=97=90=20rules=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/configs/all.js | 2 +- lib/configs/recommended.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/configs/all.js b/lib/configs/all.js index 993539a..06fa784 100644 --- a/lib/configs/all.js +++ b/lib/configs/all.js @@ -2,6 +2,6 @@ module.exports = { plugins: ['fsd'], rules: { // All rules enabled - // 'fsd/example-rule': 'error', + 'fsd/no-cross-layer-import': 'error', }, }; diff --git a/lib/configs/recommended.js b/lib/configs/recommended.js index 9192b0c..0d551f2 100644 --- a/lib/configs/recommended.js +++ b/lib/configs/recommended.js @@ -1,6 +1,6 @@ module.exports = { plugins: ['fsd'], rules: { - // 'fsd/example-rule': 'error', + 'fsd/no-cross-layer-import': 'error', }, }; From 62d38a70df4aad7d8a081352864902d5789d5687 Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 10:58:10 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20'error'=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/index.js | 4 +- lib/utils/index.js | 124 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 9836cb3..219e237 100644 --- a/lib/index.js +++ b/lib/index.js @@ -23,7 +23,7 @@ configs['flat/recommended'] = { }, rules: { // Add recommended rules here - // 'fsd/example-rule': 'error', + 'fsd/no-cross-layer-import': 'error', }, }; @@ -33,7 +33,7 @@ configs['flat/all'] = { }, rules: { // Add all rules here - // 'fsd/example-rule': 'error', + 'fsd/no-cross-layer-import': 'error', }, }; diff --git a/lib/utils/index.js b/lib/utils/index.js index e69de29..05f1a4a 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -0,0 +1,124 @@ +/** + * FSD 레이어 순서 정의 (낮은 숫자 = 상위 레이어) + */ +const FSD_LAYERS = { + app: 0, + pages: 1, + widgets: 2, + features: 3, + entities: 4, + shared: 5, +}; + +const LAYER_NAMES = Object.keys(FSD_LAYERS); + +/** + * 파일 경로에서 FSD 레이어를 추출합니다 + * @param {string} filePath - 파일 경로 + * @returns {string|null} 레이어 이름 또는 null + */ +function extractLayer(filePath) { + if (!filePath) return null; + + // 정규화: 백슬래시를 슬래시로 변환 + const normalizedPath = filePath.replace(/\\/g, '/'); + + // src/ 또는 프로젝트 루트 이후의 경로만 추출 + const pathParts = normalizedPath.split('/'); + + // 경로에서 레이어 찾기 + for (const part of pathParts) { + if (LAYER_NAMES.includes(part)) { + return part; + } + } + + return null; +} + +/** + * import 경로에서 FSD 레이어를 추출합니다 + * @param {string} importPath - import 경로 (예: '@/entities/user', '../../features/auth') + * @returns {string|null} 레이어 이름 또는 null + */ +function extractLayerFromImport(importPath) { + if (!importPath) return null; + + // 정규화 + const normalizedPath = importPath.replace(/\\/g, '/'); + + // @ 알리아스 제거 (@/, @shared/ 등) + const pathWithoutAlias = normalizedPath.replace(/^@[^/]*\//, ''); + + // 경로 부분들로 분리 + const pathParts = pathWithoutAlias.split('/'); + + // 모든 경로 부분을 검사하여 레이어 찾기 (상대 경로 대응) + for (const part of pathParts) { + if (LAYER_NAMES.includes(part)) { + return part; + } + } + + return null; +} + +/** + * 두 레이어 간의 import가 허용되는지 확인합니다 + * @param {string} fromLayer - import하는 파일의 레이어 + * @param {string} toLayer - import되는 파일의 레이어 + * @returns {boolean} 허용 여부 + */ +function isImportAllowed(fromLayer, toLayer) { + if (!fromLayer || !toLayer) return true; + if (fromLayer === toLayer) return true; + + const fromLevel = FSD_LAYERS[fromLayer]; + const toLevel = FSD_LAYERS[toLayer]; + + // 상위 레이어(낮은 숫자)는 하위 레이어(높은 숫자)만 import 가능 + return fromLevel < toLevel; +} + +/** + * 레이어 레벨을 반환합니다 (낮을수록 상위 레이어) + * @param {string} layer - 레이어 이름 + * @returns {number} 레이어 레벨 + */ +function getLayerLevel(layer) { + return FSD_LAYERS[layer]; +} + +/** + * 경로가 FSD 프로젝트 내부 경로인지 확인합니다 + * @param {string} importPath - import 경로 + * @returns {boolean} 내부 경로 여부 + */ +function isInternalImport(importPath) { + if (!importPath) return false; + + // 상대 경로 (../, ./) + if (importPath.startsWith('.')) return true; + + // @ 알리아스로 시작 + if (importPath.startsWith('@/')) return true; + + // 프로젝트 이름으로 시작하는 경우 (선택적) + // 패키지 import는 제외 (node_modules) + if (!importPath.includes('/')) return false; + + // FSD 레이어로 시작하는 경우 + const firstPart = importPath.split('/')[0].replace(/^@/, ''); + return LAYER_NAMES.includes(firstPart); +} + +module.exports = { + FSD_LAYERS, + LAYER_NAMES, + extractLayer, + extractLayerFromImport, + isImportAllowed, + getLayerLevel, + isInternalImport, +}; + From 641f2ebee6373fa2ba3210aa9b42589b5b6e2dc0 Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 10:58:38 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/lib/rules/no-cross-layer-import.test.js | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/lib/rules/no-cross-layer-import.test.js diff --git a/tests/lib/rules/no-cross-layer-import.test.js b/tests/lib/rules/no-cross-layer-import.test.js new file mode 100644 index 0000000..b71f4f4 --- /dev/null +++ b/tests/lib/rules/no-cross-layer-import.test.js @@ -0,0 +1,219 @@ +const { RuleTester } = require('eslint'); +const rule = require('../../../lib/rules/no-cross-layer-import'); + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, +}); + +ruleTester.run('no-cross-layer-import', rule, { + valid: [ + // ✅ app이 pages를 import (허용) + { + code: "import { MainPage } from '@/pages/main';", + filename: '/project/src/app/App.js', + }, + + // ✅ pages가 widgets를 import (허용) + { + code: "import { Header } from '@/widgets/header';", + filename: '/project/src/pages/home/index.js', + }, + + // ✅ widgets가 features를 import (허용) + { + code: "import { LoginForm } from '@/features/auth';", + filename: '/project/src/widgets/sidebar/Sidebar.js', + }, + + // ✅ features가 entities를 import (허용) + { + code: "import { User } from '@/entities/user';", + filename: '/project/src/features/profile/index.js', + }, + + // ✅ entities가 shared를 import (허용) + { + code: "import { Button } from '@/shared/ui';", + filename: '/project/src/entities/post/ui/PostCard.js', + }, + + // ✅ 같은 레이어 내 import (허용) + { + code: "import { UserCard } from './UserCard';", + filename: '/project/src/entities/user/index.js', + }, + + // ✅ 외부 패키지 import (허용) + { + code: "import React from 'react';", + filename: '/project/src/pages/home/index.js', + }, + + // ✅ FSD 레이어가 아닌 파일 (체크 안함) + { + code: "import { something } from '@/app/config';", + filename: '/project/src/config/index.js', + }, + + // ✅ require 문법도 허용되는 경우 + { + code: "const User = require('@/entities/user');", + filename: '/project/src/features/auth/index.js', + }, + + // ✅ dynamic import + { + code: "const module = import('@/entities/user');", + filename: '/project/src/features/auth/index.js', + }, + + // ✅ 상대 경로로 하위 레이어 import + { + code: "import { User } from '../../../entities/user';", + filename: '/project/src/features/auth/index.js', + }, + ], + + invalid: [ + // ❌ pages가 app을 import (위반) + { + code: "import { config } from '@/app/config';", + filename: '/project/src/pages/home/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'pages', + toLayer: 'app', + }, + }, + ], + }, + + // ❌ widgets가 pages를 import (위반) + { + code: "import { HomePage } from '@/pages/home';", + filename: '/project/src/widgets/header/Header.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'widgets', + toLayer: 'pages', + }, + }, + ], + }, + + // ❌ features가 widgets를 import (위반) + { + code: "import { Sidebar } from '@/widgets/sidebar';", + filename: '/project/src/features/auth/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'features', + toLayer: 'widgets', + }, + }, + ], + }, + + // ❌ entities가 features를 import (위반) + { + code: "import { login } from '@/features/auth';", + filename: '/project/src/entities/user/model.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'entities', + toLayer: 'features', + }, + }, + ], + }, + + // ❌ shared가 entities를 import (위반) + { + code: "import { User } from '@/entities/user';", + filename: '/project/src/shared/ui/Avatar.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'shared', + toLayer: 'entities', + }, + }, + ], + }, + + // ❌ shared가 app을 import (위반 - 여러 레이어 건너뛰기) + { + code: "import { App } from '@/app';", + filename: '/project/src/shared/config/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'shared', + toLayer: 'app', + }, + }, + ], + }, + + // ❌ require 문법도 체크 + { + code: "const Header = require('@/widgets/header');", + filename: '/project/src/features/auth/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'features', + toLayer: 'widgets', + }, + }, + ], + }, + + // ❌ dynamic import도 체크 + { + code: "const module = import('@/pages/home');", + filename: '/project/src/widgets/header/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'widgets', + toLayer: 'pages', + }, + }, + ], + }, + + // ❌ 상대 경로로 상위 레이어 import + { + code: "import { HomePage } from '../../../pages/home';", + filename: '/project/src/entities/user/index.js', + errors: [ + { + messageId: 'crossLayerImport', + data: { + fromLayer: 'entities', + toLayer: 'pages', + }, + }, + ], + }, + ], +}); + +console.log('✅ All tests passed for no-cross-layer-import rule!'); + From e7a768f0f62b936d730da335d56c155ecd573059 Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 10:58:58 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20no-cross-layer-import=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=ED=95=9C=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/rules/no-cross-layer-import.md | 160 ++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 docs/rules/no-cross-layer-import.md diff --git a/README.md b/README.md index 1aaee2e..9d4db93 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,11 @@ Or configure rules manually: ## Rules - +### Import 관계 규칙 + +| Rule | Description | Recommended | +|------|-------------|:-----------:| +| [no-cross-layer-import](./docs/rules/no-cross-layer-import.md) | FSD 아키텍처에서 상위 레이어가 하위 레이어를 import하는 것을 방지합니다 | ✅ | ## Contributing diff --git a/docs/rules/no-cross-layer-import.md b/docs/rules/no-cross-layer-import.md new file mode 100644 index 0000000..5456603 --- /dev/null +++ b/docs/rules/no-cross-layer-import.md @@ -0,0 +1,160 @@ +# no-cross-layer-import + +FSD 아키텍처에서 상위 레이어가 하위 레이어를 import하는 것을 방지합니다. + +## 📖 규칙 설명 + +Feature-Sliced Design (FSD) 아키텍처는 계층적 구조를 가지고 있으며, 각 레이어는 자신보다 아래에 있는 레이어만 import할 수 있습니다. + +**FSD 레이어 순서 (위→아래):** + +``` +app (최상위) + ↓ +pages + ↓ +widgets + ↓ +features + ↓ +entities + ↓ +shared (최하위) +``` + +**허용되는 import 방향:** +- ✅ 상위 레이어 → 하위 레이어 (예: `pages` → `widgets`) +- ✅ 같은 레이어 내부 (예: `features/auth` → `features/auth/ui`) +- ❌ 하위 레이어 → 상위 레이어 (예: `entities` → `features`) + +## 🔴 잘못된 코드 예시 + +```javascript +// ❌ pages가 app을 import (하위가 상위를 import) +// File: src/pages/home/index.js +import { config } from '@/app/config'; + +// ❌ widgets가 pages를 import +// File: src/widgets/header/Header.js +import { HomePage } from '@/pages/home'; + +// ❌ features가 widgets를 import +// File: src/features/auth/index.js +import { Sidebar } from '@/widgets/sidebar'; + +// ❌ entities가 features를 import +// File: src/entities/user/model.js +import { login } from '@/features/auth'; + +// ❌ shared가 entities를 import +// File: src/shared/ui/Avatar.js +import { User } from '@/entities/user'; + +// ❌ require 문법도 동일하게 체크 +// File: src/features/auth/index.js +const Header = require('@/widgets/header'); + +// ❌ dynamic import도 체크 +// File: src/widgets/header/index.js +const module = await import('@/pages/home'); +``` + +## 🟢 올바른 코드 예시 + +```javascript +// ✅ app이 pages를 import (상위가 하위를 import) +// File: src/app/App.js +import { MainPage } from '@/pages/main'; + +// ✅ pages가 widgets를 import +// File: src/pages/home/index.js +import { Header } from '@/widgets/header'; + +// ✅ widgets가 features를 import +// File: src/widgets/sidebar/Sidebar.js +import { LoginForm } from '@/features/auth'; + +// ✅ features가 entities를 import +// File: src/features/profile/index.js +import { User } from '@/entities/user'; + +// ✅ entities가 shared를 import +// File: src/entities/post/ui/PostCard.js +import { Button } from '@/shared/ui'; + +// ✅ 같은 레이어 내 import +// File: src/entities/user/index.js +import { UserCard } from './UserCard'; + +// ✅ 외부 패키지 import +// File: src/pages/home/index.js +import React from 'react'; +import { useQuery } from 'react-query'; +``` + +## ⚙️ 옵션 + +### `alias` + +Path alias prefix를 지정합니다. 기본값은 `"@"`입니다. + +```json +{ + "rules": { + "@yh-kim/fsd/no-cross-layer-import": ["error", { + "alias": "@" + }] + } +} +``` + +### `ignorePatterns` + +체크를 무시할 파일 패턴을 정규표현식 배열로 지정합니다. + +```json +{ + "rules": { + "@yh-kim/fsd/no-cross-layer-import": ["error", { + "ignorePatterns": [ + "\\.test\\.", + "\\.spec\\.", + "/tests/", + "/stories/" + ] + }] + } +} +``` + +## 💡 사용 시기 + +이 규칙은 다음과 같은 경우에 유용합니다: + +- Feature-Sliced Design 아키텍처를 프로젝트에 적용할 때 +- 레이어 간 의존성을 명확하게 관리하고 싶을 때 +- 순환 의존성을 방지하고 싶을 때 +- 코드베이스의 구조를 강제하고 싶을 때 + +## 🔗 관련 링크 + +- [Feature-Sliced Design 공식 문서](https://feature-sliced.design/) +- [FSD - Architectural Requirements](https://feature-sliced.design/docs/reference/layers) + +## ⚡ 구현 세부사항 + +이 규칙은 다음과 같은 import 구문을 모두 체크합니다: + +- ES6 `import` 문 +- CommonJS `require()` 호출 +- Dynamic `import()` 표현식 + +**지원하는 경로 형식:** +- Absolute alias (`@/entities/user`, `~/features/auth`) +- Relative path (`../../entities/user`) + +**체크하지 않는 경우:** +- 외부 패키지 import (`react`, `lodash` 등) +- FSD 레이어가 아닌 디렉토리의 파일 +- 같은 레이어 내부의 import + From fe5fcd5b73cbf5971f5e384b74c59a4e5fd305f7 Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 11:25:01 +0900 Subject: [PATCH 6/8] test: add import directions in test result --- tests/lib/rules/no-cross-layer-import.test.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/lib/rules/no-cross-layer-import.test.js b/tests/lib/rules/no-cross-layer-import.test.js index b71f4f4..9a0d7cf 100644 --- a/tests/lib/rules/no-cross-layer-import.test.js +++ b/tests/lib/rules/no-cross-layer-import.test.js @@ -14,66 +14,77 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { MainPage } from '@/pages/main';", filename: '/project/src/app/App.js', + name: '[app → pages] import 허용', }, // ✅ pages가 widgets를 import (허용) { code: "import { Header } from '@/widgets/header';", filename: '/project/src/pages/home/index.js', + name: '[pages → widgets] import 허용', }, // ✅ widgets가 features를 import (허용) { code: "import { LoginForm } from '@/features/auth';", filename: '/project/src/widgets/sidebar/Sidebar.js', + name: '[widgets → features] import 허용', }, // ✅ features가 entities를 import (허용) { code: "import { User } from '@/entities/user';", filename: '/project/src/features/profile/index.js', + name: '[features → entities] import 허용', }, // ✅ entities가 shared를 import (허용) { code: "import { Button } from '@/shared/ui';", filename: '/project/src/entities/post/ui/PostCard.js', + name: '[entities → shared] import 허용', }, // ✅ 같은 레이어 내 import (허용) { code: "import { UserCard } from './UserCard';", filename: '/project/src/entities/user/index.js', + name: '[entities → entities] 같은 레이어 내 import 허용', }, // ✅ 외부 패키지 import (허용) { code: "import React from 'react';", filename: '/project/src/pages/home/index.js', + name: '[pages] 외부 패키지 import 허용', }, // ✅ FSD 레이어가 아닌 파일 (체크 안함) { code: "import { something } from '@/app/config';", filename: '/project/src/config/index.js', + name: '[non-FSD] FSD 레이어가 아닌 파일은 체크 안함', }, // ✅ require 문법도 허용되는 경우 { code: "const User = require('@/entities/user');", filename: '/project/src/features/auth/index.js', + name: '[features → entities] require 문법 허용', }, // ✅ dynamic import { code: "const module = import('@/entities/user');", filename: '/project/src/features/auth/index.js', + name: '[features → entities] dynamic import 허용', }, // ✅ 상대 경로로 하위 레이어 import { code: "import { User } from '../../../entities/user';", filename: '/project/src/features/auth/index.js', + name: '[features → entities] 상대 경로 import 허용', }, ], @@ -82,6 +93,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { config } from '@/app/config';", filename: '/project/src/pages/home/index.js', + name: '[pages ✗ app] 상위 레이어 import 불가', errors: [ { messageId: 'crossLayerImport', @@ -97,6 +109,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { HomePage } from '@/pages/home';", filename: '/project/src/widgets/header/Header.js', + name: '[widgets ✗ pages] 상위 레이어 import 불가', errors: [ { messageId: 'crossLayerImport', @@ -112,6 +125,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { Sidebar } from '@/widgets/sidebar';", filename: '/project/src/features/auth/index.js', + name: '[features ✗ widgets] 상위 레이어 import 불가', errors: [ { messageId: 'crossLayerImport', @@ -127,6 +141,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { login } from '@/features/auth';", filename: '/project/src/entities/user/model.js', + name: '[entities ✗ features] 상위 레이어 import 불가', errors: [ { messageId: 'crossLayerImport', @@ -142,6 +157,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { User } from '@/entities/user';", filename: '/project/src/shared/ui/Avatar.js', + name: '[shared ✗ entities] 상위 레이어 import 불가', errors: [ { messageId: 'crossLayerImport', @@ -157,6 +173,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { App } from '@/app';", filename: '/project/src/shared/config/index.js', + name: '[shared ✗ app] 여러 레이어 건너뛰기 불가', errors: [ { messageId: 'crossLayerImport', @@ -172,6 +189,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "const Header = require('@/widgets/header');", filename: '/project/src/features/auth/index.js', + name: '[features ✗ widgets] require 문법도 체크', errors: [ { messageId: 'crossLayerImport', @@ -187,6 +205,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "const module = import('@/pages/home');", filename: '/project/src/widgets/header/index.js', + name: '[widgets ✗ pages] dynamic import도 체크', errors: [ { messageId: 'crossLayerImport', @@ -202,6 +221,7 @@ ruleTester.run('no-cross-layer-import', rule, { { code: "import { HomePage } from '../../../pages/home';", filename: '/project/src/entities/user/index.js', + name: '[entities ✗ pages] 상대 경로도 체크', errors: [ { messageId: 'crossLayerImport', From 7bb21048557483d27617e097f5568587a7286e21 Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 11:33:25 +0900 Subject: [PATCH 7/8] chore: add pr-comment based on test result when PR request is made --- .github/workflows/ci.yml | 18 +++- .github/workflows/pr-test-comment.yml | 124 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pr-test-comment.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37151b2..77cbe45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [main, develop] - pull_request: - branches: [main, develop] jobs: test: @@ -32,4 +30,18 @@ jobs: run: npm run format:check - name: Run tests - run: npm test + id: test + run: npm test | tee test-output-${{ matrix.node-version }}.txt + continue-on-error: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.node-version }} + path: test-output-${{ matrix.node-version }}.txt + retention-days: 7 + + - name: Fail if tests failed + if: steps.test.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/pr-test-comment.yml b/.github/workflows/pr-test-comment.yml new file mode 100644 index 0000000..85da2d3 --- /dev/null +++ b/.github/workflows/pr-test-comment.yml @@ -0,0 +1,124 @@ +name: PR Test Comment + +on: + pull_request: + branches: [main, develop] + +permissions: + pull-requests: write + contents: read + +jobs: + test-and-comment: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm ci + + - name: Run tests + id: test + run: npm test | tee test-output-${{ matrix.node-version }}.txt + continue-on-error: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.node-version }} + path: test-output-${{ matrix.node-version }}.txt + retention-days: 7 + + comment-pr: + needs: test-and-comment + runs-on: ubuntu-latest + if: always() + permissions: + pull-requests: write + + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + + - name: Comment PR with test results + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // 모든 test-results 디렉토리 찾기 + const dirs = fs.readdirSync('.', { withFileTypes: true }) + .filter(dirent => dirent.isDirectory() && dirent.name.startsWith('test-results-')) + .map(dirent => dirent.name) + .sort(); + + if (dirs.length === 0) { + console.log('No test results found'); + return; + } + + let allResults = ''; + let hasFailures = false; + + for (const dir of dirs) { + const nodeVersion = dir.replace('test-results-', ''); + const files = fs.readdirSync(dir); + + if (files.length > 0) { + const content = fs.readFileSync(path.join(dir, files[0]), 'utf8'); + allResults += `### Node ${nodeVersion}\n\n\`\`\`\n${content}\n\`\`\`\n\n`; + + // 실패 체크 + if (content.includes('failing')) { + hasFailures = true; + } + } + } + + const statusEmoji = hasFailures ? '❌' : '✅'; + const body = `## ${statusEmoji} 테스트 결과\n\n${allResults}`; + + // 기존 코멘트 찾기 + const comments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('테스트 결과') + ); + + if (botComment) { + // 기존 코멘트 업데이트 + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + } else { + // 새 코멘트 생성 + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + } + From 6f070d2be1c58ee0f809f1090bfffba6023fc0ae Mon Sep 17 00:00:00 2001 From: kyh0726 Date: Fri, 28 Nov 2025 11:59:13 +0900 Subject: [PATCH 8/8] chore: test code result formatting --- .github/workflows/pr-test-comment.yml | 37 ++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-test-comment.yml b/.github/workflows/pr-test-comment.yml index 85da2d3..e933e47 100644 --- a/.github/workflows/pr-test-comment.yml +++ b/.github/workflows/pr-test-comment.yml @@ -29,7 +29,7 @@ jobs: - name: Run tests id: test - run: npm test | tee test-output-${{ matrix.node-version }}.txt + run: npm test --no-color | tee test-output-${{ matrix.node-version }}.txt continue-on-error: true - name: Upload test results @@ -60,6 +60,11 @@ jobs: const fs = require('fs'); const path = require('path'); + // ANSI 색상 코드 제거 함수 + function stripAnsi(str) { + return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); + } + // 모든 test-results 디렉토리 찾기 const dirs = fs.readdirSync('.', { withFileTypes: true }) .filter(dirent => dirent.isDirectory() && dirent.name.startsWith('test-results-')) @@ -73,24 +78,44 @@ jobs: let allResults = ''; let hasFailures = false; + let totalPassing = 0; + let totalFailing = 0; for (const dir of dirs) { const nodeVersion = dir.replace('test-results-', ''); const files = fs.readdirSync(dir); if (files.length > 0) { - const content = fs.readFileSync(path.join(dir, files[0]), 'utf8'); - allResults += `### Node ${nodeVersion}\n\n\`\`\`\n${content}\n\`\`\`\n\n`; + let content = fs.readFileSync(path.join(dir, files[0]), 'utf8'); + + // ANSI 색상 코드 제거 + content = stripAnsi(content); + + // 테스트 결과 파싱 + const passingMatch = content.match(/(\d+) passing/); + const failingMatch = content.match(/(\d+) failing/); - // 실패 체크 - if (content.includes('failing')) { + if (passingMatch) totalPassing += parseInt(passingMatch[1]); + if (failingMatch) { + totalFailing += parseInt(failingMatch[1]); hasFailures = true; } + + // 깔끔하게 포맷팅 + const cleanContent = content + .split('\n') + .filter(line => line.trim()) + .join('\n'); + + allResults += `
\n🔍 Node ${nodeVersion} 테스트 결과\n\n\`\`\`\n${cleanContent}\n\`\`\`\n\n
\n\n`; } } + // 요약 정보 const statusEmoji = hasFailures ? '❌' : '✅'; - const body = `## ${statusEmoji} 테스트 결과\n\n${allResults}`; + const summary = `**총 ${totalPassing + totalFailing}개 테스트** | ✅ ${totalPassing}개 통과${totalFailing > 0 ? ` | ❌ ${totalFailing}개 실패` : ''}`; + + const body = `## ${statusEmoji} 테스트 결과\n\n${summary}\n\n${allResults}`; // 기존 코멘트 찾기 const comments = await github.rest.issues.listComments({