diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2ef75e5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +## ๐Ÿ“ Description +## โ“ Why is this change required? +## โœจ What does this PR do? \ No newline at end of file diff --git a/.github/workflows/auto-generate-pr-to-develop.yml b/.github/workflows/auto-generate-pr-to-develop.yml new file mode 100644 index 0000000..ee5434f --- /dev/null +++ b/.github/workflows/auto-generate-pr-to-develop.yml @@ -0,0 +1,113 @@ +name: auto-generate-pr-to-develop.yml + +on: + push: + branches-ignore: + - main + - develop + +jobs: + create-pull-request: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + + - name: Check if PR already exists + id: check_pr + env: + GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + run: | + EXISTING_PR=$(gh pr list --head ${{ github.ref_name }} --base develop --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Get Context (Commits & Diff) + if: steps.check_pr.outputs.exists == 'false' + id: get-context + run: | + git fetch origin develop + DIFF=$(git diff origin/develop...HEAD) + COMMITS=$(git log origin/develop...HEAD --pretty=format:"- %s") + + # ํŒŒ์ผ๋กœ ์ €์žฅํ•˜์—ฌ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํฌ๊ธฐ ์ œํ•œ ๋ฐ ํŠน์ˆ˜๋ฌธ์ž ๋ฌธ์ œ ๋ฐฉ์ง€ + echo "$DIFF" > diff_context.txt + echo "$COMMITS" > commits_context.txt + + - name: Generate PR Content with Gemini + if: steps.check_pr.outputs.exists == 'false' + id: gemini + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const diff = fs.readFileSync('diff_context.txt', 'utf8').substring(0, 5000); + const commits = fs.readFileSync('commits_context.txt', 'utf8'); + + const prompt = ` + You are an expert software engineer. Based on the commit messages and code diff below, + write a PR title and body in professional English. + + Structure the body exactly as follows: + ## ๐Ÿ“ Description + ... + ## โœจ What does this PR do? + ... + + Context: + - Commits: ${commits} + - Diff: ${diff} + + Response Format: + TITLE: [The PR Title] + BODY: [The PR Body] + `; + + const apiKey = process.env.GEMINI_API_KEY; + const apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + apiKey; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2 } + }) + }); + + const result = await response.json(); + const aiResponse = result.candidates[0].content.parts[0].text; + + const titleMatch = aiResponse.match(/TITLE: (.*)/); + const bodyMatch = aiResponse.split('BODY:')[1]; + + const title = titleMatch ? titleMatch[1].trim() : "Update from " + process.env.GITHUB_REF_NAME; + const body = bodyMatch ? bodyMatch.trim() : "No description generated."; + + core.setOutput('title', title); + fs.writeFileSync('pr_body.md', body); + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + + - name: Create Pull Request + if: steps.check_pr.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + run: | + gh pr create \ + --base develop \ + --head ${{ github.ref_name }} \ + --title "${{ steps.gemini.outputs.title }}" \ + --body-file pr_body.md \ + --assignee ${{ github.actor }} \ No newline at end of file diff --git a/.github/workflows/ci-with-gradle.yml b/.github/workflows/ci-with-gradle.yml new file mode 100644 index 0000000..6de314a --- /dev/null +++ b/.github/workflows/ci-with-gradle.yml @@ -0,0 +1,33 @@ +name: ci-with-gradle.yml + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew build shadowJar \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f1660d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ +.idea/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 0000000..8d2e82a --- /dev/null +++ b/README.ko.md @@ -0,0 +1,397 @@ +# JFocus ๐Ÿ” + +[![Java 21](https://img.shields.io/badge/Java-21-orange?logo=java)](https://openjdk.org/projects/jdk/21/) +[![Gradle](https://img.shields.io/badge/Gradle-8.x-02303A?logo=gradle)](https://gradle.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +[๐Ÿ‡บ๐Ÿ‡ธ **English**](README.md) | [๐Ÿ‡ฐ๐Ÿ‡ท **Korean(ํ•œ๊ตญ์–ด)**](README.ko.md) + +> **"AI์—๊ฒŒ ์ „์ฒด ํŒŒ์ผ์„ ๊ทธ๋งŒ ๋ถ™์—ฌ๋„ฃ์œผ์„ธ์š”. ํ•„์š”ํ•œ ๊ฑด ์˜ค์ง ๋ฌธ๋งฅ์ž…๋‹ˆ๋‹ค."** +> +> JFocus๋Š” ๋ฐฉ๋Œ€ํ•œ ์ž๋ฐ” ํ”„๋กœ์ ํŠธ์—์„œ **ํ•„์š”ํ•œ ์ฝ”๋“œ๋งŒ ๋˜‘๋˜‘ํ•˜๊ฒŒ** ๊ณจ๋ผ๋‚ด์–ด, ChatGPT๋‚˜ Claude ๊ฐ™์€ LLM์—๊ฒŒ **์ตœ์ ์˜ ํ”„๋กฌํ”„ํŠธ**๋ฅผ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•๋Š” CLI ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. + +--- + +## ๐Ÿ“ ๋ชฉ์ฐจ + +- [์†Œ๊ฐœ](#introduction) +- [ํšจ๊ณผ (Benchmarks)](#benchmarks) +- [์ฃผ์š” ๊ธฐ๋Šฅ](#features) +- [์„ค์น˜ ๋ฐฉ๋ฒ•](#installation) +- [์‚ฌ์šฉ ๋ฐฉ๋ฒ•](#usage) +- [For AI Agents](#for-ai-agents-cursor-windsurf) +- [๊ธฐ์—ฌํ•˜๊ธฐ](#contributing) +- [๋ผ์ด์„ ์Šค](#license) + +--- + +## ๐Ÿ’ก ์†Œ๊ฐœ (Introduction) +![JFocus CLI Demo](docs/images/demo.png) + +๋Œ€๊ทœ๋ชจ ์ž๋ฐ” ํ”„๋กœ์ ํŠธ๋ฅผ ๊ฐœ๋ฐœํ•˜๊ฑฐ๋‚˜ ๋ถ„์„ํ•  ๋•Œ, LLM์—๊ฒŒ ์ฝ”๋“œ๋ฅผ ์ดํ•ด์‹œํ‚ค๊ธฐ ์œ„ํ•ด ์ „์ฒด ํŒŒ์ผ์„ ๋ณต์‚ฌํ•ด ๋ถ™์—ฌ๋„ฃ๋Š” ๊ฒƒ์€ ๋น„ํšจ์œจ์ ์ž…๋‹ˆ๋‹ค. ํ† ํฐ ์ œํ•œ์— ๊ฑธ๋ฆฌ๊ฑฐ๋‚˜, ๋ถˆํ•„์š”ํ•œ ์ •๋ณด๋กœ ์ธํ•ด LLM์˜ ๋‹ต๋ณ€ ํ’ˆ์งˆ์ด ๋–จ์–ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +**JFocus**๋Š” ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ๋ถ„์„ํ•˜๊ณ ์ž ํ•˜๋Š” ํŠน์ • ๋ฉ”์„œ๋“œ๋ฅผ ์„ ํƒํ•˜๋ฉด, ํ•ด๋‹น ๋ฉ”์„œ๋“œ๊ฐ€ ์˜์กดํ•˜๋Š” **ํ•„์ˆ˜์ ์ธ ๋ฌธ๋งฅ(๋ณ€์ˆ˜, ํ˜ธ์ถœ๋œ ๋ฉ”์„œ๋“œ, ํด๋ž˜์Šค ๊ตฌ์กฐ ๋“ฑ)**๋งŒ์„ ๋ถ„์„ํ•˜์—ฌ ๋งˆํฌ๋‹ค์šด ํ˜•ํƒœ๋กœ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. + +### ๐ŸŽฏ ์–ธ์ œ JFocus๋ฅผ ์จ์•ผ ํ•˜๋‚˜์š”? (Use Cases) + +- **ํŠน์ • ๋ฉ”์„œ๋“œ ํ•˜๋‚˜๋งŒ ์ง‘์ค‘ ๋ถ„์„ํ•  ๋•Œ**: ํŒŒ์ผ ์ „์ฒด๋ฅผ ๋„ฃ๊ธฐ์—” ๋„ˆ๋ฌด ํฌ๊ณ , ๋ฉ”์„œ๋“œ๋งŒ ๋„ฃ๊ธฐ์—” ๋ฌธ๋งฅ์ด ๋ถ€์กฑํ•  ๋•Œ +- **๊ฑฐ๋Œ€ ํด๋ž˜์Šค(God Class)๋ฅผ ๋‹ค๋ฃฐ ๋•Œ**: 500์ค„์ด ๋„˜๋Š” ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ์—์„œ, ๋‚ด๊ฐ€ ์ˆ˜์ •ํ•  ๋ถ€๋ถ„๊ณผ ์—ฐ๊ด€๋œ ๋กœ์ง๋งŒ ์™ ๋ฝ‘์•„๋‚ด๊ณ  ์‹ถ์„ ๋•Œ +- **์˜์กด์„ฑ์ด ๋ณต์žกํ•˜๊ฒŒ ์–ฝํ˜€ ์žˆ์„ ๋•Œ**: `this.validate()`, `service.process()` ๋“ฑ ๋‚ด๋ถ€/์™ธ๋ถ€ ํ˜ธ์ถœ ๊ด€๊ณ„๋ฅผ ํ•œ ๋ˆˆ์— ํŒŒ์•…ํ•ด์•ผ ํ•  ๋•Œ +- **LLM๊ณผ ํ•จ๊ป˜ ๋ฆฌํŒฉํ† ๋ง/๋””๋ฒ„๊น…ํ•  ๋•Œ**: AI์—๊ฒŒ "์ด ๋ฉ”์„œ๋“œ๋ž‘ ๊ด€๋ จ๋œ ๊ฒƒ๋งŒ ๋ณด๊ณ  ์กฐ์–ธํ•ด์ค˜"๋ผ๊ณ  ํ•˜๊ณ  ์‹ถ์„ ๋•Œ + +### ๐Ÿšซ JFocus๊ฐ€ ํ•˜์ง€ ์•Š๋Š” ๊ฒƒ (Non-Goals) + +- **๋Ÿฐํƒ€์ž„ ๋ถ„์„์„ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค**: ์‹ค์ œ ์‹คํ–‰ ์‹œ์ ์˜ ๋ฐ์ดํ„ฐ ํ๋ฆ„์ด๋‚˜ ๋ฆฌํ”Œ๋ ‰์…˜, AOP ๋“ฑ์€ ์ถ”์ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +- **์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๋ถ„์„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค**: ํ”„๋กœ์ ํŠธ ๋‚ด์˜ ์†Œ์Šค ์ฝ”๋“œ(.java)๋งŒ ๋ถ„์„ํ•˜๋ฉฐ, Spring Bean ๊ทธ๋ž˜ํ”„๋‚˜ JAR ๋‚ด๋ถ€์˜ ์ฝ”๋“œ๋Š” ๋“ค์—ฌ๋‹ค๋ณด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +- **IDE๋ฅผ ๋Œ€์ฒดํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค**: ์ „์ฒด ํ”„๋กœ์ ํŠธ ํƒ์ƒ‰์€ IDE๊ฐ€ ํ›จ์”ฌ ๊ฐ•๋ ฅํ•ฉ๋‹ˆ๋‹ค. JFocus๋Š” **"ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ"**์—๋งŒ ์ง‘์ค‘ํ•ฉ๋‹ˆ๋‹ค. + +### ๐Ÿ›ก๏ธ ์™œ ์ •๊ทœํ‘œํ˜„์‹์ด ์•„๋‹Œ AST์ธ๊ฐ€์š”? (Why AST?) + +"๊ทธ๋ƒฅ `grep`์ด๋‚˜ ์ •๊ทœ์‹์œผ๋กœ ์ฐพ์œผ๋ฉด ๋˜์ง€ ์•Š๋‚˜์š”?" + +ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰์€ ๋ฉ”์„œ๋“œ ์˜ค๋ฒ„๋กœ๋”ฉ, ๋‚ด๋ถ€ ํด๋ž˜์Šค, ๋™๋ช…์ด์ธ(๊ฐ™์€ ์ด๋ฆ„์˜ ๋‹ค๋ฅธ ๋ฉ”์„œ๋“œ)์„ ๊ตฌ๋ถ„ํ•˜์ง€ ๋ชปํ•ด **์ž˜๋ชป๋œ ๋ฌธ๋งฅ(Hallucination Context)**์„ LLM์—๊ฒŒ ์ฃผ์ž…ํ•  ์œ„ํ—˜์ด ํฝ๋‹ˆ๋‹ค. + +**JFocus**๋Š” JavaParser๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ฝ”๋“œ๋ฅผ **์ถ”์ƒ ๊ตฌ๋ฌธ ํŠธ๋ฆฌ(AST)**๋กœ ๋ณ€ํ™˜ํ•˜๊ณ  ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. +- **์ •ํ™•ํ•œ ์ฐธ์กฐ ์ถ”์ **: ๋ฌธ์ž์—ด ์ผ์น˜๊ฐ€ ์•„๋‹Œ, **Symbol Resolution**์„ ํ†ตํ•ด ์‹ค์ œ ์‹ฌ๋ณผ์ด ๊ฐ€๋ฆฌํ‚ค๋Š” ๋Œ€์ƒ์„ ์ •ํ™•ํžˆ ์ถ”์ ํ•ฉ๋‹ˆ๋‹ค. +- **๋…ธ์ด์ฆˆ ์ œ๊ฑฐ**: ์ฃผ์„, import ๊ตฌ๋ฌธ ๋“ฑ ๋ถˆํ•„์š”ํ•œ ํ† ํฐ์„ ๋ฐฐ์ œํ•˜๊ณ  ๋กœ์ง์—๋งŒ ์ง‘์ค‘ํ•ฉ๋‹ˆ๋‹ค. + +--- + +## ๐Ÿ“Š ํšจ๊ณผ (Benchmarks) + +`jfocus`๋Š” LLM ์—์ด์ „ํŠธ์˜ ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ๋Ÿ‰์„ ํš๊ธฐ์ ์œผ๋กœ ์ตœ์ ํ™”ํ•ฉ๋‹ˆ๋‹ค. ํƒ€๊ฒŸ ๋ฉ”์„œ๋“œ์˜ ๋กœ์ง๊ณผ ์ฐธ์กฐ๋œ ์˜์กด์„ฑ์˜ ์„œ๋ช…(Signature)๋งŒ ์ถ”์ถœํ•˜์—ฌ, ํ† ํฐ ์†Œ๋น„๋ฅผ ์ตœ์†Œํ™”ํ•˜๋ฉด์„œ๋„ ์ฝ”๋“œ ์ดํ•ด์— ํ•„์š”ํ•œ ์ถฉ๋ถ„ํ•œ ๋ฌธ๋งฅ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. + +**ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ:** +- **Target:** Production-level Java Spring Boot Project +- **Metric:** Character count comparison (Raw File vs. `jfocus` Output) + +| Component Type | File Name | Raw Size (Chars) | jfocus Output (Chars) | **Reduction Rate** | +| :--- | :--- | :--- | :--- | :--- | +| **Controller** | `Controller.java` | 11,705 | ~2,400 | **๐Ÿ”ป 79.5%** | +| **Logic (Mid)** | `Service.java` | 4,913 | ~2,400 | **๐Ÿ”ป 51.1%** | +| **Logic (Small)** | `SimpleService.java` | 2,304 | ~2,014 | **๐Ÿ”ป 12.6%** | +| **Entity** | `Entity.java` | 1,728 | ~225 | **๐Ÿ”ป 87.0%** | + +> **Key Findings:** +> - **๊ฑฐ๋Œ€ํ•œ ํŒŒ์ผ์—์„œ ์••๋„์  ์ ˆ๊ฐ:** ๋ณต์žกํ•œ ์ปจํŠธ๋กค๋Ÿฌ๋‚˜ ์„œ๋น„์Šค ํด๋ž˜์Šค์—์„œ **์ตœ๋Œ€ 80%**๊นŒ์ง€ ์šฉ๋Ÿ‰์„ ์ค„์—ฌ, ๋™์ผํ•œ ์ปจํ…์ŠคํŠธ ์œˆ๋„์šฐ ๋‚ด์—์„œ 5๋ฐฐ ๋” ๋งŽ์€ ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +> - **์ž‘์€ ํŒŒ์ผ๋„ ๋†“์น˜์ง€ ์•Š๋Š” ๋ฌธ๋งฅ:** ์ ˆ๊ฐ๋ฅ ์ด ๋‚ฎ์€(12%) ์ž‘์€ ํŒŒ์ผ์ด๋ผ๋„, ์›๋ณธ ํŒŒ์ผ์—๋Š” ์—†๋Š” **์™ธ๋ถ€ ์˜์กด์„ฑ ์ •๋ณด(Dependency Signatures)**๋ฅผ ํฌํ•จํ•˜์—ฌ ์—์ด์ „ํŠธ์—๊ฒŒ ๋” ํ’๋ถ€ํ•œ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +> +> *์ธก์ • ๊ธฐ์ค€: GPT-4o Tokenizer (๊ทผ์‚ฌ์น˜), ์‹ค์ œ ํ† ํฐ ์ˆ˜๋Š” ์‚ฌ์šฉํ•˜๋Š” ๋ชจ๋ธ ๋ฐ ํ† ํฌ๋‚˜์ด์ €์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.* +> +> **๐Ÿ’ก ์ •์„ฑ์  ํšจ๊ณผ (Qualitative Result):** +> ์‹ค์ œ ์‚ฌ์šฉ ๊ฒฐ๊ณผ, LLM์˜ ์‘๋‹ต์ด ํ›จ์”ฌ **๋ช…ํ™•ํ•ด์กŒ์œผ๋ฉฐ(Focused)**, ๊ด€๋ จ ์—†๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์ฐธ์กฐํ•˜์—ฌ ๋ฐœ์ƒํ•˜๋Š” **ํ™˜๊ฐ(Hallucination)์ด ํ˜„์ €ํžˆ ๊ฐ์†Œ**ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### ๐Ÿš€ ํ™•์žฅ์„ฑ ๋ฐ ์„ฑ๋Šฅ (Scalability) + +> "์ˆ˜์ฒœ ๊ฐœ์˜ ํด๋ž˜์Šค๊ฐ€ ์žˆ๋Š” ๋ชจ๋…ธ๋ ˆํฌ์—์„œ๋„ ์ž‘๋™ํ•˜๋‚˜์š”?" + +๋„ค, ๊ฐ€๋Šฅ์€ ํ•˜์ง€๋งŒ **๋‹จ์ผ ๋ชจ๋“ˆ(Single Module)** ๋˜๋Š” **๋ชจ๋†€๋ฆฌ์‹(Monolithic)** ๊ตฌ์กฐ์— ์ตœ์ ํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +- **โœ… Single Module**: ์™„๋ฒฝํ•œ ์‹ฌ๋ณผ ์ถ”์ ๊ณผ ์˜์กด์„ฑ ๋ถ„์„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. +- **โš ๏ธ Multi-Module**: ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ ํ”„๋กœ์ ํŠธ๋„ ์ง€์›ํ•˜์ง€๋งŒ, ํด๋ž˜์Šค๋ช…์ด ๋ชจ๋“ˆ ๊ฐ„์— **์œ ๋‹ˆํฌํ•  ๋•Œ ์ตœ์ ์˜ ์ •ํ™•๋„**๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. (ํƒ€ ๋ชจ๋“ˆ ํด๋ž˜์Šค๋Š” ํŒŒ์ผ๋ช… ๋งค์นญ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.) + + +--- + +## โœจ ์ฃผ์š” ๊ธฐ๋Šฅ (Features) + +- **๐ŸŽฏ ์ •๋ฐ€ํ•œ ๋ฌธ๋งฅ ์ถ”์ถœ**: + - ๋ฉ”์„œ๋“œ ๋‚ด๋ถ€์—์„œ ํ˜ธ์ถœ๋˜๋Š” ๋‹ค๋ฅธ ๋ฉ”์„œ๋“œ, ํ•„๋“œ ๋ณ€์ˆ˜, ์ƒ์† ๊ตฌ์กฐ ๋“ฑ์„ ์žฌ๊ท€์ ์œผ๋กœ ๋ถ„์„ํ•˜์ง€ ์•Š๊ณ , **์ง์ ‘์ ์ธ ์—ฐ๊ด€์„ฑ**์„ ํŒŒ์•…ํ•˜์—ฌ ํ•ต์‹ฌ ์ •๋ณด๋งŒ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + - (Deep Dive ์˜ต์…˜์„ ํ†ตํ•ด ๊นŠ์ด ์žˆ๋Š” ๋ถ„์„ ์ง€์› ์˜ˆ์ •) + +- **๐Ÿ–ฅ๏ธ ๋Œ€ํ™”ํ˜• CLI (Interactive Mode)**: + - ๋ณต์žกํ•œ ๊ฒฝ๋กœ๋ฅผ ์ž…๋ ฅํ•  ํ•„์š” ์—†์ด, ํŒŒ์ผ๋ช…๋งŒ ์ž…๋ ฅํ•˜๋ฉด ํ•ด๋‹น ํŒŒ์ผ ๋‚ด์˜ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์„ ๋ณด์—ฌ์ฃผ๊ณ  ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- **๐Ÿ“‹ ํด๋ฆฝ๋ณด๋“œ ์ž๋™ ๋ณต์‚ฌ**: + - `-c` ๋˜๋Š” `--copy` ์˜ต์…˜์œผ๋กœ ์ถ”์ถœ๋œ ๊ฒฐ๊ณผ๋ฅผ ์ฆ‰์‹œ ํด๋ฆฝ๋ณด๋“œ์— ์ €์žฅํ•˜์—ฌ, LLM ์ฑ„ํŒ…์ฐฝ์— ๋ฐ”๋กœ ๋ถ™์—ฌ๋„ฃ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- **๐Ÿงฉ ์ฐธ์กฐ ์ฝ”๋“œ ํฌํ•จ ๋ถ„์„**: + - `-v` ๋˜๋Š” `--verbose` ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด, ๋ถ„์„ ์ค‘์ธ ๋ฉ”์„œ๋“œ๊ฐ€ **์ง์ ‘ ์ฐธ์กฐํ•˜๋Š”(Directly Referenced)** ๋ฉ”์„œ๋“œ์˜ ๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + - (ํ”„๋กœ์ ํŠธ ๋‚ด ์ฝ”๋“œ์— ํ•œํ•˜๋ฉฐ, ๊นŠ์€ ์˜์กด์„ฑ ์ฒด์ธ์„ ๋ฌด์กฐ๊ฑด์ ์œผ๋กœ ์žฌ๊ท€ ํ™•์žฅํ•˜์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค.) + +--- + +## ๐Ÿ“ฆ ์„ค์น˜ ๋ฐฉ๋ฒ• (Installation) + +### ์‚ฌ์ „ ์ค€๋น„ + +JFocus๋ฅผ ์‹คํ–‰ํ•˜๋ ค๋ฉด **Java 21 ์ด์ƒ**์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. + +```bash +java -version # Java 21 ์ด์ƒ์ธ์ง€ ํ™•์ธ +``` + +> Java๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค๋ฉด: [Oracle JDK](https://www.oracle.com/java/technologies/downloads/) ๋˜๋Š” [OpenJDK](https://openjdk.org/) ๋‹ค์šด๋กœ๋“œ + +--- + +### ์ž๋™ ์„ค์น˜ (๊ถŒ์žฅ) + +์„ค์น˜ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ๋‹ค์Œ์„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค: +1. โœ… GitHub Releases์—์„œ ์ตœ์‹  ๋นŒ๋“œ๋œ JAR ๋‹ค์šด๋กœ๋“œ +2. โœ… SHA256 ์ฒดํฌ์„ฌ์œผ๋กœ ํŒŒ์ผ ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์ฆ +3. โœ… `~/.jfocus/` ๋””๋ ‰ํ† ๋ฆฌ์— ์„ค์น˜ +4. โœ… ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ ์ƒ์„ฑ + +#### macOS / Linux (One-Line Install) + +์„ค์น˜์™€ ํ•จ๊ป˜ `jfocus` ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก Alias๋ฅผ ์ž๋™์œผ๋กœ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. + +```bash +# โš ๏ธ ๋ณด์•ˆ์ด ์šฐ๋ ค๋œ๋‹ค๋ฉด ์‹คํ–‰ ์ „ ์Šคํฌ๋ฆฝํŠธ ๋‚ด์šฉ์„ ํ™•์ธํ•˜์„ธ์š”. +curl -sL https://raw.githubusercontent.com/jher235/j-focus/main/scripts/install.sh | bash +``` + +#### Windows (PowerShell) + +๊ด€๋ฆฌ์ž ๊ถŒํ•œ ์—†์ด๋„ ์‹คํ–‰ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ์„ค์น˜ ํ›„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜(PATH)๊นŒ์ง€ ์ž๋™์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. + +```powershell +iwr -useb https://raw.githubusercontent.com/jher235/j-focus/main/scripts/install.ps1 | iex +``` + +--- + +### ์„ค์น˜ ํ™•์ธ + +```bash +jfocus --version +# ์ถœ๋ ฅ: JFocus v1.0.0 +``` + +--- + +
+๐Ÿ“‚ ์ˆ˜๋™ ์„ค์น˜ (Manual Installation) + +์ž๋™ ์Šคํฌ๋ฆฝํŠธ ์—†์ด ์ง์ ‘ ์„ค์น˜ํ•˜๋ ค๋ฉด: + +1. **JAR ๋‹ค์šด๋กœ๋“œ:** + - [Releases ํŽ˜์ด์ง€](https://github.com/jher235/j-focus/releases)์—์„œ `j-focus-1.0.0-all.jar` ๋‹ค์šด๋กœ๋“œ + +2. **์„ค์น˜:** + ```bash + mkdir -p ~/.jfocus + mv j-focus-1.0.0-all.jar ~/.jfocus/j-focus.jar + ``` + +3. **Alias ์„ค์ • (Linux/macOS):** + ```bash + echo "alias jfocus='java -jar ~/.jfocus/j-focus.jar'" >> ~/.zshrc + source ~/.zshrc + ``` + +4. **Windows:** + - `C:\Users\์‚ฌ์šฉ์ž๋ช…\.jfocus\` ํด๋” ์ƒ์„ฑ + - JAR ํŒŒ์ผ ์ด๋™ + - `jfocus.bat` ํŒŒ์ผ ์ƒ์„ฑ: + ```bat + @echo off + java -jar "%USERPROFILE%\.jfocus\j-focus.jar" %* + ``` + - PATH์— `%USERPROFILE%\.jfocus` ์ถ”๊ฐ€๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. +
+ +--- + +### ๋ฌธ์ œ ํ•ด๊ฒฐ + +#### "java: command not found" +โ†’ Java๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜ PATH์— ์—†์Šต๋‹ˆ๋‹ค. +```bash +# macOS (Homebrew) +brew install openjdk@21 + +# Ubuntu/Debian +sudo apt install openjdk-21-jdk + +# Windows +# Oracle JDK ๋˜๋Š” OpenJDK ์„ค์น˜ ํ›„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • +``` + +#### "jfocus: command not found" (์„ค์น˜ ํ›„) +โ†’ Alias๊ฐ€ ๋“ฑ๋ก๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์œ„์˜ "์„ค์น˜ ํ›„ alias ์ถ”๊ฐ€" ๋‹จ๊ณ„๋ฅผ ๋‹ค์‹œ ์‹คํ–‰ํ•˜์„ธ์š”. + +#### Windows์—์„œ "๋ณด์•ˆ ๊ฒฝ๊ณ " ๋ฐœ์ƒ +โ†’ PowerShell ์‹คํ–‰ ์ •์ฑ… ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค. +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + + + + +--- + +## ๐Ÿ› ๏ธ ๊ฐœ๋ฐœ์ž์šฉ (์†Œ์Šค์—์„œ ๋นŒ๋“œ) + +ํ”„๋กœ์ ํŠธ์— ๊ธฐ์—ฌํ•˜๊ฑฐ๋‚˜ ์ตœ์‹  ๊ฐœ๋ฐœ ๋ฒ„์ „์„ ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด: + +```bash +# 1. ์ €์žฅ์†Œ ํด๋ก  +git clone https://github.com/jher235/j-focus.git +cd j-focus + +# 2. ๋นŒ๋“œ (Gradle Wrapper ์‚ฌ์šฉ - Gradle ์„ค์น˜ ๋ถˆํ•„์š”) +./gradlew clean shadowJar + +# 3. ์‹คํ–‰ +java -jar build/libs/j-focus-*-all.jar --version +``` + +**๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ:** +```bash +# Alias๋กœ ๋“ฑ๋ก (๊ฐœ๋ฐœ ์ค‘์ธ JAR ์ง์ ‘ ์‹คํ–‰) +alias jfocus-dev='java -jar ~/projects/j-focus/build/libs/j-focus-*-all.jar' +``` + +--- + +## ๐ŸŽฎ ์‚ฌ์šฉ ๋ฐฉ๋ฒ• (Usage) + +### ๊ธฐ๋ณธ ์‹คํ–‰ +์„ค์น˜๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด `jfocus` ๋ช…๋ น์–ด๋กœ ์–ด๋””์„œ๋“  ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํŒŒ์ผ๋ช…์ด๋‚˜ ๋ฉ”์„œ๋“œ๋ช…์„ ์ธ์ž๋กœ ์ฃผ์ง€ ์•Š์œผ๋ฉด **๋Œ€ํ™”ํ˜• ๋ชจ๋“œ**๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค. + +```bash +jfocus +``` + +### CLI ์˜ต์…˜ (Options) + +```bash +Usage: jfocus [-cvhV] [fileName] [methodName] +``` + +| ์˜ต์…˜ | ์„ค๋ช… | ์˜ˆ์‹œ | +|------|------|------| +| `[fileName]` | ๋ถ„์„ํ•  ์ž๋ฐ” ํŒŒ์ผ๋ช… (ํ™•์žฅ์ž ์ƒ๋žต ๊ฐ€๋Šฅ) | `UserController` | +| `[methodName]` | ๋ถ„์„ํ•  ๋ฉ”์„œ๋“œ๋ช… | `login` | +| `-c`, `--copy` | ๊ฒฐ๊ณผ๋ฅผ ํ„ฐ๋ฏธ๋„์— ์ถœ๋ ฅํ•˜๋Š” ๋Œ€์‹  **ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ**ํ•ฉ๋‹ˆ๋‹ค. | `jfocus -c` | +| `-v`, `--verbose` | **์ง์ ‘ ์ฐธ์กฐ๋œ** ๋‹ค๋ฅธ ๋ฉ”์„œ๋“œ์˜ ์†Œ์Šค ์ฝ”๋“œ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. (๊นŠ์€ ์žฌ๊ท€ ํƒ์ƒ‰ ์ œ์™ธ) | `jfocus -v` | +| `-h`, `--help` | ๋„์›€๋ง ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. | | +| `-V`, `--version` | ๋ฒ„์ „ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. | | + +### ์‚ฌ์šฉ ์˜ˆ์‹œ (Scenario) + + **์‹œ๋‚˜๋ฆฌ์˜ค**: `ContextExtractor.java` ํŒŒ์ผ์˜ `extractContext` ๋ฉ”์„œ๋“œ๋ฅผ ๋ถ„์„ํ•˜์—ฌ LLM์—๊ฒŒ ์งˆ๋ฌธํ•˜๊ณ  ์‹ถ์„ ๋•Œ + + 1. **๋ช…๋ น์–ด ์‹คํ–‰**: + ```bash + jfocus conte + ``` + 2. **๋ฉ”์„œ๋“œ ์„ ํƒ (๋Œ€ํ™”ํ˜•)**: + ```text + Searching for: conte... + Source Root configured: C:\open_source\j-focus\src\main\java + Ambiguous file name. Found 3 matches: + [1] ContextResult.java (src/main/java/com/jher235/jfocus/model) + [2] ContextExtractor.java (src/main/java/com/jher235/jfocus/core) + [3] ContextExtractorTest.java (src/test/java/com/jher235/jfocus/core) + Select (1-3): 2 + Found File: ...\src\main\java\com\jher235\jfocus\core\ContextExtractor.java + + Available Methods: + [1] extractContext(MethodDeclaration targetMethod) + [2] extractRecursive(MethodDeclaration rootTarget, ... ) + + Select method number: 1 + ``` + 3. **๊ฒฐ๊ณผ ํ™•์ธ**: "-c" ์˜ต์…˜์„ ์ผ๋‹ค๋ฉด ํด๋ฆฝ๋ณด๋“œ์—, ์•„๋‹ˆ๋ฉด ํ™”๋ฉด์— ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค. + +### ๐Ÿ“„ ์ถœ๋ ฅ ๊ฒฐ๊ณผ ์˜ˆ์‹œ (Output Example) + +> ์ƒ์„ฑ๋œ ๋งˆํฌ๋‹ค์šด์€ **ChatGPT๋‚˜ Claude์— ๊ทธ๋Œ€๋กœ ๋ถ™์—ฌ๋„ฃ์–ด๋„ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.** (Safe to paste) + + `jfocus`๊ฐ€ ์ƒ์„ฑํ•˜๋Š” ์‹ค์ œ ๋งˆํฌ๋‹ค์šด ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค. (ํŽผ์ณ์„œ ํ™•์ธ) + +
+ ๐Ÿ”Ž ContextExtractor.extractContext() ๋ถ„์„ ๊ฒฐ๊ณผ ๋ณด๊ธฐ + + ````markdown + # Target Method + The main logic to analyze. + + ```java + /** + * Extracts the full context for the given target method recursively. + * ... + */ + public ContextResult extractContext(MethodDeclaration targetMethod) { + ContextResult result = new ContextResult(targetMethod); + // Fields + result.setUsedFields(dependencyResolver.resolveFields(targetMethod)); + // Track visited methods to prevent infinite loops during recursion + Set visited = new HashSet<>(); + visited.add(AstUtils.createMethodId(targetMethod)); + // Start recursive analysis + extractRecursive(targetMethod, targetMethod, result, visited); + return result; + } + ``` + + + ## Internal Context (Same Class) + Methods called by the target, defined within the same class. + + ```java + /** + * Recursively traverses method calls to find all related user code. + * External libraries are automatically excluded as they lack source code definitions. + */ + private void extractRecursive(MethodDeclaration rootTarget, MethodDeclaration currentMethod, ContextResult result, Set visited) { + List dependencies = dependencyResolver.resolveMethods(currentMethod); + // ... (์ƒ๋žต: ์žฌ๊ท€์  ํƒ์ƒ‰ ๋กœ์ง) ... + } + ``` + + ## Related Fields + Class fields accessed by the target method. + + ```java + private final DependencyResolver dependencyResolver; + ``` + + + +```` + +
+ +--- + +## ๐Ÿค– For AI Agents (Cursor, Windsurf) + +**JFocus**๋Š” AI ์—์ด์ „ํŠธ(Cursor, Windsurf)์™€ ๊ฒฐํ•ฉํ–ˆ์„ ๋•Œ ๊ฐ€์žฅ ๊ฐ•๋ ฅํ•ฉ๋‹ˆ๋‹ค. ๋งค๋ฒˆ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•  ํ•„์š” ์—†์ด, ํ”„๋กœ์ ํŠธ ์„ค์ • ํŒŒ์ผ์— ๊ทœ์น™์„ ์ถ”๊ฐ€ํ•˜์—ฌ **์—์ด์ „ํŠธ๊ฐ€ ์Šค์Šค๋กœ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก** ๋งŒ๋“œ์„ธ์š”. + + +### 1. ์—์ด์ „ํŠธ ๊ทœ์น™ ์„ค์ • (Configure Agent Rules) + +ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์—์ด์ „ํŠธ ์„ค์ • ํŒŒ์ผ(์˜ˆ: `.cursorrules`, `.windsurfrules` ๋“ฑ)์— [docs/rules.md](docs/rules.md) ํŒŒ์ผ์˜ ๋‚ด์šฉ์„ ๋ณต์‚ฌํ•ด ๋ถ™์—ฌ๋„ฃ์œผ์„ธ์š”. + +### 2. ์‚ฌ์šฉ ์˜ˆ์‹œ + +์ด์ œ ์—์ด์ „ํŠธ์—๊ฒŒ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์งˆ๋ฌธํ•˜์„ธ์š”: + +> "์ด ํ”„๋กœ์ ํŠธ์˜ `PaymentService.process()` ๋ฉ”์„œ๋“œ๋ฅผ ๋ถ„์„ํ•ด์„œ ๋ฆฌํŒฉํ† ๋ง ์ œ์•ˆํ•ด์ค˜." + +์—์ด์ „ํŠธ๋Š” ์ž๋™์œผ๋กœ `jfocus`๋ฅผ ์‹คํ–‰ํ•˜์—ฌ ๋ฌธ๋งฅ์„ ํŒŒ์•…ํ•œ ๋’ค, ์ •ํ™•ํ•œ ๋‹ต๋ณ€์„ ์ œ๊ณตํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. + +--- + +## ๐Ÿค ๊ธฐ์—ฌํ•˜๊ธฐ (Contributing) + +์ด ํ”„๋กœ์ ํŠธ๋Š” ์˜คํ”ˆ ์†Œ์Šค์ด๋ฉฐ, ์—ฌ๋Ÿฌ๋ถ„์˜ ๊ธฐ์—ฌ๋ฅผ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค! ๐ŸŽ‰ + +1. ์ด ์ €์žฅ์†Œ๋ฅผ **Fork** ํ•˜์„ธ์š”. +2. ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ๋ธŒ๋žœ์น˜๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š” (`git checkout -b feature/amazing-feature`). +3. ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ปค๋ฐ‹ํ•˜์„ธ์š” (`git commit -m 'Add some amazing feature'`). +4. ๋ธŒ๋žœ์น˜์— ํ‘ธ์‹œํ•˜์„ธ์š” (`git push origin feature/amazing-feature`). +5. **Pull Request**๋ฅผ ์—ด์–ด์ฃผ์„ธ์š”. + +๋ฒ„๊ทธ ์ œ๋ณด๋‚˜ ๊ธฐ๋Šฅ ์ œ์•ˆ์€ [Issues](https://github.com/jher235/j-focus/issues) ํƒญ์„ ์ด์šฉํ•ด ์ฃผ์„ธ์š”. + +--- + +## ๐Ÿ“œ ๋ผ์ด์„ ์Šค (License) + +์ด ํ”„๋กœ์ ํŠธ๋Š” **MIT License**์— ๋”ฐ๋ผ ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ `LICENSE` ํŒŒ์ผ์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..adfc072 --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +# JFocus ๐Ÿ” + +[![Java 21](https://img.shields.io/badge/Java-21-orange?logo=java)](https://openjdk.org/projects/jdk/21/) +[![Gradle](https://img.shields.io/badge/Gradle-8.x-02303A?logo=gradle)](https://gradle.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +[๐Ÿ‡บ๐Ÿ‡ธ **English**](README.md) | [๐Ÿ‡ฐ๐Ÿ‡ท **Korean(ํ•œ๊ตญ์–ด)**](README.ko.md) + +> **"Stop pasting entire files into AI. All you need is context."** +> +> JFocus is a CLI tool that **intelligently extracts only the necessary code** from massive Java projects, providing **optimal prompts** for LLMs like ChatGPT or Claude. + +--- + +## ๐Ÿ“ Table of Contents + +- [Introduction](#introduction) +- [Benchmarks](#benchmarks) +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) +- [For AI Agents](#for-ai-agents-cursor-windsurf) +- [Contributing](#contributing) +- [License](#license) + +--- + +## ๐Ÿ’ก Introduction +![JFocus CLI Demo](docs/images/demo.png) + +When developing or analyzing large-scale Java projects, copy-pasting entire files to help LLMs understand code is inefficient. It hits token limits and degrades response quality with unnecessary noise. + +**JFocus** solves this problem. When you select a specific method to analyze, it extracts only the **essential context (variables, called methods, class structures, etc.)** that the method depends on, formatted in Markdown. + +### ๐ŸŽฏ Use Cases + +- **Focused Analysis**: When an entire file is too large, but a single method provides insufficient context. +- **Legacy Code Navigation**: When you need to extract logic related to your changes from a 500+ line God Class. +- **Complex Dependencies**: When you need to visualize internal/external call relationships like `this.validate()` or `service.process()` at a glance. +- **LLM-Assisted Refactoring**: When you want to tell AI, "Focus only on things related to this method and advise me." + +### ๐Ÿšซ Non-Goals + +- **No Runtime Analysis**: Does not track runtime data flow, reflection, or AOP. +- **No External Library Analysis**: Analyzes only source code (.java) within the project. Does not inspect Spring Bean graphs or JAR internals. +- **Not an IDE Replacement**: IDEs are far superior for full project navigation. JFocus focuses solely on **"Prompt Generation"**. + +### ๐Ÿ›ก๏ธ Why AST instead of Regex? + +"Can't I just use `grep`?" + +Text-based searches fail to distinguish method overloading, inner classes, or methods with identical names, risking the injection of **Hallucinated Context** into the LLM. + +**JFocus** uses JavaParser to convert code into an **Abstract Syntax Tree (AST)** for analysis. +- **Precise Reference Tracking**: Uses **Symbol Resolution** to track the exact target a symbol points to, rather than just matching strings. +- **Noise Reduction**: excludes comments, imports, and other unnecessary tokens to focus purely on logic. + +--- + +## ๐Ÿ“Š Benchmarks + +`jfocus` drastically optimizes context usage for LLM agents. By extracting only the target method's logic and the signatures of referenced dependencies, it minimizes token consumption while maintaining sufficient context for code understanding. + +**Test Environment:** +- **Target:** Production-level Java Spring Boot Project +- **Metric:** Character count comparison (Raw File vs. `jfocus` Output) + +| Component Type | File Name | Raw Size (Chars) | jfocus Output (Chars) | **Reduction Rate** | +| :--- | :--- | :--- | :--- | :--- | +| **Controller** | `Controller.java` | 11,705 | ~2,400 | **๐Ÿ”ป 79.5%** | +| **Logic (Mid)** | `Service.java` | 4,913 | ~2,400 | **๐Ÿ”ป 51.1%** | +| **Logic (Small)** | `SimpleService.java` | 2,304 | ~2,014 | **๐Ÿ”ป 12.6%** | +| **Entity** | `Entity.java` | 1,728 | ~225 | **๐Ÿ”ป 87.0%** | + +> **Key Findings:** +> - **Massive Reduction in Large Files:** Reduces size by **up to 80%** in complex controllers or service classes, allowing you to process 5x more files within the same context window. +> - **Rich Context for Small Files:** Even for small files with lower reduction rates (12%), it provides **Dependency Signatures** that are absent in the raw file, offering richer information to the agent. +> +> *Measurement: GPT-4o Tokenizer (approximate). Actual token count may vary depending on the model and tokenizer.* +> +> **๐Ÿ’ก Qualitative Result:** +> In practical usage, LLM responses became much more **Focused**, and **Hallucinations** referencing unrelated methods were significantly reduced. + +### ๐Ÿš€ Scalability + +> "Does it work on monorepos with thousands of classes?" + +Yes, it is supported, but it is optimized for **Single Module** or **Monolithic** structures. + +- **โœ… Single Module**: Fully supports symbol tracking and dependency analysis. +- **โš ๏ธ Multi-Module**: Supports multi-module projects, but ensures **optimal accuracy when class names are unique** across modules. (It uses fuzzy file matching for cross-module classes.) + + +--- + +## โœจ Features + +- **๐ŸŽฏ Precise Context Extraction**: + - Determines **direct relevance** and provides core information without recursively analyzing methods called within methods, field variables, or inheritance structures. + - (Deep Dive option for in-depth analysis is planned.) + +- **๐Ÿ–ฅ๏ธ Interactive CLI**: + - No need to type complex paths; just enter the filename to list and select methods within that file. + +- **๐Ÿ“‹ Auto-Clipboard**: + - Use `-c` or `--copy` option to save the extracted result immediately to the clipboard for instant pasting into LLM chat. + +- **๐Ÿงฉ Reference Code Inclusion**: + - Use `-v` or `--verbose` option to include the implementation code of methods **Directly Referenced** by the method being analyzed. + - (Limited to project source code; does not unconditionally expand deep dependency chains.) + +--- + +## ๐Ÿ“ฆ Installation + +### Prerequisites + +To run JFocus, **Java 21 or higher** is required. + +```bash +java -version # Check if Java 21+ +``` + +> If Java is not installed: Download [Oracle JDK](https://www.oracle.com/java/technologies/downloads/) or [OpenJDK](https://openjdk.org/). + +--- + +### Automatic Installation (Recommended) + +The installation script handles the following automatically: +1. โœ… Download latest built JAR from GitHub Releases +2. โœ… Verify file integrity with SHA256 checksum +3. โœ… Install to `~/.jfocus/` directory +4. โœ… Create execution script + +#### macOS / Linux (One-Line Install) + +Automatically registers Alias for `jfocus` command along with installation. + +```bash +# โš ๏ธ Review the install script before piping to bash if you have security concerns. +curl -sL https://raw.githubusercontent.com/jher235/j-focus/main/scripts/install.sh | bash +``` + +#### Windows (PowerShell) + +This script can be executed without administrator privileges. Automatically sets environment variables (PATH) after installation. + +```powershell +iwr -useb https://raw.githubusercontent.com/jher235/j-focus/main/scripts/install.ps1 | iex +``` + +--- + +### Verification + +```bash +jfocus --version +# Output: JFocus v1.0.0 +``` + +--- + +
+๐Ÿ“‚ Manual Installation + +If you cannot use the auto-script or prefer to install manually: + +1. **Download JAR:** + - Download `jfocus-1.0.0-all.jar` from [Releases Page](https://github.com/jher235/j-focus/releases) + +2. **Install:** + ```bash + mkdir -p ~/.jfocus + mv j-focus-1.0.0-all.jar ~/.jfocus/j-focus.jar + ``` + +3. **Set Alias (Linux/macOS):** + ```bash + echo "alias jfocus='java -jar ~/.jfocus/j-focus.jar'" >> ~/.zshrc + source ~/.zshrc + ``` + +4. **Windows:** + - Create `C:\Users\Username\.jfocus\` folder + - Move JAR file + - Create `jfocus.bat` file: + ```bat + @echo off + java -jar "%USERPROFILE%\.jfocus\j-focus.jar" %* + ``` + - Recommend adding `%USERPROFILE%\.jfocus` to PATH. +
+ +--- + +### Troubleshooting + +#### "java: command not found" +โ†’ Java is not installed or not in PATH. +```bash +# macOS (Homebrew) +brew install openjdk@21 + +# Ubuntu/Debian +sudo apt install openjdk-21-jdk + +# Windows +# Install Oracle JDK or OpenJDK and set environment variables +``` + +#### "jfocus: command not found" (After Install) +โ†’ Alias is not registered. Re-run the "Set Alias" step above. + +#### "Security Warning" on Windows +โ†’ PowerShell execution policy issue. +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +--- + +## ๐Ÿ› ๏ธ For Developers (Build from Source) + +To contribute to the project or test the latest development version: + +```bash +# 1. Clone Repository +git clone https://github.com/jher235/j-focus.git +cd j-focus + +# 2. Build (Use Gradle Wrapper - No Gradle install needed) +./gradlew clean shadowJar + +# 3. Run +java -jar build/libs/j-focus-*-all.jar --version +``` + +**Local Development Environment:** +```bash +# Register Alias (Run development JAR directly) +alias jfocus-dev='java -jar ~/projects/j-focus/build/libs/j-focus-*-all.jar' +``` + +--- + +## ๐ŸŽฎ Usage + +### Basic Execution +Once installed, you can run `jfocus` from anywhere. If no filename or method name is provided, **Interactive Mode** starts. + +```bash +jfocus +``` + +### CLI Options + +```bash +Usage: jfocus [-cvhV] [fileName] [methodName] +``` + +| Option | Description | Example | +|------|------|------| +| `[fileName]` | Java filename to analyze (extension optional) | `UserController` | +| `[methodName]` | Method name to analyze | `login` | +| `-c`, `--copy` | **Copies result to clipboard** instead of printing to terminal. | `jfocus -c` | +| `-v`, `--verbose` | Includes source code of **Directly Referenced** methods. (Excludes deep recursive search) | `jfocus -v` | +| `-h`, `--help` | Show help message. | | +| `-V`, `--version` | Show version information. | | + +### Scenario + + **Scenario**: You want to analyze the `extractContext` method in `ContextExtractor.java` to ask an LLM about it. + + 1. **Run Command**: + ```bash + jfocus conte + ``` + 2. **Select Method (Interactive)**: + ```text + Searching for: conte... + Source Root configured: C:\open_source\j-focus\src\main\java + Ambiguous file name. Found 3 matches: + [1] ContextResult.java (src/main/java/com/jher235/jfocus/model) + [2] ContextExtractor.java (src/main/java/com/jher235/jfocus/core) + [3] ContextExtractorTest.java (src/test/java/com/jher235/jfocus/core) + Select (1-3): 2 + Found File: ...\src\main\java\com\jher235\jfocus\core\ContextExtractor.java + + Available Methods: + [1] extractContext(MethodDeclaration targetMethod) + [2] extractRecursive(MethodDeclaration rootTarget, ... ) + + Select method number: 1 + ``` + 3. **Check Result**: Extracted to clipboard if "-c" is used, otherwise printed to screen. + +### ๐Ÿ“„ Output Example + + > The generated markdown is **Safe to paste** directly into ChatGPT or Claude. + + Actual extracted result from `jfocus`. (Expand to view) + +
+ ๐Ÿ”Ž View ContextExtractor.extractContext() Analysis Result + + ````markdown + # Target Method + The main logic to analyze. + + ```java + /** + * Extracts the full context for the given target method recursively. + * ... + */ + public ContextResult extractContext(MethodDeclaration targetMethod) { + ContextResult result = new ContextResult(targetMethod); + // Fields + result.setUsedFields(dependencyResolver.resolveFields(targetMethod)); + // Track visited methods to prevent infinite loops during recursion + Set visited = new HashSet<>(); + visited.add(AstUtils.createMethodId(targetMethod)); + // Start recursive analysis + extractRecursive(targetMethod, targetMethod, result, visited); + return result; + } + ``` + + + ## Internal Context (Same Class) + Methods called by the target, defined within the same class. + + ```java + /** + * Recursively traverses method calls to find all related user code. + * External libraries are automatically excluded as they lack source code definitions. + */ + private void extractRecursive(MethodDeclaration rootTarget, MethodDeclaration currentMethod, ContextResult result, Set visited) { + List dependencies = dependencyResolver.resolveMethods(currentMethod); + // ... (omitted: recursive traversal logic) ... + } + ``` + + ## Related Fields + Class fields accessed by the target method. + + ```java + private final DependencyResolver dependencyResolver; + ``` + + ```` + +
+ +--- + +## ๐Ÿค– For AI Agents (Cursor, Windsurf) + +**JFocus** is most powerful when combined with AI Agents (Cursor, Windsurf). Instead of typing prompts every time, add rules to your project configuration to make the **Agent use the tool autonomously**. + +### 1. Configure Agent Rules + +Copy and paste the content of [docs/rules.md](docs/rules.md) into your agent's configuration file (e.g., `.cursorrules`, `.windsurfrules`, or `.instructions`) at your project root. + +### 2. Usage + +Now ask the agent naturally: + +> "Analyze `PaymentService.process()` method in this project and suggest refactoring." + +The agent will automatically run `jfocus`, grasp the context, and provide an accurate response. + +--- + +## ๐Ÿค Contributing + +This project is open source and contributions are welcome! ๐ŸŽ‰ + +1. **Fork** this repository. +2. Create a new feature branch (`git checkout -b feature/amazing-feature`). +3. Commit your changes (`git commit -m 'Add some amazing feature'`). +4. Push to the branch (`git push origin feature/amazing-feature`). +5. Open a **Pull Request**. + +Please use the [Issues](https://github.com/jher235/j-focus/issues) tab for bug reports or feature suggestions. + +--- + +## ๐Ÿ“œ License + +This project is distributed under the **MIT License**. See `LICENSE` file for details. diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..9a112df --- /dev/null +++ b/agent.md @@ -0,0 +1,53 @@ +# J-Focus Lead Developer Persona + +๋‹น์‹ ์€ 'J-Focus(jfocus)' ํ”„๋กœ์ ํŠธ์˜ ์ˆ˜์„ ๋ฉ”์ธํ…Œ์ด๋„ˆ์ด์ž Java ์–ธ์–ด ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. +์‚ฌ์šฉ์ž(Junior Backend Dev)์™€ ํ•จ๊ป˜ ๊ณ ํ’ˆ์งˆ์˜ Java CLI ๋„๊ตฌ๋ฅผ ๊ฐœ๋ฐœํ•˜๋Š” ๊ฒƒ์ด ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค. + +## Project Overview +- **Project Name:** J-Focus (`jfocus`) +- **Identity:** ๋‹จ์ˆœํ•œ ์ •์  ๋ถ„์„๊ธฐ๊ฐ€ ์•„๋‹Œ, **"LLM์„ ์œ„ํ•œ ํ”„๋กฌํ”„ํŠธ ์ „์ฒ˜๋ฆฌ(Preprocessing) ๋„๊ตฌ"**์ž…๋‹ˆ๋‹ค. +- **Goal:** Java ์ฝ”๋“œ์˜ ๋…ธ์ด์ฆˆ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ , LLM์ด ์ดํ•ดํ•˜๊ธฐ ๊ฐ€์žฅ ์ข‹์€ ํ˜•ํƒœ์˜ ๋ฌธ๋งฅ(Context)์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. +- **Key Value:** "Don't feed the Noise. Just Focus." (ํ† ํฐ ์ ˆ์•ฝ ๋ฐ ํ• ๋ฃจ์‹œ๋„ค์ด์…˜ ๋ฐฉ์ง€) +- **Target User:** ํ„ฐ๋ฏธ๋„ ํ™˜๊ฒฝ์— ์ต์ˆ™ํ•œ ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ์ž. + +## Tech Stack & Constraints +1. **Language:** Java 21 (Record, Pattern Matching, Switch Expression ๋“ฑ ์ตœ์‹  ๋ฌธ๋ฒ• ์ ๊ทน ๊ถŒ์žฅ). +2. **Build Tool:** Gradle (Kotlin DSL `build.gradle.kts`). +3. **No Spring Framework:** DI Container ์—†์ด ์ˆœ์ˆ˜ ๊ฐ์ฒด์ง€ํ–ฅ ์„ค๊ณ„๋กœ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. +4. **Libraries:** + - `com.github.javaparser:javaparser-symbol-solver-core`: AST ํŒŒ์‹ฑ ์—”์ง„. + - `info.picocli:picocli`: CLI ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„. + - `org.eclipse.jgit:org.eclipse.jgit`: Git ๋ณ€๊ฒฝ ์‚ฌํ•ญ(Diff) ์ถ”์ ์šฉ. + - `org.junit.jupiter` & `org.assertj`: ํ…Œ์ŠคํŠธ. +5. **Packaging:** `ShadowJar`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Standalone Fat Jar๋กœ ๋นŒ๋“œํ•ฉ๋‹ˆ๋‹ค. + +## Core Features & Domain Logic (์—…๋ฐ์ดํŠธ๋จ) +๋‹น์‹ ์€ ๋‹ค์Œ์˜ **"3๋‹จ๊ณ„ ์˜์กด์„ฑ ์ „๋žต(Onion Strategy)"**์„ ์ฒ ์ €ํžˆ ๋”ฐ๋ผ์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +1. **Extraction Depth Policy (์ค‘์š”):** + - **Layer 0 (Target):** ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์ •ํ•œ ๋ฉ”์„œ๋“œ๋Š” **Body๋ฅผ ํฌํ•จํ•œ ์ „์ฒด ์ฝ”๋“œ**๋ฅผ ์ถ”์ถœ. + - **Layer 1 (Internal):** Target์ด ํ˜ธ์ถœํ•˜๋Š” *๊ฐ™์€ ํด๋ž˜์Šค ๋‚ด*์˜ `private` ๋ฉ”์„œ๋“œ ๋ฐ ํ•„๋“œ๋Š” **Body ํฌํ•จ ์ถ”์ถœ**. + - **Layer 2 (External):** ์™ธ๋ถ€ ํด๋ž˜์Šค(Service, DTO)์˜ ์˜์กด์„ฑ์€ **์‹œ๊ทธ๋‹ˆ์ฒ˜(Signature)์™€ ํ•„๋“œ๋งŒ** ์ถ”์ถœ (Body ์ œ์™ธ). + - **Layer 3 (Ignore):** JDK, Framework, Library ์ฝ”๋“œ๋Š” ๋ฌด์‹œ. + +2. **Output Format (Markdown Report):** + - ์ถ”์ถœ๋œ ๊ฒฐ๊ณผ๋Š” ๋ฐ˜๋“œ์‹œ LLM์ด ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด **Markdown ํฌ๋งท**์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + - ์„น์…˜ ๊ตฌ๋ถ„: `### 1๏ธโƒฃ Core Logic`, `### 2๏ธโƒฃ Internal Context`, `### 3๏ธโƒฃ External Signatures` + +3. **Lombok Handling:** + - ์†Œ์Šค ์ฝ”๋“œ(AST)์— Getter/Setter๊ฐ€ ์—†์–ด์„œ `SymbolSolver`๊ฐ€ ํ•ด๊ฒฐํ•˜์ง€ ๋ชปํ•  ๊ฒฝ์šฐ, ์—๋Ÿฌ๋ฅผ ๋‚ด์ง€ ๋ง๊ณ  **"Unresolved" ์ƒํƒœ๋กœ ์šฐ์•„ํ•˜๊ฒŒ ๋„˜์–ด๊ฐ€๋„๋ก(Graceful Fallback)** ์ฒ˜๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +4. **Focus Modes:** + - `--clip`: ์‹œ์Šคํ…œ ํด๋ฆฝ๋ณด๋“œ ๊ฐ์ง€. + - `--git`: `JGit`์„ ์‚ฌ์šฉํ•˜์—ฌ Staging/Unstaged ๋ณ€๊ฒฝ ํŒŒ์ผ์˜ ๋ผ์ธ์„ ์ถ”์ ํ•˜๊ณ , ํ•ด๋‹น ๋ผ์ธ์ด ํฌํ•จ๋œ ๋ฉ”์„œ๋“œ ์ถ”์ถœ. + +## Coding Conventions +1. **Immutability:** ๋ชจ๋“  ๋ณ€์ˆ˜๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ `final`, ๋ฐ์ดํ„ฐ ๊ฐ์ฒด๋Š” `record` ์‚ฌ์šฉ. +2. **Error Handling:** ์‚ฌ์šฉ์ž์—๊ฒŒ StackTrace ๋…ธ์ถœ ๊ธˆ์ง€. ๋ช…ํ™•ํ•œ ์›์ธ(System.err)๊ณผ ์ข…๋ฃŒ ์ฝ”๋“œ ๋ฐ˜ํ™˜. +3. **Testing:** `core` ํŒจํ‚ค์ง€์˜ ํŒŒ์‹ฑ ๋กœ์ง์€ ๋ฐ˜๋“œ์‹œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆ. ๊ฐ€์ƒ์˜ ์ž๋ฐ” ์†Œ์Šค ๋ฌธ์ž์—ด์„ ์ด์šฉํ•ด ํ…Œ์ŠคํŠธํ•œ๋‹ค. +4. **Naming:** `JFocusCli`, `DependencyResolver`, `MarkdownExporter` ๋“ฑ ์—ญํ•  ์ค‘์‹ฌ ๋ช…๋ช…. + +## Interaction Rules +1. **Plan First:** ์ฝ”๋“œ๋ฅผ ์งœ๊ธฐ ์ „์— "์–ด๋–ค ํด๋ž˜์Šค๋ฅผ ๊ฑด๋“œ๋ฆด์ง€", "์˜์กด์„ฑ ๊นŠ์ด๋Š” ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ์ง€" ๋จผ์ € ์„ค๋ช…ํ•˜๋ผ. +2. **Vertical Slicing:** ํ•œ ๋ฒˆ์— ์™„๋ฒฝํ•œ ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค๋ ค ํ•˜์ง€ ๋ง๊ณ , PR ๋‹จ์œ„(๊ธฐ๋Šฅ ๋‹จ์œ„)๋กœ ๋Š์–ด์„œ ์ œ์•ˆํ•˜๋ผ. +3. **Refactoring:** ์‚ฌ์šฉ์ž๊ฐ€ ๋ถˆํ•„์š”ํ•œ ์˜์กด์„ฑ์„ ๊ฐ€์ ธ์˜ค๋ ค ํ•˜๊ฑฐ๋‚˜(Spring ๋“ฑ), Java 8 ์Šคํƒ€์ผ๋กœ ์ฝ”๋“œ๋ฅผ ์งœ๋ฉด ์ฆ‰์‹œ ์ง€์ ํ•˜๋ผ. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..97c4722 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "8.3.6" +} + +group = "com.jher235" +version = "1.0.0" + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +dependencies { + + // Java Parser + implementation("com.github.javaparser:javaparser-symbol-solver-core:3.26.2") + + // CLI + implementation("info.picocli:picocli:4.7.6") + annotationProcessor("info.picocli:picocli-codegen:4.7.6") + + // JGit + implementation("org.eclipse.jgit:org.eclipse.jgit:7.1.0.202411261347-r") + + // Test + testImplementation(platform("org.junit:junit-bom:5.11.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + + testImplementation("org.assertj:assertj-core:3.26.3") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.jar { + manifest { + attributes["Main-Class"] = "com.jher235.jfocus.cli.JFocusCli" + } +} + +tasks.shadowJar { + manifest { + attributes["Main-Class"] = "com.jher235.jfocus.cli.JFocusCli" + } +} + + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/coderabbit.yaml b/coderabbit.yaml new file mode 100644 index 0000000..21727b0 --- /dev/null +++ b/coderabbit.yaml @@ -0,0 +1,37 @@ +# CodeRabbit config file +# docs: https://docs.coderabbit.ai/guides/configure-coderabbit + +early_access: true + +reviews: + profile: assertive + high_level_summary: true + incremental_review: true + review_status: true + collapse_walkthrough: true + + poem: false + request_changes_workflow: false + + auto_review: + enabled: true + drafts: true + base_branches: + - develop + - main + + # excluded files + path_filters: + - "!*.md" + - "!.github/**" + - "!gradle/**" + - "!build/**" + - "!.idea/**" + - "!*.iml" + +chat: + auto_reply: true + +issue_enrichment: + auto_enrich: + enabled: false \ No newline at end of file diff --git a/docs/images/demo.png b/docs/images/demo.png new file mode 100644 index 0000000..b9eda52 Binary files /dev/null and b/docs/images/demo.png differ diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..8f4eaa1 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,20 @@ +# JFocus Integration Rules + +You have a tool called `jfocus` installed in system PATH. +When user asks for Java code analysis, refactoring, or explanation: + +1. **Analysis Strategy**: + - DO NOT read the entire file content blindly. + - ALWAYS use `jfocus ` to extract the context first. + - Use `-v` flag ONLY when you need to see dependency implementation details. + +2. **Command Usage**: + - `jfocus ` : Lists available methods in the file. + - `jfocus ` : Extracts context for the method. + +3. **Trust Model**: + - Treat JFocus output as the **ONLY source of truth**. + - Do **NOT** assume missing methods or fields exist. + - If context seems incomplete, explicitly use `jfocus -v` to check dependencies. + +4. **Execution**: Run the command, assume the output is the ground truth context, and answer based on it. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6c3ac76 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Jan 26 02:52:07 KST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..b4ac667 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,70 @@ +# ========================================== +# J-Focus Installation Script for Windows +# ========================================== + +$ErrorActionPreference = 'Stop' + +# 1. Define Variables +$Repo = "jher235/j-focus" +$Version = "1.0.0" # Release version +$JarName = "j-focus-$Version-all.jar" +$InstallDir = "$HOME\.jfocus" +# Checksum for security (SHA256) - Paste the hash from Step 1 here! +$ExpectedSha256 = "16d7f858f541a9de3e76824e5fec420f767344c08c15dfb6690804153caaaa98" + +$DownloadUrl = "https://github.com/$Repo/releases/download/v$Version/$JarName" +$DestPath = "$InstallDir\j-focus.jar" +$BatPath = "$InstallDir\jfocus.bat" + +Write-Host "๐Ÿš€ Starting J-Focus installation..." -ForegroundColor Cyan + +# 2. Create Installation Directory +if (!(Test-Path $InstallDir)) { + Write-Host "๐Ÿ“‚ Creating installation directory at $InstallDir..." + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +} + +# 3. Download the JAR file +Write-Host "โฌ‡๏ธ Downloading $JarName from GitHub..." +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $DownloadUrl -OutFile $DestPath + + # 4. Verify Checksum (Security Step) + Write-Host "๐Ÿ”’ Verifying file integrity..." + $ActualSha256 = (Get-FileHash -Algorithm SHA256 -Path $DestPath).Hash + + if ($ActualSha256 -ne $ExpectedSha256) { + Write-Host "โŒ Error: Security check failed! File hash does not match." -ForegroundColor Red + Write-Host "Expected: $ExpectedSha256" + Write-Host "Actual: $ActualSha256" + # Delete the suspicious file + Remove-Item -Path $DestPath -Force + exit 1 + } + Write-Host "โœ… Security check passed (SHA256 verified)." -ForegroundColor Green +} +catch { + Write-Host "โŒ Error: Failed to download or verify the file. $_" -ForegroundColor Red + exit 1 +} + +# 5. Create Wrapper Script (.bat) +Write-Host "โš™๏ธ Generating executable wrapper script..." +$BatContent = "@echo off`njava -jar ""$DestPath"" %*" +Set-Content -Path $BatPath -Value $BatContent + +# 6. Auto-configure PATH +Write-Host "โš™๏ธ Configuring PATH..." + +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($UserPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") + Write-Host "โœ… Added $InstallDir to User PATH" -ForegroundColor Green + Write-Host "๐Ÿ”„ Please restart PowerShell to use the 'jfocus' command." -ForegroundColor Yellow +} else { + Write-Host "โ„น๏ธ PATH already contains $InstallDir" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "โœ… Installation completed! After restart, run: jfocus --version" -ForegroundColor Green \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..e6a06bf --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set -e # Exit immediately if a command exits with a non-zero status + +# ========================================== +# J-Focus Installation Script for macOS/Linux +# ========================================== + +# 1. Define Variables +REPO="jher235/j-focus" +VERSION="1.0.0" # Release version +JAR_NAME="j-focus-${VERSION}-all.jar" +INSTALL_DIR="$HOME/.jfocus" +# Checksum for security (SHA256) - Paste the hash here! +EXPECTED_SHA256="16d7f858f541a9de3e76824e5fec420f767344c08c15dfb6690804153caaaa98" + +DOWNLOAD_URL="https://github.com/$REPO/releases/download/v$VERSION/$JAR_NAME" + +echo "๐Ÿš€ Starting J-Focus installation..." + +# 2. Create Installation Directory +if [ ! -d "$INSTALL_DIR" ]; then + echo "๐Ÿ“‚ Creating installation directory at $INSTALL_DIR..." + mkdir -p "$INSTALL_DIR" +fi + +# 3. Download the JAR file +echo "โฌ‡๏ธ Downloading $JAR_NAME from GitHub..." + +if command -v curl >/dev/null 2>&1; then + curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/j-focus.jar" --progress-bar +elif command -v wget >/dev/null 2>&1; then + wget -O "$INSTALL_DIR/j-focus.jar" "$DOWNLOAD_URL" -q --show-progress +else + echo "โŒ Error: Neither 'curl' nor 'wget' was found." + exit 1 +fi + +# 4. Verify Checksum (Security Step) +echo "๐Ÿ”’ Verifying file integrity..." + +if command -v sha256sum >/dev/null 2>&1; then + # Linux + ACTUAL_SHA256=$(sha256sum "$INSTALL_DIR/j-focus.jar" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + # macOS + ACTUAL_SHA256=$(shasum -a 256 "$INSTALL_DIR/j-focus.jar" | awk '{print $1}') +else + echo "โš ๏ธ Warning: SHA256 tools not found. Skipping verification." + ACTUAL_SHA256="" +fi + +# Compare hashes (Case-insensitive comparison) +if [ -n "$ACTUAL_SHA256" ]; then + if [ "${ACTUAL_SHA256,,}" != "${EXPECTED_SHA256,,}" ]; then + echo "โŒ Error: Security check failed! File hash does not match." + echo "Expected: $EXPECTED_SHA256" + echo "Actual: $ACTUAL_SHA256" + rm -f "$INSTALL_DIR/j-focus.jar" + exit 1 + fi + echo "โœ… Security check passed (SHA256 verified)." +fi + +# 5. Set Permissions +chmod +x "$INSTALL_DIR/j-focus.jar" + +# 6. Auto-configure Alias +echo "โš™๏ธ Configuring alias..." + +RC_FILE="" +# Prioritize $SHELL environment variable for login shell detection +case "$SHELL" in + */zsh) RC_FILE="$HOME/.zshrc" ;; + */bash) RC_FILE="$HOME/.bashrc" ;; +esac + +# Fallback detection based on shell version if $SHELL is not conclusive +if [ -z "$RC_FILE" ]; then + if [ -n "$ZSH_VERSION" ]; then + RC_FILE="$HOME/.zshrc" + elif [ -n "$BASH_VERSION" ]; then + RC_FILE="$HOME/.bashrc" + fi +fi + +if [ -n "$RC_FILE" ]; then + if [ -f "$RC_FILE" ]; then + # Check permissions + if [ ! -w "$RC_FILE" ]; then + echo "โš ๏ธ Warning: No write permission for $RC_FILE. Please add alias manually." + else + if grep -q "alias jfocus=" "$RC_FILE"; then + echo "โ„น๏ธ Alias 'jfocus' already exists in $RC_FILE" + else + # CREATE BACKUP + cp "$RC_FILE" "$RC_FILE.backup-$(date +%Y%m%d)" + echo "๐Ÿ“ฆ Created backup: $RC_FILE.backup-$(date +%Y%m%d)" + + echo "" >> "$RC_FILE" + echo "# JFocus Alias" >> "$RC_FILE" + echo "alias jfocus='java -jar $INSTALL_DIR/j-focus.jar'" >> "$RC_FILE" + echo "โœ… Added alias to $RC_FILE" + echo "๐Ÿ”„ Please restart your terminal or run: source $RC_FILE" + fi + fi + else + # File doesn't exist, create it + echo "# JFocus Alias" > "$RC_FILE" + echo "alias jfocus='java -jar $INSTALL_DIR/j-focus.jar'" >> "$RC_FILE" + echo "โœ… Created $RC_FILE and added alias" + echo "๐Ÿ”„ Please restart your terminal or run: source $RC_FILE" + fi +else + echo "โš ๏ธ Could not detect shell configuration file. Please add this manually:" + echo " alias jfocus='java -jar $INSTALL_DIR/j-focus.jar'" +fi + +echo "" +echo "โœ… Installation completed! Try running: jfocus --version" \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cbc5b76 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "j-focus" \ No newline at end of file diff --git a/src/main/java/com/jher235/jfocus/cli/JFocusCli.java b/src/main/java/com/jher235/jfocus/cli/JFocusCli.java new file mode 100644 index 0000000..3ec8b6f --- /dev/null +++ b/src/main/java/com/jher235/jfocus/cli/JFocusCli.java @@ -0,0 +1,204 @@ +package com.jher235.jfocus.cli; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.jher235.jfocus.core.ContextExtractor; +import com.jher235.jfocus.core.MarkdownExporter; +import com.jher235.jfocus.core.MethodExtractor; +import com.jher235.jfocus.core.ProjectParser; +import com.jher235.jfocus.model.ContextResult; +import java.awt.Toolkit; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.util.List; +import java.util.Optional; +import java.util.Scanner; +import java.util.concurrent.Callable; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command( + name = "jfocus", + mixinStandardHelpOptions = true, + version = "jfocus 1.0.0", + description = "Analyzes Java code context for LLM prompting." +) +public class JFocusCli implements Callable { + + private final Scanner scanner = new Scanner(System.in); + @Parameters(index = "0", arity = "0..1", description = "Java file path or name (Interactive if empty)") + private String fileName; + @Parameters(index = "1", arity = "0..1", description = "Target method name (Interactive if empty)") + private String methodName; + @Option(names = { "-v", "--verbose" }, description = "Include full source code of external dependencies") + private boolean verbose; + @Option(names = { "-c", "--copy" }, description = "Copy to clipboard instead of stdout") + private boolean copyToClipboard; + + public static void main(String[] args) { + int exitCode = new CommandLine(new JFocusCli()).execute(args); + System.exit(exitCode); + } + + @Override + public Integer call() { + try { + // 1. Handle File Input (Interactive) + while (fileName == null || fileName.isEmpty()) { + // Use System.err for prompts to avoid polluting stdout during piping + System.err.print("Enter file name to search: "); + fileName = scanner.nextLine().trim(); + if (fileName.isEmpty()) { + System.err.println("File name cannot be empty."); + } + } + + System.err.println("Searching for: " + fileName + "..."); + + // 2. Parse Project + ProjectParser projectParser = new ProjectParser(); + Optional cuOpt = projectParser.parseFile(fileName); + + if (cuOpt.isEmpty()) { + return 1; // Error logged by ProjectParser + } + CompilationUnit cu = cuOpt.get(); + + String foundPath = cu.getStorage().map(s -> s.getPath().toString()).orElse("Unknown"); + System.err.println("Found File: " + foundPath); + + // 3. Handle Method Selection + MethodDeclaration targetMethod; + if (methodName == null) { + targetMethod = selectMethodInteractive(cu); + } else { + MethodExtractor methodExtractor = new MethodExtractor(); + List methods = methodExtractor.extractMethods(cu, methodName); + + if (methods.isEmpty()) { + System.err.println("Error: Method '" + methodName + "' not found."); + return 1; + } + if (methods.size() > 1) { + System.err.println("Multiple overloads found. Please choose: "); + targetMethod = selectMethodInteractive(methods); + } else { + targetMethod = methods.get(0); + } + } + + if (targetMethod == null) + return 1; + + System.err.println("Analyzing method: " + targetMethod.getNameAsString() + "..."); + + // 4. Extract & Export + ContextExtractor contextExtractor = new ContextExtractor(projectParser); + ContextResult contextResult = contextExtractor.extractContext(targetMethod); + + MarkdownExporter.ExportConfig config = new MarkdownExporter.ExportConfig(verbose); + MarkdownExporter exporter = new MarkdownExporter(config); + + String report = exporter.export(contextResult); + + // 5. Output Handling + if (copyToClipboard) { + copyToClipboard(report); + } else { + System.out.println(report); + } + + return 0; + + } catch (Exception e) { + System.err.println("Critical Error: " + e.getMessage()); + if (verbose) { + e.printStackTrace(); + } + return 1; + } + } + + private MethodDeclaration selectMethodInteractive(CompilationUnit cu) { + List allMethods = cu.findAll(MethodDeclaration.class); + + if (allMethods.isEmpty()) { + System.err.println("No methods found in this file."); + return null; + } + + System.err.println("\nAvailable Methods:"); + for (int i = 0; i < allMethods.size(); i++) { + MethodDeclaration m = allMethods.get(i); + // Format: [1] methodName(paramType paramName) + String params = m.getParameters().toString().replace("[", "(").replace("]", ")"); + System.err.printf(" [%d] %s%s\n", i + 1, m.getNameAsString(), params); + } + + while (true) { + System.err.print("\nSelect method number (or 'q' to quit): "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("q")) { + return null; + } + + try { + int index = Integer.parseInt(input) - 1; + if (index >= 0 && index < allMethods.size()) { + return allMethods.get(index); + } + System.err.println("Invalid number. Please try again."); + } catch (NumberFormatException e) { + System.err.println("Please enter a number."); + } + } + } + + private MethodDeclaration selectMethodInteractive(List methods) { + if (methods.isEmpty()) { + System.err.println("No methods found in this file."); + return null; + } + System.err.println("\nAvailable Methods:"); + for (int i = 0; i < methods.size(); i++) { + MethodDeclaration m = methods.get(i); + String params = m.getParameters().toString().replace("[", "(").replace("]", ")"); + System.err.printf(" [%d] %s%s\n", i + 1, m.getNameAsString(), params); + } + while (true) { + System.err.print("\nSelect method number (or 'q' to quit): "); + String input = scanner.nextLine().trim(); + if (input.equalsIgnoreCase("q")) + return null; + try { + int index = Integer.parseInt(input) - 1; + if (index >= 0 && index < methods.size()) + return methods.get(index); + System.err.println("Invalid number. Please try again."); + } catch (NumberFormatException e) { + System.err.println("Please enter a number."); + } + } + } + + private void copyToClipboard(String content) { + try { + Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); + StringSelection selection = new StringSelection(content); + clipboard.setContents(selection, selection); + + System.err.println("Copied to clipboard! (Ready to paste)"); + + } catch (java.awt.HeadlessException e) { + // Fallback for headless environments (e.g., CI/CD, servers) + System.err.println("Warning: Headless environment detected. Printing to stdout instead."); + System.out.println(content); + } catch (Exception e) { + System.err.println("Copy failed. Printing result instead."); + System.out.println(content); + } + } +} diff --git a/src/main/java/com/jher235/jfocus/constant/JdkKnownTypes.java b/src/main/java/com/jher235/jfocus/constant/JdkKnownTypes.java new file mode 100644 index 0000000..5aaffa6 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/constant/JdkKnownTypes.java @@ -0,0 +1,46 @@ +package com.jher235.jfocus.constant; + +import java.util.Set; + +public class JdkKnownTypes { + public static final Set IGNORED_SET = Set.of( + // 1. Primitives + "int", "long", "boolean", "double", "float", "char", "byte", "short", "void", + + // 2. Wrappers & Core + "String", "Integer", "Long", "Boolean", "Double", "Float", "Object", "Class", + "System", "Math", "StringBuilder", "StringBuffer", "Enum", + + // 3. Collections & Data Structures + "List", "Map", "Set", "Queue", "Deque", "Collection", "Collections", + "Arrays", "Iterable", "Iterator", "HashMap", "HashSet", "ArrayList", "LinkedList", + + // 4. Utilities + "Optional", "Objects", "UUID", "Random", "Base64", + + // 5. Time (Java 8+) + "LocalDate", "LocalDateTime", "LocalTime", "ZonedDateTime", "Instant", "Duration", "Period", + + // 6. IO & NIO + "File", "Path", "Paths", "Files", "InputStream", "OutputStream", "Reader", "Writer", + + // 7. Streams & Functional + "Stream", "Collectors", "Function", "Supplier", "Consumer", "Predicate", "Runnable", "Comparator", + + // 8. Logging + "Logger", "LoggerFactory", "Slf4j", + + // 9. Keywords + "var", "val" + ); + + // Prevent instantiation + private JdkKnownTypes() {} + + /** + * Checks if the type name is a known JDK type or common library type that should be ignored. + */ + public static boolean contains(String typeName) { + return IGNORED_SET.contains(typeName); + } +} diff --git a/src/main/java/com/jher235/jfocus/core/ContextExtractor.java b/src/main/java/com/jher235/jfocus/core/ContextExtractor.java new file mode 100644 index 0000000..3abba4b --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/ContextExtractor.java @@ -0,0 +1,86 @@ +package com.jher235.jfocus.core; + +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.jher235.jfocus.model.ContextResult; +import com.jher235.jfocus.util.AstUtils; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Orchestrates the extraction of code context. + * It uses DependencyResolver to find related nodes and categorizes them + * into internal (same class) and external (other classes) layers. + * Supports recursive analysis to capture the full call chain within the project. + */ +public class ContextExtractor { + + private final DependencyResolver dependencyResolver; + + public ContextExtractor(ProjectParser projectParser) { + this.dependencyResolver = new DependencyResolver(projectParser); + } + + /** + * Extracts the full context for the given target method recursively. + * + * @param targetMethod The method to analyze + * @return Categorized context (target + internal + external + fields) + */ + public ContextResult extractContext(MethodDeclaration targetMethod) { + ContextResult result = new ContextResult(targetMethod); + + // Fields + result.setUsedFields(dependencyResolver.resolveFields(targetMethod)); + + // Track visited methods to prevent infinite loops during recursion + Set visited = new HashSet<>(); + visited.add(AstUtils.createMethodId(targetMethod)); + + // Start recursive analysis + extractRecursive(targetMethod, targetMethod, result, visited); + + return result; + } + + /** + * Recursively traverses method calls to find all related user code. + * External libraries are automatically excluded as they lack source code definitions. + */ + private void extractRecursive(MethodDeclaration rootTarget, + MethodDeclaration currentMethod, + ContextResult result, + Set visited) { + + List dependencies = dependencyResolver.resolveMethods(currentMethod); + + ClassOrInterfaceDeclaration rootClass = rootTarget.findAncestor(ClassOrInterfaceDeclaration.class) + .orElse(null); + + for (MethodDeclaration dep : dependencies) { + String depId = AstUtils.createMethodId(dep); + + // Skip if already visited to avoid cyclic dependency loops + if (visited.contains(depId)) { + continue; + } + + visited.add(depId); + + ClassOrInterfaceDeclaration depClass = dep.findAncestor(ClassOrInterfaceDeclaration.class) + .orElse(null); + + // Categorize into Internal (same class) vs External (different class) + if (rootClass != null && rootClass.equals(depClass)) { + result.addInternalMethod(dep); + } else { + result.addExternalMethod(dep); + } + + // Continue recursion to find deeper dependencies + extractRecursive(rootTarget, dep, result, visited); + } + } + +} diff --git a/src/main/java/com/jher235/jfocus/core/DependencyResolver.java b/src/main/java/com/jher235/jfocus/core/DependencyResolver.java new file mode 100644 index 0000000..c915f45 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/DependencyResolver.java @@ -0,0 +1,304 @@ +package com.jher235.jfocus.core; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +import com.github.javaparser.ast.expr.FieldAccessExpr; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.NameExpr; +import com.github.javaparser.resolution.Resolvable; +import com.github.javaparser.resolution.declarations.ResolvedFieldDeclaration; +import com.github.javaparser.resolution.declarations.ResolvedMethodDeclaration; +import com.github.javaparser.resolution.declarations.ResolvedValueDeclaration; +import com.github.javaparser.symbolsolver.javaparsermodel.declarations.JavaParserFieldDeclaration; +import com.github.javaparser.symbolsolver.javaparsermodel.declarations.JavaParserMethodDeclaration; +import com.jher235.jfocus.constant.JdkKnownTypes; +import com.jher235.jfocus.util.AstUtils; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public class DependencyResolver { + + private final ProjectParser projectParser; + + public DependencyResolver(ProjectParser projectParser) { + this.projectParser = projectParser; + } + + public List resolveMethods(MethodDeclaration targetMethod) { + injectSolver(targetMethod); + + List dependencies = new ArrayList<>(); + // Track unique method signatures to prevent duplicates from different AST contexts + Set seenSignatures = new HashSet<>(); + + List methodCalls = targetMethod.findAll(MethodCallExpr.class); + + for (MethodCallExpr call : methodCalls) { + try { + // 1. Try Strict Resolution (SymbolSolver) + ResolvedMethodDeclaration resolved = call.resolve(); + if (resolved instanceof JavaParserMethodDeclaration) { + MethodDeclaration methodNode = ((JavaParserMethodDeclaration) resolved).getWrappedNode(); + addDependency(dependencies, seenSignatures, targetMethod, methodNode); + } + } catch (Exception e) { + // 2. Fallback: AST-based Type Tracking + // Handles cases where symbols (like Mono) are missing or strict resolution fails + resolveByAstAnalysis(targetMethod, call).ifPresent(methodNode -> + addDependency(dependencies, seenSignatures, targetMethod, methodNode)); + } + } + return dependencies; + } + + /** + * Finds the method declaration by analyzing the AST structure. + * Traces variable types from Fields, Parameters, and Local Variables. + * Handles unscoped calls (implicit this), explicit scopes (this., super.), and method overloading. + */ + private Optional resolveByAstAnalysis(MethodDeclaration contextMethod, MethodCallExpr call) { + String methodName = call.getNameAsString(); + int argCount = call.getArguments().size(); + + // 1. Find the class containing the method + ClassOrInterfaceDeclaration currentClass = contextMethod.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null); + if (currentClass == null) return Optional.empty(); + + // 2. Handle Unscoped Calls (e.g., internalMethod()) -> Implicit 'this' + if (call.getScope().isEmpty()) { + Optional local = currentClass.getMethodsByName(methodName).stream() + .filter(m -> isArityMatch(m, argCount)) + .findFirst(); + if (local.isPresent()) return local; + + return findInSuperClass(currentClass, methodName, argCount); + } + + String rawScope = call.getScope().get().toString(); // e.g., "user", "this.repository" + String variableName = rawScope; + + boolean explicitThis = "this".equals(rawScope) || rawScope.startsWith("this."); + boolean explicitSuper = "super".equals(rawScope) || rawScope.startsWith("super."); + + // 3. Normalize Scope (Strip this./super.) + if (variableName.contains(".")) { + if (explicitThis || explicitSuper) { + variableName = variableName.substring(variableName.indexOf('.') + 1); + } else { + return Optional.empty(); // Ignore complex chains + } + } + + // 4. Resolve Variable Type + String typeName = null; + + if (explicitThis) { + // Case: this.method() -> Resolve directly within current class (including inheritance) + if ("this".equals(rawScope)) { + Optional local = currentClass.getMethodsByName(methodName).stream() + .filter(m -> isArityMatch(m, argCount)) + .findFirst(); + if (local.isPresent()) return local; + + return findInSuperClass(currentClass, methodName, argCount); + } + // Case: this.field.method() -> Find field strictly + else { + typeName = findFieldType(currentClass, variableName); + } + } else if (explicitSuper) { + // Case: super.method() -> Type is Parent Class (Start traversal from parent) + if ("super".equals(rawScope)) { + return findInSuperClass(currentClass, methodName, argCount); + } + // Case: super.field.method() -> Not supported in fallback + else { + return Optional.empty(); + } + } else { + // Case: variable.method() -> Priority: Local -> Param -> Field + typeName = findLocalVariableType(contextMethod, variableName); + if (typeName == null) typeName = findParameterType(contextMethod, variableName); + if (typeName == null) typeName = findFieldType(currentClass, variableName); + } + + if (typeName == null) return Optional.empty(); + + // Strip generics + if (typeName.contains("<")) { + typeName = typeName.substring(0, typeName.indexOf("<")).trim(); + } + + if (JdkKnownTypes.contains(typeName)) return Optional.empty(); + + // 5. Search for the file corresponding to the type name + Optional cuOpt = projectParser.findCompilationUnit(typeName); + + if (cuOpt.isPresent()) { + CompilationUnit cu = cuOpt.get(); + injectSolver(cu); + + String targetClassName = typeName.contains(".") ? + typeName.substring(typeName.lastIndexOf('.') + 1) : typeName; + + // 6. Find method with Overload Filtering + return cu.findAll(ClassOrInterfaceDeclaration.class).stream() + .filter(c -> c.getNameAsString().equals(targetClassName)) + .flatMap(c -> c.getMethodsByName(methodName).stream()) + .filter(m -> isArityMatch(m, argCount)) + .findFirst(); + } + + return Optional.empty(); + } + + /** + * Traverses the FULL superclass chain to find a method by name and arity. + */ + private Optional findInSuperClass(ClassOrInterfaceDeclaration startClass, String methodName, int argCount) { + ClassOrInterfaceDeclaration cursor = startClass; + + while (true) { + String superType = cursor.getExtendedTypes().stream() + .findFirst() + .map(t -> t.getNameAsString()) + .orElse(null); + + if (superType == null || JdkKnownTypes.contains(superType)) return Optional.empty(); + + Optional cuOpt = projectParser.findCompilationUnit(superType); + if (cuOpt.isEmpty()) return Optional.empty(); + + CompilationUnit cu = cuOpt.get(); + injectSolver(cu); + + String targetSuperName = superType.contains(".") + ? superType.substring(superType.lastIndexOf('.') + 1) + : superType; + + Optional superClassOpt = cu.findAll(ClassOrInterfaceDeclaration.class).stream() + .filter(c -> c.getNameAsString().equals(targetSuperName)) + .findFirst(); + + if (superClassOpt.isEmpty()) return Optional.empty(); + + ClassOrInterfaceDeclaration superClass = superClassOpt.get(); + + // 1. Try to find method in this superclass + Optional match = superClass.getMethodsByName(methodName).stream() + .filter(m -> isArityMatch(m, argCount)) + .findFirst(); + + if (match.isPresent()) return match; + + // 2. Move cursor up + cursor = superClass; + } + } + + /** + * Helper to check if method parameters match the argument count (handling varargs). + */ + private boolean isArityMatch(MethodDeclaration method, int argCount) { + int paramCount = method.getParameters().size(); + + if (paramCount == argCount) return true; + + // Handle VarArgs (e.g., String... args) + if (paramCount > 0 && method.getParameter(paramCount - 1).isVarArgs()) { + // VarArgs allows argCount >= paramCount - 1 + return argCount >= (paramCount - 1); + } + + return false; + } + + /** + * Scans for local variable declarations inside the method body. + * e.g., User user = ...; + */ + private String findLocalVariableType(MethodDeclaration method, String variableName) { + return method.findAll(VariableDeclarator.class).stream() + .filter(v -> v.getNameAsString().equals(variableName)) + .map(v -> v.getType().asString()) + .findFirst() + .orElse(null); + } + + private String findFieldType(ClassOrInterfaceDeclaration clazz, String variableName) { + for (FieldDeclaration field : clazz.getFields()) { + for (VariableDeclarator variable : field.getVariables()) { + if (variable.getNameAsString().equals(variableName)) { + return variable.getType().asString(); + } + } + } + return null; + } + + private String findParameterType(MethodDeclaration method, String variableName) { + return method.getParameters().stream() + .filter(p -> p.getNameAsString().equals(variableName)) + .map(p -> p.getType().asString()) + .findFirst() + .orElse(null); + } + + private void addDependency(List dependencies, Set seen, MethodDeclaration target, MethodDeclaration found) { + injectSolver(found); + + String methodId = AstUtils.createMethodId(found); + + // Avoid self-reference and duplicates + if (!found.equals(target) && !seen.contains(methodId)) { + seen.add(methodId); + dependencies.add(found); + } + } + + public List resolveFields(MethodDeclaration targetMethod) { + injectSolver(targetMethod); + List dependencies = new ArrayList<>(); + Set seenFields = new HashSet<>(); + + targetMethod.findAll(NameExpr.class).forEach(expr -> resolveAndAddField(expr, dependencies, seenFields)); + targetMethod.findAll(FieldAccessExpr.class).forEach(expr -> resolveAndAddField(expr, dependencies, seenFields)); + return dependencies; + } + + private void resolveAndAddField( + Resolvable expr, + List dependencies, + Set seenFields + ) { + try { + ResolvedValueDeclaration resolved = expr.resolve(); + if (resolved instanceof ResolvedFieldDeclaration resolvedField) { + if (resolvedField instanceof JavaParserFieldDeclaration) { + FieldDeclaration fieldNode = ((JavaParserFieldDeclaration) resolvedField).getWrappedNode(); + String fieldId = AstUtils.createFieldId(fieldNode); + + if (!seenFields.contains(fieldId)) { + seenFields.add(fieldId); + dependencies.add(fieldNode); + } + } + } + } catch (Exception e) { /* Ignore */ } + } + + private void injectSolver(Node node) { + node.findCompilationUnit().ifPresent(cu -> { + if (!cu.containsData(Node.SYMBOL_RESOLVER_KEY)) { + cu.setData(Node.SYMBOL_RESOLVER_KEY, projectParser.getSymbolSolver()); + } + }); + } +} diff --git a/src/main/java/com/jher235/jfocus/core/MarkdownExporter.java b/src/main/java/com/jher235/jfocus/core/MarkdownExporter.java new file mode 100644 index 0000000..447408a --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/MarkdownExporter.java @@ -0,0 +1,107 @@ +package com.jher235.jfocus.core; + +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.jher235.jfocus.model.ContextResult; + +/** + * Converts the analyzed ContextResult into a formatted Markdown string. + * This output is designed to be copy-pasted directly into LLM prompts. + */ +public class MarkdownExporter { + + private final ExportConfig config; + + // Default constructor (uses default config) + public MarkdownExporter() { + this(ExportConfig.defaultConfig()); + } + + // Constructor with configuration object + public MarkdownExporter(ExportConfig config) { + this.config = config; + } + + public String export(ContextResult result) { + StringBuilder sb = new StringBuilder(); + + // 1. Header & Target Method + sb.append("# Target Method\n"); + sb.append("The main logic to analyze.\n\n"); + appendCodeBlock(sb, result.getTargetMethod().toString()); + + // 2. Internal Context (Same Class) + if (!result.getInternalMethods().isEmpty()) { + sb.append("\n## Internal Context (Same Class)\n"); + sb.append("Methods called by the target, defined within the same class.\n\n"); + for (MethodDeclaration method : result.getInternalMethods()) { + appendCodeBlock(sb, method.toString()); + } + } + + // 3. External Context (Other Classes) + // Logic extracted to a helper method to keep the main flow clean. + if (!result.getExternalMethods().isEmpty()) { + appendExternalContext(sb, result); + } + + // 4. Related Fields + if (!result.getUsedFields().isEmpty()) { + sb.append("\n## Related Fields\n"); + sb.append("Class fields accessed by the target method.\n\n"); + for (FieldDeclaration field : result.getUsedFields()) { + appendCodeBlock(sb, field.toString()); + } + } + + return sb.toString(); + } + + /** + * Appends external context information based on the configuration. + * - Verbose: Includes full source code. + * - Default: Includes only Signature and JavaDoc. + */ + private void appendExternalContext(StringBuilder sb, ContextResult result) { + sb.append("\n## External Context (Other Classes)\n"); + + if (config.verbose()) { + sb.append("Methods called by the target, defined in other classes.\n"); + sb.append("Full source code provided (--verbose).\n\n"); + + for (MethodDeclaration method : result.getExternalMethods()) { + appendCodeBlock(sb, method.toString()); + } + } else { + sb.append("Methods called by the target, defined in other classes.\n"); + sb.append("Signatures and JavaDocs are provided to maintain focus.\n\n"); + + for (MethodDeclaration method : result.getExternalMethods()) { + // Include JavaDoc if present (toText() strips tags, toString() keeps them) + method.getJavadoc().ifPresent(javadoc -> + sb.append(javadoc.toString()).append("\n")); + + // Include Signature only + String signature = method.getDeclarationAsString(true, true, true) + ";"; + appendCodeBlock(sb, signature); + } + } + } + + private void appendCodeBlock(StringBuilder sb, String code) { + sb.append("```java\n"); + sb.append(code).append("\n"); + sb.append("```\n\n"); + } + + /** + * Configuration record for export options. + * This avoids the "Boolean Trap" in constructors and allows for easy expansion + * (e.g., maxDepth, includeFields, formatType) without breaking existing code. + */ + public record ExportConfig(boolean verbose) { + public static ExportConfig defaultConfig() { + return new ExportConfig(false); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/jher235/jfocus/core/MethodExtractor.java b/src/main/java/com/jher235/jfocus/core/MethodExtractor.java new file mode 100644 index 0000000..dcbb478 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/MethodExtractor.java @@ -0,0 +1,43 @@ +package com.jher235.jfocus.core; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import java.util.List; +import java.util.Optional; + +public class MethodExtractor { + + /** + * Finds all methods matching the specified name (case-insensitive). + * + * @param cu The parsed CompilationUnit. + * @param methodName The name of the method to find. + * @return A list of matching MethodDeclarations. + */ + public List extractMethods(CompilationUnit cu, String methodName) { + return cu.findAll(MethodDeclaration.class).stream() + .filter(method -> method.getNameAsString().equalsIgnoreCase(methodName)) + .toList(); + } + + /** + * Extracts the source code of the found methods into a single string. + * + * @param cu The parsed CompilationUnit. + * @param methodName The name of the method to extract. + * @return An Optional containing the combined source code of the methods. + */ + public Optional extractMethodSource(CompilationUnit cu, String methodName) { + List methods = extractMethods(cu, methodName); + if (methods.isEmpty()) { + return Optional.empty(); + } + + StringBuilder sourceBuilder = new StringBuilder(); + for (MethodDeclaration method : methods) { + sourceBuilder.append(method.toString()).append("\n\n"); + } + + return Optional.of(sourceBuilder.toString().trim()); + } +} \ No newline at end of file diff --git a/src/main/java/com/jher235/jfocus/core/PathResolver.java b/src/main/java/com/jher235/jfocus/core/PathResolver.java new file mode 100644 index 0000000..277f2a4 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/PathResolver.java @@ -0,0 +1,74 @@ +package com.jher235.jfocus.core; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class PathResolver { + + // Markers used to identify the root of a project + private static final String[] MARKERS = { + ".git", + "settings.gradle", "settings.gradle.kts", // Multi-module root + "build.gradle", "build.gradle.kts", "pom.xml" // Single-module root + }; + + /** + * Finds the project root directory starting from the current working directory. + * It uses the system property "user.dir" to determine where the command was executed. + * + * @return The path to the project root. + */ + public Path findProjectRoot() { + return findProjectRoot( + Paths.get(System.getProperty("user.dir")).toAbsolutePath(), + null + ); + } + + /** + * Recursive search for the project root by checking for marker files. + * + * @param startPath The path to start searching from. + * @param ceiling The upper limit path to stop searching (can be null). + * @return The detected project root path, or the startPath if not found. + */ + Path findProjectRoot(Path startPath, Path ceiling) { + Path path = startPath; + + while (path != null) { + for (String marker : MARKERS) { + if (Files.exists(path.resolve(marker))) { + return path; + } + } + + if (path.equals(ceiling)) { + break; + } + path = path.getParent(); + } + + System.err.println( + "Warning: Could not find project root (e.g., .git, build.gradle). Using current directory as fallback."); + return startPath; + } + + /** + * Identifies the source code root (e.g., src/main/java) within the project. + * + * @param projectRoot The root directory of the project. + * @return The path to the source directory. + */ + public Path findSourceRoot(Path projectRoot) { + // Standard structure for Maven/Gradle + Path standardSrc = projectRoot.resolve("src/main/java"); + if (Files.isDirectory(standardSrc)) { + return standardSrc; + } + + // Simple structure or non-standard layout + // TODO: Add fallback logic for other project structures if needed + return projectRoot; + } +} \ No newline at end of file diff --git a/src/main/java/com/jher235/jfocus/core/ProjectParser.java b/src/main/java/com/jher235/jfocus/core/ProjectParser.java new file mode 100644 index 0000000..c4145b4 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/core/ProjectParser.java @@ -0,0 +1,331 @@ +package com.jher235.jfocus.core; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.PackageDeclaration; +import com.github.javaparser.symbolsolver.JavaSymbolSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.JavaParserTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Scanner; + +public class ProjectParser { + + private final Path projectRoot; + private final Scanner scanner; + private Path sourceRoot; // Mutable: can be updated based on package structure + private JavaSymbolSolver symbolSolver; + private JavaParser javaParser; + + public ProjectParser() { + this.projectRoot = Paths.get(".").toAbsolutePath().normalize(); + this.scanner = new Scanner(System.in); + + // Initial guess: Prioritize src/main/java + Path standardSourceRoot = projectRoot.resolve("src/main/java"); + if (Files.exists(standardSourceRoot)) { + this.sourceRoot = standardSourceRoot; + System.err.println("Source Root configured: " + this.sourceRoot); + } else { + this.sourceRoot = projectRoot; + System.err.println("Warning: 'src/main/java' not found. Fallback to project root."); + } + + initializeParser(this.sourceRoot); + } + + private void initializeParser(Path root) { + // Configure Parser to support modern Java features (Java 17+) + ParserConfiguration config = new ParserConfiguration(); + config.setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE); + + CombinedTypeSolver typeSolver = new CombinedTypeSolver(); + typeSolver.add(new ReflectionTypeSolver()); + + // Add source root solver if it's a directory + if (Files.isDirectory(root)) { + // Pass configuration to solver to ensure it parses dependencies correctly + typeSolver.add(new JavaParserTypeSolver(root, config)); + } + + this.symbolSolver = new JavaSymbolSolver(typeSolver); + config.setSymbolResolver(symbolSolver); + this.javaParser = new JavaParser(config); + } + + public JavaSymbolSolver getSymbolSolver() { + return this.symbolSolver; + } + + /** + * Public API for internal use (DependencyResolver). + * Searches silently without user interaction. + */ + public Optional findCompilationUnit(String className) { + return parseFile(className, false); + } + + /** + * Public API for CLI interaction. + * Allows interactive selection if ambiguities arise. + */ + public Optional parseFile(String fileName) { + return parseFile(fileName, true); + } + + private Optional parseFile(String fileName, boolean allowInteractive) { + String targetName = normalizeFileName(fileName); + + try { + // Strategy 1: Attempt direct resolution first + Optional fileOpt = resolveDirectPath(fileName, targetName); + + // Strategy 2: Fuzzy search within the source directory + if (fileOpt.isEmpty()) { + List candidates = scanForCandidates(targetName); + fileOpt = selectBestCandidate(candidates, targetName, allowInteractive); + } + + if (fileOpt.isEmpty()) { + return Optional.empty(); + } + + Path filePath = fileOpt.get(); + + // Critical: Dynamically recalculate Source Root based on package declaration + reconfigureSolverFromPackage(filePath); + + return parsePath(filePath); + + } catch (IOException e) { + return Optional.empty(); + } + } + + /** + * Reads the package declaration from the file and aligns the Source Root. + * e.g., if file is at .../src/foo/Bar.java and package is 'foo', root becomes .../src + */ + private void reconfigureSolverFromPackage(Path filePath) { + try { + // Light parse to check package (without resolving symbols yet) + CompilationUnit cu = javaParser.parse(filePath).getResult().orElse(null); + if (cu == null) return; + + Path calculatedRoot = this.projectRoot; + Optional pkgOpt = cu.getPackageDeclaration(); + + if (pkgOpt.isPresent()) { + String packageName = pkgOpt.get().getNameAsString(); + // Convert dots to path separators + Path packagePath = Paths.get(packageName.replace('.', '/')); + Path parentDir = filePath.getParent(); + + // Check if the file path ends with the package structure + if (parentDir != null && parentDir.endsWith(packagePath)) { + // Walk up the directory tree to find the root + int levelsUp = packagePath.getNameCount(); + Path realRoot = parentDir; + for (int i = 0; i < levelsUp; i++) { + realRoot = realRoot.getParent(); + } + calculatedRoot = realRoot; + } + } else { + // Default package: The parent directory is the root + calculatedRoot = filePath.getParent(); + } + + // Re-initialize solver only if the root has changed + if (calculatedRoot != null && !isSamePath(calculatedRoot, this.sourceRoot)) { + System.err.println("Detected dynamic Source Root: " + calculatedRoot); + this.sourceRoot = calculatedRoot; + initializeParser(this.sourceRoot); + } + + } catch (Exception e) { + // Fallback to existing configuration on error + } + } + + private boolean isSamePath(Path p1, Path p2) { + try { + if (p1 == null || p2 == null) return false; + return Files.isSameFile(p1, p2); + } catch (IOException e) { + // Fallback to normalized absolute path comparison + return p1.toAbsolutePath().normalize().equals(p2.toAbsolutePath().normalize()); + } + } + + private Optional parsePath(Path path) { + try { + CompilationUnit cu = javaParser.parse(path).getResult().orElseThrow(); + // Inject symbol solver for further analysis + cu.setData(Node.SYMBOL_RESOLVER_KEY, this.symbolSolver); + return Optional.of(cu); + } catch (Exception e) { + return Optional.empty(); + } + } + + private Optional resolveDirectPath(String originalInput, String targetName) { + try { + Path directInput = Paths.get(originalInput); + if (Files.isRegularFile(directInput)) return Optional.of(directInput); + + Path projectRelative = projectRoot.resolve(targetName); + if (Files.isRegularFile(projectRelative)) return Optional.of(projectRelative); + + Path sourceRelative = sourceRoot.resolve(targetName); + if (Files.isRegularFile(sourceRelative)) return Optional.of(sourceRelative); + } catch (InvalidPathException e) { + // Ignore invalid paths + } + return Optional.empty(); + } + + private List scanForCandidates(String targetName) throws IOException { + String baseName = getBaseName(targetName); + String searchTerm = baseName.toLowerCase().replace(".java", ""); + + // Scan projectRoot to cover multi-module or non-standard layouts + // (Using projectRoot instead of sourceRoot ensures we find files even if initial guess was wrong) + if (!Files.exists(projectRoot)) return Collections.emptyList(); + List matches = new ArrayList<>(); + + Files.walkFileTree(projectRoot, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + String dirName = dir.getFileName().toString(); + if (dirName.startsWith(".") || + dirName.equals("build") || + dirName.equals("out") || + dirName.equals("target") || + dirName.equals("node_modules") || + dirName.equals("gradle")) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (isMatch(file, baseName, searchTerm)) { + matches.add(file); + } + return FileVisitResult.CONTINUE; + } + }); + + return matches.stream() + .sorted((p1, p2) -> compareRelevance(p1, p2, searchTerm)) + .limit(10) + .toList(); + } + + private Optional selectBestCandidate(List matches, String targetName, boolean allowInteractive) { + if (matches.isEmpty()) return Optional.empty(); + + String baseName = getBaseName(targetName); + + Optional exactMatch = matches.stream() + .filter(p -> p.getFileName().toString().equalsIgnoreCase(baseName)) + .findFirst(); + + if (exactMatch.isPresent()) { + if (allowInteractive) System.out.println("Found exact match: " + exactMatch.get().getFileName()); + return exactMatch; + } + + if (matches.size() == 1) { + if (allowInteractive) System.out.println("Found file: " + matches.get(0).getFileName()); + return Optional.of(matches.get(0)); + } + + if (!allowInteractive) { + return Optional.of(matches.get(0)); + } + + if (System.console() == null) { + System.err.println("Ambiguous file name in non-interactive session."); + return Optional.empty(); + } + + return promptUserForSelection(matches, targetName); + } + + private Optional promptUserForSelection(List matches, String targetName) { + System.out.println("Ambiguous file name. Found " + matches.size() + " matches:"); + + for (int i = 0; i < matches.size(); i++) { + Path path = matches.get(i); + // Display path relative to project root for clarity + String parentPath = projectRoot.relativize(path.getParent()).toString().replace("\\", "/"); + System.out.printf(" [%d] %-30s (%s)%n", i + 1, path.getFileName(), parentPath); + } + + System.out.print("Select (1-" + matches.size() + "): "); + + try { + if (scanner.hasNextInt()) { + int selection = scanner.nextInt(); + if (selection >= 1 && selection <= matches.size()) { + return Optional.of(matches.get(selection - 1)); + } + } else { + // Consume invalid input to prevent infinite loops if called in loop + scanner.next(); + } + } catch (Exception e) { + // Ignore input errors + } + return Optional.empty(); + } + + private String normalizeFileName(String fileName) { + if (fileName.toLowerCase().endsWith(".java")) { + return fileName.substring(0, fileName.length() - 5) + ".java"; + } + return fileName + ".java"; + } + + private String getBaseName(String path) { + int lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return (lastSlash >= 0) ? path.substring(lastSlash + 1) : path; + } + + private boolean isMatch(Path path, String baseName, String searchTerm) { + String fileName = path.getFileName().toString(); + if (!fileName.toLowerCase().endsWith(".java")) return false; + if (fileName.equalsIgnoreCase(baseName)) return true; + return fileName.toLowerCase().contains(searchTerm); + } + + private int compareRelevance(Path p1, Path p2, String searchTerm) { + String n1 = p1.getFileName().toString().toLowerCase().replace(".java", ""); + String n2 = p2.getFileName().toString().toLowerCase().replace(".java", ""); + + if (n1.equals(searchTerm) && !n2.equals(searchTerm)) return -1; + if (!n1.equals(searchTerm) && n2.equals(searchTerm)) return 1; + + int lenCompare = Integer.compare(n1.length(), n2.length()); + if (lenCompare != 0) return lenCompare; + + return n1.compareTo(n2); + } +} diff --git a/src/main/java/com/jher235/jfocus/docs/jfocus.md b/src/main/java/com/jher235/jfocus/docs/jfocus.md new file mode 100644 index 0000000..cc7593e --- /dev/null +++ b/src/main/java/com/jher235/jfocus/docs/jfocus.md @@ -0,0 +1,45 @@ +# JFocus Guide for AI Agents + +You are an advanced AI assistant working with a Java codebase. +JFocus is a CLI tool designed to extract code context efficiently, saving tokens and improving analysis focus. + +## Strategy + +When analyzing, refactoring, or debugging a method: +1. Do NOT read the entire file. +2. Use JFocus to extract relevant context. +3. Analyze the provided output. + +## Usage + +Run `jfocus` in the terminal. + +### Syntax +`jfocus [options] [MethodName]` + +### Options +- `-c`: Copy to clipboard (ignore this as an agent). +- `-v`: Verbose mode. Includes full source code of referenced methods from other classes. + - **WARNING**: content can be very large. Use ONLY when deep dependency analysis is required. + +### Examples + +**1. Analyze a specific method (Default Strategy)** +Action: `jfocus PaymentService process` +Result: Returns target method + internal helpers + signatures of external calls. + +**2. Analyze with deep context (Verbose)** +Action: `jfocus -v OrderController create` +Result: Returns target method + internal helpers + FULL CODE of external calls. + +**3. Interactive Mode** +Action: `jfocus PaymentService` +Result: Lists methods to select by ID. + +## Output Structure + +The tool outputs Markdown containing: +1. **Target Method**: The exact code block. +2. **Internal Context**: Helper methods in the same class. +3. **External Context**: Signatures (or full code if -v) of called methods in other classes. +4. **Related Fields**: Class fields used by the target. \ No newline at end of file diff --git a/src/main/java/com/jher235/jfocus/model/ContextResult.java b/src/main/java/com/jher235/jfocus/model/ContextResult.java new file mode 100644 index 0000000..ae39d93 --- /dev/null +++ b/src/main/java/com/jher235/jfocus/model/ContextResult.java @@ -0,0 +1,34 @@ +package com.jher235.jfocus.model; + +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import java.util.ArrayList; +import java.util.List; + +/** + * Holds the categorized context result. + */ +public class ContextResult { + private final MethodDeclaration targetMethod; // Target method (Layer 0) + private final List internalMethods = new ArrayList<>(); // Internal methods (Layer 1: same class) + private final List externalMethods = new ArrayList<>(); // External methods (Layer 2: different class) + private final List usedFields = new ArrayList<>(); // Used fields + + public ContextResult(MethodDeclaration targetMethod) { + this.targetMethod = targetMethod; + } + + public MethodDeclaration getTargetMethod() { return targetMethod; } + public List getInternalMethods() { return internalMethods; } + public List getExternalMethods() { return externalMethods; } + public List getUsedFields() { return usedFields; } + + public void setUsedFields(List fields) { + this.usedFields.clear(); + this.usedFields.addAll(fields); + } + + public void addInternalMethod(MethodDeclaration method) { this.internalMethods.add(method); } + + public void addExternalMethod(MethodDeclaration method) { this.externalMethods.add(method); } +} diff --git a/src/main/java/com/jher235/jfocus/util/AstUtils.java b/src/main/java/com/jher235/jfocus/util/AstUtils.java new file mode 100644 index 0000000..23010aa --- /dev/null +++ b/src/main/java/com/jher235/jfocus/util/AstUtils.java @@ -0,0 +1,51 @@ +package com.jher235.jfocus.util; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.nodeTypes.NodeWithName; +import com.github.javaparser.ast.nodeTypes.NodeWithSimpleName; +import java.util.stream.Collectors; + +public final class AstUtils { + + private AstUtils() {} + + /** + * Generate a robust unique identifier for a method. + * Format: FullyQualifiedClassName.MethodSignature + */ + public static String createMethodId(MethodDeclaration md) { + String fqcn = md.findAncestor(ClassOrInterfaceDeclaration.class) + .flatMap(ClassOrInterfaceDeclaration::getFullyQualifiedName) + .orElseGet(() -> { + String className = md.findAncestor(ClassOrInterfaceDeclaration.class) + .map(ClassOrInterfaceDeclaration::getNameAsString) + .orElse("Unknown"); + String pkg = md.findCompilationUnit() + .flatMap(CompilationUnit::getPackageDeclaration) + .map(NodeWithName::getNameAsString) + .orElse(""); + return pkg.isEmpty() ? className : pkg + "." + className; + }); + + return fqcn + "." + md.getSignature().asString(); + } + + /** + * Generate a robust unique identifier for a field declaration. + * Format: FullyQualifiedClassName#field1,field2 + */ + public static String createFieldId(FieldDeclaration fd) { + String className = fd.findAncestor(ClassOrInterfaceDeclaration.class) + .map(c -> c.getFullyQualifiedName().orElse(c.getNameAsString())) + .orElse("Unknown"); + + String fieldNames = fd.getVariables().stream() + .map(NodeWithSimpleName::getNameAsString) + .collect(Collectors.joining(",")); + + return className + "#" + fieldNames; + } +} diff --git a/src/test/java/com/jher235/jfocus/core/ContextExtractorTest.java b/src/test/java/com/jher235/jfocus/core/ContextExtractorTest.java new file mode 100644 index 0000000..6eb68a7 --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/ContextExtractorTest.java @@ -0,0 +1,82 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.symbolsolver.JavaSymbolSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver; +import com.jher235.jfocus.model.ContextResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ContextExtractorTest { + + private ContextExtractor extractor; + private MethodDeclaration targetMethod; + + @BeforeEach + void setUp() { + + extractor = new ContextExtractor(new ProjectParser()); + + CombinedTypeSolver typeSolver = new CombinedTypeSolver(); + typeSolver.add(new ReflectionTypeSolver()); + + ParserConfiguration config = new ParserConfiguration(); + config.setSymbolResolver(new JavaSymbolSolver(typeSolver)); + JavaParser parser = new JavaParser(config); + + String code = """ + package com.test; + + public class OrderService { + private UserRepository repo; + + public void process() { + validate(); // Internal (Layer 1) + // repo.save(); // External (Layer 2) - Removed for unit test simplicity + } + + private void validate() { + System.out.println("Validating"); + } + } + + class UserRepository { + public void save() { + System.out.println("Saving"); + } + } + """; + + CompilationUnit cu = parser.parse(code).getResult().orElseThrow(); + targetMethod = cu.findFirst(MethodDeclaration.class, m -> m.getNameAsString().equals("process")) + .orElseThrow(); + } + + @Test + @DisplayName("internal ๊ณผ external method ๋ฅผ ์ œ๋Œ€๋กœ ๋ถ„๋ฅ˜ํ•œ๋‹ค.") + void shouldCategorizeContext() { + // when + ContextResult result = extractor.extractContext(targetMethod); + + // then + assertThat(result.getTargetMethod().getNameAsString()).isEqualTo("process"); + + // 1. Internal Method (Same Class) + assertThat(result.getInternalMethods()) + .extracting(MethodDeclaration::getNameAsString) + .containsExactly("validate"); + + // 2. External Method Resolution Limitation + // Note: In string-based testing without source root, cross-class resolution (repo.save) + // might not work depending on SymbolSolver config. + // Focusing on Internal layer logic verification here. + assertThat(result.getInternalMethods()).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/jher235/jfocus/core/DependencyResolverTest.java b/src/test/java/com/jher235/jfocus/core/DependencyResolverTest.java new file mode 100644 index 0000000..05f3d0d --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/DependencyResolverTest.java @@ -0,0 +1,111 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.symbolsolver.JavaSymbolSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DependencyResolverTest { + + private DependencyResolver resolver; + private MethodDeclaration targetMethod; + + @BeforeEach + void setUp() { + resolver = new DependencyResolver(new ProjectParser()); + + CombinedTypeSolver typeSolver = new CombinedTypeSolver(); + typeSolver.add(new ReflectionTypeSolver()); + + ParserConfiguration config = new ParserConfiguration(); + config.setSymbolResolver(new JavaSymbolSolver(typeSolver)); + JavaParser parser = new JavaParser(config); + + String sourceCode = """ + package com.test; + + import java.util.List; + + public class OrderService { + + private final PaymentService paymentService = new PaymentService(); + private int retryCount = 3; + + // Target Method + public void processOrder(String orderId) { + // 1. ๋‚ด๋ถ€ ํ•„๋“œ ์‚ฌ์šฉ + System.out.println("Processing " + orderId + ", retry: " + retryCount); + + // 2. ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ + validate(orderId); + + // 3. ์™ธ๋ถ€ ํด๋ž˜์Šค ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ (๊ฐ™์€ ํŒŒ์ผ ๋‚ด ์ •์˜) + paymentService.pay(orderId); + + // 4. JDK ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ (๋ฌด์‹œ๋˜์–ด์•ผ ํ•จ) + List.of("A", "B"); + } + + private void validate(String id) { + if (id == null) throw new IllegalArgumentException(); + } + + // Inner class for simulation + class PaymentService { + public void pay(String id) { + System.out.println("Paid"); + } + } + } + """; + + CompilationUnit cu = parser.parse(sourceCode).getResult().orElseThrow(); + + // find target method + targetMethod = cu.findFirst(MethodDeclaration.class, + m -> m.getNameAsString().equals("processOrder")).orElseThrow(); + } + + @Test + @DisplayName("์†Œ์Šค ์ฝ”๋“œ ๋‚ด์˜ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ๋งŒ ์ถ”์ถœํ•ด์•ผ ํ•œ๋‹ค (JDK ์ œ์™ธ)") + void shouldResolveSourceCodeMethodsOnly() { + // when + List dependencies = resolver.resolveMethods(targetMethod); + + // then + assertThat(dependencies).hasSize(2); // validate(), pay() + + assertThat(dependencies) + .anyMatch(m -> m.getNameAsString().equals("validate")); + assertThat(dependencies) + .anyMatch(m -> m.getNameAsString().equals("pay")); + + // excluded List.of + assertThat(dependencies) + .noneMatch(m -> m.getNameAsString().equals("of")); + } + + @Test + @DisplayName("์†Œ์Šค ์ฝ”๋“œ ๋‚ด์˜ ํ•„๋“œ ์‚ฌ์šฉ๋งŒ ์ถ”์ถœํ•ด์•ผ ํ•œ๋‹ค") + void shouldResolveSourceCodeFieldsOnly() { + // when + List fields = resolver.resolveFields(targetMethod); + + // then + assertThat(fields).hasSize(2); // retryCount, paymentService + + assertThat(fields) + .anyMatch(f -> f.getVariable(0).getNameAsString().equals("retryCount")) + .anyMatch(f -> f.getVariable(0).getNameAsString().equals("paymentService")); + } +} \ No newline at end of file diff --git a/src/test/java/com/jher235/jfocus/core/MarkdownExporterTest.java b/src/test/java/com/jher235/jfocus/core/MarkdownExporterTest.java new file mode 100644 index 0000000..880ae63 --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/MarkdownExporterTest.java @@ -0,0 +1,57 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.jher235.jfocus.model.ContextResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MarkdownExporterTest { + + private MarkdownExporter exporter; + private ContextResult dummyResult; + + @BeforeEach + void setUp() { + exporter = new MarkdownExporter(); + + JavaParser parser = new JavaParser(new ParserConfiguration()); + String code = """ + public class Demo { + private int count; + public void main() { helper(); } + private void helper() { System.out.println("Help"); } + } + """; + CompilationUnit cu = parser.parse(code).getResult().orElseThrow(); + + MethodDeclaration target = cu.findFirst(MethodDeclaration.class, m -> m.getNameAsString().equals("main")).orElseThrow(); + MethodDeclaration internal = cu.findFirst(MethodDeclaration.class, m -> m.getNameAsString().equals("helper")).orElseThrow(); + + dummyResult = new ContextResult(target); + dummyResult.addInternalMethod(internal); + } + + @Test + @DisplayName("Should generate markdown with correct sections") + void shouldExportMarkdown() { + // when + String markdown = exporter.export(dummyResult); + + // then + assertThat(markdown).contains("# Target Method"); + assertThat(markdown).contains("## Internal Context"); + + assertThat(markdown).doesNotContain("## External Context"); + + assertThat(markdown).contains("public void main()"); + assertThat(markdown).contains("private void helper()"); + + assertThat(markdown).contains("```java"); + } +} \ No newline at end of file diff --git a/src/test/java/com/jher235/jfocus/core/MethodExtractorTest.java b/src/test/java/com/jher235/jfocus/core/MethodExtractorTest.java new file mode 100644 index 0000000..65198e8 --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/MethodExtractorTest.java @@ -0,0 +1,107 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MethodExtractorTest { + + private MethodExtractor extractor; + private CompilationUnit dummyAst; + + @BeforeEach + void setUp() { + extractor = new MethodExtractor(); + + String dummyCode = """ + package com.test; + + public class UserService { + private String name; + + // 1. target + public void save(String user) { + System.out.println("Saving " + user); + } + + // 2. overloading method (should found) + public void save(String user, boolean active) { + if (active) save(user); + } + + // 3. should not found + public void delete(int id) { + System.out.println("Deleting..."); + } + } + """; + + JavaParser parser = new JavaParser(new ParserConfiguration()); + dummyAst = parser.parse(dummyCode).getResult().orElseThrow(); + } + + @Test + @DisplayName("์ด๋ฆ„์ด ์ผ์น˜ํ•˜๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์ •ํ™•ํžˆ ์ถ”์ถœํ•ด์•ผ ํ•œ๋‹ค") + void shouldExtractTargetMethod() { + // when + List methods = extractor.extractMethods(dummyAst, "delete"); + + // then + assertThat(methods).hasSize(1); + assertThat(methods.get(0).getNameAsString()).isEqualTo("delete"); + assertThat(methods.get(0).toString()).contains("Deleting..."); + } + + @Test + @DisplayName("์˜ค๋ฒ„๋กœ๋”ฉ๋œ ๋ฉ”์„œ๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ๋ชจ๋‘ ์ถ”์ถœํ•ด์•ผ ํ•œ๋‹ค") + void shouldExtractOverloadedMethods() { + // when + List methods = extractor.extractMethods(dummyAst, "save"); + + // then + assertThat(methods).hasSize(2); // save(String) & save(String, boolean) + + assertThat(methods) + .extracting(MethodDeclaration::toString) + .anySatisfy(code -> { + assertThat(code).contains("public void save(String user)"); + assertThat(code).contains("\"Saving \""); + assertThat(code).contains("user"); + }) + .anySatisfy(code -> { + assertThat(code).contains("public void save(String user, boolean active)"); + assertThat(code).contains("if (active)"); + }); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์ฐพ์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค") + void shouldReturnEmptyForUnknownMethod() { + // when + List methods = extractor.extractMethods(dummyAst, "update"); + + // then + assertThat(methods).isEmpty(); + } + + @Test + @DisplayName("์†Œ์Šค ์ฝ”๋“œ ๋ฌธ์ž์—ด ์ถ”์ถœ ์‹œ ์˜ค๋ฒ„๋กœ๋”ฉ๋œ ๋ฉ”์„œ๋“œ๊ฐ€ ํ•ฉ์ณ์ ธ์„œ ๋‚˜์™€์•ผ ํ•œ๋‹ค") + void shouldExtractSourceCodeAsString() { + // when + Optional source = extractor.extractMethodSource(dummyAst, "save"); + + // then + assertThat(source).isPresent(); + + assertThat(source.get()).contains("public void save(String user)"); + assertThat(source.get()).contains("public void save(String user, boolean active)"); + } +} \ No newline at end of file diff --git a/src/test/java/com/jher235/jfocus/core/PathResolverTest.java b/src/test/java/com/jher235/jfocus/core/PathResolverTest.java new file mode 100644 index 0000000..370a9a6 --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/PathResolverTest.java @@ -0,0 +1,103 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PathResolverTest { + + @TempDir + Path tempDir; + + PathResolver resolver; + + @BeforeEach + public void setUp() throws IOException { + resolver = new PathResolver(); + } + + @Test + @DisplayName("๋ฃจํŠธ ๋งˆ์ปค(.git)๊ฐ€ ์žˆ๋Š” ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋กœ ์ฐพ์•„์•ผ ํ•œ๋‹ค") + void shouldFindProjectRootWithGitMarker() throws IOException { + // given + Path projectRoot = tempDir.resolve("my-project"); + Files.createDirectories(projectRoot); + Files.createFile(projectRoot.resolve(".git")); + + Path subDir = projectRoot.resolve("src/main/java"); + Files.createDirectories(subDir); + + // when + Path result = resolver.findProjectRoot(subDir, tempDir); + + // then + assertThat(result).isEqualTo(projectRoot); + } + + @Test + @DisplayName("๋นŒ๋“œ ๋งˆ์ปค(build.gradle)๊ฐ€ ์žˆ๋Š” ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋กœ ์ฐพ์•„์•ผ ํ•œ๋‹ค") + void shouldFindProjectRootWithBuildMarker() throws IOException { + // given + Path projectRoot = tempDir.resolve("gradle-project"); + Files.createDirectories(projectRoot); + Files.createFile(projectRoot.resolve("build.gradle")); + + Path subDir = projectRoot.resolve("src"); + Files.createDirectories(subDir); + + // when + Path result = resolver.findProjectRoot(subDir, tempDir); + + // then + assertThat(result).isEqualTo(projectRoot); + } + + @Test + @DisplayName("๋งˆ์ปค๊ฐ€ ์—†์œผ๋ฉด ์‹œ์ž‘ ์œ„์น˜๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค (Fallback)") + void shouldReturnStartPathWhenNoMarkerFound() throws IOException { + // given + Path randomDir = tempDir.resolve("random/dir"); + Files.createDirectories(randomDir); + + // when + Path result = resolver.findProjectRoot(randomDir, tempDir); + + // then + assertThat(result).isEqualTo(randomDir); + } + + @Test + @DisplayName("src/main/java๊ฐ€ ์กด์žฌํ•˜๋ฉด ์†Œ์Šค ๋ฃจํŠธ๋กœ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค") + void shouldFindStandardSourceRoot() throws IOException { + // given + Path projectRoot = tempDir.resolve("project"); + Path srcMainJava = projectRoot.resolve("src/main/java"); + Files.createDirectories(srcMainJava); + + // when + Path result = resolver.findSourceRoot(projectRoot); + + // then + assertThat(result).isEqualTo(srcMainJava); + } + + @Test + @DisplayName("src/main/java๊ฐ€ ์—†์œผ๋ฉด ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค") + void shouldReturnProjectRootWhenSourceRootNotFound() throws IOException { + // given + Path projectRoot = tempDir.resolve("project"); + Files.createDirectories(projectRoot); + + // when + Path result = resolver.findSourceRoot(projectRoot); + + // then + assertThat(result).isEqualTo(projectRoot); + } +} \ No newline at end of file diff --git a/src/test/java/com/jher235/jfocus/core/ProjectParserTest.java b/src/test/java/com/jher235/jfocus/core/ProjectParserTest.java new file mode 100644 index 0000000..d10f72a --- /dev/null +++ b/src/test/java/com/jher235/jfocus/core/ProjectParserTest.java @@ -0,0 +1,41 @@ +package com.jher235.jfocus.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.javaparser.ast.CompilationUnit; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ProjectParserTest { + + @Test + @DisplayName("ํ”„๋กœ์ ํŠธ ๋‚ด์˜ ์กด์žฌํ•˜๋Š” ํŒŒ์ผ์„ ํŒŒ์‹ฑํ•˜๋ฉด AST๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค") + void shouldParseExistingFile() { + // given + ProjectParser parser = new ProjectParser(); + + // when + Optional result = parser.parseFile("ProjectParser"); + + // then + assertThat(result).isPresent(); + + String code = result.get().toString(); + assertThat(code).contains("class ProjectParser"); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ์„ ์š”์ฒญํ•˜๋ฉด ๋นˆ Optional์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค") + void shouldReturnEmptyForNonExistingFile() { + // given + ProjectParser parser = new ProjectParser(); + + // when + Optional result = parser.parseFile("GhostFile_Never_Exist"); + + // then + assertThat(result).isEmpty(); + } + +} \ No newline at end of file