From 3c90f5ef30f16346a6d56098170922448482ee92 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:27:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?perf(commands):=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=ED=95=9C=20=EB=B2=88=EC=9C=BC=EB=A1=9C=20=EB=AC=B6?= =?UTF-8?q?=EC=96=B4=20=EC=99=95=EB=B3=B5=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모델이 개별 명령을 하나씩 실행하던 조회 구간을 스크립트 한 번 호출로 바꾼다. 판단(브랜치 분기·PR 재사용·리뷰 내용)은 그대로 모델이 한다. /implement ls·find·cat package.json·git log·node -v·PRD → repo-context.sh 런당 14회 조회 → 1회 /review-pr gh pr view×2·gh pr diff → pr-context.sh /auto-commit git fetch/status/diff×2/remote·gh pr list×2·ls → repo-state.sh 측정 (Claude Code 헤드리스, Opus 5, 복제본을 --plugin-dir 로 교체): /implement 비용 가중 −25.2% [−32.6, −16.9] 턴 −30.6% 10런 /review-pr 비용 가중 −19.2% [−33.5, −5.0] 턴 −34.4% 10런 /auto-commit 비용 가중 −8.3% [−12.4, −3.9] 턴 −12.9% 92런 품질 지표(결함 탐지·기능 커버) 저하 없음, 완주율 전 조건 100%. 비용 가중 = 입력×1 + 캐시쓰기×1.25 + 캐시읽기×0.1 + 출력×5. 커맨드 본문에 "개별 git/gh 명령을 따로 실행하지 말 것"을 함께 넣었다. 이 문구가 없으면 스크립트를 돌리고도 확인차 git status 를 다시 부른다. repo-state.sh 의 changed_files 가 git diff --cached 로만 만들어져 스테이징 전 변경이 누락됐다. staged/unstaged/untracked 를 모두 담도록 고쳤다. 정확성 수정이고 절감폭 영향은 확인되지 않았다 (n=5, p=0.50). 위 −8.3% 는 수정 전 측정치다. CLAUDE.md·CONTRIBUTING.md 의 레이아웃 블록에 scripts/ 를 추가하고, 번들 스크립트 규약을 CONTRIBUTING 에 적었다. 버전 0.1.16 → 0.1.17 (patch). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 8 +++ README.cn.md | 2 +- README.ko.md | 2 +- README.md | 2 +- .../wigtn-plugins/.claude-plugin/plugin.json | 2 +- plugins/wigtn-plugins/commands/auto-commit.md | 36 +++++++------- plugins/wigtn-plugins/commands/implement.md | 12 ++++- plugins/wigtn-plugins/commands/review-pr.md | 13 ++--- plugins/wigtn-plugins/scripts/pr-context.sh | 39 +++++++++++++++ plugins/wigtn-plugins/scripts/repo-context.sh | 49 +++++++++++++++++++ plugins/wigtn-plugins/scripts/repo-state.sh | 39 +++++++++++++++ 14 files changed, 180 insertions(+), 34 deletions(-) create mode 100755 plugins/wigtn-plugins/scripts/pr-context.sh create mode 100755 plugins/wigtn-plugins/scripts/repo-context.sh create mode 100755 plugins/wigtn-plugins/scripts/repo-state.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d3a448f..7c3226d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. Unified plugin with 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles, screen-spec generator.", - "version": "0.1.16" + "version": "0.1.17" }, "plugins": [ { "name": "wigtn-plugins", "description": "Unified AI-native Vibe Coding plugin: PRD generation, digging analysis, screen-spec (IA / User Flow / Wireframe HTML / Dev Handoff), team-based parallel implementation, quality-gated auto-commit, 20 design styles, frontend (React/Next.js), backend (NestJS/Prisma), mobile (React Native/Expo), AI integration (STT/LLM), DevOps (Docker/CI-CD). 11 agents, 5 commands, 7 skills.", "source": "./plugins/wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "author": { "name": "WIGTN Crew", "email": "contact@wigtn.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ae704e..55b9958 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. Unified plugin with 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles.", "author": { "name": "WIGTN Crew", diff --git a/CLAUDE.md b/CLAUDE.md index ee60f61..0316788 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ A unified Claude Code plugin enabling AI-powered Vibe Coding: idea to production with minimal friction. -**Version**: 0.1.16 +**Version**: 0.1.17 **License**: Apache-2.0 **Repository**: https://github.com/wigtn/wigtn-plugins @@ -19,6 +19,7 @@ wigtn-plugins/ │ ├── agents/ # 11 agent definitions │ ├── commands/ # 5 commands (/prd, /screen-spec, /implement, /auto-commit, /review-pr) │ ├── skills/ # 7 skills (code-review-levels, design-system-reference, handdrawn-diagram, screen-spec, team-memory-protocol, wigtn-ppt) +│ ├── scripts/ # Bundled shell scripts commands call directly │ └── hooks/ # Hooks configuration ├── CLAUDE.md # This file ├── README.md # English docs @@ -33,6 +34,7 @@ plugins/wigtn-plugins/ ├── agents/ # Agent definitions (.md) ├── commands/ # User-invocable commands (.md) ├── skills/ # Skills with SKILL.md + reference files +├── scripts/ # Bundled scripts invoked via ${CLAUDE_PLUGIN_ROOT} └── hooks/hooks.json # Hooks configuration ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f2d594..b319a92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ plugins/wigtn-plugins/ ├── agents/ *.md # one file per agent ├── commands/ *.md # one file per slash command ├── skills/ /SKILL.md # one folder per skill +├── scripts/ *.sh # bundled scripts commands call directly └── hooks/hooks.json ``` @@ -42,6 +43,13 @@ plugins/wigtn-plugins/ `allowed-tools`. See existing skills for the pattern. - **Mermaid in generated docs**: quote every label (`["..."]`), use only valid shapes, no `mindmap` (use `flowchart LR`). +- **Bundled scripts**: a command that needs several read-only lookups (`git`, + `gh`, file probes) calls one script under `scripts/` via + `bash "${CLAUDE_PLUGIN_ROOT}/scripts/.sh"` instead of listing the commands + for the model to run one by one. Return a single JSON object, cap unbounded + fields, and never let a missing tool abort the script. Keep the decision in the + command (the model), not in the script. Add a line telling the model not to run + the individual commands anyway — without it the model re-runs them to confirm. ## Versioning & releases diff --git a/README.cn.md b/README.cn.md index a2bb04d..3220082 100644 --- a/README.cn.md +++ b/README.cn.md @@ -6,7 +6,7 @@ **一个插件。11 个智能体。从创意到通过验证的提交。** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/README.ko.md b/README.ko.md index c1c5bab..d8b8184 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ **하나의 플러그인. 11개 에이전트. 아이디어에서 검증된 커밋까지.** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/README.md b/README.md index 4ddb138..de17526 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **One plugin. 11 agents. From idea to a verified commit.** -![Version](https://img.shields.io/badge/v0.1.16-Unified_Plugin-FF6B6B?style=for-the-badge) +![Version](https://img.shields.io/badge/v0.1.17-Unified_Plugin-FF6B6B?style=for-the-badge) ![Agents](https://img.shields.io/badge/11-Agents-5A67D8?style=for-the-badge) ![Commands](https://img.shields.io/badge/5-Commands-38B2AC?style=for-the-badge) ![Skills](https://img.shields.io/badge/6-Skills-00D4AA?style=for-the-badge) diff --git a/plugins/wigtn-plugins/.claude-plugin/plugin.json b/plugins/wigtn-plugins/.claude-plugin/plugin.json index 8c7776b..afbd766 100644 --- a/plugins/wigtn-plugins/.claude-plugin/plugin.json +++ b/plugins/wigtn-plugins/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wigtn-plugins", - "version": "0.1.16", + "version": "0.1.17", "description": "AI-native Vibe Coding — From idea to Deploy, Zero friction. 11 agents, 5 commands, 7 skills, team-based parallel build, 20 design styles, screen-spec generator (IA / User Flow / Wireframe HTML), WIGTN-brand PPT generator.", "agents": [ "./agents/ai-agent.md", diff --git a/plugins/wigtn-plugins/commands/auto-commit.md b/plugins/wigtn-plugins/commands/auto-commit.md index 6cfc8af..c51ba73 100644 --- a/plugins/wigtn-plugins/commands/auto-commit.md +++ b/plugins/wigtn-plugins/commands/auto-commit.md @@ -157,26 +157,28 @@ fi ### Step 1: 상태 확인 및 브랜치 판단 -```bash -git fetch origin # 최신 변경사항 가져오기 -current_branch=$(git branch --show-current) -git status -git diff --stat -git diff --stat HEAD # 스테이징 안된 파일 포함 -git remote -v +상태 조회는 스크립트가 한 번에 처리한다. **개별 `git`/`gh` 명령을 따로 실행하지 말 것.** -# ⚠️ Stale 검사 — 현재 브랜치에 머지/닫힌 PR이 있는가? (Stale Branch Handling 참조) -gh pr list --head "$current_branch" --state all --json number,state,title --limit 1 - -# 다른 브랜치 재사용 판단용 open PR 목록 -gh pr list --state open --json number,title,headRefName - -# PRD/PLAN 파일 확인 (feature name 추출) -# 정본 경로는 `/prd`가 쓰는 docs/todo_plan/ 이다. 루트 fallback은 구버전 산출물용. -ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo .) -ls "$ROOT"/docs/todo_plan/PLAN_*.md "$ROOT"/PLAN_*.md "$ROOT"/PRD.md 2>/dev/null +```bash +bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-state.sh" ``` +반환 JSON 필드: + +| 필드 | 쓰임 | +|---|---| +| `branch` · `on_main` | 브랜치 판단 | +| `staged_stat` · `unstaged_stat` | 변경 규모 | +| `changed_files` · `file_count` | 전체 변경 파일 (staged + unstaged + untracked) | +| `staged_files` · `unstaged_files` · `untracked_files` | 스테이징 상태별 분류 | +| `remotes` | remote 여러 개인지 | +| `head_pr` | 현재 브랜치의 PR (Stale 검사) | +| `stale_branch` | **머지/닫힌 PR이면 `true`** — 새 브랜치 분기 필요 | +| `open_prs` | 다른 브랜치 재사용 판단용 | +| `plan_files` | PRD/PLAN 경로 (feature name 추출) | + +`git fetch origin`은 스크립트가 이미 실행한다. 파일 내용이 더 필요하면 그때 Read 한다. + **브랜치 판단:** 1. `--direct` → Direct 모드, 브랜치 판단 스킵 2. 피처 브랜치: Stale 검사 먼저 (머지/닫힌 PR이면 새 브랜치 분기) → Open PR이면 재사용 → PR 없으면 그대로 사용 + 새 PR diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index f810361..1d4b867 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -135,7 +135,17 @@ BLOCKED이면 Critical 이슈(번호·위치·영향)를 나열하고 ① PRD ### Step 3~4: 프로젝트 상태 분석 -기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 새 코드는 발견한 컨벤션을 따른다. +레포 상태 조회는 스크립트가 한 번에 처리한다. **`ls`/`find`/`cat package.json`/`git log`/`node -v` 같은 조회 명령을 따로 실행하지 말 것.** + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-context.sh" +``` + +반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. + +**검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 `node --test` 변형을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. + +이 JSON으로 기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 파일 내용이 더 필요하면 그때 Read 한다. 새 코드는 발견한 컨벤션을 따른다. **화면정의서가 있으면 읽는다.** `docs/prd/screens/{feature}/` 가 존재하면 `03-SCREEN-SPEC.md`(화면별 상태·컴포넌트)와 `05-DEV-HANDOFF.md`(FR ↔ 화면 ↔ 컴포넌트 매핑)를 diff --git a/plugins/wigtn-plugins/commands/review-pr.md b/plugins/wigtn-plugins/commands/review-pr.md index 302f09e..f193361 100644 --- a/plugins/wigtn-plugins/commands/review-pr.md +++ b/plugins/wigtn-plugins/commands/review-pr.md @@ -35,17 +35,14 @@ GitHub Pull Request를 터미널에서 리뷰하고 피드백을 남깁니다. ### Step 1: PR 정보 수집 -```bash -# PR 메타데이터 가져오기 -gh pr view $PR_NUMBER --json title,body,author,baseRefName,headRefName,files,additions,deletions,changedFiles,reviewDecision,reviews,state - -# PR diff 가져오기 -gh pr diff $PR_NUMBER +PR 정보 수집은 스크립트가 한 번에 처리한다. **개별 `gh` 명령을 따로 실행하지 말 것.** -# PR에 달린 기존 리뷰/코멘트 확인 -gh pr view $PR_NUMBER --json comments,reviews +```bash +bash "${CLAUDE_PLUGIN_ROOT}/scripts/pr-context.sh" $PR_NUMBER ``` +반환 JSON에 메타데이터(`state`·`title`·`body`·`author`·`files`·`stats`), 전체 `diff`, 기존 코멘트·리뷰가 모두 들어 있다. `diff_truncated`가 true일 때만 추가로 조회한다. + **PR 상태 확인:** - `state: MERGED` → "이미 머지된 PR입니다. 리뷰를 계속할까요?" (AskUserQuestion) - `state: CLOSED` → "닫힌 PR입니다. 리뷰를 계속할까요?" (AskUserQuestion) diff --git a/plugins/wigtn-plugins/scripts/pr-context.sh b/plugins/wigtn-plugins/scripts/pr-context.sh new file mode 100755 index 0000000..6cbd7a4 --- /dev/null +++ b/plugins/wigtn-plugins/scripts/pr-context.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# PR 리뷰에 필요한 정보를 한 번에 모아 JSON으로 반환한다. +# 모델이 gh pr view / gh pr diff / gh pr view --json comments 를 따로 부를 필요가 없다. +PR="$1" +exec python3 - "$PR" <<'PY' +import subprocess, json, sys +pr = sys.argv[1] if len(sys.argv) > 1 else '' +def run(c, d=''): + try: + r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=40) + return r.stdout.strip() if r.returncode == 0 else d + except Exception: + return d +def j(c): + o = run(c) + try: return json.loads(o) if o else {} + except Exception: return {} + +meta = j(f'gh pr view {pr} --json title,body,author,baseRefName,headRefName,files,' + f'additions,deletions,changedFiles,reviewDecision,reviews,state') +conv = j(f'gh pr view {pr} --json comments,reviews') +diff = run(f'gh pr diff {pr}') +LIMIT = 60000 +print(json.dumps({ + 'pr': pr, + 'state': meta.get('state'), + 'title': meta.get('title'), + 'body': meta.get('body'), + 'author': (meta.get('author') or {}).get('login'), + 'base': meta.get('baseRefName'), 'head': meta.get('headRefName'), + 'files': meta.get('files', []), + 'stats': {'additions': meta.get('additions'), 'deletions': meta.get('deletions'), + 'changed_files': meta.get('changedFiles')}, + 'existing_comments': conv.get('comments', []), + 'existing_reviews': conv.get('reviews', []), + 'diff_truncated': len(diff) > LIMIT, + 'diff': diff[:LIMIT], +}, ensure_ascii=False, indent=1)) +PY diff --git a/plugins/wigtn-plugins/scripts/repo-context.sh b/plugins/wigtn-plugins/scripts/repo-context.sh new file mode 100755 index 0000000..0f424a5 --- /dev/null +++ b/plugins/wigtn-plugins/scripts/repo-context.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# 구현 시작 전 레포 상태를 한 번에 수집해 JSON으로 반환한다. +# 모델이 ls/find/cat/npm test 를 개별로 돌려가며 알아낼 필요가 없다. +exec python3 - <<'PY' +import subprocess, json, os, glob, re +def run(c, d=''): + try: + r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode == 0 else d + except Exception: + return d + +root = run('git rev-parse --show-toplevel', '.') +os.chdir(root) + +# 소스 트리 (노이즈 제외, 상한 200) +files = [f for f in run( + "git ls-files 2>/dev/null || find . -type f -not -path './.git/*' -not -path './node_modules/*'" +).split('\n') if f][:200] + +pkg = {} +if os.path.exists('package.json'): + try: pkg = json.load(open('package.json')) + except Exception: pkg = {} +scripts = pkg.get('scripts', {}) + +# 실제로 도는 검증 명령만 고른다 — 모델이 시행착오로 찾지 않도록 +verify = {} +for key in ('test', 'typecheck', 'lint', 'build'): + if key in scripts: + verify[key] = f'npm run {key}' if key != 'test' else 'npm test' + +state = { + 'root': root, + 'branch': run('git branch --show-current'), + 'file_count': len(files), + 'files': files, + 'dirs': sorted({os.path.dirname(f) for f in files if os.path.dirname(f)})[:40], + 'package_json': {'type': pkg.get('type'), 'scripts': scripts, 'deps': sorted((pkg.get('dependencies') or {}).keys())[:30]}, + 'verify_commands': verify, + 'runtime': {'node': run('node -v'), 'python': run('python3 -V')}, + 'prd_files': sorted(glob.glob('docs/prd/PRD_*.md') + glob.glob('docs/todo_plan/PRD.md') + glob.glob('PRD.md')), + 'plan_files': sorted(glob.glob('docs/todo_plan/PLAN_*.md') + glob.glob('PLAN_*.md')), + 'screen_specs': sorted(glob.glob('docs/prd/screens/*/*.md'))[:20], + 'git_status': run('git status --short'), + 'recent_commits': run('git log --oneline -5'), +} +print(json.dumps(state, ensure_ascii=False, indent=1)) +PY diff --git a/plugins/wigtn-plugins/scripts/repo-state.sh b/plugins/wigtn-plugins/scripts/repo-state.sh new file mode 100755 index 0000000..da1742d --- /dev/null +++ b/plugins/wigtn-plugins/scripts/repo-state.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# 커밋 전 상태를 한 번에 수집해 JSON으로 반환한다. 모델이 개별 명령을 돌릴 필요가 없다. +exec python3 - <<'PY' +import subprocess, json, os, glob +def run(c, d=''): + try: + r=subprocess.run(c, shell=True, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode==0 else d + except Exception: return d +def jrun(c): + o=run(c) + try: return json.loads(o) if o else [] + except Exception: return [] +root=run('git rev-parse --show-toplevel','.') +br=run('git branch --show-current') +run('git fetch origin --quiet') +staged=[x for x in run('git diff --cached --name-only').split('\n') if x] +unstaged=[x for x in run('git diff --name-only').split('\n') if x] +untracked=[x for x in run('git ls-files --others --exclude-standard').split('\n') if x] +files=sorted(set(staged+unstaged+untracked))[:50] +head_pr=jrun(f'gh pr list --head "{br}" --state all --json number,state,title --limit 1') +state={ + 'branch':br, + 'on_main': br in ('main','master'), + 'staged_stat': (run('git diff --cached --stat').split('\n') or [''])[-1], + 'unstaged_stat': (run('git diff --stat').split('\n') or [''])[-1], + 'changed_files': files, + 'file_count': len(files), + 'staged_files': staged[:50], + 'unstaged_files': unstaged[:50], + 'untracked_files': untracked[:50], + 'remotes': [x for x in run('git remote').split('\n') if x], + 'head_pr': head_pr, + 'open_prs': jrun('gh pr list --state open --json number,title,headRefName --limit 20'), + 'plan_files': sorted(glob.glob(f'{root}/docs/todo_plan/PLAN_*.md')+glob.glob(f'{root}/PLAN_*.md')+glob.glob(f'{root}/PRD.md')), + 'stale_branch': bool(head_pr) and head_pr[0].get('state') in ('MERGED','CLOSED'), +} +print(json.dumps(state, ensure_ascii=False, indent=1)) +PY From dfe23618ad96ae83110a3faf8f364920d18ac5d8 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:49:39 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(implement):=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EA=B8=B0=EA=B0=80=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EB=A1=9C=20=EC=98=A4=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EB=8D=98=20--test=20=EB=A5=BC=20=EB=B0=B1=ED=8B=B1=20=EB=B0=96?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_contracts.py 는 인라인 코드(`...`) 안의 --flag 를 그 커맨드의 파라미터 사용으로 해석한다. `node --test` 예시가 ## Parameters 에 정의되지 않은 --test 사용으로 잡혀 CI 가 실패했다. 의미는 그대로 두고 백틱 밖 일반 텍스트로 바꿨다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- plugins/wigtn-plugins/commands/implement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index 1d4b867..e5b9a32 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -143,7 +143,7 @@ bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-context.sh" 반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. -**검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 `node --test` 변형을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. +**검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 다른 테스트 러너 호출 방식(node --test 변형 등)을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. 이 JSON으로 기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 파일 내용이 더 필요하면 그때 Read 한다. 새 코드는 발견한 컨벤션을 따른다. From 9ca6656fa7e70a8671c432547e7d1fb297a79cbc Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:03:02 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(scripts):=20PR=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=85=B8=EC=97=90=20=EC=A1=B0=EB=A6=BD=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=9D=B8=EC=A0=9D?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-context.sh 가 인자를 f-string 으로 gh 명령에 끼워 넣고 shell=True 로 실행했다. PR 번호에 셸 구문을 넣으면 임의 명령이 실행된다. `44; touch /tmp/PWNED; #` 로 파일 생성이 재현됐다. - subprocess 를 인자 배열(shell=False)로 바꿔 조립 자체를 없앴다 - PR 참조는 숫자 또는 GitHub PR URL 만 받는다. 그 외는 JSON 오류로 종료 (44 / #44 / https://.../pull/44 는 그대로 동작) repo-state.sh 도 브랜치명을 gh pr list 에 보간하고 있었다. git 이 ref 이름에 따옴표를 허용하지 않아 실제로는 뚫리지 않았지만, ref 규칙에 기대지 않도록 같은 방식(인자 배열)으로 바꿨다. 세 스크립트 모두 f-string 명령 조립이 0건이다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- plugins/wigtn-plugins/scripts/pr-context.sh | 33 ++++++++++++++------- plugins/wigtn-plugins/scripts/repo-state.sh | 13 +++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/plugins/wigtn-plugins/scripts/pr-context.sh b/plugins/wigtn-plugins/scripts/pr-context.sh index 6cbd7a4..76f624a 100755 --- a/plugins/wigtn-plugins/scripts/pr-context.sh +++ b/plugins/wigtn-plugins/scripts/pr-context.sh @@ -3,23 +3,36 @@ # 모델이 gh pr view / gh pr diff / gh pr view --json comments 를 따로 부를 필요가 없다. PR="$1" exec python3 - "$PR" <<'PY' -import subprocess, json, sys -pr = sys.argv[1] if len(sys.argv) > 1 else '' -def run(c, d=''): +import subprocess, json, sys, re + +# PR 참조는 숫자 또는 GitHub PR URL 만 받는다. 셸에 넘기지 않지만, +# 잘못된 입력을 조용히 흘려보내지 않도록 여기서 잘라낸다. +raw = sys.argv[1] if len(sys.argv) > 1 else '' +m = re.fullmatch(r'\s*(?:https?://[^\s/]+/[^\s]*?/pull/)?#?(\d{1,10})\s*', raw or '') +if not m: + print(json.dumps({'error': 'PR 번호 또는 GitHub PR URL 이 필요하다', 'given': raw}, + ensure_ascii=False)) + sys.exit(1) +pr = m.group(1) + +def run(args, d=''): + # shell=False. 인자 배열이라 PR 번호가 명령으로 해석되지 않는다. try: - r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=40) + r = subprocess.run(args, capture_output=True, text=True, timeout=40) return r.stdout.strip() if r.returncode == 0 else d except Exception: return d -def j(c): - o = run(c) + +def j(args): + o = run(args) try: return json.loads(o) if o else {} except Exception: return {} -meta = j(f'gh pr view {pr} --json title,body,author,baseRefName,headRefName,files,' - f'additions,deletions,changedFiles,reviewDecision,reviews,state') -conv = j(f'gh pr view {pr} --json comments,reviews') -diff = run(f'gh pr diff {pr}') +meta = j(['gh', 'pr', 'view', pr, '--json', + 'title,body,author,baseRefName,headRefName,files,' + 'additions,deletions,changedFiles,reviewDecision,reviews,state']) +conv = j(['gh', 'pr', 'view', pr, '--json', 'comments,reviews']) +diff = run(['gh', 'pr', 'diff', pr]) LIMIT = 60000 print(json.dumps({ 'pr': pr, diff --git a/plugins/wigtn-plugins/scripts/repo-state.sh b/plugins/wigtn-plugins/scripts/repo-state.sh index da1742d..ac74801 100755 --- a/plugins/wigtn-plugins/scripts/repo-state.sh +++ b/plugins/wigtn-plugins/scripts/repo-state.sh @@ -7,6 +7,16 @@ def run(c, d=''): r=subprocess.run(c, shell=True, capture_output=True, text=True, timeout=25) return r.stdout.strip() if r.returncode==0 else d except Exception: return d +def runa(args, d=''): + # 브랜치명 등 외부 값이 섞이는 명령은 shell=False 인자 배열로 실행한다. + try: + r=subprocess.run(args, capture_output=True, text=True, timeout=25) + return r.stdout.strip() if r.returncode==0 else d + except Exception: return d +def jruna(args): + o=runa(args) + try: return json.loads(o) if o else [] + except Exception: return [] def jrun(c): o=run(c) try: return json.loads(o) if o else [] @@ -18,7 +28,8 @@ staged=[x for x in run('git diff --cached --name-only').split('\n') if x] unstaged=[x for x in run('git diff --name-only').split('\n') if x] untracked=[x for x in run('git ls-files --others --exclude-standard').split('\n') if x] files=sorted(set(staged+unstaged+untracked))[:50] -head_pr=jrun(f'gh pr list --head "{br}" --state all --json number,state,title --limit 1') +head_pr=jruna(['gh','pr','list','--head',br,'--state','all', + '--json','number,state,title','--limit','1']) if br else [] state={ 'branch':br, 'on_main': br in ('main','master'), From 430356bbb1514769e54be44a8ffb7fc69a3fa3e6 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:16:45 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(scripts):=20npm=20=EC=99=B8=20=EC=83=9D?= =?UTF-8?q?=ED=83=9C=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80,=20=EC=9E=98=EB=A6=B0=20file=5Fcount=20?= =?UTF-8?q?=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_commands 가 package.json 스크립트만 보고 만들어졌다. implement.md 는 "이 목록의 명령만 쓴다"고 지시하므로, package.json 이 없는 Python/Go/Rust 레포에서는 목록이 비고 구현 에이전트가 검증을 아예 건너뛴다. - pyproject.toml(pytest·ruff·mypy), tox.ini/pytest.ini, go.mod(go test·go vet), Cargo.toml(cargo test·clippy), Makefile 의 실제 타깃을 감지한다 - JS 는 락파일로 패키지 매니저를 맞춘다 (npm/pnpm/yarn/bun) - verify_detected 를 함께 반환한다. implement.md 계약을 고쳐 false 일 때는 검증을 건너뛰지 말고 직접 찾도록 했다. 감지 범위도 커맨드에 적었다 repo-state.sh 는 변경 파일을 50개로 자른 뒤 그 길이를 file_count 로 냈다. auto-commit.md 는 이를 전체 변경 규모로 설명하므로 대형 변경에서 조용히 틀린다. file_count 를 자르기 전 개수로 계산하고 files_truncated 를 추가했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- plugins/wigtn-plugins/commands/auto-commit.md | 3 +- plugins/wigtn-plugins/commands/implement.md | 6 ++- plugins/wigtn-plugins/scripts/repo-context.sh | 44 +++++++++++++++++-- plugins/wigtn-plugins/scripts/repo-state.sh | 7 ++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/plugins/wigtn-plugins/commands/auto-commit.md b/plugins/wigtn-plugins/commands/auto-commit.md index c51ba73..92e4a8d 100644 --- a/plugins/wigtn-plugins/commands/auto-commit.md +++ b/plugins/wigtn-plugins/commands/auto-commit.md @@ -169,7 +169,8 @@ bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-state.sh" |---|---| | `branch` · `on_main` | 브랜치 판단 | | `staged_stat` · `unstaged_stat` | 변경 규모 | -| `changed_files` · `file_count` | 전체 변경 파일 (staged + unstaged + untracked) | +| `file_count` | 전체 변경 파일 수 (staged + unstaged + untracked) | +| `changed_files` · `files_truncated` | 변경 파일 목록. 50개를 넘으면 잘리고 `files_truncated`가 true | | `staged_files` · `unstaged_files` · `untracked_files` | 스테이징 상태별 분류 | | `remotes` | remote 여러 개인지 | | `head_pr` | 현재 브랜치의 PR (Stale 검사) | diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index e5b9a32..47259e8 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -141,9 +141,11 @@ BLOCKED이면 Critical 이슈(번호·위치·영향)를 나열하고 ① PRD bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-context.sh" ``` -반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. +반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`, 감지 여부는 `verify_detected`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. -**검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 다른 테스트 러너 호출 방식(node --test 변형 등)을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. +**`verify_detected`가 true면 검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 다른 테스트 러너 호출 방식(node --test 변형 등)을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. + +**`verify_detected`가 false면 검증을 건너뛰지 말고 직접 찾는다.** 스크립트가 감지하는 것은 npm/pnpm/yarn/bun 스크립트, `pyproject.toml`(pytest·ruff·mypy), `go.mod`, `Cargo.toml`, Makefile 타깃까지다. 그 밖의 빌드 체계는 레포에서 검증 방법을 찾아 실행하고, 정말 없으면 없다는 사실을 보고한다. 이 JSON으로 기존 구현 여부, 관련 파일 위치, 사용 중인 패턴·컨벤션을 파악해 **이미 된 부분은 다시 만들지 않는다**. 파일 내용이 더 필요하면 그때 Read 한다. 새 코드는 발견한 컨벤션을 따른다. diff --git a/plugins/wigtn-plugins/scripts/repo-context.sh b/plugins/wigtn-plugins/scripts/repo-context.sh index 0f424a5..0c38c6b 100755 --- a/plugins/wigtn-plugins/scripts/repo-context.sh +++ b/plugins/wigtn-plugins/scripts/repo-context.sh @@ -24,11 +24,46 @@ if os.path.exists('package.json'): except Exception: pkg = {} scripts = pkg.get('scripts', {}) -# 실제로 도는 검증 명령만 고른다 — 모델이 시행착오로 찾지 않도록 +# 검증 명령을 실제 프로젝트 파일에서 감지한다 — 모델이 시행착오로 찾지 않도록. +# 감지 못 하면 verify_commands 를 비우고 verify_detected=false 로 알린다. +# (커맨드 계약: 비어 있으면 모델이 직접 찾는다. 임의로 검증을 건너뛰지 않는다.) verify = {} -for key in ('test', 'typecheck', 'lint', 'build'): - if key in scripts: - verify[key] = f'npm run {key}' if key != 'test' else 'npm test' + +# JS/TS — 락파일로 패키지 매니저를 맞춘다 +if scripts: + pm = 'npm' + for lock, name in (('pnpm-lock.yaml','pnpm'), ('yarn.lock','yarn'), ('bun.lockb','bun')): + if os.path.exists(lock): pm = name; break + for key in ('test', 'typecheck', 'lint', 'build'): + if key in scripts: + verify[key] = f'{pm} test' if (key == 'test' and pm != 'pnpm') else f'{pm} run {key}' + +# Python +if os.path.exists('pyproject.toml'): + try: t = open('pyproject.toml', encoding='utf-8').read() + except Exception: t = '' + if 'pytest' in t: verify.setdefault('test', 'pytest') + if 'ruff' in t: verify.setdefault('lint', 'ruff check .') + if 'mypy' in t: verify.setdefault('typecheck', 'mypy .') +elif os.path.exists('tox.ini') or os.path.exists('pytest.ini'): + verify.setdefault('test', 'pytest') + +# Go +if os.path.exists('go.mod'): + verify.setdefault('test', 'go test ./...') + verify.setdefault('typecheck', 'go vet ./...') + +# Rust +if os.path.exists('Cargo.toml'): + verify.setdefault('test', 'cargo test') + verify.setdefault('lint', 'cargo clippy') + +# Makefile — 실제로 정의된 타깃만 +if os.path.exists('Makefile'): + try: mk = open('Makefile', encoding='utf-8').read() + except Exception: mk = '' + for tgt in ('test', 'lint', 'check', 'typecheck'): + if re.search(rf'^{tgt}:', mk, re.M): verify.setdefault(tgt, f'make {tgt}') state = { 'root': root, @@ -38,6 +73,7 @@ state = { 'dirs': sorted({os.path.dirname(f) for f in files if os.path.dirname(f)})[:40], 'package_json': {'type': pkg.get('type'), 'scripts': scripts, 'deps': sorted((pkg.get('dependencies') or {}).keys())[:30]}, 'verify_commands': verify, + 'verify_detected': bool(verify), 'runtime': {'node': run('node -v'), 'python': run('python3 -V')}, 'prd_files': sorted(glob.glob('docs/prd/PRD_*.md') + glob.glob('docs/todo_plan/PRD.md') + glob.glob('PRD.md')), 'plan_files': sorted(glob.glob('docs/todo_plan/PLAN_*.md') + glob.glob('PLAN_*.md')), diff --git a/plugins/wigtn-plugins/scripts/repo-state.sh b/plugins/wigtn-plugins/scripts/repo-state.sh index ac74801..68c6dee 100755 --- a/plugins/wigtn-plugins/scripts/repo-state.sh +++ b/plugins/wigtn-plugins/scripts/repo-state.sh @@ -27,7 +27,9 @@ run('git fetch origin --quiet') staged=[x for x in run('git diff --cached --name-only').split('\n') if x] unstaged=[x for x in run('git diff --name-only').split('\n') if x] untracked=[x for x in run('git ls-files --others --exclude-standard').split('\n') if x] -files=sorted(set(staged+unstaged+untracked))[:50] +all_files=sorted(set(staged+unstaged+untracked)) +LIMIT=50 +files=all_files[:LIMIT] head_pr=jruna(['gh','pr','list','--head',br,'--state','all', '--json','number,state,title','--limit','1']) if br else [] state={ @@ -36,7 +38,8 @@ state={ 'staged_stat': (run('git diff --cached --stat').split('\n') or [''])[-1], 'unstaged_stat': (run('git diff --stat').split('\n') or [''])[-1], 'changed_files': files, - 'file_count': len(files), + 'file_count': len(all_files), + 'files_truncated': len(all_files) > LIMIT, 'staged_files': staged[:50], 'unstaged_files': unstaged[:50], 'untracked_files': untracked[:50], From eacbc810a018d35ed243317539be9dba157f5073 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:25:36 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(scripts):=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=95=88=20=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=ED=8A=B8=EB=A6=AC=EC=97=90=EC=84=9C=20=EB=B9=A0?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repo-context.sh 가 git ls-files 만 썼다. tracked 파일만 나오는데 명령이 성공하므로 뒤의 find 폴백도 타지 않는다. 새 프로젝트나 작업 중 레포에서는 file_count: 0, files: [] 이 나와 /implement 가 빈 트리로 분석하게 된다. 임시 저장소에 커밋하지 않은 package.json·src/app.ts 를 두고 재현했다. - git ls-files 와 git ls-files --others --exclude-standard 를 합친다 (.gitignore 는 계속 존중, node_modules 는 그대로 제외) - git 저장소가 아니면 find 로 떨어진다 - file_count 를 자르기 전 전체 수로 내고 files_truncated 를 추가했다. dirs 도 잘리기 전 목록에서 뽑는다 - implement.md 에 위 표기를 적었다 점검한 경우: 커밋 없는 새 레포 / 커밋+untracked 혼재 / git 아닌 디렉터리 / gitignore 존중 / 200개 초과 자르기. repo-state.sh·pr-context.sh 도 같은 부류로 다시 확인했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- plugins/wigtn-plugins/commands/implement.md | 2 +- plugins/wigtn-plugins/scripts/repo-context.sh | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/wigtn-plugins/commands/implement.md b/plugins/wigtn-plugins/commands/implement.md index 47259e8..006e93b 100644 --- a/plugins/wigtn-plugins/commands/implement.md +++ b/plugins/wigtn-plugins/commands/implement.md @@ -141,7 +141,7 @@ BLOCKED이면 Critical 이슈(번호·위치·영향)를 나열하고 ① PRD bash "${CLAUDE_PLUGIN_ROOT}/scripts/repo-context.sh" ``` -반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`, 감지 여부는 `verify_detected`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. +반환 JSON에 파일 목록·디렉터리 구조·`package.json` 스크립트·**검증 명령(`verify_commands`, 감지 여부는 `verify_detected`)**·PRD/PLAN 경로·런타임 버전·git 상태가 들어 있다. 파일 목록은 tracked·untracked 를 모두 담되 200개에서 자르고, 잘리면 `files_truncated`가 true다(`file_count`는 자르기 전 전체 수). **`verify_detected`가 true면 검증은 `verify_commands`에 적힌 명령만 쓴다.** `npm test`가 실패한다고 다른 테스트 러너 호출 방식(node --test 변형 등)을 시도하지 말 것 — 명령이 틀린 게 아니라 코드가 틀린 것이다. diff --git a/plugins/wigtn-plugins/scripts/repo-context.sh b/plugins/wigtn-plugins/scripts/repo-context.sh index 0c38c6b..f35b50a 100755 --- a/plugins/wigtn-plugins/scripts/repo-context.sh +++ b/plugins/wigtn-plugins/scripts/repo-context.sh @@ -13,10 +13,18 @@ def run(c, d=''): root = run('git rev-parse --show-toplevel', '.') os.chdir(root) -# 소스 트리 (노이즈 제외, 상한 200) -files = [f for f in run( - "git ls-files 2>/dev/null || find . -type f -not -path './.git/*' -not -path './node_modules/*'" -).split('\n') if f][:200] +# 소스 트리 — tracked + untracked(.gitignore 존중). +# git ls-files 만 쓰면 아직 커밋 안 한 파일이 통째로 빠진다(새 프로젝트/작업 중 레포). +tracked = run('git ls-files') +untracked = run('git ls-files --others --exclude-standard') +found = [f for f in (tracked + '\n' + untracked).split('\n') if f] +if not found: # git 저장소가 아니면 + found = [f for f in run( + "find . -type f -not -path './.git/*' -not -path './node_modules/*'" + ).split('\n') if f] +all_files = sorted(set(found)) +FILE_LIMIT = 200 +files = all_files[:FILE_LIMIT] pkg = {} if os.path.exists('package.json'): @@ -68,9 +76,10 @@ if os.path.exists('Makefile'): state = { 'root': root, 'branch': run('git branch --show-current'), - 'file_count': len(files), + 'file_count': len(all_files), 'files': files, - 'dirs': sorted({os.path.dirname(f) for f in files if os.path.dirname(f)})[:40], + 'files_truncated': len(all_files) > FILE_LIMIT, + 'dirs': sorted({os.path.dirname(f) for f in all_files if os.path.dirname(f)})[:40], 'package_json': {'type': pkg.get('type'), 'scripts': scripts, 'deps': sorted((pkg.get('dependencies') or {}).keys())[:30]}, 'verify_commands': verify, 'verify_detected': bool(verify), From 2fcfda850647fd4bdadfe05729d0f2db72d10693 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:46:48 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(scripts):=20=EC=84=9C=EB=B8=8C=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=84=B0=EB=A6=AC=20=EA=B2=BD=EB=A1=9C=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EA=B3=BC=20gh=20=EC=8B=A4=ED=8C=A8=EC=9D=98=20?= =?UTF-8?q?=EB=B9=88=20PR=20=EC=84=B1=EA=B3=B5=20=EC=B2=98=EB=A6=AC,=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 잡힌 실패 모드 두 가지. 둘 다 실패했는데 성공처럼 보이는 종류다. repo-state.sh 가 repo 루트로 이동하지 않았다. git ls-files --others 는 cwd 하위만 cwd 기준 경로로 내므로, 서브디렉터리에서 /auto-commit 을 돌리면 다른 디렉터리의 새 파일이 통째로 빠지고 changed_files 에 root 기준과 cwd 기준 경로가 섞인다. os.chdir(root) 로 수집 기준을 맞췄다. pr-context.sh 는 gh 조회가 실패해도 전 필드 null 에 diff 빈 문자열로 exit 0 했다. review-pr.md 가 state 를 MERGED/CLOSED 만 분기하므로 null 은 통과하고 모델이 빈 diff 로 리뷰를 만든다. 입력 검증과 같은 fail-closed 로 맞추고, review-pr.md 에 error 필드가 있으면 중단하도록 적었다. 그 외: - PR URL 은 owner/repo 를 살려 --repo 로 넘긴다. 번호만 뽑으면 현재 레포의 동일 번호 PR 을 조용히 리뷰한다 - bun·pnpm 의 test 서브커맨드는 package.json 스크립트를 돌리지 않으므로 항상 run 을 거친다. bun.lock(Bun 1.2+ 기본)도 감지한다 - 세 스크립트에 python3 존재 확인을 넣어 exit 127 대신 안내 JSON 을 낸다 test_context_scripts.sh 를 추가하고 CI 에 등록했다(28개). 임시 저장소를 만들어 실제 실행하고 JSON 필드만 본다. 모델 호출 없음. 이 PR 에서 나온 결함 - tracked-only 수집, 잘린 개수를 전체로 보고, 서브디렉터리 경로 어긋남, gh 실패의 빈 PR - 을 각각 되돌려 테스트가 실패하는 것까지 확인했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- .github/scripts/test_context_scripts.sh | 137 ++++++++++++++++++ .github/workflows/validate.yml | 3 + plugins/wigtn-plugins/commands/review-pr.md | 2 + plugins/wigtn-plugins/scripts/pr-context.sh | 27 +++- plugins/wigtn-plugins/scripts/repo-context.sh | 12 +- plugins/wigtn-plugins/scripts/repo-state.sh | 8 + 6 files changed, 182 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/test_context_scripts.sh diff --git a/.github/scripts/test_context_scripts.sh b/.github/scripts/test_context_scripts.sh new file mode 100755 index 0000000..fc9d061 --- /dev/null +++ b/.github/scripts/test_context_scripts.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# 번들 컨텍스트 스크립트 회귀 테스트 (repo-context / repo-state / pr-context). +# +# 이 세 스크립트는 /implement·/auto-commit·/review-pr 의 첫 단계라, 조용히 틀리면 +# 모델이 빈 트리나 없는 PR 을 그대로 믿고 진행한다. 실제로 리뷰에서 잡힌 결함이 +# 전부 "실패했는데 성공처럼 보이는" 종류였다 - tracked-only 수집, 잘린 개수를 +# 전체로 보고, 서브디렉터리 경로 어긋남, gh 실패의 빈 PR 성공 처리. +# +# 임시 저장소를 만들어 실제 실행하고 JSON 필드만 본다. 모델 호출 없음. +# +# 실행: bash .github/scripts/test_context_scripts.sh + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +S="$REPO_ROOT/plugins/wigtn-plugins/scripts" +PASS=0; FAIL=0 +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +git_init() { git init -q "$1" && git -C "$1" config user.email t@t && git -C "$1" config user.name t; } + +# $1 라벨 $2 기대 $3 실제 +eq() { + if [ "$2" = "$3" ]; then echo " ok $1"; PASS=$((PASS+1)) + else echo " FAIL $1 - 기대 [$2] 실제 [$3]"; FAIL=$((FAIL+1)); fi +} +# $1 라벨 $2 JSON $3 python 표현식(d 로 접근) +field() { python3 -c " +import json,sys +d=json.loads(sys.stdin.read()) +print($3)" <<< "$2"; } + +echo "repo-context.sh" + +# 커밋하지 않은 파일도 소스 트리에 들어간다 (git ls-files 는 tracked 만 낸다) +D="$TMP/untracked"; mkdir -p "$D/src"; git_init "$D" +echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; echo x > "$D/src/app.ts" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "커밋 전 파일이 트리에 잡힌다" "2" "$(field _ "$J" "d['file_count']")" +eq " 경로가 root 기준이다" "src/app.ts" "$(field _ "$J" "[f for f in d['files'] if f.endswith('app.ts')][0]")" + +# tracked 가 이미 있는 레포에서도 untracked 가 합쳐져야 한다. +# (커밋이 없으면 find 폴백이 결함을 가리므로 이 케이스가 진짜 회귀 검사다) +D="$TMP/mixed"; mkdir -p "$D/src"; git_init "$D" +echo a > "$D/src/committed.ts"; git -C "$D" add -A; git -C "$D" commit -q -m init +echo b > "$D/src/new.ts" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "tracked+untracked 가 합쳐진다" "2" "$(field _ "$J" "d['file_count']")" +eq " untracked 가 포함된다" "True" "$(field _ "$J" "'src/new.ts' in d['files']")" + +# git 저장소가 아니면 find 로 떨어진다 +D="$TMP/nogit"; mkdir -p "$D/src"; echo x > "$D/src/a.ts"; echo y > "$D/README.md" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "git 아닌 디렉터리도 수집한다" "2" "$(field _ "$J" "d['file_count']")" + +# .gitignore 는 존중한다 +D="$TMP/ignore"; mkdir -p "$D/node_modules/p"; git_init "$D" +echo "node_modules/" > "$D/.gitignore"; echo x > "$D/app.ts"; echo n > "$D/node_modules/p/f.js" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "node_modules 는 빠진다" "0" "$(field _ "$J" "len([f for f in d['files'] if 'node_modules' in f])")" + +# 자를 때 file_count 는 자르기 전 전체 수여야 한다 +D="$TMP/trunc"; mkdir -p "$D"; git_init "$D" +for i in $(seq 1 250); do echo x > "$D/f$i.txt"; done +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "file_count 는 자르기 전 개수" "250" "$(field _ "$J" "d['file_count']")" +eq " 목록은 상한까지만" "200" "$(field _ "$J" "len(d['files'])")" +eq " 잘림을 표기한다" "True" "$(field _ "$J" "d['files_truncated']")" + +# 검증 명령 감지 - npm 외 생태계도 잡아야 한다 (빈 목록이면 모델이 검증을 건너뛴다) +detect() { # $1 디렉터리명 $2.. 생성할 파일 + local d="$TMP/eco_$1"; shift; mkdir -p "$d"; git_init "$d" + for f in "$@"; do printf '%s' "$3" > "$d/$f"; done + (cd "$d" && bash "$S/repo-context.sh") +} +D="$TMP/py"; mkdir -p "$D"; git_init "$D" +printf '[tool.pytest.ini_options]\nx=1\n[tool.ruff]\ny=2\n' > "$D/pyproject.toml" +J=$(cd "$D" && bash "$S/repo-context.sh") +eq "pyproject 에서 pytest 감지" "pytest" "$(field _ "$J" "d['verify_commands'].get('test')")" + +D="$TMP/go"; mkdir -p "$D"; git_init "$D"; printf 'module x\ngo 1.22\n' > "$D/go.mod" +eq "go.mod 에서 go test 감지" "go test ./..." "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/rust"; mkdir -p "$D"; git_init "$D"; printf '[package]\nname="x"\n' > "$D/Cargo.toml" +eq "Cargo.toml 에서 cargo test 감지" "cargo test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/mk"; mkdir -p "$D"; git_init "$D"; printf 'test:\n\techo t\n' > "$D/Makefile" +eq "Makefile 타깃 감지" "make test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +# bun·pnpm 의 test 서브커맨드는 package.json 스크립트를 돌리지 않는다 +for lk in bun.lock bun.lockb; do + D="$TMP/bun_$lk"; mkdir -p "$D"; git_init "$D" + echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; touch "$D/$lk" + eq "$lk -> bun run test" "bun run test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" +done +D="$TMP/pnpm"; mkdir -p "$D"; git_init "$D" +echo '{"scripts":{"test":"vitest"}}' > "$D/package.json"; touch "$D/pnpm-lock.yaml" +eq "pnpm -> pnpm run test" "pnpm run test" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_commands'].get('test')")" + +D="$TMP/plain"; mkdir -p "$D"; git_init "$D"; echo hi > "$D/README.md" +eq "감지 실패는 verify_detected=false" "False" "$(field _ "$(cd "$D" && bash "$S/repo-context.sh")" "d['verify_detected']")" + +echo "repo-state.sh" + +# staged·unstaged·untracked 가 모두 changed_files 에 들어간다 +D="$TMP/state"; mkdir -p "$D/sub" "$D/sub2"; git_init "$D" +git -C "$D" commit -q --allow-empty -m init +echo r > "$D/root_untracked.txt"; echo s > "$D/sub/sub_untracked.txt" +echo x > "$D/sub2/x.txt"; git -C "$D" add sub2/x.txt +J=$(cd "$D" && bash "$S/repo-state.sh") +eq "세 상태가 모두 잡힌다" "3" "$(field _ "$J" "d['file_count']")" + +# 서브디렉터리에서 실행해도 결과가 같아야 한다 +# (git ls-files --others 는 cwd 하위만 cwd 기준 경로로 낸다) +J2=$(cd "$D/sub" && bash "$S/repo-state.sh") +eq "서브디렉터리에서도 동일" "3" "$(field _ "$J2" "d['file_count']")" +eq " root 파일이 안 빠진다" "True" "$(field _ "$J2" "'root_untracked.txt' in d['changed_files']")" +eq " 경로가 root 기준이다" "True" "$(field _ "$J2" "'sub/sub_untracked.txt' in d['changed_files']")" + +echo "pr-context.sh" + +# 셸 인젝션 - 인자가 명령으로 해석되면 안 된다 +MARK="$TMP/pwned" +bash "$S/pr-context.sh" "44; touch $MARK; #" >/dev/null 2>&1 +eq "인젝션 차단" "no" "$([ -e "$MARK" ] && echo yes || echo no)" + +for bad in "" "abc" "44abc" '$(id)'; do + out=$(bash "$S/pr-context.sh" "$bad" 2>/dev/null) + eq "잘못된 입력 거부 [${bad:-빈값}]" "True" "$(field _ "$out" "'error' in d")" +done + +# gh 조회 실패를 빈 PR 로 성공 처리하지 않는다 (없는 코드에 대한 리뷰 방지) +out=$(bash "$S/pr-context.sh" 99999999 2>/dev/null); rc=$? +eq "조회 실패는 error + 비정상 종료" "True" "$(field _ "$out" "'error' in d")" +eq " exit code 가 0 이 아니다" "1" "$rc" + +echo +echo "통과 $PASS · 실패 $FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 317627a..97642fb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,6 +37,9 @@ jobs: - name: Dangerous-command hook tests (real JSON -> shell -> grep chain) run: bash .github/scripts/test_danger_hook.sh + - name: Context script tests (real temp repos, no model calls) + run: bash .github/scripts/test_context_scripts.sh + - name: Prompt surface measurement (reported, non-blocking) run: | bash .github/scripts/measure.sh HEAD >> "$GITHUB_STEP_SUMMARY" || true diff --git a/plugins/wigtn-plugins/commands/review-pr.md b/plugins/wigtn-plugins/commands/review-pr.md index f193361..cd5d00b 100644 --- a/plugins/wigtn-plugins/commands/review-pr.md +++ b/plugins/wigtn-plugins/commands/review-pr.md @@ -43,6 +43,8 @@ bash "${CLAUDE_PLUGIN_ROOT}/scripts/pr-context.sh" $PR_NUMBER 반환 JSON에 메타데이터(`state`·`title`·`body`·`author`·`files`·`stats`), 전체 `diff`, 기존 코멘트·리뷰가 모두 들어 있다. `diff_truncated`가 true일 때만 추가로 조회한다. +**JSON에 `error` 필드가 있으면 리뷰를 진행하지 말고 중단한 뒤 사용자에게 알린다.** PR 번호·인증·레포 중 하나가 잘못된 것이라, 빈 diff로 리뷰를 만들면 없는 코드를 리뷰하게 된다. + **PR 상태 확인:** - `state: MERGED` → "이미 머지된 PR입니다. 리뷰를 계속할까요?" (AskUserQuestion) - `state: CLOSED` → "닫힌 PR입니다. 리뷰를 계속할까요?" (AskUserQuestion) diff --git a/plugins/wigtn-plugins/scripts/pr-context.sh b/plugins/wigtn-plugins/scripts/pr-context.sh index 76f624a..017f1a9 100755 --- a/plugins/wigtn-plugins/scripts/pr-context.sh +++ b/plugins/wigtn-plugins/scripts/pr-context.sh @@ -2,18 +2,28 @@ # PR 리뷰에 필요한 정보를 한 번에 모아 JSON으로 반환한다. # 모델이 gh pr view / gh pr diff / gh pr view --json comments 를 따로 부를 필요가 없다. PR="$1" +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi exec python3 - "$PR" <<'PY' import subprocess, json, sys, re # PR 참조는 숫자 또는 GitHub PR URL 만 받는다. 셸에 넘기지 않지만, # 잘못된 입력을 조용히 흘려보내지 않도록 여기서 잘라낸다. raw = sys.argv[1] if len(sys.argv) > 1 else '' -m = re.fullmatch(r'\s*(?:https?://[^\s/]+/[^\s]*?/pull/)?#?(\d{1,10})\s*', raw or '') +m = re.fullmatch( + r'\s*(?:https?://[^\s/]+/(?P[^\s/]+)/(?P[^\s/]+)/pull/)?#?(?P\d{1,10})\s*', + raw or '') if not m: print(json.dumps({'error': 'PR 번호 또는 GitHub PR URL 이 필요하다', 'given': raw}, ensure_ascii=False)) sys.exit(1) -pr = m.group(1) +pr = m.group('num') +# URL 로 받았으면 그 레포를 그대로 쓴다. 번호만 뽑으면 현재 레포의 동일 번호 PR 을 +# 조용히 리뷰하게 된다. +repo_args = (['--repo', f"{m.group('owner')}/{m.group('repo')}"] + if m.group('owner') and m.group('repo') else []) def run(args, d=''): # shell=False. 인자 배열이라 PR 번호가 명령으로 해석되지 않는다. @@ -28,11 +38,18 @@ def j(args): try: return json.loads(o) if o else {} except Exception: return {} -meta = j(['gh', 'pr', 'view', pr, '--json', +meta = j(['gh', 'pr', 'view', pr] + repo_args + ['--json', 'title,body,author,baseRefName,headRefName,files,' 'additions,deletions,changedFiles,reviewDecision,reviews,state']) -conv = j(['gh', 'pr', 'view', pr, '--json', 'comments,reviews']) -diff = run(['gh', 'pr', 'diff', pr]) +conv = j(['gh', 'pr', 'view', pr] + repo_args + ['--json', 'comments,reviews']) +# 조회가 실패하면 빈 PR 로 성공 처리하지 않는다. 리뷰 커맨드의 가장 위험한 실패 +# 모드가 "없는 코드에 대한 리뷰"라, 입력 검증과 같은 fail-closed 로 맞춘다. +if not meta: + print(json.dumps({'pr': pr, + 'error': 'gh pr view 실패 — PR 번호·인증·레포를 확인'}, + ensure_ascii=False)) + sys.exit(1) +diff = run(['gh', 'pr', 'diff', pr] + repo_args) LIMIT = 60000 print(json.dumps({ 'pr': pr, diff --git a/plugins/wigtn-plugins/scripts/repo-context.sh b/plugins/wigtn-plugins/scripts/repo-context.sh index f35b50a..e8470d5 100755 --- a/plugins/wigtn-plugins/scripts/repo-context.sh +++ b/plugins/wigtn-plugins/scripts/repo-context.sh @@ -1,6 +1,10 @@ #!/bin/bash # 구현 시작 전 레포 상태를 한 번에 수집해 JSON으로 반환한다. # 모델이 ls/find/cat/npm test 를 개별로 돌려가며 알아낼 필요가 없다. +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi exec python3 - <<'PY' import subprocess, json, os, glob, re def run(c, d=''): @@ -40,11 +44,15 @@ verify = {} # JS/TS — 락파일로 패키지 매니저를 맞춘다 if scripts: pm = 'npm' - for lock, name in (('pnpm-lock.yaml','pnpm'), ('yarn.lock','yarn'), ('bun.lockb','bun')): + for lock, name in (('pnpm-lock.yaml','pnpm'), ('yarn.lock','yarn'), + ('bun.lockb','bun'), ('bun.lock','bun')): if os.path.exists(lock): pm = name; break for key in ('test', 'typecheck', 'lint', 'build'): if key in scripts: - verify[key] = f'{pm} test' if (key == 'test' and pm != 'pnpm') else f'{pm} run {key}' + # pnpm·bun 의 'test' 서브커맨드는 package.json 스크립트를 돌리지 않는다 + # (bun test 는 Bun 내장 러너). 둘은 항상 run 을 거친다. + direct = key == 'test' and pm in ('npm', 'yarn') + verify[key] = f'{pm} test' if direct else f'{pm} run {key}' # Python if os.path.exists('pyproject.toml'): diff --git a/plugins/wigtn-plugins/scripts/repo-state.sh b/plugins/wigtn-plugins/scripts/repo-state.sh index 68c6dee..157a33a 100755 --- a/plugins/wigtn-plugins/scripts/repo-state.sh +++ b/plugins/wigtn-plugins/scripts/repo-state.sh @@ -1,5 +1,9 @@ #!/bin/bash # 커밋 전 상태를 한 번에 수집해 JSON으로 반환한다. 모델이 개별 명령을 돌릴 필요가 없다. +if ! command -v python3 >/dev/null 2>&1; then + printf '{"error":"python3 가 필요하다 — 설치 후 다시 실행"}\n' + exit 1 +fi exec python3 - <<'PY' import subprocess, json, os, glob def run(c, d=''): @@ -22,6 +26,10 @@ def jrun(c): try: return json.loads(o) if o else [] except Exception: return [] root=run('git rev-parse --show-toplevel','.') +# git ls-files --others 는 cwd 하위만 cwd 기준 경로로 낸다. 서브디렉터리에서 실행하면 +# 다른 디렉터리의 새 파일이 빠지고 경로 기준도 섞이므로 repo 루트로 이동해서 수집한다. +try: os.chdir(root) +except Exception: pass br=run('git branch --show-current') run('git fetch origin --quiet') staged=[x for x in run('git diff --cached --name-only').split('\n') if x] From 00307f41de26a7329026c6f17f42410f2c41c965 Mon Sep 17 00:00:00 2001 From: KIM HYEONSANG <62788445+HyeonsangKim@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:52:56 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(pr-context):=20gh=20pr=20diff=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=8F=84=20=EC=98=A4=EB=A5=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋에서 gh pr view 실패만 fail-closed 로 바꾸고 diff 조회는 빠뜨렸다. 메타데이터는 성공하고 diff 만 실패하면(인증·네트워크·권한) 빈 diff 로 exit 0 이 나가고, review-pr.md 의 error 중단 조건도 우회한다. 코드가 없는 PR 을 리뷰하게 되는 경로가 그대로 남아 있었다. - run_ok() 로 성공 여부를 함께 받아 실패를 빈 문자열로 뭉개지 않는다 - diff 조회 실패 시 error + detail 을 내고 비정상 종료한다 test_context_scripts.sh 에 가짜 gh 를 PATH 앞에 두는 회귀 테스트를 넣었다 (메타 실패 / 메타 성공+diff 실패 / 둘 다 성공). 인증 상태와 무관하게 돌고, 이 커밋의 수정을 되돌리면 해당 2건이 실패하는 것을 확인했다. 33개. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWDghVZqdr4ZRFhm1aVDSN --- .github/scripts/test_context_scripts.sh | 39 +++++++++++++++++++-- plugins/wigtn-plugins/scripts/pr-context.sh | 17 ++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.github/scripts/test_context_scripts.sh b/.github/scripts/test_context_scripts.sh index fc9d061..153a9c8 100755 --- a/.github/scripts/test_context_scripts.sh +++ b/.github/scripts/test_context_scripts.sh @@ -127,11 +127,44 @@ for bad in "" "abc" "44abc" '$(id)'; do eq "잘못된 입력 거부 [${bad:-빈값}]" "True" "$(field _ "$out" "'error' in d")" done -# gh 조회 실패를 빈 PR 로 성공 처리하지 않는다 (없는 코드에 대한 리뷰 방지) -out=$(bash "$S/pr-context.sh" 99999999 2>/dev/null); rc=$? -eq "조회 실패는 error + 비정상 종료" "True" "$(field _ "$out" "'error' in d")" +# gh 조회 실패를 빈 PR 로 성공 처리하지 않는다 (없는 코드에 대한 리뷰 방지). +# 가짜 gh 를 PATH 앞에 두어 인증 상태와 무관하게 각 실패 지점을 재현한다. +FAKE="$TMP/bin"; mkdir -p "$FAKE" +make_gh() { # $1 view 결과(ok|fail) $2 diff 결과(ok|fail) + cat > "$FAKE/gh" <&2; exit 1; } + echo "diff --git a/a.ts b/a.ts"; exit 0 +fi +if [ "\$1" = "pr" ] && [ "\$2" = "view" ]; then + [ "$1" = "ok" ] || { echo "error: not found" >&2; exit 1; } + echo '{"title":"x","state":"OPEN","files":[{"path":"a.ts"}],"additions":1,"deletions":0,"changedFiles":1,"author":{"login":"u"},"baseRefName":"main","headRefName":"f","body":"","reviewDecision":null,"reviews":[],"comments":[]}' + exit 0 +fi +exit 1 +GHEOF + chmod +x "$FAKE/gh" +} + +make_gh fail fail +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "메타 조회 실패는 error" "True" "$(field _ "$out" "'error' in d")" eq " exit code 가 0 이 아니다" "1" "$rc" +# 메타는 성공하고 diff 만 실패하는 경우 - 가장 위험한 경로 +# (코드 없이 리뷰가 만들어진다) +make_gh ok fail +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "diff 조회 실패도 error" "True" "$(field _ "$out" "'error' in d")" +eq " exit code 가 0 이 아니다" "1" "$rc" + +make_gh ok ok +out=$(PATH="$FAKE:$PATH" bash "$S/pr-context.sh" 44 2>/dev/null); rc=$? +eq "둘 다 성공하면 error 없음" "False" "$(field _ "$out" "'error' in d")" +eq " exit code 0" "0" "$rc" +eq " diff 가 담긴다" "True" "$(field _ "$out" "len(d['diff'])>0")" + echo echo "통과 $PASS · 실패 $FAIL" [ "$FAIL" -eq 0 ] || exit 1 diff --git a/plugins/wigtn-plugins/scripts/pr-context.sh b/plugins/wigtn-plugins/scripts/pr-context.sh index 017f1a9..6da80fe 100755 --- a/plugins/wigtn-plugins/scripts/pr-context.sh +++ b/plugins/wigtn-plugins/scripts/pr-context.sh @@ -33,6 +33,14 @@ def run(args, d=''): except Exception: return d +def run_ok(args): + # 실패를 빈 문자열로 뭉개지 않는다. 호출부가 fail-closed 로 판단할 수 있어야 한다. + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=40) + return r.returncode == 0, (r.stdout or '').strip(), (r.stderr or '').strip() + except Exception as e: + return False, '', str(e) + def j(args): o = run(args) try: return json.loads(o) if o else {} @@ -49,7 +57,14 @@ if not meta: 'error': 'gh pr view 실패 — PR 번호·인증·레포를 확인'}, ensure_ascii=False)) sys.exit(1) -diff = run(['gh', 'pr', 'diff', pr] + repo_args) +# diff 조회 실패도 빈 diff 로 흘려보내지 않는다. 메타데이터만 있고 코드가 없으면 +# 모델이 "변경 없음"으로 읽고 리뷰를 만들어 버린다. +diff_ok, diff, diff_err = run_ok(['gh', 'pr', 'diff', pr] + repo_args) +if not diff_ok: + print(json.dumps({'pr': pr, + 'error': 'gh pr diff 실패 — 인증·네트워크·권한을 확인', + 'detail': diff_err[:300]}, ensure_ascii=False)) + sys.exit(1) LIMIT = 60000 print(json.dumps({ 'pr': pr,