diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 0000000..56f9b42 --- /dev/null +++ b/README.ko.md @@ -0,0 +1,517 @@ +# πŸ“– github-mobile-reader + +> `github-mobile-reader`λŠ” git diffλ₯Ό κΉ”λ”ν•˜κ²Œ μ„Έλ‘œ 슀크둀둜 읽을 수 μžˆλŠ” Markdown λ¬Έμ„œλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€ β€” 더 이상 쒌우 ν•€μΉ˜μ€Œμ΄λ‚˜ κ°€λ‘œ μŠ€μ™€μ΄ν”„λŠ” ν•„μš” μ—†μŠ΅λ‹ˆλ‹€. + +[![npm version](https://img.shields.io/npm/v/github-mobile-reader.svg)](https://www.npmjs.com/package/github-mobile-reader) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![Node.js β‰₯ 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) + +> μ˜μ–΄ λ¬Έμ„œλŠ” [README.md](./README.md)μ—μ„œ ν™•μΈν•˜μ„Έμš”. + +--- + +## 문제 상황 + +GitHub의 λͺ¨λ°”일 μ›Ή λ·°λŠ” μ½”λ“œλ₯Ό κ³ μ • λ„ˆλΉ„μ˜ λͺ¨λ…ΈμŠ€νŽ˜μ΄μŠ€ λΈ”λ‘μœΌλ‘œ λ Œλ”λ§ν•©λ‹ˆλ‹€. κΈ΄ 쀄은 κ°€λ‘œ μŠ€ν¬λ‘€μ„ μš”κ΅¬ν•˜κ³ , 깊게 μ€‘μ²©λœ λ‘œμ§μ€ ν•œλˆˆμ— νŒŒμ•…μ΄ λΆˆκ°€λŠ₯ν•˜λ©°, μΆœν‡΄κ·Ό μ§€ν•˜μ² μ—μ„œ PR 리뷰λ₯Ό ν•˜λŠ” 건 사싀상 λΆˆκ°€λŠ₯에 κ°€κΉμŠ΅λ‹ˆλ‹€. + +## ν•΄κ²°μ±… + +`github-mobile-reader`λŠ” git diffλ₯Ό νŒŒμ‹±ν•΄μ„œ **Logical Flow** β€” λ‹¨μˆœνžˆ μ–΄λ–€ λ¬Έμžκ°€ λ°”λ€Œμ—ˆλŠ”μ§€κ°€ μ•„λ‹ˆλΌ *μ½”λ“œκ°€ 무엇을 ν•˜λŠ”μ§€*λ₯Ό λ³΄μ—¬μ£ΌλŠ” κ°„κ²°ν•œ 트리 β€” λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. 결과물은 μ–΄λ–€ ν™”λ©΄ λ„ˆλΉ„μ—μ„œλ„ μœ„μ—μ„œ μ•„λž˜λ‘œ μ½νžˆλŠ” Markdown λ¬Έμ„œμž…λ‹ˆλ‹€. + +**Before** (κΈ°μ‘΄ diff, λͺ¨λ°”일 μ›Ή): + +``` +← μŠ€μ™€μ΄ν”„ β†’ μŠ€μ™€μ΄ν”„ β†’ μŠ€μ™€μ΄ν”„ β†’ ++ const result = data.map(item => item.value).filter(v => v > 10).reduce((a,b) => a+b, 0) +``` + +**After** (Reader Markdown): + +``` +data + └─ map(item β†’ value) + └─ filter(callback) + └─ reduce(callback) +``` + +--- + +## μ£Όμš” κΈ°λŠ₯ + +- **μ˜μ‘΄μ„± 제둜 μ½”μ–΄** β€” νŒŒμ„œλŠ” Node.js β‰₯ 18이 μžˆλŠ” μ–΄λ””μ„œλ‚˜ λ™μž‘ν•©λ‹ˆλ‹€ +- **이쀑 좜λ ₯ 포맷** β€” CJS (`require`)와 ESM (`import`) λͺ¨λ‘ 지원, TypeScript νƒ€μž… 포함 +- **GitHub Action** β€” λ ˆν¬μ— YAML 파일 ν•˜λ‚˜λ§Œ μΆ”κ°€ν•˜λ©΄ PRλ§ˆλ‹€ Reader λ¬Έμ„œκ°€ μžλ™ μƒμ„±λ©λ‹ˆλ‹€ +- **μ–‘λ°©ν–₯ diff 좔적** β€” μΆ”κ°€λœ μ½”λ“œμ™€ μ‚­μ œλœ μ½”λ“œλ₯Ό 각각 별도 μ„Ήμ…˜μœΌλ‘œ ν‘œμ‹œ +- **보수적 섀계** β€” νŒ¨ν„΄μ΄ μ• λ§€ν•  λ•ŒλŠ” 잘λͺ»λœ 정보λ₯Ό λ³΄μ—¬μ£ΌλŠ” λŒ€μ‹  덜 λ³΄μ—¬μ€λ‹ˆλ‹€ + +--- + +## λͺ©μ°¨ + +1. [λΉ λ₯Έ μ‹œμž‘](#λΉ λ₯Έ-μ‹œμž‘) +2. [μ–Έμ–΄ 지원](#μ–Έμ–΄-지원) +3. [GitHub Action (ꢌμž₯)](#github-action-ꢌμž₯) +4. [npm 라이브러리 μ‚¬μš©λ²•](#npm-라이브러리-μ‚¬μš©λ²•) +5. [좜λ ₯ ν˜•μ‹](#좜λ ₯-ν˜•μ‹) +6. [API 레퍼런슀](#api-레퍼런슀) +7. [νŒŒμ„œ λ™μž‘ 원리](#νŒŒμ„œ-λ™μž‘-원리) +8. [κΈ°μ—¬ν•˜κΈ°](#κΈ°μ—¬ν•˜κΈ°) +9. [λΌμ΄μ„ μŠ€](#λΌμ΄μ„ μŠ€) + +--- + +## λΉ λ₯Έ μ‹œμž‘ + +```bash +npm install github-mobile-reader +``` + +```ts +import { generateReaderMarkdown } from "github-mobile-reader"; +import { execSync } from "child_process"; + +const diff = execSync("git diff HEAD~1", { encoding: "utf8" }); +const markdown = generateReaderMarkdown(diff, { file: "src/utils.ts" }); + +console.log(markdown); +``` + +--- + +## μ–Έμ–΄ 지원 + +νŒŒμ„œλŠ” μ •κ·œμ‹ 기반 νŒ¨ν„΄ 맀칭으둜 λ™μž‘ν•˜λ―€λ‘œ κΈ°μˆ μ μœΌλ‘œλŠ” μ–΄λ–€ μ–Έμ–΄μ˜ diff도 μž…λ ₯받을 수 μžˆμŠ΅λ‹ˆλ‹€. λ‹€λ§Œ 감지 νŒ¨ν„΄μ΄ JavaScript/TypeScript 문법에 맞좰 μ„€κ³„λ˜μ–΄ μžˆμ–΄ **Logical Flow 좜λ ₯ ν’ˆμ§ˆμ΄ μ–Έμ–΄λ§ˆλ‹€ λ‹€λ¦…λ‹ˆλ‹€**. + +### ν˜„μž¬ 지원 ν˜„ν™© (v0.1) + +| μ–Έμ–΄ | ν™•μž₯자 | ν’ˆμ§ˆ | λΉ„κ³  | +| ----------------------- | ------------------------- | :------------: | --------------------------------------------------------------------------- | +| **JavaScript** | `.js` `.mjs` `.cjs` | βœ… μ™„μ „ | νŒŒμ„œμ˜ κΈ°μ€€ μ–Έμ–΄ | +| **TypeScript** | `.ts` | βœ… μ™„μ „ | JS μƒμœ„ μ§‘ν•© β€” λͺ¨λ“  νŒ¨ν„΄ 적용 | +| **React JSX** | `.jsx` | βœ… μ™„μ „ | JS와 λ™μΌν•œ 문법 | +| **React TSX** | `.tsx` | βœ… μ™„μ „ | TS와 λ™μΌν•œ 문법 | +| **Next.js** | `.js` `.ts` `.jsx` `.tsx` | βœ… μ™„μ „ | JS/TS μœ„μ—μ„œ λ™μž‘ν•˜λŠ” ν”„λ ˆμž„μ›Œν¬ | +| **Java** | `.java` | ⚠️ λΆ€λΆ„ (~55%) | `if/for/while`κ³Ό 체이닝은 λ™μž‘; ν•¨μˆ˜ μ„ μ–Έ 감지 μ‹€νŒ¨ (`const/let/var` μ—†μŒ) | +| **C#** | `.cs` | ⚠️ λΆ€λΆ„ (~35%) | LINQ 체이닝(`.Where().Select()`)은 λ™μž‘; `using`/`namespace`/`class` 미감지 | +| **C** | `.c` `.h` | ❌ μ΅œμ†Œ (~15%) | λ§€μΉ­ ν‚€μ›Œλ“œ μ—†μŒ; 포인터 문법(`->`, `*`) 미지원 | +| **Python, Go, Rust λ“±** | β€” | πŸ”œ μ˜ˆμ • | μ•„λž˜ λ‘œλ“œλ§΅ μ°Έκ³  | + +> **μ°Έκ³ :** Java, C#, C νŒŒμΌμ€ 기본적으둜 GitHub Actionμ—μ„œ μ²˜λ¦¬λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. +> Action은 `.js .jsx .ts .tsx .mjs .cjs` 파일만 μŠ€μΊ”ν•©λ‹ˆλ‹€ ([`src/action.ts` 66번째 쀄](src/action.ts)). +> λ‹€λ₯Έ μ–Έμ–΄λ₯Ό μ²˜λ¦¬ν•˜λ €λ©΄ μ»€μŠ€ν…€ μ–΄λŒ‘ν„°κ°€ ν•„μš”ν•©λ‹ˆλ‹€ ([κΈ°μ—¬ν•˜κΈ°](#κΈ°μ—¬ν•˜κΈ°) μ°Έκ³ ). + +### JS/TS/React/Next.jsκ°€ μ™„μ „ μ§€μ›λ˜λŠ” 이유 + +λ„€ κ°€μ§€ λͺ¨λ‘ λ™μΌν•œ 기반 문법을 κ³΅μœ ν•©λ‹ˆλ‹€. νŒŒμ„œκ°€ μΈμ‹ν•˜λŠ” 것: + +- **λ©”μ„œλ“œ 체이닝** β€” `)`λ‚˜ `}`둜 λλ‚˜λŠ” 쀄 λ‹€μŒμ— `.`으둜 μ‹œμž‘ν•˜λŠ” 쀄 + ```ts + data + .filter((item) => item.active) // P1 μ²΄μ΄λ‹μœΌλ‘œ 감지 + .map((item) => item.value); // P1 μ²΄μ΄λ‹μœΌλ‘œ 감지 + ``` +- **ν•¨μˆ˜ μ„ μ–Έ** β€” `const`, `let`, `var`, `function`, `async` +- **쑰건문** β€” `if / else / switch` +- **반볡문** β€” `for / while` +- **λ…Έμ΄μ¦ˆ 필터링** β€” `import`, `export`, `type`, `interface`, `console.log`λŠ” μžλ™μœΌλ‘œ 제거 + +### C / C# / Javaκ°€ μ œν•œμ μΈ 이유 + +이 언어듀은 μœ„ νŒ¨ν„΄μ— λŒ€ν•΄ λ‹€λ₯Έ ν‘œκΈ° 방식을 μ‚¬μš©ν•©λ‹ˆλ‹€: + +| κ°œλ… | JS/TS (βœ… 감지됨) | Java / C# / C (❌ 미감지) | +| ------------- | ---------------------- | -------------------------------- | +| λ³€μˆ˜ μ„ μ–Έ | `const x = …` | `int x = …` / `String x = …` | +| ν™”μ‚΄ν‘œ 콜백 | `x => x.value` | μ–Έμ–΄λ§ˆλ‹€ λžŒλ‹€ 문법 닀름 | +| λ…Έμ΄μ¦ˆ import | `import` / `export` | `using` / `#include` / `package` | +| 비동기 ν•¨μˆ˜ | `async function foo()` | `async Task Foo()` | + +### λ‘œλ“œλ§΅ β€” Language Adapter μ‹œμŠ€ν…œ (v0.2) + +μΆ”κ°€ μ–Έμ–΄ 지원을 μœ„ν•΄ **Language Adapter** μ•„ν‚€ν…μ²˜κ°€ κ³„νšλ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€: + +``` +src/languages/ +β”œβ”€β”€ base.adapter.ts ← 곡톡 μΈν„°νŽ˜μ΄μŠ€ +β”œβ”€β”€ js-ts.adapter.ts ← ν˜„μž¬ 둜직 (parser.tsμ—μ„œ 뢄리) +β”œβ”€β”€ java.adapter.ts ← public/private/void μ„ μ–Έ, Stream 체이닝 +└── csharp.adapter.ts ← using/namespace, LINQ 체이닝 +``` + +각 μ–΄λŒ‘ν„°κ°€ μ œκ³΅ν•˜λŠ” 것: + +- 지원 파일 ν™•μž₯자 λͺ©λ‘ +- ν•¨μˆ˜ μ„ μ–Έ 감지 νŒ¨ν„΄ +- λ¬΄μ‹œν•  ν‚€μ›Œλ“œ λͺ©λ‘ (λ…Έμ΄μ¦ˆ) +- 체이닝 ν‘œκΈ° 방식 (점(`.`) vs. ν™”μ‚΄ν‘œ(`->`)) + +μ–Έμ–΄ μ–΄λŒ‘ν„°λ₯Ό κΈ°μ—¬ν•˜κ³  μ‹Άλ‹€λ©΄ [κΈ°μ—¬ν•˜κΈ°](#κΈ°μ—¬ν•˜κΈ°)λ₯Ό ν™•μΈν•˜μ„Έμš”. + +--- + +## GitHub Action (ꢌμž₯) + +이 라이브러리λ₯Ό μ‚¬μš©ν•˜λŠ” κ°€μž₯ μ‰¬μš΄ λ°©λ²•μž…λ‹ˆλ‹€. λ§€ PRλ§ˆλ‹€ μžλ™μœΌλ‘œ: + +1. λ³€κ²½λœ `.js` / `.ts` 파일의 diffλ₯Ό νŒŒμ‹± +2. `docs/reader/pr-<번호>.md` νŒŒμΌμ„ λ ˆν¬μ— μ €μž₯ +3. PR에 μš”μ•½ μ½”λ©˜νŠΈλ₯Ό μžλ™μœΌλ‘œ λ‹¬μ•„μ€λ‹ˆλ‹€ + +### Step 1 β€” μ›Œν¬ν”Œλ‘œμš° 파일 μΆ”κ°€ + +λ ˆν¬μ— `.github/workflows/mobile-reader.yml`을 λ§Œλ“€μ–΄ μ£Όμ„Έμš”: + +```yaml +name: πŸ“– Mobile Reader + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write # .md 파일 컀밋 + pull-requests: write # PR μ½”λ©˜νŠΈ μž‘μ„± + +jobs: + generate-reader: + name: Generate Mobile Reader View + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # git diff에 전체 νžˆμŠ€ν† λ¦¬ ν•„μš” + + - name: Generate Reader Markdown + uses: 3rdflr/github-mobile-reader@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + base_branch: ${{ github.base_ref }} + output_dir: docs/reader + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + + - name: Commit Reader Markdown + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/reader/ + if git diff --cached --quiet; then + echo "변경사항 μ—†μŒ" + else + git commit -m "docs(reader): PR #${{ github.event.pull_request.number }} λͺ¨λ°”일 리더 μ—…λ°μ΄νŠΈ [skip ci]" + git push + fi +``` + +### Step 2 β€” PR μ—΄κΈ° + +이게 μ „λΆ€μž…λ‹ˆλ‹€. 이후 λͺ¨λ“  PR에 μžλ™μœΌλ‘œ: + +- `docs/reader/pr-<번호>.md` 파일 생성 +- μƒμ„±λœ 파일 링크가 λ‹΄κΈ΄ PR μ½”λ©˜νŠΈ μžλ™ κ²Œμ‹œ + +### Action μž…λ ₯κ°’ + +| μž…λ ₯κ°’ | ν•„μˆ˜ | κΈ°λ³Έκ°’ | μ„€λͺ… | +| -------------- | ---- | ------------- | ---------------------------------- | +| `github_token` | βœ… | β€” | `${{ secrets.GITHUB_TOKEN }}` μ‚¬μš© | +| `base_branch` | ❌ | `main` | PR이 λ¨Έμ§€λ˜λŠ” λŒ€μƒ 브랜치 | +| `output_dir` | ❌ | `docs/reader` | μƒμ„±λœ `.md` 파일 μ €μž₯ 경둜 | + +--- + +## npm 라이브러리 μ‚¬μš©λ²• + +CI 슀크립트, μ»€μŠ€ν…€ 봇, 둜컬 도ꡬ λ“± λͺ¨λ“  Node.js ν”„λ‘œμ νŠΈμ—μ„œ 라이브러리둜 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +### μ„€μΉ˜ + +```bash +# npm +npm install github-mobile-reader + +# pnpm +pnpm add github-mobile-reader + +# yarn +yarn add github-mobile-reader +``` + +### CommonJS + +```js +const { generateReaderMarkdown } = require("github-mobile-reader"); +``` + +### ESM / TypeScript + +```ts +import { + generateReaderMarkdown, + parseDiffToLogicalFlow, +} from "github-mobile-reader"; +``` + +### κΈ°λ³Έ μ‚¬μš© μ˜ˆμ‹œ + +```ts +import { generateReaderMarkdown } from "github-mobile-reader"; +import { execSync } from "child_process"; +import { writeFileSync } from "fs"; + +// λ§ˆμ§€λ§‰ μ»€λ°‹μ˜ diff κ°€μ Έμ˜€κΈ° +const diff = execSync("git diff HEAD~1 HEAD", { encoding: "utf8" }); + +// 메타데이터와 ν•¨κ»˜ Reader Markdown 생성 +const markdown = generateReaderMarkdown(diff, { + pr: "42", + commit: "a1b2c3d", + file: "src/api/users.ts", + repo: "my-org/my-repo", +}); + +// 파일 μ €μž₯ λ˜λŠ” Slack / Discord / GitHub에 κ²Œμ‹œ +writeFileSync("reader.md", markdown, "utf8"); +``` + +### μ €μˆ˜μ€€ API μ˜ˆμ‹œ + +트리 ꡬ쑰만 ν•„μš”ν•œ 경우 (예: μ»€μŠ€ν…€ λ Œλ”λŸ¬ μ œμž‘): + +```ts +import { parseDiffToLogicalFlow, renderFlowTree } from "github-mobile-reader"; + +const { root, rawCode, removedCode } = parseDiffToLogicalFlow(diff); + +// root β†’ FlowNode[] (논리 트리) +// rawCode β†’ string (μΆ”κ°€λœ 쀄, μ€„λ°”κΏˆμœΌλ‘œ μ—°κ²°) +// removedCode β†’ string (μ‚­μ œλœ 쀄, μ€„λ°”κΏˆμœΌλ‘œ μ—°κ²°) + +const treeLines = renderFlowTree(root); +console.log(treeLines.join("\n")); +``` + +--- + +## 좜λ ₯ ν˜•μ‹ + +μƒμ„±λœ Reader Markdown λ¬Έμ„œλŠ” λ„€ 개의 μ„Ήμ…˜μœΌλ‘œ κ΅¬μ„±λ©λ‹ˆλ‹€: + +````markdown +# πŸ“– GitHub Reader View + +> Generated by **github-mobile-reader** +> Repository: my-org/my-repo +> Pull Request: #42 +> Commit: `a1b2c3d` +> File: `src/api/users.ts` + +--- + +## 🧠 Logical Flow + +``` +getData() + └─ filter(callback) + └─ map(item β†’ value) + └─ reduce(callback) +``` + +## βœ… Added Code + +```typescript +const result = getData() + .filter((item) => item.active) + .map((item) => item.value) + .reduce((a, b) => a + b, 0); +``` + +## ❌ Removed Code + +```typescript +const result = getData().map((item) => item.value); +``` + +--- + +πŸ›  Auto-generated by github-mobile-reader. Do not edit manually. +```` + +--- + +## API 레퍼런슀 + +### `generateReaderMarkdown(diffText, meta?)` + +메인 μ§„μž…μ . μ›μ‹œ git diff λ¬Έμžμ—΄μ„ νŒŒμ‹±ν•΄μ„œ μ™„μ„±λœ Reader Markdown λ¬Έμ„œλ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. + +| νŒŒλΌλ―Έν„° | νƒ€μž… | μ„€λͺ… | +| ------------- | --------- | ----------------------------- | +| `diffText` | `string` | `git diff`의 μ›μ‹œ 좜λ ₯ | +| `meta.pr` | `string?` | PR 번호 | +| `meta.commit` | `string?` | 컀밋 SHA | +| `meta.file` | `string?` | 헀더에 ν‘œμ‹œν•  파일λͺ… | +| `meta.repo` | `string?` | `owner/repo` ν˜•μ‹μ˜ 레포 이름 | + +**λ°˜ν™˜κ°’:** `string` β€” μ™„μ„±λœ Markdown λ¬Έμ„œ + +--- + +### `parseDiffToLogicalFlow(diffText)` + +λ Œλ”λ§ 없이 diffλ₯Ό κ΅¬μ‘°ν™”λœ 결과둜 νŒŒμ‹±ν•©λ‹ˆλ‹€. + +**λ°˜ν™˜κ°’:** `ParseResult` + +```ts +interface ParseResult { + root: FlowNode[]; // 논리 트리 (μΆ”κ°€λœ 쀄) + rawCode: string; // μΆ”κ°€λœ 쀄 (\n으둜 μ—°κ²°) + removedCode: string; // μ‚­μ œλœ 쀄 (\n으둜 μ—°κ²°) +} +``` + +--- + +### `renderFlowTree(nodes, indent?)` + +`FlowNode[]` 트리λ₯Ό Markdown μ•ˆμ „ν•œ ν…μŠ€νŠΈ 쀄 λ°°μ—΄λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€. + +```ts +const lines = renderFlowTree(root); +// [ 'getData()', ' └─ filter(callback)', ' └─ map(item β†’ value)' ] +``` + +--- + +### `FlowNode` + +```ts +interface FlowNode { + type: "root" | "chain" | "condition" | "loop" | "function" | "call"; + name: string; + children: FlowNode[]; + depth: number; + priority: Priority; +} +``` + +--- + +### `Priority` (μ—΄κ±°ν˜•) + +| κ°’ | 의미 | +| ----------------- | ----------------------------------------------- | +| `CHAINING = 1` | λ©”μ„œλ“œ 체인 (`.map()`, `.filter()`, …) β€” μ΅œμš°μ„  | +| `CONDITIONAL = 2` | `if` / `else` / `switch` 블둝 | +| `LOOP = 3` | `for` / `while` 반볡문 | +| `FUNCTION = 4` | ν•¨μˆ˜ μ„ μ–Έ | +| `OTHER = 5` | κ·Έ μ™Έ | + +--- + +## νŒŒμ„œ λ™μž‘ 원리 + +νŒŒμ„œλŠ” 결정둠적 νŒŒμ΄ν”„λΌμΈμœΌλ‘œ λ™μž‘ν•©λ‹ˆλ‹€ β€” AI μ—†μŒ, μ™ΈλΆ€ μ˜μ‘΄μ„± μ—†μŒ. + +``` +git diff ν…μŠ€νŠΈ + β”‚ + β–Ό +1. filterDiffLines() β€” + / - 쀄 뢄리, +++ / --- 헀더 제거 + β”‚ + β–Ό +2. normalizeCode() β€” ; 제거, 주석 제거, 곡백 정리 + β”‚ + β–Ό +3. getIndentDepth() β€” 쀑첩 레벨 계산 (2 spaces = 1 레벨) + β”‚ + β–Ό +4. parseToFlowTree() β€” μš°μ„ μˆœμœ„ μˆœμ„œλ‘œ νŒ¨ν„΄ λ§€μΉ­: + β”‚ P1 체이닝 (.map .filter .reduce …) + β”‚ P2 쑰건문 (if / else / switch) + β”‚ P3 반볡문 (for / while) + β”‚ P4 ν•¨μˆ˜ μ„ μ–Έ + β”‚ + β–Ό +5. renderFlowTree() β€” 트리 β†’ λ“€μ—¬μ“°κΈ°λœ ν…μŠ€νŠΈ μ€„λ‘œ λ³€ν™˜ + β”‚ + β–Ό +generateReaderMarkdown() β€” μ΅œμ’… Markdown λ¬Έμ„œ 쑰립 +``` + +**μ£Όμš” 섀계 κ²°μ •:** + +- **보수적** β€” λΆ„λ₯˜λ˜μ§€ μ•ŠλŠ” 쀄은 잘λͺ»λœ 정보 λŒ€μ‹  쑰용히 κ±΄λ„ˆλœλ‹ˆλ‹€ +- **import / export / type / interface / console.log**λŠ” λ¬΄μ‹œλ©λ‹ˆλ‹€. 흐름 이해에 κΈ°μ—¬ν•˜μ§€ μ•ŠκΈ° λ•Œλ¬Έμž…λ‹ˆλ‹€ +- **콜백 인자 μΆ•μ•½** β€” 본문이 단일 속성 접근일 λ•Œ `.map(item => item.value)`λ₯Ό `map(item β†’ value)`둜 μΆ•μ•½ν•©λ‹ˆλ‹€. κ·Έ μ™Έμ—λŠ” `map(callback)`으둜 ν‘œμ‹œν•©λ‹ˆλ‹€ +- **ν•¨μˆ˜ 선언은 μ΅œμš°μ„  체크** β€” `const foo = async …`κ°€ `extractRoot`에 잘λͺ» λΆ„λ₯˜λ˜μ§€ μ•Šλ„λ‘, ν•¨μˆ˜ μ„ μ–Έ 감지λ₯Ό 루트 μΆ”μΆœλ³΄λ‹€ λ¨Όμ € μˆ˜ν–‰ν•©λ‹ˆλ‹€ +- **depth**λŠ” λ“€μ—¬μ“°κΈ° 기반 (2-space κΈ°μ€€)으둜 μΆ”μ λ˜λ©°, 체이닝 감지가 μ• λ§€ν•  λ•Œ 보쑰 μ •λ³΄λ‘œλ§Œ μ‚¬μš©λ©λ‹ˆλ‹€ + +### 지원 μ–Έμ–΄ (v0.1) + +[μ–Έμ–΄ 지원](#μ–Έμ–΄-지원) μ„Ήμ…˜μ˜ 전체 ν‘œλ₯Ό ν™•μΈν•˜μ„Έμš”. +μš”μ•½: **JS / TS / React / Next.js μ™„μ „ 지원**, Java와 C#은 λΆ€λΆ„ 지원, C 등은 Language Adapter μ‹œμŠ€ν…œ(v0.2)으둜 κ³„νš 쀑. + +--- + +## κΈ°μ—¬ν•˜κΈ° + +PR은 μ–Έμ œλ“ μ§€ ν™˜μ˜ν•©λ‹ˆλ‹€! μ‹œμž‘ 방법: + +```bash +# 레포 클둠 +git clone https://github.com/3rdflr/github-mobile-reader.git +cd github-mobile-reader + +# μ˜μ‘΄μ„± μ„€μΉ˜ +npm install + +# λΉŒλ“œ (라이브러리 + Action runner) +npm run build:all + +# 개발 쀑 watch λͺ¨λ“œ +npm run dev + +# ν…ŒμŠ€νŠΈ μ‹€ν–‰ +npx ts-node src/test.ts +``` + +### ν”„λ‘œμ νŠΈ ꡬ쑰 + +``` +github-mobile-reader/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ parser.ts ← 핡심 diff β†’ logical flow νŒŒμ„œ +β”‚ β”œβ”€β”€ index.ts ← npm 곡개 API +β”‚ β”œβ”€β”€ action.ts ← GitHub Action μ§„μž…μ  +β”‚ └── test.ts ← 슀λͺ¨ν¬ ν…ŒμŠ€νŠΈ (33개) +β”œβ”€β”€ dist/ ← 컴파일 κ²°κ³Όλ¬Ό (μžλ™ 생성, μˆ˜μ • κΈˆμ§€) +β”œβ”€β”€ .github/ +β”‚ └── workflows/ +β”‚ └── mobile-reader.yml ← μ‚¬μš©μžμš© μ˜ˆμ‹œ μ›Œν¬ν”Œλ‘œμš° +β”œβ”€β”€ action.yml ← GitHub Action μ •μ˜ +β”œβ”€β”€ README.md ← μ˜μ–΄ λ¬Έμ„œ +β”œβ”€β”€ README.ko.md ← ν•œκ΅­μ–΄ λ¬Έμ„œ (ν˜„μž¬ 파일) +β”œβ”€β”€ package.json +└── tsconfig.json +``` + +### μƒˆ μ–Έμ–΄ μ–΄λŒ‘ν„° μΆ”κ°€ν•˜κΈ° + +νŒŒμ„œλŠ” ν˜„μž¬ JS/TS 문법 νœ΄λ¦¬μŠ€ν‹±μ— μ˜μ‘΄ν•©λ‹ˆλ‹€ (점 체이닝, `const`/`let`/`var`, `function`, `if`/`for`/`while`). μƒˆ μ–Έμ–΄λ₯Ό μΆ”κ°€ν•˜λ €λ©΄: + +1. `src/parser.ts`μ—μ„œ 감지 헬퍼 μΆ”κ°€ (κΈ°μ‘΄ `isChaining`, `isConditional` νŒ¨ν„΄ μ°Έκ³ ) +2. `src/action.ts`의 `filterDiffLines`μ—μ„œ μƒˆ 파일 ν™•μž₯자 ν—ˆμš© +3. `src/test.ts`에 ν•΄λ‹Ή μ–Έμ–΄μ˜ diff μ˜ˆμ‹œλ₯Ό ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€λ‘œ μΆ”κ°€ +4. μ˜ˆμ‹œ diffλ₯Ό ν¬ν•¨ν•΄μ„œ PR μ˜€ν”ˆ + +--- + +## λΌμ΄μ„ μŠ€ + +MIT Β© [3rdflr](https://github.com/3rdflr) + +--- diff --git a/README.md b/README.md index dc6ab09..f9a3297 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # πŸ“– github-mobile-reader -> **Stop squinting at code on your phone.** > `github-mobile-reader` transforms raw git diffs into clean, vertically-scrollable Markdown β€” no more pinch-zooming or swiping left and right to read a single line. [![npm version](https://img.shields.io/npm/v/github-mobile-reader.svg)](https://www.npmjs.com/package/github-mobile-reader) @@ -15,15 +14,17 @@ GitHub's mobile web view renders code in a fixed-width monospace block. Long lin ## The Solution -`github-mobile-reader` parses a git diff and produces a **Logical Flow** β€” a compact tree that shows *what the code does*, not just what characters changed. The result is a Markdown document that reads top-to-bottom on any screen width. +`github-mobile-reader` parses a git diff and produces a **Logical Flow** β€” a compact tree that shows _what the code does_, not just what characters changed. The result is a Markdown document that reads top-to-bottom on any screen width. **Before** (raw diff, mobile web): + ``` ← swipe β†’ swipe β†’ swipe β†’ + const result = data.map(item => item.value).filter(v => v > 10).reduce((a,b) => a+b, 0) ``` **After** (Reader Markdown): + ``` data └─ map(item β†’ value) @@ -38,7 +39,7 @@ data - **Zero-dependency core** β€” the parser runs anywhere Node.js β‰₯ 18 is available - **Dual output format** β€” CJS (`require`) and ESM (`import`) with full TypeScript types - **GitHub Action** β€” drop one YAML block into any repo and get auto-generated Reader docs on every PR -- **Tracks both sides of a diff** β€” shows added *and* removed code in separate sections +- **Tracks both sides of a diff** β€” shows added _and_ removed code in separate sections - **Conservative by design** β€” when a pattern is ambiguous, the library shows less rather than showing something wrong --- @@ -63,17 +64,17 @@ The parser is built on regex-based pattern matching, so it can technically recei ### Current support (v0.1) -| Language | Extensions | Flow Quality | Notes | -|----------|-----------|:------------:|-------| -| **JavaScript** | `.js` `.mjs` `.cjs` | βœ… Full | Baseline target language | -| **TypeScript** | `.ts` | βœ… Full | JS superset β€” all patterns apply | -| **React JSX** | `.jsx` | βœ… Full | Same syntax as JS | -| **React TSX** | `.tsx` | βœ… Full | Same syntax as TS | -| **Next.js** | `.js` `.ts` `.jsx` `.tsx` | βœ… Full | Framework on top of JS/TS | -| **Java** | `.java` | ⚠️ Partial (~55%) | `if/for/while` and dot-chaining work; function declarations missed (no `const/let/var`) | -| **C#** | `.cs` | ⚠️ Partial (~35%) | LINQ chaining (`.Where().Select()`) works; `using`/`namespace`/`class` not detected | -| **C** | `.c` `.h` | ❌ Minimal (~15%) | No matching keywords; pointer syntax (`->`, `*`) not understood | -| **Python, Go, Rust, etc.** | β€” | πŸ”œ Planned | See roadmap below | +| Language | Extensions | Flow Quality | Notes | +| -------------------------- | ------------------------- | :---------------: | --------------------------------------------------------------------------------------- | +| **JavaScript** | `.js` `.mjs` `.cjs` | βœ… Full | Baseline target language | +| **TypeScript** | `.ts` | βœ… Full | JS superset β€” all patterns apply | +| **React JSX** | `.jsx` | βœ… Full | Same syntax as JS | +| **React TSX** | `.tsx` | βœ… Full | Same syntax as TS | +| **Next.js** | `.js` `.ts` `.jsx` `.tsx` | βœ… Full | Framework on top of JS/TS | +| **Java** | `.java` | ⚠️ Partial (~55%) | `if/for/while` and dot-chaining work; function declarations missed (no `const/let/var`) | +| **C#** | `.cs` | ⚠️ Partial (~35%) | LINQ chaining (`.Where().Select()`) works; `using`/`namespace`/`class` not detected | +| **C** | `.c` `.h` | ❌ Minimal (~15%) | No matching keywords; pointer syntax (`->`, `*`) not understood | +| **Python, Go, Rust, etc.** | β€” | πŸ”œ Planned | See roadmap below | > **Note:** Java, C#, and C files are not processed by the GitHub Action by default. > The Action only scans `.js .jsx .ts .tsx .mjs .cjs` files ([`src/action.ts` line 66](src/action.ts)). @@ -86,8 +87,8 @@ All four share the same underlying syntax. The parser recognises: - **Method chaining** β€” line starting with `.` after a line ending with `)` or `}` ```ts data - .filter(item => item.active) // detected as P1 chain - .map(item => item.value) // detected as P1 chain + .filter((item) => item.active) // detected as P1 chain + .map((item) => item.value); // detected as P1 chain ``` - **Function declarations** β€” `const`, `let`, `var`, `function`, `async` - **Conditionals** β€” `if / else / switch` @@ -98,12 +99,12 @@ All four share the same underlying syntax. The parser recognises: These languages use different conventions for the patterns above: -| Concept | JS/TS (βœ… detected) | Java / C# / C (❌ missed) | -|---------|---------------------|--------------------------| -| Variable declaration | `const x = …` | `int x = …` / `String x = …` | -| Arrow callbacks | `x => x.value` | Lambdas differ per language | -| Noise imports | `import` / `export` | `using` / `#include` / `package` | -| Async functions | `async function foo()` | `async Task Foo()` | +| Concept | JS/TS (βœ… detected) | Java / C# / C (❌ missed) | +| -------------------- | ---------------------- | -------------------------------- | +| Variable declaration | `const x = …` | `int x = …` / `String x = …` | +| Arrow callbacks | `x => x.value` | Lambdas differ per language | +| Noise imports | `import` / `export` | `using` / `#include` / `package` | +| Async functions | `async function foo()` | `async Task Foo()` | ### Roadmap β€” Language Adapter system (v0.2) @@ -118,6 +119,7 @@ src/languages/ ``` Each adapter will declare: + - Supported file extensions - Function-declaration detection pattern - Keywords to ignore (noise list) @@ -134,13 +136,13 @@ npm install github-mobile-reader ``` ```ts -import { generateReaderMarkdown } from 'github-mobile-reader' -import { execSync } from 'child_process' +import { generateReaderMarkdown } from "github-mobile-reader"; +import { execSync } from "child_process"; -const diff = execSync('git diff HEAD~1', { encoding: 'utf8' }) -const markdown = generateReaderMarkdown(diff, { file: 'src/utils.ts' }) +const diff = execSync("git diff HEAD~1", { encoding: "utf8" }); +const markdown = generateReaderMarkdown(diff, { file: "src/utils.ts" }); -console.log(markdown) +console.log(markdown); ``` --- @@ -165,8 +167,8 @@ on: types: [opened, synchronize, reopened] permissions: - contents: write # commit the generated .md file - pull-requests: write # post the PR comment + contents: write # commit the generated .md file + pull-requests: write # post the PR comment jobs: generate-reader: @@ -177,7 +179,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 # full history required for git diff + fetch-depth: 0 # full history required for git diff - name: Generate Reader Markdown uses: 3rdflr/github-mobile-reader@v1 @@ -210,11 +212,11 @@ That's it. Every subsequent PR will automatically get: ### Action Inputs -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `github_token` | βœ… | β€” | Use `${{ secrets.GITHUB_TOKEN }}` | -| `base_branch` | ❌ | `main` | The branch the PR is merging into | -| `output_dir` | ❌ | `docs/reader` | Directory for generated `.md` files | +| Input | Required | Default | Description | +| -------------- | -------- | ------------- | ----------------------------------- | +| `github_token` | βœ… | β€” | Use `${{ secrets.GITHUB_TOKEN }}` | +| `base_branch` | ❌ | `main` | The branch the PR is merging into | +| `output_dir` | ❌ | `docs/reader` | Directory for generated `.md` files | --- @@ -238,35 +240,38 @@ yarn add github-mobile-reader ### CommonJS ```js -const { generateReaderMarkdown } = require('github-mobile-reader') +const { generateReaderMarkdown } = require("github-mobile-reader"); ``` ### ESM / TypeScript ```ts -import { generateReaderMarkdown, parseDiffToLogicalFlow } from 'github-mobile-reader' +import { + generateReaderMarkdown, + parseDiffToLogicalFlow, +} from "github-mobile-reader"; ``` ### Basic Example ```ts -import { generateReaderMarkdown } from 'github-mobile-reader' -import { execSync } from 'child_process' -import { writeFileSync } from 'fs' +import { generateReaderMarkdown } from "github-mobile-reader"; +import { execSync } from "child_process"; +import { writeFileSync } from "fs"; // Get the diff for the last commit -const diff = execSync('git diff HEAD~1 HEAD', { encoding: 'utf8' }) +const diff = execSync("git diff HEAD~1 HEAD", { encoding: "utf8" }); // Generate Reader Markdown with metadata const markdown = generateReaderMarkdown(diff, { - pr: '42', - commit: 'a1b2c3d', - file: 'src/api/users.ts', - repo: 'my-org/my-repo', -}) + pr: "42", + commit: "a1b2c3d", + file: "src/api/users.ts", + repo: "my-org/my-repo", +}); // Write to a file or post to Slack / Discord / GitHub -writeFileSync('reader.md', markdown, 'utf8') +writeFileSync("reader.md", markdown, "utf8"); ``` ### Low-level API Example @@ -274,16 +279,16 @@ writeFileSync('reader.md', markdown, 'utf8') If you only need the parsed tree (e.g. to build your own renderer): ```ts -import { parseDiffToLogicalFlow, renderFlowTree } from 'github-mobile-reader' +import { parseDiffToLogicalFlow, renderFlowTree } from "github-mobile-reader"; -const { root, rawCode, removedCode } = parseDiffToLogicalFlow(diff) +const { root, rawCode, removedCode } = parseDiffToLogicalFlow(diff); // root β†’ FlowNode[] (the logical tree) // rawCode β†’ string (added lines, joined) // removedCode β†’ string (removed lines, joined) -const treeLines = renderFlowTree(root) -console.log(treeLines.join('\n')) +const treeLines = renderFlowTree(root); +console.log(treeLines.join("\n")); ``` --- @@ -304,13 +309,14 @@ A generated Reader Markdown document has four sections: --- ## 🧠 Logical Flow - ``` + getData() - └─ filter(callback) - └─ map(item β†’ value) - └─ reduce(callback) -``` +└─ filter(callback) +└─ map(item β†’ value) +└─ reduce(callback) + +```` ## βœ… Added Code @@ -319,17 +325,19 @@ const result = getData() .filter(item => item.active) .map(item => item.value) .reduce((a, b) => a + b, 0) -``` +```` ## ❌ Removed Code ```typescript -const result = getData().map(item => item.value) +const result = getData().map((item) => item.value); ``` --- + πŸ›  Auto-generated by github-mobile-reader. Do not edit manually. -``` + +```` --- @@ -363,7 +371,7 @@ interface ParseResult { rawCode: string // added lines joined with \n removedCode: string // removed lines joined with \n } -``` +```` --- @@ -372,7 +380,7 @@ interface ParseResult { Converts a `FlowNode[]` tree into an array of Markdown-safe text lines. ```ts -const lines = renderFlowTree(root) +const lines = renderFlowTree(root); // [ 'getData()', ' └─ filter(callback)', ' └─ map(item β†’ value)' ] ``` @@ -382,11 +390,11 @@ const lines = renderFlowTree(root) ```ts interface FlowNode { - type: 'root' | 'chain' | 'condition' | 'loop' | 'function' | 'call' - name: string - children: FlowNode[] - depth: number - priority: Priority + type: "root" | "chain" | "condition" | "loop" | "function" | "call"; + name: string; + children: FlowNode[]; + depth: number; + priority: Priority; } ``` @@ -394,13 +402,13 @@ interface FlowNode { ### `Priority` (enum) -| Value | Meaning | -|-------|---------| -| `CHAINING = 1` | Method chains (`.map()`, `.filter()`, …) β€” highest priority | -| `CONDITIONAL = 2` | `if` / `else` / `switch` blocks | -| `LOOP = 3` | `for` / `while` loops | -| `FUNCTION = 4` | Function declarations | -| `OTHER = 5` | Everything else | +| Value | Meaning | +| ----------------- | ----------------------------------------------------------- | +| `CHAINING = 1` | Method chains (`.map()`, `.filter()`, …) β€” highest priority | +| `CONDITIONAL = 2` | `if` / `else` / `switch` blocks | +| `LOOP = 3` | `for` / `while` loops | +| `FUNCTION = 4` | Function declarations | +| `OTHER = 5` | Everything else | --- @@ -499,6 +507,3 @@ The parser currently relies on JS/TS syntax heuristics (dot-chaining, `const`/`l MIT Β© [3rdflr](https://github.com/3rdflr) --- - -> **"The era of per-device number crunching is over. -> One logic. Every screen."** diff --git a/src/parser.ts b/src/parser.ts index a7c5dd4..f782b19 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -64,10 +64,10 @@ export function normalizeCode(lines: string[]): string[] { return lines .map(line => { let normalized = line; - normalized = normalized.replace(/;$/, ''); normalized = normalized.replace(/\/\/.*$/, ''); normalized = normalized.replace(/\/\*.*?\*\//, ''); normalized = normalized.trim(); + normalized = normalized.replace(/;$/, ''); return normalized; }) .filter(line => line.length > 0); @@ -137,9 +137,14 @@ export function isLoop(line: string): boolean { * Step 9: Detect function declaration (P4) */ export function isFunctionDeclaration(line: string): boolean { + const t = line.trim(); return ( - /^(function|const|let|var)\s+\w+\s*=?\s*(async\s*)?\(/.test(line.trim()) || - /^(async\s+)?function\s+\w+/.test(line.trim()) + // function foo() / async function foo() + /^(async\s+)?function\s+\w+/.test(t) || + // const foo = () => / const foo = async () => / const foo = async (x: T) => + /^(const|let|var)\s+\w+\s*=\s*(async\s*)?\(/.test(t) || + // const foo = function / const foo = async function + /^(const|let|var)\s+\w+\s*=\s*(async\s+)?function/.test(t) ); } @@ -229,6 +234,22 @@ export function parseToFlowTree(lines: string[]): FlowNode[] { continue; } + // P4: function declaration β€” must be checked BEFORE extractRoot, + // because "const foo = async ..." would otherwise match extractRoot first. + if (isFunctionDeclaration(line)) { + const funcMatch = line.match(/(?:function|const|let|var)\s+(\w+)/); + roots.push({ + type: 'function', + name: funcMatch ? `${funcMatch[1]}()` : 'function()', + children: [], + depth: relativeDepth, + priority: Priority.FUNCTION, + }); + currentChain = null; + prevLine = line; + continue; + } + // New root / chain start const root = extractRoot(line); if (root) { @@ -260,16 +281,6 @@ export function parseToFlowTree(lines: string[]): FlowNode[] { priority: Priority.LOOP, }); currentChain = null; - } else if (isFunctionDeclaration(line)) { - const funcMatch = line.match(/(?:function|const|let|var)\s+(\w+)/); - roots.push({ - type: 'function', - name: funcMatch ? `${funcMatch[1]}()` : 'function()', - children: [], - depth: relativeDepth, - priority: Priority.FUNCTION, - }); - currentChain = null; } prevLine = line; diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..6227001 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,373 @@ +/** + * github-mobile-reader β€” manual smoke tests + * Run: npx ts-node src/test.ts + */ + +import { + filterDiffLines, + normalizeCode, + parseDiffToLogicalFlow, + generateReaderMarkdown, + renderFlowTree, +} from './parser'; + +// ─── ANSI helpers ──────────────────────────────────────────────────────────── +const GREEN = (s: string) => `\x1b[32m${s}\x1b[0m`; +const RED = (s: string) => `\x1b[31m${s}\x1b[0m`; +const YELLOW = (s: string) => `\x1b[33m${s}\x1b[0m`; +const BOLD = (s: string) => `\x1b[1m${s}\x1b[0m`; +const DIM = (s: string) => `\x1b[2m${s}\x1b[0m`; + +let passed = 0; +let failed = 0; + +function assert(label: string, condition: boolean, detail?: string) { + if (condition) { + console.log(` ${GREEN('βœ“')} ${label}`); + passed++; + } else { + console.log(` ${RED('βœ—')} ${label}`); + if (detail) console.log(` ${RED('β†’')} ${detail}`); + failed++; + } +} + +function section(name: string) { + console.log(`\n${BOLD(YELLOW(`β–Ά ${name}`))}`); +} + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const CHAINING_DIFF = ` +diff --git a/src/utils.ts b/src/utils.ts +--- a/src/utils.ts ++++ b/src/utils.ts +@@ -1,3 +1,7 @@ ++const result = data ++ .map(item => item.value) ++ .filter(v => v > 10) ++ .reduce((a, b) => a + b, 0) +`; + +const CONDITIONAL_DIFF = ` +diff --git a/src/auth.ts b/src/auth.ts +--- a/src/auth.ts ++++ b/src/auth.ts +@@ -0,0 +1,6 @@ ++if (isValid) { ++ process(data) ++} else { ++ return fallback ++} +`; + +const FUNCTION_DIFF = ` +diff --git a/src/api.ts b/src/api.ts +--- a/src/api.ts ++++ b/src/api.ts +@@ -0,0 +1,4 @@ ++const fetchUser = async (id: string) => { ++ const res = await fetch('/api/users/' + id) ++ return res.json() ++} +`; + +const MIXED_DIFF = ` +diff --git a/src/data.ts b/src/data.ts +--- a/src/data.ts ++++ b/src/data.ts +@@ -1,5 +1,10 @@ +-const old = legacy() ++import { api } from './api' ++const users = api.getAll() ++ .filter(u => u.active) ++ .map(u => u.name) ++ ++if (users.length > 0) { ++ for (let i = 0; i < users.length; i++) { ++ process(users[i]) ++ } ++} +`; + +const NOISE_DIFF = ` +diff --git a/src/types.ts b/src/types.ts +--- a/src/types.ts ++++ b/src/types.ts +@@ -0,0 +1,5 @@ ++import { Foo } from './foo' ++export type Bar = { id: string } ++interface Baz { name: string } ++console.log('debug') ++throw new Error('oops') +`; + +const EMPTY_DIFF = ` +diff --git a/src/style.css b/src/style.css +--- a/src/style.css ++++ b/src/style.css +@@ -0,0 +1,2 @@ ++.container { display: flex; } ++.item { color: red; } +`; + +// ─── Test Suite 1: filterDiffLines ─────────────────────────────────────────── + +section('filterDiffLines'); + +const { added: chainAdded, removed: chainRemoved } = filterDiffLines(CHAINING_DIFF); +assert( + 'extracts added lines (+ prefix)', + chainAdded.length === 4, + `expected 4 added lines, got ${chainAdded.length}: ${JSON.stringify(chainAdded)}` +); +assert( + 'skips +++ file header', + chainAdded.every(l => !l.startsWith('+')), + 'a line still starts with +' +); +assert( + 'removed lines empty when no - lines in diff', + chainRemoved.length === 0, + `expected 0 removed lines, got ${chainRemoved.length}` +); + +const { removed: mixedRemoved } = filterDiffLines(MIXED_DIFF); +assert( + 'detects removed lines (- prefix)', + mixedRemoved.length === 1, + `expected 1 removed line, got ${mixedRemoved.length}: ${JSON.stringify(mixedRemoved)}` +); +assert( + 'removed line content is correct', + mixedRemoved[0].includes('legacy()'), + `expected "legacy()" in removed, got: ${mixedRemoved[0]}` +); + +// ─── Test Suite 2: normalizeCode ───────────────────────────────────────────── + +section('normalizeCode'); + +const rawLines = [ + 'const x = foo(); // trailing comment', + 'const y = bar();', + ' ', + '.map(i => i.val); /* inline comment */', +]; +const normalized = normalizeCode(rawLines); + +assert( + 'removes trailing semicolons', + !normalized.some(l => l.endsWith(';')), + `found semicolons in: ${JSON.stringify(normalized)}` +); +assert( + 'removes // inline comments', + !normalized.some(l => l.includes('//')), + `found // in: ${JSON.stringify(normalized)}` +); +assert( + 'removes /* */ block comments', + !normalized.some(l => l.includes('/*')), + `found /* in: ${JSON.stringify(normalized)}` +); +assert( + 'filters out empty/whitespace-only lines', + !normalized.some(l => l.trim() === ''), + `found empty line in: ${JSON.stringify(normalized)}` +); + +// ─── Test Suite 3: parseDiffToLogicalFlow β€” chaining ───────────────────────── + +section('parseDiffToLogicalFlow β€” method chaining'); + +const chainResult = parseDiffToLogicalFlow(CHAINING_DIFF); + +assert( + 'produces at least one root node', + chainResult.root.length > 0, + `root is empty` +); +assert( + 'root node is "data" (right-hand side of assignment)', + chainResult.root[0]?.name === 'data', + `expected "data", got "${chainResult.root[0]?.name}"` +); +assert( + 'root has children (chain nodes)', + chainResult.root[0]?.children.length > 0, + 'no children on root node' +); +assert( + 'rawCode contains the added lines', + chainResult.rawCode.includes('data'), + `rawCode missing "data": ${chainResult.rawCode.slice(0, 80)}` +); + +// ─── Test Suite 4: parseDiffToLogicalFlow β€” conditionals ───────────────────── + +section('parseDiffToLogicalFlow β€” conditionals'); + +const condResult = parseDiffToLogicalFlow(CONDITIONAL_DIFF); + +assert( + 'detects if-block as root node', + condResult.root.some(n => n.type === 'condition'), + `no condition node found. nodes: ${JSON.stringify(condResult.root.map(n => n.type))}` +); +assert( + 'condition name contains "if"', + condResult.root.some(n => n.name.startsWith('if')), + `condition name: ${condResult.root[0]?.name}` +); + +// ─── Test Suite 5: parseDiffToLogicalFlow β€” function declaration ────────────── + +section('parseDiffToLogicalFlow β€” function declaration'); + +const funcResult = parseDiffToLogicalFlow(FUNCTION_DIFF); + +assert( + 'detects function node', + funcResult.root.some(n => n.type === 'function'), + `no function node. types: ${JSON.stringify(funcResult.root.map(n => n.type))}` +); +assert( + 'function name includes "fetchUser"', + funcResult.root.some(n => n.name.includes('fetchUser')), + `function names: ${JSON.stringify(funcResult.root.map(n => n.name))}` +); + +// ─── Test Suite 6: noise filtering ─────────────────────────────────────────── + +section('noise filtering (import / export / type / interface / console / throw)'); + +const noiseResult = parseDiffToLogicalFlow(NOISE_DIFF); + +assert( + 'pure-noise diff produces empty root (nothing to show)', + noiseResult.root.length === 0, + `expected 0 root nodes, got ${noiseResult.root.length}: ${JSON.stringify(noiseResult.root.map(n => n.name))}` +); +// rawCode preserves all added lines verbatim (including noise) so the +// "Actual Code" section in the output shows the real diff unchanged. +assert( + 'rawCode contains the original added lines (noise preserved for display)', + noiseResult.rawCode.includes('import') && noiseResult.rawCode.includes('interface'), + `rawCode: ${noiseResult.rawCode.slice(0, 80)}` +); + +// ─── Test Suite 7: non-JS diff (CSS) ───────────────────────────────────────── + +section('non-JS diff input (CSS β€” graceful degradation)'); + +const cssResult = parseDiffToLogicalFlow(EMPTY_DIFF); + +assert( + 'does not throw on non-JS input', + true // reaching here means no throw +); +assert( + 'produces empty root for unrecognised syntax', + cssResult.root.length === 0, + `expected 0 root nodes, got ${cssResult.root.length}` +); + +// ─── Test Suite 8: renderFlowTree ──────────────────────────────────────────── + +section('renderFlowTree'); + +const treeLines = renderFlowTree(chainResult.root); + +assert( + 'returns at least one line', + treeLines.length > 0, + 'renderFlowTree returned empty array' +); +assert( + 'first line is the root identifier (no indent)', + !treeLines[0].startsWith(' '), + `first line has unexpected indent: "${treeLines[0]}"` +); +assert( + 'child lines contain └─ connector', + treeLines.slice(1).some(l => l.includes('└─')), + `no └─ found in child lines: ${JSON.stringify(treeLines)}` +); + +// ─── Test Suite 9: generateReaderMarkdown ──────────────────────────────────── + +section('generateReaderMarkdown β€” full document'); + +const md = generateReaderMarkdown(CHAINING_DIFF, { + pr: '42', + commit: 'abc1234', + file: 'src/utils.ts', + repo: '3rdflr/github-mobile-reader', +}); + +assert( + 'output starts with # πŸ“–', + md.startsWith('# πŸ“–'), + `first chars: "${md.slice(0, 20)}"` +); +assert( + 'contains PR number in metadata', + md.includes('#42'), + 'PR number missing from output' +); +assert( + 'contains file name in metadata', + md.includes('src/utils.ts'), + 'file name missing from output' +); +assert( + 'contains Logical Flow section', + md.includes('🧠 Logical Flow'), + 'Logical Flow section missing' +); +assert( + 'contains Added Code section', + md.includes('βœ… Added Code'), + 'Added Code section missing' +); +assert( + 'does not contain Removed Code section when nothing removed', + !md.includes('❌ Removed Code'), + 'Removed Code section appeared unexpectedly' +); +assert( + 'contains auto-generated footer', + md.includes('Auto-generated'), + 'footer missing' +); + +// ─── Test Suite 10: generateReaderMarkdown with removed lines ───────────────── + +section('generateReaderMarkdown β€” shows removed code'); + +const mdMixed = generateReaderMarkdown(MIXED_DIFF, { file: 'src/data.ts' }); + +assert( + 'contains Removed Code section when - lines exist', + mdMixed.includes('❌ Removed Code'), + 'Removed Code section missing despite removed lines in diff' +); +assert( + 'removed code contains the old line content', + mdMixed.includes('legacy()'), + `removed section missing "legacy()"` +); + +// ─── Summary ───────────────────────────────────────────────────────────────── + +const total = passed + failed; +console.log(`\n${'─'.repeat(50)}`); +console.log(BOLD(`Results: ${GREEN(`${passed} passed`)} ${failed > 0 ? RED(`${failed} failed`) : DIM('0 failed')} / ${total} total`)); + +if (failed > 0) { + console.log(RED('\nSome tests failed. Check output above.')); + process.exit(1); +} else { + console.log(GREEN('\nAll tests passed! βœ“')); +}