diff --git a/.vibeready.example.json b/.vibeready.example.json index ebe0380..3f1152b 100644 --- a/.vibeready.example.json +++ b/.vibeready.example.json @@ -1,40 +1,21 @@ { + "_comment": "vibe-ready 평가 설정 예시. 기본 7개 카테고리의 기본값을 그대로 담았습니다. 필요한 항목만 수정/제거하거나, optional 항목을 추가하세요.", + "_tier": "tier 값: 'must'=필수(F 등급이면 종합 등급이 C로 제한), 'nice'=권장(가중평균에 반영), 'optional'=선택(등급에 영향 없음, 점수에 비례해 bonusCap 한도 내 가산점만 부여).", + "_adopt": "항목 채용=categories에 포함. 미채용=배열에서 제거(분석·채점 제외). 선택항목=tier를 'optional'로 지정하고 bonusCap 설정.", + "_weight": "must/nice의 weight는 합이 1.0이 아니어도 자동 정규화됩니다. optional은 weight 대신 bonusCap(최대 가산점)을 사용합니다.", "categories": [ - { - "name": "테스트 커버리지", - "tier": "must", - "weight": 0.20 - }, - { - "name": "CI/CD", - "tier": "must", - "weight": 0.20 - }, - { - "name": "훅 기반 검증", - "tier": "must", - "weight": 0.20 - }, - { - "name": "리포지토리 구조", - "tier": "nice", - "weight": 0.13 - }, - { - "name": "문서화 수준", - "tier": "nice", - "weight": 0.13 - }, - { - "name": "하네스 엔지니어링", - "tier": "nice", - "weight": 0.14 - }, + { "name": "테스트 커버리지", "tier": "must", "weight": 0.20 }, + { "name": "CI/CD", "tier": "must", "weight": 0.20 }, + { "name": "훅 기반 검증", "tier": "must", "weight": 0.20 }, + { "name": "리포지토리 구조", "tier": "nice", "weight": 0.10 }, + { "name": "문서화 수준", "tier": "nice", "weight": 0.10 }, + { "name": "하네스 엔지니어링", "tier": "nice", "weight": 0.10 }, + { "name": "이슈 트래킹 연동", "tier": "nice", "weight": 0.10 }, { "name": "보안 설정", - "tier": "must", - "weight": 0.15, - "description": "리포지토리의 보안 관련 설정을 평가합니다", + "tier": "optional", + "bonusCap": 5, + "description": "선택항목 예시: 없어도 등급에 영향을 주지 않지만, 갖추면 최대 5점까지 가산됩니다.", "checkpoints": [ ".env 파일이 .gitignore에 포함되어 있는가", "시크릿/API 키가 소스코드에 하드코딩되어 있지 않은가", diff --git a/README.ko.md b/README.ko.md index 50ed716..999a218 100644 --- a/README.ko.md +++ b/README.ko.md @@ -112,7 +112,11 @@ npm test ] }, { "name": "문서화 수준", "tier": "nice", "weight": 0.15 }, - { "name": "하네스 엔지니어링", "tier": "nice", "weight": 0.15 } + { "name": "하네스 엔지니어링", "tier": "nice", "weight": 0.15 }, + { "name": "보안 강화", "tier": "optional", "bonusCap": 5, + "description": "선택항목: 등급에 영향을 주지 않고, 점수에 비례해 최대 5점 가산", + "checkpoints": [".env가 .gitignore에 포함", "의존성 취약점 스캔 설정 여부"] + } ], "penaltyRule": { "enabled": true, @@ -124,7 +128,9 @@ npm test - 기본 카테고리의 가중치/tier 변경 가능 - `description` + `checkpoints`로 커스텀 카테고리 추가 가능 -- 가중치 합계가 1.0이 아니면 자동 정규화 +- **tier 구분**: `must`(F 등급이면 종합 등급이 C로 제한), `nice`(가중평균에 반영), `optional`(등급을 낮추지 않음 — 점수에 비례해 `bonusCap` 한도 내 가산점만 부여하므로 총점·등급이 올라갈 수 있음) +- **채용 / 미채용 / 선택항목**: 항목을 `categories`에 두면 채용, 배열에서 빼면 미채용(분석·채점 제외), `tier: "optional"` + `bonusCap`을 지정하면 가산점 전용 선택항목 +- 가중치 합계가 1.0이 아니면 자동 정규화 (`optional`은 정규화 대상에서 제외되고 `weight` 대신 `bonusCap` 사용) - 지원 파일명: `.vibeready.json`, `.vibeready.config.json`, `vibeready.config.json` - 전체 예시: [.vibeready.example.json](.vibeready.example.json) diff --git a/README.md b/README.md index 1a8cb9b..2658cf5 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,11 @@ Create a `.vibeready.json` in your repo root to customize evaluation: ] }, { "name": "Documentation", "tier": "nice", "weight": 0.15 }, - { "name": "Harness Engineering", "tier": "nice", "weight": 0.15 } + { "name": "Harness Engineering", "tier": "nice", "weight": 0.15 }, + { "name": "Security", "tier": "optional", "bonusCap": 5, + "description": "Optional item: never lowers the grade; adds up to 5 bonus points if present", + "checkpoints": [".env is in .gitignore", "Dependency vulnerability scanning configured"] + } ], "penaltyRule": { "enabled": true, @@ -126,7 +130,9 @@ Create a `.vibeready.json` in your repo root to customize evaluation: - Override default category weights and tiers - Add custom categories with `description` and `checkpoints` -- Weights are auto-normalized if they don't sum to 1.0 +- **Tiers**: `must` (F caps the overall grade at C), `nice` (counts toward the weighted average), `optional` (never lowers the grade — adds bonus points proportional to its score, up to `bonusCap`, which can raise the total) +- **Adopt / skip / optional**: include a category to adopt it; remove it from `categories` to skip it (excluded from analysis); set `tier: "optional"` with a `bonusCap` to make it a bonus-only item +- Weights are auto-normalized if they don't sum to 1.0 (`optional` categories are excluded from normalization and use `bonusCap` instead of `weight`) - `"agent"` pins Harness Engineering to one coding agent (`claude` / `codex` / `cursor` / `copilot`). Omit it for auto-detection. The `--agent` CLI flag overrides this field. - Supported filenames: `.vibeready.json`, `.vibeready.config.json`, `vibeready.config.json` - See [.vibeready.example.json](.vibeready.example.json) for a full example diff --git a/src/config.ts b/src/config.ts index 2f6ab8d..46c944b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,7 +8,10 @@ import { normalizeAgent, SUPPORTED_AGENTS } from "./agents.js"; export interface CategoryConfig { name: string; tier: CategoryTier; + /** 가중평균에 반영되는 비중(must/nice). optional 카테고리에서는 무시되며 0으로 취급. */ weight: number; + /** optional 카테고리 전용: 점수 비례로 총점에 더해지는 최대 가산점. */ + bonusCap?: number; description?: string; checkpoints?: string[]; } @@ -51,12 +54,12 @@ export function getEffectiveCategories(config: VibeReadyConfig | null): Category })); } -export function getEffectiveWeights(config: VibeReadyConfig | null): Record { +export function getEffectiveWeights(config: VibeReadyConfig | null): Record { if (!config) return CATEGORY_WEIGHTS; - const weights: Record = {}; + const weights: Record = {}; for (const cat of config.categories) { - weights[cat.name] = { tier: cat.tier, weight: cat.weight }; + weights[cat.name] = { tier: cat.tier, weight: cat.weight, bonusCap: cat.bonusCap }; } return weights; } @@ -78,23 +81,41 @@ function validateConfig(raw: unknown): VibeReadyConfig { const c = cat as Record; if (typeof c.name !== "string") throw new Error("카테고리 name이 필요합니다"); - if (c.tier !== "must" && c.tier !== "nice") throw new Error(`카테고리 tier는 "must" 또는 "nice"여야 합니다: ${c.name}`); - if (typeof c.weight !== "number" || c.weight <= 0) throw new Error(`카테고리 weight는 양수여야 합니다: ${c.name}`); + if (c.tier !== "must" && c.tier !== "nice" && c.tier !== "optional") { + throw new Error(`카테고리 tier는 "must", "nice", "optional" 중 하나여야 합니다: ${c.name}`); + } + + const isOptional = c.tier === "optional"; + let bonusCap: number | undefined; + if (isOptional) { + // optional: weight 대신 bonusCap(가산점 상한)을 사용. weight는 무시(0). + if (typeof c.bonusCap !== "number" || c.bonusCap <= 0) { + throw new Error(`optional 카테고리는 양수 bonusCap이 필요합니다: ${c.name}`); + } + bonusCap = c.bonusCap; + } else if (typeof c.weight !== "number" || c.weight <= 0) { + throw new Error(`카테고리 weight는 양수여야 합니다: ${c.name}`); + } categories.push({ name: c.name, tier: c.tier as CategoryTier, - weight: c.weight, + weight: isOptional ? 0 : (c.weight as number), + bonusCap, description: typeof c.description === "string" ? c.description : undefined, checkpoints: Array.isArray(c.checkpoints) ? c.checkpoints.map(String) : undefined, }); } - // 가중치 합계 정규화 - const totalWeight = categories.reduce((sum, c) => sum + c.weight, 0); + // 가중치 합계 정규화 (optional은 가중평균에 들어가지 않으므로 제외) + const scorable = categories.filter((c) => c.tier !== "optional"); + if (scorable.length === 0) { + throw new Error("must/nice 카테고리가 최소 1개 필요합니다 (optional만으로는 구성할 수 없음)"); + } + const totalWeight = scorable.reduce((sum, c) => sum + c.weight, 0); if (Math.abs(totalWeight - 1.0) > 0.01) { const scale = 1.0 / totalWeight; - for (const c of categories) { + for (const c of scorable) { c.weight = Math.round(c.weight * scale * 1000) / 1000; } } diff --git a/src/prompts/analyze.ts b/src/prompts/analyze.ts index f35b995..76fd1c8 100644 --- a/src/prompts/analyze.ts +++ b/src/prompts/analyze.ts @@ -56,11 +56,11 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- - Test scripts in package.json, Makefile, or equivalent - Scoring: - 0 = no tests at all - - 20 = test framework configured but no/trivial test files - - 40 = test files exist but minimal (few tests, no coverage config) - - 60 = moderate test setup (reasonable test files + test scripts) - - 80 = good test coverage setup (test scripts + coverage config) - - 100 = comprehensive with coverage thresholds enforced + - 20 = test framework/config or test script exists, but zero test files or only placeholder/smoke test files are present + - 40 = 1-2 test files or a test directory exists, but no coverage config and no evidence of broad module coverage + - 60 = 3+ test files across multiple source areas + runnable test script, but no coverage collection/threshold config + - 80 = runnable test script + coverage collection config/script, but coverage thresholds are not enforced + - 100 = runnable test script + coverage collection + coverage thresholds enforced in config or CI 2. **CI/CD** (tier: "must") Check for: @@ -69,11 +69,11 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- - Multiple environments (dev, staging, prod) - Scoring: - 0 = no CI/CD - - 20 = CI config file exists but minimal (e.g., only build step) + - 20 = CI config exists and runs exactly one non-test quality step such as build, install, or formatting - 40 = basic pipeline that runs tests - 60 = pipeline runs tests + lint or build - 80 = runs tests + lint + build - - 100 = comprehensive with multiple stages/environments + - 100 = tests + lint + build plus an appropriate gated release/deploy/publish workflow; multiple environments only required for deployable services 3. **훅 기반 검증** (tier: "must") Check for: @@ -85,11 +85,11 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- - AI coding agent commit validation (e.g., Claude Code PreCommit that runs build+test before commit) - Scoring: - 0 = no hooks - - 20 = hook framework configured but no meaningful checks - - 40 = basic single hook (lint only or AI agent PreCommit only) - - 60 = lint + format hooks or equivalent AI agent validation - - 80 = lint + test + format hooks (traditional or AI agent) - - 100 = comprehensive with commit validation + multiple hook types + - 20 = hook framework configured, but hooks are empty, disabled, echo-only, or do not run lint/test/type/format/commit-message checks + - 40 = exactly one hook type runs one concrete check such as lint, format, typecheck, test, or commit-message validation + - 60 = hooks run at least two concrete pre-commit checks, e.g. lint + format, or an AI-agent hook explicitly running equivalent named commands + - 80 = hooks run lint + test + format, and optionally typecheck, via traditional hooks or explicit AI-agent hook commands + - 100 = pre-commit/pre-push quality hooks plus commit-message validation, with at least two hook events or stages configured ### Nice-to-Have (권장) Categories — These improve AI coding effectiveness: @@ -100,26 +100,27 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- - Configuration separation (config files, environment files, .env.example) - Monorepo structure if applicable (workspace config, lerna, turborepo, nx) - Scoring: - - 0 = flat/chaotic structure - - 20 = minimal organization (some directories but no clear pattern) + - 0 = no clear source directory, dependency manifest, or separated config/test/doc directories + - 20 = 2+ top-level directories exist, but source, tests, config, and docs are not consistently separated - 40 = basic structure (src/ exists, dependency file present) - - 60 = reasonable organization (clear src/test separation, config files) - - 80 = well-organized (clear separation of concerns, config separation) - - 100 = exemplary (monorepo/workspace config, .env.example, clean layering) + - 60 = source and test/config areas are separated, and dependency manifest is present + - 80 = source, tests, config, and docs are separated with recognizable responsibility-based directories + - 100 = project-appropriate structure with dependency manifest, config separation, environment example when env vars are used, and clear responsibility-based source directories; workspace config only when monorepo 5. **문서화 수준** (tier: "nice") Check for: - - README.md existence and quality (sections: description, setup, usage, API, contributing) - - CONTRIBUTING.md + - README.md existence and quality (sections: description, setup, usage, API) - API documentation (Swagger/OpenAPI, JSDoc, docstrings, typedoc) - Architecture documentation (ADR, diagrams, design docs) + - CONTRIBUTING.md (선택 사항 — 필수 아님, 있으면 소폭 가산되는 타이브레이커일 뿐) - Scoring: - 0 = no documentation - 20 = README exists but minimal (title/description only) - 40 = basic README with setup instructions - - 60 = good README (setup + usage + structure sections) - - 80 = good README + API docs or CONTRIBUTING.md - - 100 = comprehensive docs (README + API + architecture + contributing) + - 60 = README includes setup, usage, and repository structure sections + - 80 = README includes setup, usage, and structure sections + API/CLI/configuration reference appropriate to the project type + - 100 = README + project-appropriate API/CLI/configuration reference + architecture/design documentation + - CONTRIBUTING.md은 만점 요건이 아닙니다. 부재해도 위 기준을 충족하면 만점이며, 존재 시에만 동점 상황에서 소폭 가산하세요. 6. **하네스 엔지니어링** (tier: "nice") Harness Engineering = AI 에이전트가 코드베이스를 효과적으로 이해하고 안전하게 작업할 수 있도록 구성하는 것. @@ -132,11 +133,11 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- 구성되지 않은 에이전트의 부재는 감점 사유가 아닙니다. 단일 에이전트가 완비되었다면 그것만으로 이미 만점입니다. 한 도구만 갖춘 것은 감점 사유가 아닙니다. 여러 에이전트를 동시에 지원하는 것은 점수 기준이 아니라 **소폭 가산(타이브레이커)** 요소일 뿐입니다. - (예: CLAUDE.md + .claude/settings.json + .claude/skills/·commands/·agents/ 가 완비되었다면 + (예: CLAUDE.md + .claude/settings.json + .claude/skills/·agents/ 가 완비되었다면 AGENTS.md·.cursorrules·copilot-instructions.md 가 없어도 100점입니다.) 에이전트별 신호 (이 중 하나의 에이전트만 충족해도 됨): - - **Claude Code**: CLAUDE.md(컨텍스트) + .claude/settings.json(권한/안전) + .claude/{skills,commands,agents}/(확장) + PreCommit 훅 + - **Claude Code**: CLAUDE.md(컨텍스트) + .claude/settings.json(권한/안전) + .claude/{skills,agents}/(확장) + PreCommit 훅 - **Codex**: AGENTS.md(컨텍스트) + .codex/ 설정 + .codex/skills/(확장) - **Cursor**: .cursor/rules/ 또는 .cursorrules(컨텍스트/규칙) - **GitHub Copilot**: .github/copilot-instructions.md(컨텍스트) @@ -144,15 +145,15 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- 선택한 단일 에이전트 안에서 다음 3개 축을 평가: **① 컨텍스트 제공** — 가이드 문서가 단순 설명이 아닌, 에이전트가 즉시 활용 가능한 구조화된 정보(프로젝트 개요, 기술 스택, 빌드 명령어, 아키텍처/데이터 플로우, 코딩 컨벤션)를 담는가? **② 안전 설정** — 권한/도구 제한(settings.json 등) + PreCommit 등 커밋 전 자동 검증(빌드+테스트) 훅이 있는가? - **③ 확장** — 스킬/커맨드/에이전트/MCP 서버 등 프로젝트 특화 확장이 있는가? + **③ 확장** — 스킬/에이전트/MCP 서버 등 프로젝트 특화 확장이 있는가? (커맨드는 최근 스킬로 대체되는 추세이므로 확장 신호로 보지 않음) Scoring (가장 잘 갖춰진 단일 에이전트 기준): - 0 = AI 에이전트 설정 전무 - 20 = 기본 README만 존재 (에이전트 전용 문서 없음) - - 40 = 단일 에이전트 가이드 문서만 존재 (예: CLAUDE.md 또는 AGENTS.md, 기본 수준) - - 60 = 단일 에이전트의 컨텍스트 문서가 프로젝트 정보를 충실히 포함 (①) - - 80 = 단일 에이전트에 컨텍스트 + (안전 설정 또는 확장) 구성 (① + ② 또는 ③) - - 100 = 단일 에이전트 하네스 완비: 충실한 컨텍스트 + 안전 설정(권한/PreCommit) + 확장(스킬/커맨드/에이전트/MCP) (① + ② + ③) + - 40 = 에이전트 전용 가이드 문서는 있으나 프로젝트 개요·기술 스택·빌드/테스트 명령어·아키텍처/데이터 플로우·코딩 컨벤션 중 2개 이하만 포함 + - 60 = 단일 에이전트 컨텍스트 문서가 위 5개 항목 중 3개 이상 포함 (①) + - 80 = 60점 수준의 컨텍스트 + 권한/도구 제한 또는 PreCommit 설정(②), 또는 프로젝트 특화 스킬/에이전트/MCP 중 하나(③) (① + ② 또는 ③) + - 100 = 60점 수준의 컨텍스트 + 권한/도구 제한 또는 PreCommit 설정(②) + 프로젝트 특화 스킬/에이전트/MCP 중 하나(③) (① + ② + ③) - 가산점(타이브레이커): 위 기준으로 단일 에이전트 점수를 정한 뒤, 다른 에이전트 생태계도 추가 지원하면 동급 내에서 소폭 가산(상한 100). 단일 에이전트가 이미 완비(100)면 추가 가산 없음. 7. **이슈 트래킹 연동** (tier: "nice") @@ -165,15 +166,15 @@ Score each of the following 7 categories from 0 to 100. Be precise and evidence- - Scoring: - 0 = 이슈 연동 흔적 없음 - 20 = 템플릿만 존재, 커밋 참조 없음 - - 40 = 커밋 이슈 참조율 낮음(<30%) 또는 PR 워크플로만 존재 + - 40 = 커밋 이슈 참조율 낮음(<30%), 또는 Git Log Context에서 PR 머지/squash 패턴이 감지되거나 PR 템플릿/브랜치 보호 워크플로가 존재 - 60 = 참조율 보통(30~60%) + PR 워크플로 - 80 = 참조율 높음(60% 이상) + PR 워크플로 + 템플릿 - - 100 = 참조율 높음 + 템플릿 + 강제 장치(commitlint 규칙, 자동화 워크플로 등)${gitLogNote} + - 100 = 참조율 높음(60% 이상) + 템플릿 + 강제 장치(commitlint 규칙, 자동화 워크플로 등)${gitLogNote} ## Output Requirements For each category, provide: - **name**: The exact Korean category name as listed above -- **tier**: "must" or "nice" +- **tier**: "must", "nice", or "optional" (optional 카테고리는 등급에 영향을 주지 않고 가산점으로만 반영되지만, 평가/점수 산정은 동일하게 수행) - **score**: 0-100 integer based on evidence found - **recommendations**: Array of actionable recommendations (severity: critical/warning/info) - **rawFindings**: Array of specific items checked (item name, found: boolean, details) diff --git a/src/reporter.ts b/src/reporter.ts index 76bb136..59004a6 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -18,7 +18,9 @@ function colorScore(score: number, grade: Grade): string { } function tierLabel(tier: string): string { - return tier === "must" ? chalk.red("필수") : chalk.gray("권장"); + if (tier === "must") return chalk.red("필수"); + if (tier === "optional") return chalk.cyan("선택"); + return chalk.gray("권장"); } function pad(str: string, len: number): string { @@ -58,12 +60,17 @@ export function printReport(result: AnalysisResult): void { const mustCategories = categories.filter((c) => c.tier === "must"); const niceCategories = categories.filter((c) => c.tier === "nice"); + const optionalCategories = categories.filter((c) => c.tier === "optional"); - for (const cat of [...mustCategories, ...niceCategories]) { + for (const cat of [...mustCategories, ...niceCategories, ...optionalCategories]) { const line = ` ${pad(cat.name, 20)} ${pad(tierLabel(cat.tier), 8)} ${pad(colorScore(cat.score, cat.grade), 8)} ${colorGrade(cat.grade)}`; console.log(line); } + if (optionalCategories.length > 0) { + console.log(chalk.gray(" 선택 항목은 등급을 낮추지 않으며, 점수에 비례해 가산점만 더합니다.")); + } + console.log(chalk.gray(" ─────────────────────────────────────────────────")); console.log(); @@ -131,9 +138,10 @@ export function buildMarkdownReport(result: AnalysisResult, verbose: boolean, re const mustCategories = categories.filter((c) => c.tier === "must"); const niceCategories = categories.filter((c) => c.tier === "nice"); + const optionalCategories = categories.filter((c) => c.tier === "optional"); - for (const cat of [...mustCategories, ...niceCategories]) { - const tier = cat.tier === "must" ? "필수" : "권장"; + for (const cat of [...mustCategories, ...niceCategories, ...optionalCategories]) { + const tier = cat.tier === "must" ? "필수" : cat.tier === "optional" ? "선택" : "권장"; lines.push(`| ${cat.name} | ${tier} | ${cat.score} | ${cat.grade} |`); } @@ -251,9 +259,10 @@ export function buildMultiBranchMarkdown(branches: BranchResult[], verbose: bool const allCats = branches[0].result.categories; const mustCats = allCats.filter((c) => c.tier === "must"); const niceCats = allCats.filter((c) => c.tier === "nice"); + const optionalCats = allCats.filter((c) => c.tier === "optional"); - for (const cat of [...mustCats, ...niceCats]) { - const tier = cat.tier === "must" ? "필수" : "권장"; + for (const cat of [...mustCats, ...niceCats, ...optionalCats]) { + const tier = cat.tier === "must" ? "필수" : cat.tier === "optional" ? "선택" : "권장"; const scores = branches.map((b) => { const found = b.result.categories.find((c) => c.name === cat.name); return found ? `| ${found.score} (${found.grade})` : "| -"; diff --git a/src/scorer.ts b/src/scorer.ts index 1528ab1..cd3047c 100644 --- a/src/scorer.ts +++ b/src/scorer.ts @@ -10,12 +10,13 @@ import { export function computeResult( llmOutput: LLMAnalysisOutput, - customWeights?: Record, + customWeights?: Record, ): AnalysisResult { const weights = customWeights ?? CATEGORY_WEIGHTS; const categories: CategoryResult[] = llmOutput.categories.map((cat) => ({ name: cat.name, - tier: cat.tier, + // 설정 파일의 tier를 우선 적용(optional 등은 설정에서만 정의됨). 없으면 LLM 출력 tier. + tier: weights[cat.name]?.tier ?? cat.tier, score: Math.round(Math.max(0, Math.min(100, cat.score))), grade: gradeFromScore(cat.score), recommendations: cat.recommendations.map((r) => ({ @@ -26,7 +27,9 @@ export function computeResult( rawFindings: cat.rawFindings, })); - const totalScore = computeWeightedAverage(categories, weights); + const weightedAverage = computeWeightedAverage(categories, weights); + const bonus = computeOptionalBonus(categories, weights); + const totalScore = Math.round(Math.max(0, Math.min(100, weightedAverage + bonus)) * 100) / 100; let totalGrade = gradeFromScore(totalScore); const { penaltyApplied, penaltyReason } = checkPenalty(categories); @@ -46,12 +49,14 @@ export function computeResult( function computeWeightedAverage( categories: CategoryResult[], - weights: Record, + weights: Record, ): number { let weightedSum = 0; let totalWeight = 0; for (const cat of categories) { + // optional 카테고리는 가중평균에 포함하지 않는다(등급에 영향 없음). + if (cat.tier === "optional") continue; const config = weights[cat.name]; const weight = config?.weight ?? (cat.tier === "must" ? 0.20 : 0.10); weightedSum += cat.score * weight; @@ -62,6 +67,20 @@ function computeWeightedAverage( return Math.round(weightedSum / totalWeight * 100) / 100; } +/** optional 카테고리는 점수 비례로 가산점(최대 bonusCap)을 총점에 더한다. */ +function computeOptionalBonus( + categories: CategoryResult[], + weights: Record, +): number { + let bonus = 0; + for (const cat of categories) { + if (cat.tier !== "optional") continue; + const cap = weights[cat.name]?.bonusCap ?? 0; + bonus += (cat.score / 100) * cap; + } + return bonus; +} + function checkPenalty(categories: CategoryResult[]): { penaltyApplied: boolean; penaltyReason?: string; diff --git a/src/types.ts b/src/types.ts index 64a4799..5d02b00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ export type Grade = "A" | "B" | "C" | "D" | "F"; -export type CategoryTier = "must" | "nice"; +export type CategoryTier = "must" | "nice" | "optional"; export interface RawFinding { item: string; @@ -77,7 +77,7 @@ export const ANALYSIS_JSON_SCHEMA = { type: "object" as const, properties: { name: { type: "string" as const }, - tier: { type: "string" as const, enum: ["must", "nice"] }, + tier: { type: "string" as const, enum: ["must", "nice", "optional"] }, score: { type: "number" as const, minimum: 0, maximum: 100 }, recommendations: { type: "array" as const, diff --git a/tests/analyze-prompt.test.ts b/tests/analyze-prompt.test.ts index 243acaa..f247e18 100644 --- a/tests/analyze-prompt.test.ts +++ b/tests/analyze-prompt.test.ts @@ -13,8 +13,8 @@ describe("buildAnalysisPrompt", () => { }); it("should include AI agent hooks in scoring criteria", () => { - expect(prompt).toContain("AI agent PreCommit only"); - expect(prompt).toContain("AI agent validation"); + expect(prompt).toContain("AI-agent hook explicitly running equivalent named commands"); + expect(prompt).toContain("explicit AI-agent hook commands"); }); it("should still include traditional git hook items", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..68a3b7c --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, getEffectiveWeights } from "../src/config.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "vibeready-config-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function writeConfig(obj: unknown): void { + writeFileSync(join(dir, ".vibeready.json"), JSON.stringify(obj), "utf-8"); +} + +describe("optional 카테고리 설정", () => { + it("optional 카테고리는 bonusCap이 필요하다", () => { + writeConfig({ + categories: [ + { name: "테스트 커버리지", tier: "must", weight: 1.0 }, + { name: "보안", tier: "optional" }, + ], + }); + expect(() => loadConfig(dir)).toThrow(/bonusCap/); + }); + + it("optional은 weight 정규화에서 제외되고 must/nice만 1.0으로 정규화된다", () => { + writeConfig({ + categories: [ + { name: "테스트 커버리지", tier: "must", weight: 0.5 }, + { name: "CI/CD", tier: "must", weight: 0.5 }, + { name: "보안", tier: "optional", bonusCap: 5 }, + ], + }); + const config = loadConfig(dir); + const weights = getEffectiveWeights(config); + const scorableSum = config!.categories + .filter((c) => c.tier !== "optional") + .reduce((s, c) => s + c.weight, 0); + expect(scorableSum).toBeCloseTo(1.0, 5); + expect(weights["보안"]).toEqual({ tier: "optional", weight: 0, bonusCap: 5 }); + }); + + it("must/nice 없이 optional만으로는 구성할 수 없다", () => { + writeConfig({ + categories: [{ name: "보안", tier: "optional", bonusCap: 5 }], + }); + expect(() => loadConfig(dir)).toThrow(/must\/nice/); + }); + + it("알 수 없는 tier는 거부된다", () => { + writeConfig({ + categories: [{ name: "테스트", tier: "bonus", weight: 1.0 }], + }); + expect(() => loadConfig(dir)).toThrow(/tier/); + }); +}); diff --git a/tests/scorer.test.ts b/tests/scorer.test.ts index fc33007..515c814 100644 --- a/tests/scorer.test.ts +++ b/tests/scorer.test.ts @@ -165,6 +165,113 @@ describe("computeResult", () => { expect(scores["CI/CD"]).toBe(0); }); + it("optional 카테고리는 가중평균(분모)에 들어가지 않고 가산점으로만 더해진다", () => { + const llm: LLMAnalysisOutput = { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 80, recommendations: [], rawFindings: [] }, + { name: "CI/CD", tier: "must", score: 80, recommendations: [], rawFindings: [] }, + { name: "보안 설정", tier: "optional", score: 100, recommendations: [], rawFindings: [] }, + ], + summary: "", + }; + const weights = { + "테스트 커버리지": { tier: "must" as const, weight: 0.5 }, + "CI/CD": { tier: "must" as const, weight: 0.5 }, + "보안 설정": { tier: "optional" as const, weight: 0, bonusCap: 5 }, + }; + const result = computeResult(llm, weights); + // 가중평균 = 80, 가산점 = 100/100 * 5 = 5 → 총점 85 + expect(result.totalScore).toBeCloseTo(85, 0); + }); + + it("optional 가산점이 등급 경계를 넘으면 종합 등급을 올린다 (등급을 낮추지는 않음)", () => { + // 가중평균 88(B) + 가산점 5 = 93 → A + const result = computeResult( + { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 88, recommendations: [], rawFindings: [] }, + { name: "CI/CD", tier: "must", score: 88, recommendations: [], rawFindings: [] }, + { name: "보안 설정", tier: "optional", score: 100, recommendations: [], rawFindings: [] }, + ], + summary: "", + }, + { + "테스트 커버리지": { tier: "must" as const, weight: 0.5 }, + "CI/CD": { tier: "must" as const, weight: 0.5 }, + "보안 설정": { tier: "optional" as const, weight: 0, bonusCap: 5 }, + }, + ); + expect(result.totalScore).toBeCloseTo(93, 0); + expect(result.totalGrade).toBe("A"); + }); + + it("optional 카테고리가 0점이면 등급/총점에 영향이 없다", () => { + const base = { + "테스트 커버리지": { tier: "must" as const, weight: 0.5 }, + "CI/CD": { tier: "must" as const, weight: 0.5 }, + }; + const withoutOpt = computeResult( + { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 70, recommendations: [], rawFindings: [] }, + { name: "CI/CD", tier: "must", score: 70, recommendations: [], rawFindings: [] }, + ], + summary: "", + }, + base, + ); + const withOpt = computeResult( + { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 70, recommendations: [], rawFindings: [] }, + { name: "CI/CD", tier: "must", score: 70, recommendations: [], rawFindings: [] }, + { name: "보안 설정", tier: "optional", score: 0, recommendations: [], rawFindings: [] }, + ], + summary: "", + }, + { ...base, "보안 설정": { tier: "optional" as const, weight: 0, bonusCap: 10 } }, + ); + expect(withOpt.totalScore).toBe(withoutOpt.totalScore); + expect(withOpt.totalGrade).toBe(withoutOpt.totalGrade); + }); + + it("optional 가산점이 더해져도 총점은 100을 넘지 않는다", () => { + const result = computeResult( + { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 100, recommendations: [], rawFindings: [] }, + { name: "보안 설정", tier: "optional", score: 100, recommendations: [], rawFindings: [] }, + ], + summary: "", + }, + { + "테스트 커버리지": { tier: "must" as const, weight: 1.0 }, + "보안 설정": { tier: "optional" as const, weight: 0, bonusCap: 20 }, + }, + ); + expect(result.totalScore).toBe(100); + }); + + it("optional 카테고리는 F여도 페널티(등급 C 제한) 대상이 아니다", () => { + const result = computeResult( + { + categories: [ + { name: "테스트 커버리지", tier: "must", score: 100, recommendations: [], rawFindings: [] }, + { name: "CI/CD", tier: "must", score: 100, recommendations: [], rawFindings: [] }, + { name: "보안 설정", tier: "optional", score: 0, recommendations: [], rawFindings: [] }, + ], + summary: "", + }, + { + "테스트 커버리지": { tier: "must" as const, weight: 0.5 }, + "CI/CD": { tier: "must" as const, weight: 0.5 }, + "보안 설정": { tier: "optional" as const, weight: 0, bonusCap: 5 }, + }, + ); + expect(result.penaltyApplied).toBe(false); + expect(result.totalGrade).toBe("A"); + }); + it("penalty caps grade at C even if weighted average is A", () => { const result = computeResult( makeLLMOutput({