diff --git a/LESSONS.md b/LESSONS.md index fc2b993..d0736cf 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -1,5 +1,16 @@ # LESSONS(実装知見ログ) +## 2026-09-12: settings-advisor のパス判定を root 相対の「パス成分」へ統一(PR #20 レビュー対応) + +- 却下した案: Codex の指摘どおり CI 検知(`.circleci` 等の部分文字列一致)と本番設定検知(絶対パスのディレクトリ名走査)を**別々に**直す案。前者に区切り境界チェックを足し、後者だけ root 相対化する。 +- 決め手: 両者は同一バグの別断面だった。`isCIPath` は絶対パス `slashPath` を見ていたため、`~/.circleci/repos/app/foo.yml` のように**スキャンルート外の祖先ディレクトリ名**でも `HasCI` が立つ(prod 側と同じ祖先混入)。別々に直すと prod 側だけ root 相対になり、CI 側の祖先混入が残る。実際、追加した `TestScanWorkspaceAncestorOutsideRoot/.circleci` は修正前コードで FAIL することを確認した。 +- 覆す条件: スキャンルート外のパス情報(親リポジトリの CI 設定など)を意図的に判定材料に使う要件が出た場合。 + +## 2026-09-11: settings-advisor のモデル選定を traits 駆動へ移行 +- 却下した案: `main.go` 内でモデル名文字列(`Claude Sonnet 4.6 (Thinking)` や `GPT-OSS 120B (Medium)`)をハードコードして分岐する方式 +- 決め手: `models.json` 側のモデル名や定義を更新した際に、コード内のハードコード文字列と不整合が生じて推奨モデルが選定されなくなる。また世代交代(Gemini 3.5→3.8等)のたびに Go ソースの修正・再ビルド・全プラットフォームバイナリ再配布が必要になるという密結合の制約を観測。 +- 覆す条件: モデルの選定要件が `traits` の一致(`instruction-following`, `quota-independent` 等)では表現できず、Go 側で複雑な外部条件分岐や動的プロファイリングが必要になった場合。 + ## 2026-07-02: agy 1.0.15 での hooks/rules 実機再検証 ### 学んだこと diff --git a/PLAN.md b/PLAN.md index 6ada55a..02c0cb5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -195,3 +195,17 @@ PR #19 に寄せられた Codex Review 指摘(Go コード 6件、CI ワーク - [x] ステップ1: `worktree-manager/src/main.go` のバグ・境界条件の修正(main worktree 判定、大文字小文字比較、NUL 区切りパース、ハイフン引数保護、locked 削除、prune stderr 捕捉) - [x] ステップ2: `main_test.go` に指摘事項の再現・回帰テストを追加し全テスト通過を確認 - [x] ステップ3: 決定論的再ビルド(Go 1.26.5)および CI 設定(govulncheck)の確認・検証 + +## フェーズ12: settings-advisor の検知精度向上とモデル選定刷新(完了) + +### 目的 +settings-advisor のスキャン精度(CI/CD、言語、除外ディレクトリ、.env誤検知、本番設定パス)を向上させ、models.json の最新化およびモデル名ハードコードの排除(traits駆動)を行う。 + +### 完了条件 +1. スキャン検知の拡充(GitLab CI等のCI検知、Dart/Swift等の言語追加、.next/target等のスキップ、.env.example等の除外、prodディレクトリ検知)が実装され、テストで検証されていること。 +2. models.json が最新モデルに更新され、main.go のモデル名ハードコードが traits 駆動のマッチングに置き換わっていること。 +3. ドキュメント(README.md / README_ja.md)が同期され、テスト(go test / go vet)およびビルドが通過すること。 + +- [x] ステップ1: スキャン検知機能と除外ロジックの改善・テスト追加 +- [x] ステップ2: models.json 更新と traits 駆動モデル選定ロジックの実装・テスト追加 +- [x] ステップ3: ドキュメント同期(README.md / README_ja.md)とビルド・品質検証 diff --git a/settings-advisor/README.md b/settings-advisor/README.md index a8f14b5..ae549b0 100644 --- a/settings-advisor/README.md +++ b/settings-advisor/README.md @@ -22,9 +22,9 @@ agy plugin install https://github.com/kwrkb/agy-plugins/settings-advisor ## Recommendation Logic -- **Model Tier**: Classifies into `light` / `mid` / `heavy` based on LOC and language diversity. Elevates to `heavy` when `task_hint` contains keywords like refactoring or architecture design. Prioritizes specialized models (e.g. Claude Sonnet for strict instruction following, GPT-OSS for fallback/quota diversity). -- **Sandbox**: Recommends `enableTerminalSandbox: true` upon detecting `.env` or secret configurations. -- **Tool Permissions**: Recommends `proceed-in-sandbox` when CI/CD workflows are present, and `strict` when production configuration files are detected. +- **Model Tier**: Classifies into `light` / `mid` / `heavy` based on LOC and language diversity across supported languages (Go, TS, JS, Python, Rust, Dart, Swift, Vue, Svelte, PHP, Scala, C/C++, C#, Java, Kotlin, Ruby, Zig, Lua, SQL, Shell, Terraform). Excludes build artifacts and package directories (`.next`, `.nuxt`, `out`, `target`, `.dart_tool`, `Pods`, `node_modules`, `dist`, `build`, etc.). Elevates to `heavy` when `task_hint` contains keywords like refactoring or architecture design. Dynamically matches `traits` in `models.json` (e.g. `instruction-following` for Claude Sonnet, `quota-independent` for GPT-OSS). +- **Sandbox**: Recommends `enableTerminalSandbox: true` upon detecting actual `.env` configurations (templates such as `.env.example` or `.env.sample` are safely ignored). +- **Tool Permissions**: Recommends `proceed-in-sandbox` when CI/CD configurations are present (GitHub Actions, GitLab CI, CircleCI, Bitbucket Pipelines, Azure Pipelines), and `strict` when production configuration files or directory paths (`prod` / `production` tokens) are detected. ## Bundled Skill diff --git a/settings-advisor/README_ja.md b/settings-advisor/README_ja.md index a971e0d..45ed76c 100644 --- a/settings-advisor/README_ja.md +++ b/settings-advisor/README_ja.md @@ -29,10 +29,10 @@ agy plugin install https://github.com/kwrkb/agy-plugins/settings-advisor ## 推奨ロジック -- **モデルティア**: 総行数と言語数から `light` / `mid` / `heavy` を判定。`task_hint` に「リファクタ/設計/アーキ/migrate」等が含まれる場合は `heavy` に格上げ。「手順/フォーマット/strict/document」等は instruction-following 重視の **Claude Sonnet 4.6 (Thinking)**、「quota/枯渇/フォールバック/別視点」等は **GPT-OSS 120B** を優先候補にする。 -- **モデル名**: `models.json` に定義し、`agy models` の表示名と一致させている(推奨はそのまま `/model` で選択可能)。 -- **サンドボックス**: `.env`(系)を検出したら `enableTerminalSandbox: true` を提案(機密漏洩防止)。 -- **ツール許可モード(`toolPermission`)**: `.github/workflows` 検出時は `proceed-in-sandbox`、本番設定ファイル(`*.prod.*` / `production.*` 等)検出時は最も厳格な `strict` を提案。値はいずれも agy 本体の有効列挙値(`always-proceed` / `request-review` / `strict` / `proceed-in-sandbox`)。 +- **モデルティア**: 総行数と言語数から `light` / `mid` / `heavy` を判定。Go, TS, JS, Python, Rust, Dart, Swift, Vue, Svelte, PHP, Scala, C/C++, C#, Java, Kotlin, Ruby, Zig, Lua, SQL, Shell, Terraform などの主要開発言語を幅広く走査。ビルド生成物やパッケージキャッシュ(`.next`, `.nuxt`, `out`, `target`, `.dart_tool`, `Pods`, `node_modules`, `dist`, `build` 等)はスキップ。`task_hint` に「リファクタ/設計/アーキ/migrate」等が含まれる場合は `heavy` に格上げ。 +- **モデル名**: `models.json` の定義および `traits`(`instruction-following` による Claude Sonnet、`quota-independent` による GPT-OSS など)に基づき動的に推奨モデルをマッチング。 +- **サンドボックス**: 実機密設定の `.env`(系)を検出したら `enableTerminalSandbox: true` を提案(`.env.example` や `.env.sample` などのテンプレートは誤検知防止のため除外)。 +- **ツール許可モード(`toolPermission`)**: CI/CD 設定(GitHub Actions, GitLab CI, CircleCI, Bitbucket, Azure Pipelines)検出時は `proceed-in-sandbox`、本番設定ファイルまたはパス(ファイル名や `config/prod/` などのディレクトリ名に `prod`/`production` トークンを含む場合)検出時は最も厳格な `strict` を提案。値はいずれも agy 本体の有効列挙値(`always-proceed` / `request-review` / `strict` / `proceed-in-sandbox`)。 ## 同梱スキル diff --git a/settings-advisor/bin/settings-advisor-darwin-arm64 b/settings-advisor/bin/settings-advisor-darwin-arm64 index a371f9c..db8b972 100755 Binary files a/settings-advisor/bin/settings-advisor-darwin-arm64 and b/settings-advisor/bin/settings-advisor-darwin-arm64 differ diff --git a/settings-advisor/bin/settings-advisor-linux-amd64 b/settings-advisor/bin/settings-advisor-linux-amd64 index 0230882..9889061 100755 Binary files a/settings-advisor/bin/settings-advisor-linux-amd64 and b/settings-advisor/bin/settings-advisor-linux-amd64 differ diff --git a/settings-advisor/bin/settings-advisor.exe b/settings-advisor/bin/settings-advisor.exe index 057e022..e5299d9 100755 Binary files a/settings-advisor/bin/settings-advisor.exe and b/settings-advisor/bin/settings-advisor.exe differ diff --git a/settings-advisor/models.json b/settings-advisor/models.json index 1df0f4b..09ea43f 100644 --- a/settings-advisor/models.json +++ b/settings-advisor/models.json @@ -1,8 +1,8 @@ { "models": [ - {"id": "gemini-3.5-flash-low", "name": "Gemini 3.5 Flash (Low)", "tier": "light", "traits": ["fast", "cheap"]}, - {"id": "gemini-3.5-flash-medium", "name": "Gemini 3.5 Flash (Medium)", "tier": "light", "traits": ["fast", "balanced"]}, - {"id": "gemini-3.5-flash-high", "name": "Gemini 3.5 Flash (High)", "tier": "mid", "traits": ["accurate", "cost-effective"]}, + {"id": "gemini-3.8-flash-low", "name": "Gemini 3.8 Flash (Low)", "tier": "light", "traits": ["fast", "cheap"]}, + {"id": "gemini-3.8-flash-medium", "name": "Gemini 3.8 Flash (Medium)", "tier": "light", "traits": ["fast", "balanced"]}, + {"id": "gemini-3.8-flash-high", "name": "Gemini 3.8 Flash (High)", "tier": "mid", "traits": ["accurate", "cost-effective"]}, {"id": "gemini-3.1-pro-low", "name": "Gemini 3.1 Pro (Low)", "tier": "mid", "traits": ["accurate", "context-long"]}, {"id": "gemini-3.1-pro-high", "name": "Gemini 3.1 Pro (High)", "tier": "heavy", "traits": ["most-accurate", "context-long"]}, {"id": "claude-sonnet-4.6", "name": "Claude Sonnet 4.6 (Thinking)", "tier": "mid", "traits": ["instruction-following", "format-strict"]}, diff --git a/settings-advisor/src/main.go b/settings-advisor/src/main.go index 26bdde3..551c4be 100644 --- a/settings-advisor/src/main.go +++ b/settings-advisor/src/main.go @@ -39,20 +39,45 @@ type WorkspaceMetrics struct { } var targetExts = map[string]bool{ - ".go": true, - ".ts": true, - ".js": true, - ".py": true, - ".rs": true, - ".rb": true, - ".java": true, - ".kt": true, - ".cs": true, - ".cpp": true, - ".c": true, - ".html": true, - ".css": true, - ".sh": true, + ".go": true, + ".ts": true, + ".js": true, + ".py": true, + ".rs": true, + ".rb": true, + ".java": true, + ".kt": true, + ".cs": true, + ".cpp": true, + ".c": true, + ".html": true, + ".css": true, + ".sh": true, + ".dart": true, + ".swift": true, + ".vue": true, + ".svelte": true, + ".php": true, + ".scala": true, + ".zig": true, + ".lua": true, + ".sql": true, + ".tf": true, +} + +var skipDirs = map[string]bool{ + ".git": true, + "node_modules": true, + "vendor": true, + ".venv": true, + "dist": true, + "build": true, + "target": true, + ".next": true, + ".nuxt": true, + "out": true, + ".dart_tool": true, + "Pods": true, } func main() { @@ -148,9 +173,9 @@ func scanWorkspace(root string) (WorkspaceMetrics, error) { } if info.IsDir() { - name := info.Name() - // 巨大なディレクトリはスキップ - if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" || name == "build" { + // ルート自身は利用者が明示指定したパスなので、名前が skipDirs に + // 一致しても走査する(例: "target" というリポジトリを直接指定)。 + if path != root && skipDirs[info.Name()] { return filepath.SkipDir } return nil @@ -158,27 +183,36 @@ func scanWorkspace(root string) (WorkspaceMetrics, error) { fileName := strings.ToLower(info.Name()) - // .env 検知 - if fileName == ".env" || strings.HasPrefix(fileName, ".env.") { + // .env 検知(テンプレート・サンプルは除外) + if isEnvFile(fileName) { metrics.HasEnv = true } - // CI/CD 検知。Windows では filepath.Walk が "\" 区切りを返すため、 - // スラッシュ正規化してから判定する。 - if strings.Contains(filepath.ToSlash(path), ".github/workflows") { + // パス由来の判定は root からの相対パスのみを見る。絶対パスを使うと + // ワークスペース外の祖先ディレクトリ名(例: /mnt/production/repos/app, + // ~/.circleci/repos/app)が誤検知の原因になる。 + dirParts, relOK := relDirComponents(root, path) + + // CI/CD 検知(GitHub Actions, GitLab CI, CircleCI, Bitbucket, Azure) + if isCIPath(dirParts, relOK, fileName) { metrics.HasCI = true } ext := filepath.Ext(fileName) // 本番設定ファイル検知。"product.json" / "reproduce.yaml" 等の誤検知を避けるため、 - // 拡張子を除いた名前を区切り(. - _)でトークン化し "prod"/"production" 単独一致のみ採る。 + // 拡張子を除いたファイル名およびパス中のディレクトリ名を区切り(. - _)でトークン化し + // "prod"/"production" 単独一致のみ採る。 if ext == ".json" || ext == ".yaml" || ext == ".yml" || ext == ".toml" { base := strings.TrimSuffix(fileName, ext) - for _, tok := range strings.FieldsFunc(base, isNameSeparator) { - if tok == "prod" || tok == "production" { - metrics.HasProdConfig = true - break + if hasProdToken(base) { + metrics.HasProdConfig = true + } else if relOK { + for _, part := range dirParts { + if hasProdToken(strings.ToLower(part)) { + metrics.HasProdConfig = true + break + } } } } @@ -204,7 +238,90 @@ func scanWorkspace(root string) (WorkspaceMetrics, error) { return metrics, err } -// isNameSeparator はファイル名トークン分割に使う区切り文字を判定する。 +// envTemplateMarkers は「中身がダミーのテンプレート」を示す末尾トークン。 +var envTemplateMarkers = map[string]bool{ + "example": true, + "sample": true, + "template": true, + "dist": true, + "test": true, + "defaults": true, + "schema": true, +} + +// isEnvFile は実値が入った .env ファイルであるかを判定する(.env.example 等のテンプレートは除外)。 +// 判定は接尾辞の完全一致ではなく**末尾トークン**で行う。`.env.production.example` や +// `.env.local.sample` のような修飾付きテンプレートを取りこぼさず、かつ `.env.production` +// や `.env.test.local`(実値を持つ)は env ファイルとして残すため。 +func isEnvFile(fileName string) bool { + if fileName == ".env" { + return true + } + if strings.HasPrefix(fileName, ".env.") { + suffix := strings.TrimPrefix(fileName, ".env.") + parts := strings.Split(suffix, ".") + return !envTemplateMarkers[parts[len(parts)-1]] + } + return false +} + +// relDirComponents は root から path までの相対パスの「ディレクトリ部分」を +// パス区切りで分解して返す。path が root 配下でない、または相対化できない場合は +// ok=false を返し、呼び出し側はパス由来の判定を行わない。 +func relDirComponents(root, path string) ([]string, bool) { + rel, err := filepath.Rel(root, path) + if err != nil { + return nil, false + } + slashRel := filepath.ToSlash(rel) + if slashRel == ".." || strings.HasPrefix(slashRel, "../") { + return nil, false + } + dir := filepath.ToSlash(filepath.Dir(slashRel)) + if dir == "." || dir == "" { + return nil, true // root 直下のファイル(ディレクトリ成分なし) + } + return strings.Split(dir, "/"), true +} + +// isCIPath は CI/CD 関連のパスまたは設定ファイルであるかを判定する。 +// dirParts は root からの相対ディレクトリ成分。部分文字列ではなくパス成分単位で +// 比較し、".circleci-disabled/" や ".gitlab/circle/" のような近似名を弾く。 +func isCIPath(dirParts []string, relOK bool, fileName string) bool { + if fileName == ".gitlab-ci.yml" || + fileName == "bitbucket-pipelines.yml" || + fileName == "azure-pipelines.yml" { + return true + } + if !relOK { + return false + } + for i, part := range dirParts { + if part == ".circleci" { + return true + } + if i+1 < len(dirParts) { + next := dirParts[i+1] + if (part == ".github" && next == "workflows") || + (part == ".gitlab" && next == "ci") { + return true + } + } + } + return false +} + +// hasProdToken はトークン分割した名前に prod / production が含まれるかを判定する。 +func hasProdToken(name string) bool { + for _, tok := range strings.FieldsFunc(name, isNameSeparator) { + if tok == "prod" || tok == "production" { + return true + } + } + return false +} + +// isNameSeparator はファイル名・ディレクトリ名トークン分割に使う区切り文字を判定する。 func isNameSeparator(r rune) bool { return r == '.' || r == '-' || r == '_' } @@ -347,18 +464,33 @@ func generateRecommendations(metrics WorkspaceMetrics, modelCfg ModelConfig, tas } } - // 特化モデルの優先 - preferModel := "" + // 特化モデルの優先(traits 駆動のマッチング) + var targetTrait string for _, kw := range midSonnetKeywords { if strings.Contains(taskHintLower, kw) { - preferModel = "Claude Sonnet 4.6 (Thinking)" + targetTrait = "instruction-following" break } } - if preferModel == "" { + if targetTrait == "" { for _, kw := range midOSSKeywords { if strings.Contains(taskHintLower, kw) { - preferModel = "GPT-OSS 120B (Medium)" + targetTrait = "quota-independent" + break + } + } + } + + preferModel := "" + if targetTrait != "" { + for _, m := range modelCfg.Models { + for _, tr := range m.Traits { + if tr == targetTrait { + preferModel = m.Name + break + } + } + if preferModel != "" { break } } diff --git a/settings-advisor/src/main_test.go b/settings-advisor/src/main_test.go index 6099f4a..140759e 100644 --- a/settings-advisor/src/main_test.go +++ b/settings-advisor/src/main_test.go @@ -22,6 +22,8 @@ func TestScanWorkspace(t *testing.T) { writeFile("main.go", "package main\nfunc main() {}\n") // go writeFile("app.ts", "export const x = 1\n") // ts + writeFile("lib.dart", "void main() {}\n") // dart + writeFile("App.swift", "import Foundation\n") // swift writeFile(".env", "SECRET=1\n") // → HasEnv writeFile(".github/workflows/ci.yml", "on: push\n") // → HasCI writeFile("config.production.json", "{}\n") // → HasProdConfig(トークン一致) @@ -44,11 +46,99 @@ func TestScanWorkspace(t *testing.T) { t.Error("expected HasProdConfig=true (config.production.json)") } // node_modules はスキップされるので js は言語に含まれない。決定論的にソート済み。 - if want := []string{"go", "ts"}; !reflect.DeepEqual(m.Languages, want) { + if want := []string{"dart", "go", "swift", "ts"}; !reflect.DeepEqual(m.Languages, want) { t.Errorf("Languages = %v, want %v (sorted, node_modules excluded)", m.Languages, want) } } +func TestScanWorkspaceCIAndProdDetection(t *testing.T) { + root := t.TempDir() + + writeFile := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + + writeFile(".gitlab-ci.yml", "stages:\n - test\n") + writeFile("config/prod/settings.json", "{}\n") // ディレクトリ名による prod 検出 + + m, err := scanWorkspace(root) + if err != nil { + t.Fatalf("scanWorkspace error: %v", err) + } + + if !m.HasCI { + t.Error("expected HasCI=true (.gitlab-ci.yml)") + } + if !m.HasProdConfig { + t.Error("expected HasProdConfig=true (config/prod/settings.json)") + } +} + +func TestScanWorkspaceEnvFilter(t *testing.T) { + t.Run("Env templates excluded", func(t *testing.T) { + root := t.TempDir() + for _, name := range []string{".env.example", ".env.sample", ".env.template", ".env.dist"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("A=B\n"), 0o644); err != nil { + t.Fatal(err) + } + } + m, err := scanWorkspace(root) + if err != nil { + t.Fatal(err) + } + if m.HasEnv { + t.Error("expected HasEnv=false when only .env template/sample files exist") + } + }) + + t.Run("Actual env included", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".env.production"), []byte("SECRET=1\n"), 0o644); err != nil { + t.Fatal(err) + } + m, err := scanWorkspace(root) + if err != nil { + t.Fatal(err) + } + if !m.HasEnv { + t.Error("expected HasEnv=true for .env.production") + } + }) +} + +func TestScanWorkspaceSkipDirs(t *testing.T) { + root := t.TempDir() + writeFile := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + + writeFile("main.go", "package main\n") + writeFile("target/debug/app.rs", "fn main() {}\n") + writeFile(".next/static/app.js", "console.log(1)\n") + writeFile(".dart_tool/package_config.json", "{}\n") + writeFile("Pods/SomeLib/lib.swift", "import UIKit\n") + + m, err := scanWorkspace(root) + if err != nil { + t.Fatal(err) + } + if want := []string{"go"}; !reflect.DeepEqual(m.Languages, want) { + t.Errorf("Languages = %v, want %v (build artifact directories should be skipped)", m.Languages, want) + } +} + func TestScanWorkspaceProdFalsePositive(t *testing.T) { root := t.TempDir() for _, name := range []string{"product.json", "reproduce.yaml", "productivity.toml"} { @@ -69,9 +159,9 @@ func TestGenerateRecommendations(t *testing.T) { // テスト用モデル定義 modelCfg := ModelConfig{ Models: []Model{ - {"gemini-3.5-flash-low", "Gemini 3.5 Flash (Low)", "light", []string{"fast", "cheap"}}, - {"gemini-3.5-flash-medium", "Gemini 3.5 Flash (Medium)", "light", []string{"fast", "balanced"}}, - {"gemini-3.5-flash-high", "Gemini 3.5 Flash (High)", "mid", []string{"accurate", "cost-effective"}}, + {"gemini-3.8-flash-low", "Gemini 3.8 Flash (Low)", "light", []string{"fast", "cheap"}}, + {"gemini-3.8-flash-medium", "Gemini 3.8 Flash (Medium)", "light", []string{"fast", "balanced"}}, + {"gemini-3.8-flash-high", "Gemini 3.8 Flash (High)", "mid", []string{"accurate", "cost-effective"}}, {"gemini-3.1-pro-low", "Gemini 3.1 Pro (Low)", "mid", []string{"accurate", "context-long"}}, {"gemini-3.1-pro-high", "Gemini 3.1 Pro (High)", "heavy", []string{"most-accurate", "context-long"}}, {"claude-sonnet-4.6", "Claude Sonnet 4.6 (Thinking)", "mid", []string{"instruction-following", "format-strict"}}, @@ -96,7 +186,7 @@ func TestGenerateRecommendations(t *testing.T) { } found := false for _, m := range rec.SuggestedModels { - if m == "Gemini 3.5 Flash (Medium)" || m == "Gemini 3.5 Flash (Low)" { + if m == "Gemini 3.8 Flash (Medium)" || m == "Gemini 3.8 Flash (Low)" { found = true } } @@ -164,8 +254,8 @@ func TestGenerateRecommendations(t *testing.T) { } }) - // テストケース 3: 特化キーワード (Sonnet) - t.Run("Specialized task - Sonnet", func(t *testing.T) { + // テストケース 3: 特化キーワード (instruction-following trait -> Sonnet) + t.Run("Specialized task - Sonnet via trait", func(t *testing.T) { metrics := WorkspaceMetrics{ TotalLines: 10000, Languages: []string{"go"}, @@ -177,6 +267,19 @@ func TestGenerateRecommendations(t *testing.T) { } }) + // テストケース 3b: 特化キーワード (quota-independent trait -> GPT-OSS) + t.Run("Specialized task - GPT-OSS via trait", func(t *testing.T) { + metrics := WorkspaceMetrics{ + TotalLines: 10000, + Languages: []string{"go"}, + } + rec := generateRecommendations(metrics, modelCfg, "quota枯渇時のフォールバックとして実行") + + if rec.SuggestedModels[0] != "GPT-OSS 120B (Medium)" { + t.Errorf("Expected GPT-OSS 120B (Medium) as first suggestion, got '%s'", rec.SuggestedModels[0]) + } + }) + // テストケース 4: セキュリティ推奨 t.Run("Security recommendations", func(t *testing.T) { metrics := WorkspaceMetrics{ @@ -206,3 +309,166 @@ func TestGenerateRecommendations(t *testing.T) { } }) } + +// TestScanWorkspaceRootNamedSkipDir は、利用者が明示指定したルート自身が +// skipDirs に一致する名前(target / out / Pods 等)でも走査されることを確認する。 +func TestScanWorkspaceRootNamedSkipDir(t *testing.T) { + for _, name := range []string{"target", "out", "Pods", "build", "dist"} { + t.Run(name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + + m, err := scanWorkspace(root) + if err != nil { + t.Fatalf("scanWorkspace error: %v", err) + } + if want := []string{"go"}; !reflect.DeepEqual(m.Languages, want) { + t.Errorf("Languages = %v, want %v (explicitly requested root must not be skipped)", m.Languages, want) + } + if m.TotalLines == 0 { + t.Error("TotalLines = 0, want > 0 (explicitly requested root must not be skipped)") + } + }) + } +} + +// TestScanWorkspaceCINearMissDirs は、CI ディレクトリの近似名が部分文字列一致で +// HasCI を立てないことを確認する(パス成分単位の比較)。 +func TestScanWorkspaceCINearMissDirs(t *testing.T) { + root := t.TempDir() + writeFile := func(rel, body string) { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + + writeFile(".circleci-disabled/job.yml", "jobs: {}\n") + writeFile(".gitlab/circle/config.yml", "x: 1\n") + writeFile(".github/workflows-archive/old.yml", "on: push\n") + writeFile(".gitlab/ci-templates/base.yml", "x: 1\n") + writeFile("docs/circleci.md", "notes\n") + + m, err := scanWorkspace(root) + if err != nil { + t.Fatalf("scanWorkspace error: %v", err) + } + if m.HasCI { + t.Error("expected HasCI=false for directories that only resemble CI directory names") + } +} + +// TestScanWorkspaceCIComponentMatch は、成分単位比較にしても本来の CI ディレクトリを +// 取りこぼさないことを確認する(ネストした配置を含む)。 +func TestScanWorkspaceCIComponentMatch(t *testing.T) { + cases := map[string]string{ + ".github/workflows/ci.yml": "on: push\n", + ".gitlab/ci/build.yml": "x: 1\n", + ".circleci/config.yml": "version: 2.1\n", + "sub/.github/workflows/build.yml": "on: push\n", + "sub/.circleci/config.yml": "version: 2.1\n", + } + for rel, body := range cases { + t.Run(rel, func(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + m, err := scanWorkspace(root) + if err != nil { + t.Fatalf("scanWorkspace error: %v", err) + } + if !m.HasCI { + t.Errorf("expected HasCI=true for %s", rel) + } + }) + } +} + +// TestScanWorkspaceAncestorOutsideRoot は、スキャンルートより上位(ワークスペース外)の +// ディレクトリ名が prod / CI 判定に混入しないことを確認する。 +func TestScanWorkspaceAncestorOutsideRoot(t *testing.T) { + for _, ancestor := range []string{"production", "prod", ".circleci"} { + t.Run(ancestor, func(t *testing.T) { + root := filepath.Join(t.TempDir(), ancestor, "repos", "app") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "settings.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + m, err := scanWorkspace(root) + if err != nil { + t.Fatalf("scanWorkspace error: %v", err) + } + if m.HasProdConfig { + t.Errorf("expected HasProdConfig=false: %q ancestor is outside the scan root", ancestor) + } + if m.HasCI { + t.Errorf("expected HasCI=false: %q ancestor is outside the scan root", ancestor) + } + }) + } +} + +// TestIsEnvFileQualifiedTemplates は修飾付きテンプレート(.env.production.example 等)を +// 除外しつつ、実値を持つ .env.production / .env.test.local を env ファイルとして残すことを確認する。 +func TestIsEnvFileQualifiedTemplates(t *testing.T) { + cases := map[string]bool{ + ".env": true, + ".env.production": true, + ".env.local": true, + ".env.test.local": true, + ".env.production.local": true, + ".env.example": false, + ".env.sample": false, + ".env.template": false, + ".env.dist": false, + ".env.test": false, + ".env.defaults": false, + ".env.schema": false, + ".env.production.example": false, + ".env.local.sample": false, + ".env.staging.template": false, + ".env.production.dist": false, + "env.production": false, // 先頭ドット無しは対象外 + "main.go": false, + } + for name, want := range cases { + if got := isEnvFile(name); got != want { + t.Errorf("isEnvFile(%q) = %v, want %v", name, got, want) + } + } +} + +// TestScanWorkspaceQualifiedEnvTemplates は修飾付きテンプレートだけのワークスペースで +// HasEnv が立たない(=不要な sandbox 推奨が出ない)ことを確認する。 +func TestScanWorkspaceQualifiedEnvTemplates(t *testing.T) { + root := t.TempDir() + for _, name := range []string{".env.production.example", ".env.local.sample", ".env.staging.template"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("A=B\n"), 0o644); err != nil { + t.Fatal(err) + } + } + m, err := scanWorkspace(root) + if err != nil { + t.Fatal(err) + } + if m.HasEnv { + t.Error("expected HasEnv=false for qualified env templates (.env..example etc.)") + } +}