Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

jobs:
test:
Expand Down Expand Up @@ -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
149 changes: 149 additions & 0 deletions .github/workflows/pr-test-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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 --no-color | 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');

// 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-'))
.map(dirent => dirent.name)
.sort();

if (dirs.length === 0) {
console.log('No test results found');
return;
}

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) {
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 (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 += `<details>\n<summary>🔍 Node ${nodeVersion} 테스트 결과</summary>\n\n\`\`\`\n${cleanContent}\n\`\`\`\n\n</details>\n\n`;
}
}

// 요약 정보
const statusEmoji = hasFailures ? '❌' : '✅';
const summary = `**총 ${totalPassing + totalFailing}개 테스트** | ✅ ${totalPassing}개 통과${totalFailing > 0 ? ` | ❌ ${totalFailing}개 실패` : ''}`;

const body = `## ${statusEmoji} 테스트 결과\n\n${summary}\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
});
}

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ Or configure rules manually:

## Rules

<!-- List of rules will be added here -->
### Import 관계 규칙

| Rule | Description | Recommended |
|------|-------------|:-----------:|
| [no-cross-layer-import](./docs/rules/no-cross-layer-import.md) | FSD 아키텍처에서 상위 레이어가 하위 레이어를 import하는 것을 방지합니다 | ✅ |

## Contributing

Expand Down
160 changes: 160 additions & 0 deletions docs/rules/no-cross-layer-import.md
Original file line number Diff line number Diff line change
@@ -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

2 changes: 1 addition & 1 deletion lib/configs/all.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ module.exports = {
plugins: ['fsd'],
rules: {
// All rules enabled
// 'fsd/example-rule': 'error',
'fsd/no-cross-layer-import': 'error',
},
};
2 changes: 1 addition & 1 deletion lib/configs/recommended.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
plugins: ['fsd'],
rules: {
// 'fsd/example-rule': 'error',
'fsd/no-cross-layer-import': 'error',
},
};
4 changes: 2 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ configs['flat/recommended'] = {
},
rules: {
// Add recommended rules here
// 'fsd/example-rule': 'error',
'fsd/no-cross-layer-import': 'error',
},
};

Expand All @@ -33,7 +33,7 @@ configs['flat/all'] = {
},
rules: {
// Add all rules here
// 'fsd/example-rule': 'error',
'fsd/no-cross-layer-import': 'error',
},
};

Expand Down
4 changes: 2 additions & 2 deletions lib/rules/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading