From 9be34ca2be05c36c74b66c30674d19bc8cdd7a31 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 11:53:46 +0900 Subject: [PATCH 1/9] feat: enhance MCP server with detailed coding workflow instructions and validation messages --- internal/mcp/server.go | 78 ++++++++++++++++++++++++++--- internal/validator/llm_validator.go | 5 ++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 3588b02..c71e8e9 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -272,6 +272,7 @@ func (s *Server) handleQueryConventions(params map[string]interface{}) (interfac return map[string]interface{}{ "conventions": conventions, "total": len(conventions), + "next_step": "Now implement your code following these conventions. After completion, MUST call validate_code to verify compliance.", }, nil } @@ -418,10 +419,20 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } } + var message string + if hasErrors { + message = "VALIDATION FAILED: Found error-level violations. You MUST fix these issues and re-validate before proceeding." + } else if len(allViolations) > 0 { + message = "VALIDATION WARNING: Found non-critical violations. Consider fixing these warnings for better code quality." + } else { + message = "✓ VALIDATION PASSED: Code complies with all conventions. Task can be marked as complete." + } + return map[string]interface{}{ "valid": !hasErrors, "violations": allViolations, "total": len(allViolations), + "message": message, }, nil } @@ -449,6 +460,27 @@ func (s *Server) handleInitialize(params map[string]interface{}) (interface{}, * "name": "symphony", "version": "1.0.0", }, + "instructions": `Symphony Code Convention Enforcer + +MANDATORY WORKFLOW for all coding tasks: + +STEP 1 [BEFORE CODE]: Query Conventions +→ Call query_conventions tool FIRST before writing any code +→ Filter by category (security, style, architecture, etc.) +→ Filter by language/files you'll work with +→ Review and understand the conventions + +STEP 2 [DURING CODE]: Write Code +→ Implement your code following the conventions from Step 1 +→ Keep security, style, and architecture guidelines in mind + +STEP 3 [AFTER CODE]: Validate Code +→ Call validate_code tool LAST after completing implementation +→ MANDATORY: Must validate before marking task complete +→ Fix any violations found and re-validate +→ Only proceed when validation passes with no errors + +This 3-step workflow ensures all code meets project standards. Never skip steps 1 and 3.`, }, nil } @@ -457,14 +489,26 @@ func (s *Server) handleInitialize(params map[string]interface{}) (interface{}, * func (s *Server) handleToolsList(params map[string]interface{}) (interface{}, *RPCError) { tools := []map[string]interface{}{ { - "name": "query_conventions", - "description": "Query conventions for given context (category, files, languages)", + "name": "query_conventions", + "description": `[STEP 1 - ALWAYS CALL FIRST] Query coding conventions and best practices before writing any code. + +CRITICAL WORKFLOW: +1. ALWAYS call this tool FIRST when starting any coding task +2. Query relevant conventions by category (security, style, architecture, etc.) +3. Query conventions for specific files/languages you'll be working with +4. Use the returned conventions to guide your code implementation + +This ensures your code follows project standards from the start. Never skip this step. + +Categories available: security, style, documentation, error_handling, architecture, performance, testing + +Example: Before implementing authentication, query security conventions first.`, "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "category": map[string]interface{}{ "type": "string", - "description": "Filter by category (naming, formatting, security, etc.)", + "description": "Filter by category (security, style, documentation, error_handling, architecture, performance, testing)", }, "files": map[string]interface{}{ "type": "array", @@ -474,27 +518,45 @@ func (s *Server) handleToolsList(params map[string]interface{}) (interface{}, *R "languages": map[string]interface{}{ "type": "array", "items": map[string]string{"type": "string"}, - "description": "Programming languages to filter by", + "description": "Programming languages to filter by (go, javascript, python, java, etc.)", }, }, }, }, { - "name": "validate_code", - "description": "Validate code compliance with conventions", + "name": "validate_code", + "description": `[STEP 3 - ALWAYS CALL LAST] Validate that your code complies with all project conventions. + +CRITICAL WORKFLOW: +1. Call this tool AFTER you have written or modified code +2. MANDATORY: Always validate before considering the task complete +3. If violations are found, fix them and validate again +4. Only mark the task as done after validation passes with no errors + +This is the final quality gate. Never skip this validation step. + +The tool will check: +- Security violations (hardcoded secrets, SQL injection, XSS, etc.) +- Style violations (formatting, naming, documentation) +- Architecture violations (separation of concerns, patterns) +- Error handling violations (missing error checks, empty catch blocks) + +If violations are found, you MUST fix them before proceeding.`, "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "files": map[string]interface{}{ "type": "array", "items": map[string]string{"type": "string"}, - "description": "File paths to validate", + "description": "File paths to validate (required)", + "required": true, }, "role": map[string]interface{}{ "type": "string", - "description": "RBAC role for validation", + "description": "RBAC role for validation (optional)", }, }, + "required": []string{"files"}, }, }, } diff --git a/internal/validator/llm_validator.go b/internal/validator/llm_validator.go index 674669e..0062fda 100644 --- a/internal/validator/llm_validator.go +++ b/internal/validator/llm_validator.go @@ -50,6 +50,11 @@ func (v *LLMValidator) Validate(ctx context.Context, changes []GitChange) (*Vali } addedLines := ExtractAddedLines(change.Diff) + // If no git diff format detected, treat entire diff as code to validate + if len(addedLines) == 0 && strings.TrimSpace(change.Diff) != "" { + addedLines = strings.Split(change.Diff, "\n") + } + if len(addedLines) == 0 { continue } From 4934bd5da69365fc6710d2f45fe2830564ecf2f4 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 11:54:07 +0900 Subject: [PATCH 2/9] feat: update default engine in converter and enhance test cases with llm-validator integration --- internal/converter/converter.go | 21 ++++++++++----------- tests/e2e/full_workflow_test.go | 6 ++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 3c61dba..2d9821a 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -200,10 +200,9 @@ func (c *Converter) convertRule(userRule *schema.UserRule, defaults *schema.User } } - // TODO: Implement intelligent rule inference based on userRule.Say - // For now, create a basic check structure + // For now, create a basic check structure with llm-validator as default engine check := map[string]any{ - "engine": "custom", + "engine": "llm-validator", "desc": userRule.Say, } @@ -252,10 +251,10 @@ type MultiTargetConvertOptions struct { // MultiTargetConvertResult represents the result of multi-target conversion type MultiTargetConvertResult struct { - CodePolicy *schema.CodePolicy // Internal policy - LinterConfigs map[string]*linters.LinterConfig // Linter-specific configs - Results map[string]*linters.ConversionResult // Detailed results per linter - Warnings []string // Overall warnings + CodePolicy *schema.CodePolicy // Internal policy + LinterConfigs map[string]*linters.LinterConfig // Linter-specific configs + Results map[string]*linters.ConversionResult // Detailed results per linter + Warnings []string // Overall warnings } // ConvertMultiTarget converts user policy to multiple linter configurations @@ -330,10 +329,10 @@ func (c *Converter) ConvertMultiTarget(ctx context.Context, userPolicy *schema.U // convertForLinter converts rules for a specific linter func (c *Converter) convertForLinter(ctx context.Context, userPolicy *schema.UserPolicy, converter linters.LinterConverter, confidenceThreshold float64) (*linters.ConversionResult, error) { result := &linters.ConversionResult{ - LinterName: converter.Name(), - Rules: []*linters.LinterRule{}, - Warnings: []string{}, - Errors: []error{}, + LinterName: converter.Name(), + Rules: []*linters.LinterRule{}, + Warnings: []string{}, + Errors: []error{}, RuleEngineMap: make(map[string]string), // Track which engine handles each rule } diff --git a/tests/e2e/full_workflow_test.go b/tests/e2e/full_workflow_test.go index 1f3cacc..f9eaf96 100644 --- a/tests/e2e/full_workflow_test.go +++ b/tests/e2e/full_workflow_test.go @@ -317,9 +317,15 @@ func TestE2E_CodeGenerationFeedbackLoop(t *testing.T) { Rules: []schema.PolicyRule{ { ID: "SEC-001", + Enabled: true, Category: "security", Severity: "error", Message: "No hardcoded API keys", + Desc: "API keys should not be hardcoded in source code", + Check: map[string]any{ + "engine": "llm-validator", + "desc": "API keys should not be hardcoded in source code", + }, }, }, } From 587f2afbce8794661b3ea26012f84f693359839e Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 13:52:31 +0900 Subject: [PATCH 3/9] feat: enhance MCP server to support user and code policies --- internal/mcp/server.go | 106 +++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index c71e8e9..6b150c1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -21,7 +21,8 @@ type Server struct { host string port int configPath string - policy *schema.CodePolicy + userPolicy *schema.UserPolicy // Original user-written policy + codePolicy *schema.CodePolicy // Converted validation policy loader *policy.Loader } @@ -39,12 +40,27 @@ func NewServer(host string, port int, configPath string) *Server { // It communicates via JSON-RPC over stdio or HTTP. func (s *Server) Start() error { if s.configPath != "" { - codePolicy, err := s.loader.LoadCodePolicy(s.configPath) - if err != nil { - return fmt.Errorf("failed to load policy: %w", err) + // Determine the directory and try to load both user-policy and code-policy + dir := filepath.Dir(s.configPath) + + // Try to load user-policy.json for natural language descriptions + userPolicyPath := filepath.Join(dir, "user-policy.json") + if userPolicy, err := s.loader.LoadUserPolicy(userPolicyPath); err == nil { + s.userPolicy = userPolicy + fmt.Fprintf(os.Stderr, "✓ User policy loaded: %s (%d rules)\n", userPolicyPath, len(userPolicy.Rules)) + } + + // Try to load code-policy.json for validation + codePolicyPath := filepath.Join(dir, "code-policy.json") + if codePolicy, err := s.loader.LoadCodePolicy(codePolicyPath); err == nil { + s.codePolicy = codePolicy + fmt.Fprintf(os.Stderr, "✓ Code policy loaded: %s (%d rules)\n", codePolicyPath, len(codePolicy.Rules)) + } + + // At least one policy must be loaded + if s.userPolicy == nil && s.codePolicy == nil { + return fmt.Errorf("no policy found in %s", dir) } - s.policy = codePolicy - fmt.Fprintf(os.Stderr, "policy loaded: %s\n", s.configPath) } if s.port > 0 { @@ -251,7 +267,7 @@ type ConventionItem struct { // handleQueryConventions handles convention query requests. // It finds and returns relevant conventions by category. func (s *Server) handleQueryConventions(params map[string]interface{}) (interface{}, *RPCError) { - if s.policy == nil { + if s.userPolicy == nil && s.codePolicy == nil { return map[string]interface{}{ "conventions": []ConventionItem{}, "message": "policy not loaded", @@ -280,26 +296,54 @@ func (s *Server) handleQueryConventions(params map[string]interface{}) (interfac func (s *Server) filterConventions(req QueryConventionsRequest) []ConventionItem { var conventions []ConventionItem - for _, rule := range s.policy.Rules { - if !rule.Enabled { - continue - } + // If UserPolicy is loaded, use natural language rules + if s.userPolicy != nil { + for _, rule := range s.userPolicy.Rules { + if req.Category != "" && rule.Category != req.Category { + continue + } - if req.Category != "" && rule.Category != req.Category { - continue - } + // Check language relevance + if len(req.Languages) > 0 && len(rule.Languages) > 0 { + if !containsAny(rule.Languages, req.Languages) { + continue + } + } - if !s.isRuleRelevant(rule, req) { - continue + conventions = append(conventions, ConventionItem{ + ID: rule.ID, + Category: rule.Category, + Description: rule.Say, // Use natural language description + Message: rule.Message, + Severity: rule.Severity, + }) } + return conventions + } - conventions = append(conventions, ConventionItem{ - ID: rule.ID, - Category: rule.Category, - Description: rule.Desc, - Message: rule.Message, - Severity: rule.Severity, - }) + // Fallback to CodePolicy if UserPolicy not available + if s.codePolicy != nil { + for _, rule := range s.codePolicy.Rules { + if !rule.Enabled { + continue + } + + if req.Category != "" && rule.Category != req.Category { + continue + } + + if !s.isRuleRelevant(rule, req) { + continue + } + + conventions = append(conventions, ConventionItem{ + ID: rule.ID, + Category: rule.Category, + Description: rule.Desc, + Message: rule.Message, + Severity: rule.Severity, + }) + } } return conventions @@ -368,10 +412,12 @@ type ViolationItem struct { // handleValidateCode handles code validation requests. // It uses the existing validator to validate code. func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, *RPCError) { - if s.policy == nil { + // Get policy for validation (convert UserPolicy if needed) + validationPolicy, err := s.getValidationPolicy() + if err != nil { return nil, &RPCError{ Code: -32000, - Message: "policy not loaded", + Message: fmt.Sprintf("policy not available: %v", err), } } @@ -388,7 +434,7 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, req.Files = []string{"."} } - v := validator.NewValidator(s.policy, false) // verbose = false for MCP + v := validator.NewValidator(validationPolicy, false) // verbose = false for MCP var allViolations []ViolationItem var hasErrors bool @@ -448,6 +494,14 @@ func containsAny(haystack, needles []string) bool { return false } +// getValidationPolicy returns CodePolicy for validation. +func (s *Server) getValidationPolicy() (*schema.CodePolicy, error) { + if s.codePolicy != nil { + return s.codePolicy, nil + } + return nil, fmt.Errorf("no code policy loaded - validation requires code policy") +} + // handleInitialize handles MCP initialize request. // This is the first request from the client to establish protocol version and capabilities. func (s *Server) handleInitialize(params map[string]interface{}) (interface{}, *RPCError) { From e56a2321fc4a636c94815a2004bab889274285b1 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 14:20:00 +0900 Subject: [PATCH 4/9] refactor: update policy validation to use rule IDs instead of "no" --- internal/policy/manager.go | 12 +++--- internal/policy/templates/go-template.json | 20 ++++----- internal/policy/templates/node-template.json | 20 ++++----- .../policy/templates/python-template.json | 20 ++++----- internal/policy/templates/react-template.json | 20 ++++----- .../policy/templates/typescript-template.json | 20 ++++----- internal/policy/templates/vue-template.json | 20 ++++----- internal/server/static/policy-editor.js | 42 +++++++++---------- pkg/schema/types.go | 5 +-- 9 files changed, 88 insertions(+), 91 deletions(-) diff --git a/internal/policy/manager.go b/internal/policy/manager.go index 0515f66..52777c8 100644 --- a/internal/policy/manager.go +++ b/internal/policy/manager.go @@ -92,18 +92,18 @@ func ValidatePolicy(policy *schema.UserPolicy) error { } // Validate rules - ruleNumbers := make(map[int]bool) + ruleIDs := make(map[string]bool) for i, rule := range policy.Rules { if rule.Say == "" { return fmt.Errorf("rule %d: 'say' field is required", i+1) } - if rule.No <= 0 { - return fmt.Errorf("rule %d: 'no' field must be positive", i+1) + if rule.ID == "" { + return fmt.Errorf("rule %d: 'id' field is required", i+1) } - if ruleNumbers[rule.No] { - return fmt.Errorf("duplicate rule number: %d", rule.No) + if ruleIDs[rule.ID] { + return fmt.Errorf("duplicate rule id: %s", rule.ID) } - ruleNumbers[rule.No] = true + ruleIDs[rule.ID] = true } // Validate RBAC roles diff --git a/internal/policy/templates/go-template.json b/internal/policy/templates/go-template.json index 4c01723..884c81f 100644 --- a/internal/policy/templates/go-template.json +++ b/internal/policy/templates/go-template.json @@ -15,54 +15,54 @@ }, "rules": [ { - "no": 1, + "id": "1", "say": "패키지 이름은 소문자 한 단어로 작성합니다 (예: http, json, user)", "category": "naming", "example": "// ✅ 좋은 예:\npackage user\npackage database\npackage httputil\n\n// ❌ 나쁜 예:\npackage UserManagement\npackage data_base\npackage HTTP_Util" }, { - "no": 2, + "id": "2", "say": "변수와 함수 이름은 camelCase 또는 PascalCase를 사용합니다 (public은 대문자 시작)", "category": "naming" }, { - "no": 3, + "id": "3", "say": "에러는 항상 처리하고, 무시하는 경우 명시적으로 _ 를 사용합니다", "category": "error_handling", "example": "// ✅ 좋은 예:\nfile, err := os.Open(\"config.json\")\nif err != nil {\n return fmt.Errorf(\"failed to open config: %w\", err)\n}\ndefer file.Close()\n\n// ❌ 나쁜 예:\nfile, _ := os.Open(\"config.json\")\ndefer file.Close()" }, { - "no": 4, + "id": "4", "say": "함수는 에러를 마지막 반환값으로 반환합니다", "category": "error_handling" }, { - "no": 5, + "id": "5", "say": "인터페이스 이름은 -er 접미사를 사용합니다 (예: Reader, Writer, Handler)", "category": "naming" }, { - "no": 6, + "id": "6", "say": "gofmt로 코드를 포맷팅합니다", "category": "formatting" }, { - "no": 7, + "id": "7", "say": "공개(exported) 함수와 타입에는 주석을 작성합니다", "category": "documentation" }, { - "no": 8, + "id": "8", "say": "context.Context를 함수의 첫 번째 파라미터로 전달합니다", "category": "error_handling" }, { - "no": 9, + "id": "9", "say": "goroutine 사용 시 적절한 동기화(sync, channel)를 사용합니다", "category": "error_handling" }, { - "no": 10, + "id": "10", "say": "defer를 사용하여 리소스 정리를 보장합니다", "category": "error_handling" } diff --git a/internal/policy/templates/node-template.json b/internal/policy/templates/node-template.json index d8ef657..fe4b123 100644 --- a/internal/policy/templates/node-template.json +++ b/internal/policy/templates/node-template.json @@ -19,55 +19,55 @@ }, "rules": [ { - "no": 1, + "id": 1, "say": "환경 변수는 process.env가 아닌 설정 파일(config.js)을 통해 접근합니다", "category": "security" }, { - "no": 2, + "id": 2, "say": "민감한 정보(API 키, 비밀번호)는 절대 하드코딩하지 않습니다", "category": "security", "example": "// ✅ 좋은 예:\nconst apiKey = process.env.API_KEY;\nconst dbPassword = config.get('database.password');\n\n// ❌ 나쁜 예:\nconst apiKey = 'sk-1234567890abcdef';\nconst dbPassword = 'mySecretPassword123';" }, { - "no": 3, + "id": 3, "say": "모든 비동기 함수는 try-catch 블록으로 에러를 처리합니다", "category": "error_handling", "example": "// ✅ 좋은 예:\nasync function fetchUser(id) {\n try {\n const user = await db.users.findById(id);\n return user;\n } catch (error) {\n logger.error('Failed to fetch user:', error);\n throw new ApiError('User not found', 404);\n }\n}\n\n// ❌ 나쁜 예:\nasync function fetchUser(id) {\n const user = await db.users.findById(id);\n return user;\n}" }, { - "no": 4, + "id": 4, "say": "API 라우트 핸들러는 항상 적절한 HTTP 상태 코드를 반환합니다", "category": "error_handling" }, { - "no": 5, + "id": 5, "say": "데이터베이스 쿼리는 SQL injection을 방지하기 위해 parameterized query를 사용합니다", "category": "security", "example": "// ✅ 좋은 예:\nconst result = await db.query('SELECT * FROM users WHERE id = ?', [userId]);\n\n// ❌ 나쁜 예:\nconst result = await db.query(`SELECT * FROM users WHERE id = ${userId}`);" }, { - "no": 6, + "id": 6, "say": "파일 이름은 kebab-case를 사용합니다 (예: user-controller.js, auth-service.js)", "category": "naming" }, { - "no": 7, + "id": 7, "say": "클래스 이름은 PascalCase, 함수/변수는 camelCase를 사용합니다", "category": "naming" }, { - "no": 8, + "id": 8, "say": "미들웨어 함수는 next()를 명시적으로 호출하거나 응답을 보냅니다", "category": "error_handling" }, { - "no": 9, + "id": 9, "say": "큰 의존성 패키지는 필요한 부분만 import합니다 (tree-shaking)", "category": "performance" }, { - "no": 10, + "id": 10, "say": "로그 메시지는 적절한 레벨(debug, info, warn, error)을 사용합니다", "category": "documentation" } diff --git a/internal/policy/templates/python-template.json b/internal/policy/templates/python-template.json index b89596f..6a8298a 100644 --- a/internal/policy/templates/python-template.json +++ b/internal/policy/templates/python-template.json @@ -19,55 +19,55 @@ }, "rules": [ { - "no": 1, + "id": 1, "say": "PEP 8 스타일 가이드를 준수합니다 (들여쓰기 4칸, 최대 라인 길이 79자)", "category": "formatting" }, { - "no": 2, + "id": 2, "say": "함수와 변수 이름은 snake_case를 사용합니다 (예: calculate_total, user_name)", "category": "naming", "example": "# ✅ 좋은 예:\ndef calculate_total_price(items):\n total_price = sum(item.price for item in items)\n return total_price\n\n# ❌ 나쁜 예:\ndef calculateTotalPrice(items):\n totalPrice = sum(item.price for item in items)\n return totalPrice" }, { - "no": 3, + "id": 3, "say": "클래스 이름은 PascalCase를 사용합니다 (예: UserModel, DataProcessor)", "category": "naming", "example": "# ✅ 좋은 예:\nclass UserProfile:\n def __init__(self, name):\n self.name = name\n\n# ❌ 나쁜 예:\nclass user_profile:\n def __init__(self, name):\n self.name = name" }, { - "no": 4, + "id": 4, "say": "상수는 대문자와 언더스코어를 사용합니다 (예: MAX_SIZE, API_KEY)", "category": "naming" }, { - "no": 5, + "id": 5, "say": "모든 함수와 클래스는 docstring을 작성합니다", "category": "documentation" }, { - "no": 6, + "id": 6, "say": "타입 힌트를 사용하여 함수 시그니처를 명확히 합니다", "category": "documentation" }, { - "no": 7, + "id": 7, "say": "예외는 구체적인 타입으로 처리합니다 (Exception보다는 ValueError, TypeError 등)", "category": "error_handling", "example": "# ✅ 좋은 예:\ntry:\n result = int(user_input)\nexcept ValueError as e:\n logger.error(f\"Invalid input: {e}\")\n result = 0\n\n# ❌ 나쁜 예:\ntry:\n result = int(user_input)\nexcept:\n result = 0" }, { - "no": 8, + "id": 8, "say": "리소스(파일, 데이터베이스)는 with 문으로 관리합니다", "category": "error_handling" }, { - "no": 9, + "id": 9, "say": "민감한 정보는 환경 변수나 설정 파일을 통해 관리합니다", "category": "security" }, { - "no": 10, + "id": 10, "say": "리스트 컴프리헨션은 3줄 이내로 유지하고, 복잡한 경우 일반 for문을 사용합니다", "category": "formatting" } diff --git a/internal/policy/templates/react-template.json b/internal/policy/templates/react-template.json index 3c16a26..c594129 100644 --- a/internal/policy/templates/react-template.json +++ b/internal/policy/templates/react-template.json @@ -19,60 +19,60 @@ }, "rules": [ { - "no": 1, + "id": 1, "say": "컴포넌트 이름은 PascalCase를 사용합니다 (예: UserProfile, NavBar)", "category": "naming", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction UserProfile() { ... }\nfunction NavBar() { ... }\n\n// ❌ 나쁜 예:\nfunction userProfile() { ... }\nfunction nav_bar() { ... }" }, { - "no": 2, + "id": 2, "say": "React Hooks는 함수 컴포넌트 최상단에서만 호출합니다", "category": "error_handling", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction MyComponent() {\n const [count, setCount] = useState(0);\n const value = useMemo(() => count * 2, [count]);\n return
{value}
;\n}\n\n// ❌ 나쁜 예:\nfunction MyComponent() {\n if (condition) {\n const [count, setCount] = useState(0); // 조건부 호출 금지\n }\n}" }, { - "no": 3, + "id": 3, "say": "useEffect의 의존성 배열을 정확히 명시하여 무한 루프를 방지합니다", "category": "error_handling", "languages": ["javascript", "typescript"] }, { - "no": 4, + "id": 4, "say": "Props는 구조 분해 할당으로 받습니다 (예: function Button({ label, onClick }) {...})", "category": "formatting", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction Button({ label, onClick, disabled = false }) {\n return ;\n}\n\n// ❌ 나쁜 예:\nfunction Button(props) {\n return ;\n}" }, { - "no": 5, + "id": 5, "say": "컴포넌트 파일 하나당 하나의 컴포넌트만 export합니다", "category": "formatting" }, { - "no": 6, + "id": 6, "say": "이벤트 핸들러 함수는 'handle' 접두사를 사용합니다 (예: handleClick, handleSubmit)", "category": "naming" }, { - "no": 7, + "id": 7, "say": "boolean 타입의 Props는 'is', 'has', 'should' 접두사를 사용합니다 (예: isOpen, hasError)", "category": "naming" }, { - "no": 8, + "id": 8, "say": "인라인 스타일 대신 CSS 모듈 또는 styled-components를 사용합니다", "category": "formatting" }, { - "no": 9, + "id": 9, "say": "Key prop에 배열 인덱스를 사용하지 않습니다. 고유한 ID를 사용하세요", "category": "performance", "example": "// ✅ 좋은 예:\n{users.map(user => )}\n\n// ❌ 나쁜 예:\n{users.map((user, index) => )}" }, { - "no": 10, + "id": 10, "say": "큰 리스트는 React.memo 또는 useMemo로 최적화합니다", "category": "performance" } diff --git a/internal/policy/templates/typescript-template.json b/internal/policy/templates/typescript-template.json index cdf6b2d..1a941e6 100644 --- a/internal/policy/templates/typescript-template.json +++ b/internal/policy/templates/typescript-template.json @@ -15,54 +15,54 @@ }, "rules": [ { - "no": 1, + "id": 1, "say": "any 타입 사용을 최소화하고 구체적인 타입을 정의합니다", "category": "error_handling", "example": "// ✅ 좋은 예:\ninterface User {\n id: number;\n name: string;\n}\nfunction processUser(user: User): void { ... }\n\n// ❌ 나쁜 예:\nfunction processUser(user: any): void { ... }" }, { - "no": 2, + "id": 2, "say": "인터페이스와 타입 별칭의 이름은 PascalCase를 사용합니다", "category": "naming" }, { - "no": 3, + "id": 3, "say": "타입 정의 파일(.d.ts)은 types/ 폴더에 위치시킵니다", "category": "formatting" }, { - "no": 4, + "id": 4, "say": "제네릭 타입 파라미터는 의미 있는 이름을 사용합니다 (T보다는 TItem, TResponse)", "category": "naming" }, { - "no": 5, + "id": 5, "say": "유틸리티 타입(Partial, Pick, Omit 등)을 적극 활용합니다", "category": "performance" }, { - "no": 6, + "id": 6, "say": "strict 모드를 활성화하여 엄격한 타입 체크를 수행합니다", "category": "error_handling" }, { - "no": 7, + "id": 7, "say": "열거형(enum) 대신 const assertion이나 union 타입을 고려합니다", "category": "performance" }, { - "no": 8, + "id": 8, "say": "함수 시그니처에 반환 타입을 명시적으로 선언합니다", "category": "documentation", "example": "// ✅ 좋은 예:\nfunction calculateTotal(price: number, quantity: number): number {\n return price * quantity;\n}\n\n// ❌ 나쁜 예:\nfunction calculateTotal(price: number, quantity: number) {\n return price * quantity;\n}" }, { - "no": 9, + "id": 9, "say": "readonly를 사용하여 불변성을 보장합니다", "category": "error_handling" }, { - "no": 10, + "id": 10, "say": "타입 가드를 사용하여 런타임 타입 안전성을 확보합니다", "category": "error_handling" } diff --git a/internal/policy/templates/vue-template.json b/internal/policy/templates/vue-template.json index 820a535..5346375 100644 --- a/internal/policy/templates/vue-template.json +++ b/internal/policy/templates/vue-template.json @@ -15,54 +15,54 @@ }, "rules": [ { - "no": 1, + "id": 1, "say": "컴포넌트 이름은 PascalCase를 사용합니다 (예: UserProfile.vue)", "category": "naming", "example": "\n\n\n\n" }, { - "no": 2, + "id": 2, "say": "Composition API를 사용할 때 setup() 함수 또는 \n\n\n" }, { - "no": 3, + "id": 3, "say": "Props는 타입과 기본값을 명시합니다", "category": "documentation" }, { - "no": 4, + "id": 4, "say": "이벤트 이름은 kebab-case를 사용합니다 (예: @user-updated, @item-deleted)", "category": "naming" }, { - "no": 5, + "id": 5, "say": "v-for에는 항상 :key를 지정합니다", "category": "error_handling" }, { - "no": 6, + "id": 6, "say": "computed는 부수 효과 없이 순수 함수로 작성합니다", "category": "performance" }, { - "no": 7, + "id": 7, "say": "composables 함수 이름은 'use' 접두사를 사용합니다 (예: useUser, useFetch)", "category": "naming" }, { - "no": 8, + "id": 8, "say": "템플릿 내 복잡한 표현식은 computed 속성으로 분리합니다", "category": "formatting" }, { - "no": 9, + "id": 9, "say": "글로벌 상태는 Pinia 또는 Vuex를 사용하여 관리합니다", "category": "formatting" }, { - "no": 10, + "id": 10, "say": "컴포넌트는 단일 책임 원칙을 따르며 200줄 이하로 유지합니다", "category": "formatting" } diff --git a/internal/server/static/policy-editor.js b/internal/server/static/policy-editor.js index 07671f5..fd10f59 100644 --- a/internal/server/static/policy-editor.js +++ b/internal/server/static/policy-editor.js @@ -381,27 +381,25 @@ function renderRules() { } function createRuleElement(rule, index) { - const actualIndex = appState.policy.rules.findIndex(r => r.no === rule.no); - return ` -
+
- ${rule.no}. + ${rule.id}. ${rule.say || '새 규칙 (내용을 입력하세요)'}
- +
- +
- ${Object.keys(CATEGORY_COLORS).filter(c => c !== 'default').map(cat => ` @@ -410,12 +408,12 @@ function createRuleElement(rule, index) {
- +
- +
@@ -424,11 +422,11 @@ function createRuleElement(rule, index) { } function handleRuleUpdate(e) { - const ruleNo = parseInt(e.target.dataset.ruleNo); - const rule = appState.policy.rules.find(r => r.no === ruleNo); + const ruleId = e.target.dataset.ruleId; + const rule = appState.policy.rules.find(r => r.id === ruleId); if (!rule) return; - const ruleElement = document.querySelector(`.rule-details[data-rule-no="${ruleNo}"]`); + const ruleElement = document.querySelector(`.rule-details[data-rule-id="${ruleId}"]`); if (e.target.classList.contains('say-input')) { rule.say = e.target.value.trim(); @@ -448,16 +446,11 @@ function handleRuleUpdate(e) { function handleDeleteRule(e) { e.preventDefault(); - const ruleNo = parseInt(e.target.dataset.ruleNo); + const ruleId = e.target.dataset.ruleId; if (!confirm('이 규칙을 정말 삭제하시겠습니까?')) return; - appState.policy.rules = appState.policy.rules.filter(r => r.no !== ruleNo); - - // Renumber rules - appState.policy.rules.forEach((rule, index) => { - rule.no = index + 1; - }); + appState.policy.rules = appState.policy.rules.filter(r => r.id !== ruleId); renderRules(); showToast('규칙이 삭제되었습니다'); @@ -465,14 +458,19 @@ function handleDeleteRule(e) { } function handleAddRule() { - const newNo = appState.policy.rules.length + 1; - const newRule = { no: newNo, say: '', category: '', languages: [], example: '' }; + // Generate a unique ID by finding the maximum existing ID and adding 1 + const maxId = appState.policy.rules.reduce((max, rule) => { + const ruleId = parseInt(rule.id, 10); + return isNaN(ruleId) ? max : Math.max(max, ruleId); + }, 0); + const newId = String(maxId + 1); + const newRule = { id: newId, say: '', category: '', languages: [], example: '' }; appState.policy.rules.push(newRule); renderRules(); // Open the new rule details setTimeout(() => { - const newRuleElement = document.querySelector(`.rule-details[data-rule-no="${newNo}"]`); + const newRuleElement = document.querySelector(`.rule-details[data-rule-id="${newId}"]`); if (newRuleElement) { newRuleElement.setAttribute('open', ''); newRuleElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); diff --git a/pkg/schema/types.go b/pkg/schema/types.go index e8f5c5e..d2aa28c 100644 --- a/pkg/schema/types.go +++ b/pkg/schema/types.go @@ -33,8 +33,7 @@ type UserDefaults struct { // UserRule represents a single rule in user schema type UserRule struct { - No int `json:"no,omitempty"` // symphonyclient integration: rule number for ordering - ID string `json:"id,omitempty"` + ID string `json:"id"` // Rule ID (required, can be number or string) Say string `json:"say"` Category string `json:"category,omitempty"` Languages []string `json:"languages,omitempty"` @@ -44,7 +43,7 @@ type UserRule struct { Autofix bool `json:"autofix,omitempty"` Params map[string]any `json:"params,omitempty"` Message string `json:"message,omitempty"` - Example string `json:"example,omitempty"` // symphonyclient integration: example code + Example string `json:"example,omitempty"` } // CodePolicy represents the formal validation schema (B schema) From f693f4a739f3351e91293ad1bfcc1ccbac7040b8 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 14:20:15 +0900 Subject: [PATCH 5/9] feat: add alias for dashboard command to improve usability --- internal/cmd/dashboard.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index 78b6689..c238938 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -12,8 +12,9 @@ import ( ) var dashboardCmd = &cobra.Command{ - Use: "dashboard", - Short: "Start the web dashboard", + Use: "dashboard", + Aliases: []string{"dash"}, + Short: "Start the web dashboard", Long: `Start a local web server to manage roles through a browser interface. The dashboard provides a visual interface for: From 7cb823b438c190115f6d2c1597ef746e692ebe13 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 14:39:46 +0900 Subject: [PATCH 6/9] test: skip Prettier execution in test environment if not available --- internal/adapter/prettier/executor_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/adapter/prettier/executor_test.go b/internal/adapter/prettier/executor_test.go index f101bd0..27b3ecf 100644 --- a/internal/adapter/prettier/executor_test.go +++ b/internal/adapter/prettier/executor_test.go @@ -117,6 +117,10 @@ func TestExecute_Integration(t *testing.T) { } if output == nil { - t.Error("Expected non-nil output") + t.Skip("Prettier not available in test environment") + return } + + // If we got here, Prettier is available and returned output + t.Logf("Prettier executed successfully, exit code: %d", output.ExitCode) } From f58872628f028bd6166e9101573801b235b44357 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 15:03:43 +0900 Subject: [PATCH 7/9] refactor: update rule IDs in policy templates to string format for consistency --- internal/policy/templates/node-template.json | 20 +++++++++---------- .../policy/templates/python-template.json | 20 +++++++++---------- internal/policy/templates/react-template.json | 20 +++++++++---------- .../policy/templates/typescript-template.json | 20 +++++++++---------- internal/policy/templates/vue-template.json | 20 +++++++++---------- 5 files changed, 50 insertions(+), 50 deletions(-) diff --git a/internal/policy/templates/node-template.json b/internal/policy/templates/node-template.json index fe4b123..43166ca 100644 --- a/internal/policy/templates/node-template.json +++ b/internal/policy/templates/node-template.json @@ -19,55 +19,55 @@ }, "rules": [ { - "id": 1, + "id": "1", "say": "환경 변수는 process.env가 아닌 설정 파일(config.js)을 통해 접근합니다", "category": "security" }, { - "id": 2, + "id": "2", "say": "민감한 정보(API 키, 비밀번호)는 절대 하드코딩하지 않습니다", "category": "security", "example": "// ✅ 좋은 예:\nconst apiKey = process.env.API_KEY;\nconst dbPassword = config.get('database.password');\n\n// ❌ 나쁜 예:\nconst apiKey = 'sk-1234567890abcdef';\nconst dbPassword = 'mySecretPassword123';" }, { - "id": 3, + "id": "3", "say": "모든 비동기 함수는 try-catch 블록으로 에러를 처리합니다", "category": "error_handling", "example": "// ✅ 좋은 예:\nasync function fetchUser(id) {\n try {\n const user = await db.users.findById(id);\n return user;\n } catch (error) {\n logger.error('Failed to fetch user:', error);\n throw new ApiError('User not found', 404);\n }\n}\n\n// ❌ 나쁜 예:\nasync function fetchUser(id) {\n const user = await db.users.findById(id);\n return user;\n}" }, { - "id": 4, + "id": "4", "say": "API 라우트 핸들러는 항상 적절한 HTTP 상태 코드를 반환합니다", "category": "error_handling" }, { - "id": 5, + "id": "5", "say": "데이터베이스 쿼리는 SQL injection을 방지하기 위해 parameterized query를 사용합니다", "category": "security", "example": "// ✅ 좋은 예:\nconst result = await db.query('SELECT * FROM users WHERE id = ?', [userId]);\n\n// ❌ 나쁜 예:\nconst result = await db.query(`SELECT * FROM users WHERE id = ${userId}`);" }, { - "id": 6, + "id": "6", "say": "파일 이름은 kebab-case를 사용합니다 (예: user-controller.js, auth-service.js)", "category": "naming" }, { - "id": 7, + "id": "7", "say": "클래스 이름은 PascalCase, 함수/변수는 camelCase를 사용합니다", "category": "naming" }, { - "id": 8, + "id": "8", "say": "미들웨어 함수는 next()를 명시적으로 호출하거나 응답을 보냅니다", "category": "error_handling" }, { - "id": 9, + "id": "9", "say": "큰 의존성 패키지는 필요한 부분만 import합니다 (tree-shaking)", "category": "performance" }, { - "id": 10, + "id": "10", "say": "로그 메시지는 적절한 레벨(debug, info, warn, error)을 사용합니다", "category": "documentation" } diff --git a/internal/policy/templates/python-template.json b/internal/policy/templates/python-template.json index 6a8298a..af94337 100644 --- a/internal/policy/templates/python-template.json +++ b/internal/policy/templates/python-template.json @@ -19,55 +19,55 @@ }, "rules": [ { - "id": 1, + "id": "1", "say": "PEP 8 스타일 가이드를 준수합니다 (들여쓰기 4칸, 최대 라인 길이 79자)", "category": "formatting" }, { - "id": 2, + "id": "2", "say": "함수와 변수 이름은 snake_case를 사용합니다 (예: calculate_total, user_name)", "category": "naming", "example": "# ✅ 좋은 예:\ndef calculate_total_price(items):\n total_price = sum(item.price for item in items)\n return total_price\n\n# ❌ 나쁜 예:\ndef calculateTotalPrice(items):\n totalPrice = sum(item.price for item in items)\n return totalPrice" }, { - "id": 3, + "id": "3", "say": "클래스 이름은 PascalCase를 사용합니다 (예: UserModel, DataProcessor)", "category": "naming", "example": "# ✅ 좋은 예:\nclass UserProfile:\n def __init__(self, name):\n self.name = name\n\n# ❌ 나쁜 예:\nclass user_profile:\n def __init__(self, name):\n self.name = name" }, { - "id": 4, + "id": "4", "say": "상수는 대문자와 언더스코어를 사용합니다 (예: MAX_SIZE, API_KEY)", "category": "naming" }, { - "id": 5, + "id": "5", "say": "모든 함수와 클래스는 docstring을 작성합니다", "category": "documentation" }, { - "id": 6, + "id": "6", "say": "타입 힌트를 사용하여 함수 시그니처를 명확히 합니다", "category": "documentation" }, { - "id": 7, + "id": "7", "say": "예외는 구체적인 타입으로 처리합니다 (Exception보다는 ValueError, TypeError 등)", "category": "error_handling", "example": "# ✅ 좋은 예:\ntry:\n result = int(user_input)\nexcept ValueError as e:\n logger.error(f\"Invalid input: {e}\")\n result = 0\n\n# ❌ 나쁜 예:\ntry:\n result = int(user_input)\nexcept:\n result = 0" }, { - "id": 8, + "id": "8", "say": "리소스(파일, 데이터베이스)는 with 문으로 관리합니다", "category": "error_handling" }, { - "id": 9, + "id": "9", "say": "민감한 정보는 환경 변수나 설정 파일을 통해 관리합니다", "category": "security" }, { - "id": 10, + "id": "10", "say": "리스트 컴프리헨션은 3줄 이내로 유지하고, 복잡한 경우 일반 for문을 사용합니다", "category": "formatting" } diff --git a/internal/policy/templates/react-template.json b/internal/policy/templates/react-template.json index c594129..82d0d6b 100644 --- a/internal/policy/templates/react-template.json +++ b/internal/policy/templates/react-template.json @@ -19,60 +19,60 @@ }, "rules": [ { - "id": 1, + "id": "1", "say": "컴포넌트 이름은 PascalCase를 사용합니다 (예: UserProfile, NavBar)", "category": "naming", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction UserProfile() { ... }\nfunction NavBar() { ... }\n\n// ❌ 나쁜 예:\nfunction userProfile() { ... }\nfunction nav_bar() { ... }" }, { - "id": 2, + "id": "2", "say": "React Hooks는 함수 컴포넌트 최상단에서만 호출합니다", "category": "error_handling", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction MyComponent() {\n const [count, setCount] = useState(0);\n const value = useMemo(() => count * 2, [count]);\n return
{value}
;\n}\n\n// ❌ 나쁜 예:\nfunction MyComponent() {\n if (condition) {\n const [count, setCount] = useState(0); // 조건부 호출 금지\n }\n}" }, { - "id": 3, + "id": "3", "say": "useEffect의 의존성 배열을 정확히 명시하여 무한 루프를 방지합니다", "category": "error_handling", "languages": ["javascript", "typescript"] }, { - "id": 4, + "id": "4", "say": "Props는 구조 분해 할당으로 받습니다 (예: function Button({ label, onClick }) {...})", "category": "formatting", "languages": ["javascript", "typescript", "jsx", "tsx"], "example": "// ✅ 좋은 예:\nfunction Button({ label, onClick, disabled = false }) {\n return ;\n}\n\n// ❌ 나쁜 예:\nfunction Button(props) {\n return ;\n}" }, { - "id": 5, + "id": "5", "say": "컴포넌트 파일 하나당 하나의 컴포넌트만 export합니다", "category": "formatting" }, { - "id": 6, + "id": "6", "say": "이벤트 핸들러 함수는 'handle' 접두사를 사용합니다 (예: handleClick, handleSubmit)", "category": "naming" }, { - "id": 7, + "id": "7", "say": "boolean 타입의 Props는 'is', 'has', 'should' 접두사를 사용합니다 (예: isOpen, hasError)", "category": "naming" }, { - "id": 8, + "id": "8", "say": "인라인 스타일 대신 CSS 모듈 또는 styled-components를 사용합니다", "category": "formatting" }, { - "id": 9, + "id": "9", "say": "Key prop에 배열 인덱스를 사용하지 않습니다. 고유한 ID를 사용하세요", "category": "performance", "example": "// ✅ 좋은 예:\n{users.map(user => )}\n\n// ❌ 나쁜 예:\n{users.map((user, index) => )}" }, { - "id": 10, + "id": "10", "say": "큰 리스트는 React.memo 또는 useMemo로 최적화합니다", "category": "performance" } diff --git a/internal/policy/templates/typescript-template.json b/internal/policy/templates/typescript-template.json index 1a941e6..f460f19 100644 --- a/internal/policy/templates/typescript-template.json +++ b/internal/policy/templates/typescript-template.json @@ -15,54 +15,54 @@ }, "rules": [ { - "id": 1, + "id": "1", "say": "any 타입 사용을 최소화하고 구체적인 타입을 정의합니다", "category": "error_handling", "example": "// ✅ 좋은 예:\ninterface User {\n id: number;\n name: string;\n}\nfunction processUser(user: User): void { ... }\n\n// ❌ 나쁜 예:\nfunction processUser(user: any): void { ... }" }, { - "id": 2, + "id": "2", "say": "인터페이스와 타입 별칭의 이름은 PascalCase를 사용합니다", "category": "naming" }, { - "id": 3, + "id": "3", "say": "타입 정의 파일(.d.ts)은 types/ 폴더에 위치시킵니다", "category": "formatting" }, { - "id": 4, + "id": "4", "say": "제네릭 타입 파라미터는 의미 있는 이름을 사용합니다 (T보다는 TItem, TResponse)", "category": "naming" }, { - "id": 5, + "id": "5", "say": "유틸리티 타입(Partial, Pick, Omit 등)을 적극 활용합니다", "category": "performance" }, { - "id": 6, + "id": "6", "say": "strict 모드를 활성화하여 엄격한 타입 체크를 수행합니다", "category": "error_handling" }, { - "id": 7, + "id": "7", "say": "열거형(enum) 대신 const assertion이나 union 타입을 고려합니다", "category": "performance" }, { - "id": 8, + "id": "8", "say": "함수 시그니처에 반환 타입을 명시적으로 선언합니다", "category": "documentation", "example": "// ✅ 좋은 예:\nfunction calculateTotal(price: number, quantity: number): number {\n return price * quantity;\n}\n\n// ❌ 나쁜 예:\nfunction calculateTotal(price: number, quantity: number) {\n return price * quantity;\n}" }, { - "id": 9, + "id": "9", "say": "readonly를 사용하여 불변성을 보장합니다", "category": "error_handling" }, { - "id": 10, + "id": "10", "say": "타입 가드를 사용하여 런타임 타입 안전성을 확보합니다", "category": "error_handling" } diff --git a/internal/policy/templates/vue-template.json b/internal/policy/templates/vue-template.json index 5346375..2dd1f9d 100644 --- a/internal/policy/templates/vue-template.json +++ b/internal/policy/templates/vue-template.json @@ -15,54 +15,54 @@ }, "rules": [ { - "id": 1, + "id": "1", "say": "컴포넌트 이름은 PascalCase를 사용합니다 (예: UserProfile.vue)", "category": "naming", "example": "\n\n\n\n" }, { - "id": 2, + "id": "2", "say": "Composition API를 사용할 때 setup() 함수 또는 \n\n\n" }, { - "id": 3, + "id": "3", "say": "Props는 타입과 기본값을 명시합니다", "category": "documentation" }, { - "id": 4, + "id": "4", "say": "이벤트 이름은 kebab-case를 사용합니다 (예: @user-updated, @item-deleted)", "category": "naming" }, { - "id": 5, + "id": "5", "say": "v-for에는 항상 :key를 지정합니다", "category": "error_handling" }, { - "id": 6, + "id": "6", "say": "computed는 부수 효과 없이 순수 함수로 작성합니다", "category": "performance" }, { - "id": 7, + "id": "7", "say": "composables 함수 이름은 'use' 접두사를 사용합니다 (예: useUser, useFetch)", "category": "naming" }, { - "id": 8, + "id": "8", "say": "템플릿 내 복잡한 표현식은 computed 속성으로 분리합니다", "category": "formatting" }, { - "id": 9, + "id": "9", "say": "글로벌 상태는 Pinia 또는 Vuex를 사용하여 관리합니다", "category": "formatting" }, { - "id": 10, + "id": "10", "say": "컴포넌트는 단일 책임 원칙을 따르며 200줄 이하로 유지합니다", "category": "formatting" } From 54798a5b30cf241bab745fe92cd477d735ebcbb8 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 16:30:20 +0900 Subject: [PATCH 8/9] feat: enhance MCP server with official mcp sdk and improve validation messaging --- go.mod | 9 +- go.sum | 17 +- internal/mcp/server.go | 307 ++++++++++++++++++++++++++++-------- internal/mcp/server_test.go | 260 ++++++++++++++++++++++++++++++ 4 files changed, 524 insertions(+), 69 deletions(-) create mode 100644 internal/mcp/server_test.go diff --git a/go.mod b/go.mod index eab5296..fd1833f 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,19 @@ require ( github.com/spf13/cobra v1.10.1 ) +require ( + github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/stretchr/testify v1.11.1 +) + require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.15.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 91857cd..29d48b1 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,14 @@ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -15,11 +21,18 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6b150c1..1a2593e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -2,17 +2,21 @@ package mcp import ( "bufio" + "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" + "strconv" + "strings" "time" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) // Server is a MCP (Model Context Protocol) server. @@ -21,8 +25,8 @@ type Server struct { host string port int configPath string - userPolicy *schema.UserPolicy // Original user-written policy - codePolicy *schema.CodePolicy // Converted validation policy + userPolicy *schema.UserPolicy + codePolicy *schema.CodePolicy loader *policy.Loader } @@ -71,7 +75,8 @@ func (s *Server) Start() error { fmt.Fprintf(os.Stderr, "Listening on: %s:%d\n", s.host, s.port) fmt.Fprintln(os.Stderr, "Available tools: query_conventions, validate_code") - return s.handleRequests(os.Stdin, os.Stdout) + // Use official MCP go-sdk for stdio to ensure spec-compliant framing and lifecycle + return s.runStdioWithSDK(context.Background()) } // startHTTPServer starts HTTP server for JSON-RPC. @@ -196,16 +201,135 @@ type RPCError struct { Message string `json:"message"` } +// QueryConventionsInput represents the input schema for the query_conventions tool (go-sdk). +type QueryConventionsInput struct { + Category string `json:"category,omitempty" jsonschema:"Filter by category (optional). Use 'all' or leave empty to fetch all categories. Options: security, style, documentation, error_handling, architecture, performance, testing"` + Languages []string `json:"languages,omitempty" jsonschema:"Programming languages to filter by (optional). Leave empty to get conventions for all languages. Examples: go, javascript, typescript, python, java"` +} + +// ValidateCodeInput represents the input schema for the validate_code tool (go-sdk). +type ValidateCodeInput struct { + Files []string `json:"files" jsonschema:"File paths to validate"` + Role string `json:"role,omitempty" jsonschema:"RBAC role for validation (optional)"` +} + +// runStdioWithSDK runs a spec-compliant MCP server over stdio using the official go-sdk. +func (s *Server) runStdioWithSDK(ctx context.Context) error { + server := sdkmcp.NewServer(&sdkmcp.Implementation{ + Name: "symphony", + Version: "1.0.0", + }, nil) + + // Tool: query_conventions + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "query_conventions", + Description: "Query coding conventions before you start coding.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input QueryConventionsInput) (*sdkmcp.CallToolResult, map[string]any, error) { + params := map[string]any{ + "category": input.Category, + "languages": input.Languages, + } + result, rpcErr := s.handleQueryConventions(params) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + // result is already MCP-shaped: { content: [{type:"text", text:"..."}] } + return nil, result.(map[string]any), nil + }) + + // Tool: validate_code + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "validate_code", + Description: "Validate that your code complies with all project conventions.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input ValidateCodeInput) (*sdkmcp.CallToolResult, map[string]any, error) { + params := map[string]any{ + "files": input.Files, + "role": input.Role, + } + result, rpcErr := s.handleValidateCode(params) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + + // Run the server over stdio until the client disconnects + return server.Run(ctx, &sdkmcp.StdioTransport{}) +} + // handleRequests handles incoming requests via stdio. func (s *Server) handleRequests(in io.Reader, out io.Writer) error { - scanner := bufio.NewScanner(in) - encoder := json.NewEncoder(out) + // MCP over stdio uses LSP-style framing: + // Content-Length: \r\n + // [other headers...]\r\n + // \r\n + // + reader := bufio.NewReader(in) + writer := bufio.NewWriter(out) + + for { + // Parse headers + var contentLength int = -1 + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + _ = writer.Flush() + } + return err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + // End of headers + break + } + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "content-length:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + nStr := strings.TrimSpace(parts[1]) + if n, err := strconv.Atoi(nStr); err == nil { + contentLength = n + } + } + } + // Ignore other headers + } - for scanner.Scan() { - line := scanner.Bytes() + if contentLength < 0 { + // No content-length provided → ignore and continue + // (Some clients might send keep-alive pings without body) + continue + } + // Read body + body := make([]byte, contentLength) + if _, err := io.ReadFull(reader, body); err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + + // Decode JSON-RPC request var req JSONRPCRequest - if err := json.Unmarshal(line, &req); err != nil { + if err := json.Unmarshal(body, &req); err != nil { + // Send parse error + resp := JSONRPCResponse{ + JSONRPC: "2.0", + Error: &RPCError{ + Code: -32700, + Message: "parse error", + }, + ID: nil, + } + respBytes, _ := json.Marshal(resp) + if _, err := fmt.Fprintf(writer, "Content-Length: %d\r\n\r\n", len(respBytes)); err != nil { + return err + } + if _, err := writer.Write(respBytes); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } continue } @@ -233,26 +357,33 @@ func (s *Server) handleRequests(in io.Reader, out io.Writer) error { } } + // Encode response resp := JSONRPCResponse{ JSONRPC: "2.0", Result: result, Error: rpcErr, ID: req.ID, } - - if err := encoder.Encode(resp); err != nil { - return fmt.Errorf("failed to encode response: %w", err) + respBytes, err := json.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + if _, err := fmt.Fprintf(writer, "Content-Length: %d\r\n\r\n", len(respBytes)); err != nil { + return err + } + if _, err := writer.Write(respBytes); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err } } - - return scanner.Err() } // QueryConventionsRequest is a request to query conventions. type QueryConventionsRequest struct { - Category string `json:"category"` // filter by category - Files []string `json:"files"` - Languages []string `json:"languages"` + Category string `json:"category"` // optional; use "all" or empty to fetch all categories + Languages []string `json:"languages"` // optional; empty means all languages } // ConventionItem is a convention item. @@ -267,7 +398,10 @@ type ConventionItem struct { // handleQueryConventions handles convention query requests. // It finds and returns relevant conventions by category. func (s *Server) handleQueryConventions(params map[string]interface{}) (interface{}, *RPCError) { + fmt.Fprintf(os.Stderr, "[DEBUG] handleQueryConventions called with params: %+v\n", params) + if s.userPolicy == nil && s.codePolicy == nil { + fmt.Fprintf(os.Stderr, "[DEBUG] No policy loaded\n") return map[string]interface{}{ "conventions": []ConventionItem{}, "message": "policy not loaded", @@ -277,18 +411,54 @@ func (s *Server) handleQueryConventions(params map[string]interface{}) (interfac var req QueryConventionsRequest paramBytes, _ := json.Marshal(params) if err := json.Unmarshal(paramBytes, &req); err != nil { + fmt.Fprintf(os.Stderr, "[ERROR] Failed to parse parameters: %v\n", err) return nil, &RPCError{ Code: -32602, Message: fmt.Sprintf("failed to parse parameters: %v", err), } } + // Apply defaults for missing parameters + // If category is empty or "all", return all categories + if strings.TrimSpace(req.Category) == "" || strings.EqualFold(req.Category, "all") { + req.Category = "" + } + + // If languages is empty, return all languages + // This is more user-friendly than requiring the parameter + + fmt.Fprintf(os.Stderr, "[DEBUG] Parsed request: category=%s, languages=%v\n", + req.Category, req.Languages) + conventions := s.filterConventions(req) + fmt.Fprintf(os.Stderr, "[DEBUG] Found %d conventions\n", len(conventions)) + + // Format conventions as readable text for MCP response + var textContent string + if len(conventions) == 0 { + textContent = "No conventions found for the specified criteria." + } else { + textContent = fmt.Sprintf("Found %d convention(s):\n\n", len(conventions)) + for i, conv := range conventions { + textContent += fmt.Sprintf("%d. [%s] %s\n", i+1, conv.Severity, conv.ID) + textContent += fmt.Sprintf(" Category: %s\n", conv.Category) + textContent += fmt.Sprintf(" Description: %s\n", conv.Description) + if conv.Message != "" { + textContent += fmt.Sprintf(" Message: %s\n", conv.Message) + } + textContent += "\n" + } + textContent += "\n✓ Next Step: Implement your code following these conventions. After completion, MUST call validate_code to verify compliance." + } + // Return MCP-compliant response with content array return map[string]interface{}{ - "conventions": conventions, - "total": len(conventions), - "next_step": "Now implement your code following these conventions. After completion, MUST call validate_code to verify compliance.", + "content": []map[string]interface{}{ + { + "type": "text", + "text": textContent, + }, + }, }, nil } @@ -304,18 +474,34 @@ func (s *Server) filterConventions(req QueryConventionsRequest) []ConventionItem } // Check language relevance + // Only filter by language if both req.Languages and rule.Languages are specified if len(req.Languages) > 0 && len(rule.Languages) > 0 { if !containsAny(rule.Languages, req.Languages) { continue } } + // If req.Languages is empty, include all rules (more user-friendly) + + // Apply defaults if not specified in rule + severity := rule.Severity + if severity == "" && s.userPolicy.Defaults.Severity != "" { + severity = s.userPolicy.Defaults.Severity + } + if severity == "" { + severity = "warning" // fallback default + } + + message := rule.Message + if message == "" { + message = rule.Say // Use description as message if no explicit message + } conventions = append(conventions, ConventionItem{ ID: rule.ID, Category: rule.Category, - Description: rule.Say, // Use natural language description - Message: rule.Message, - Severity: rule.Severity, + Description: rule.Say, // Use natural language description + Message: message, + Severity: severity, }) } return conventions @@ -360,35 +546,6 @@ func (s *Server) isRuleRelevant(rule schema.PolicyRule, req QueryConventionsRequ return false } } - - if len(rule.When.Include) > 0 && len(req.Files) > 0 { - matched := false - for _, file := range req.Files { - for _, pattern := range rule.When.Include { - if match, _ := filepath.Match(pattern, file); match { - matched = true - break - } - } - if matched { - break - } - } - if !matched { - return false - } - } - - if len(rule.When.Exclude) > 0 && len(req.Files) > 0 { - for _, file := range req.Files { - for _, pattern := range rule.When.Exclude { - if match, _ := filepath.Match(pattern, file); match { - return false - } - } - } - } - return true } @@ -465,20 +622,43 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } } - var message string + // Format validation results as readable text for MCP response + var textContent string if hasErrors { - message = "VALIDATION FAILED: Found error-level violations. You MUST fix these issues and re-validate before proceeding." + textContent = "❌ VALIDATION FAILED: Found error-level violations. You MUST fix these issues and re-validate before proceeding.\n\n" } else if len(allViolations) > 0 { - message = "VALIDATION WARNING: Found non-critical violations. Consider fixing these warnings for better code quality." + textContent = "⚠️ VALIDATION WARNING: Found non-critical violations. Consider fixing these warnings for better code quality.\n\n" } else { - message = "✓ VALIDATION PASSED: Code complies with all conventions. Task can be marked as complete." + textContent = "✓ VALIDATION PASSED: Code complies with all conventions. Task can be marked as complete.\n\n" } + if len(allViolations) > 0 { + textContent += fmt.Sprintf("Total violations: %d\n\n", len(allViolations)) + for i, violation := range allViolations { + textContent += fmt.Sprintf("%d. [%s] %s\n", i+1, violation.Severity, violation.RuleID) + if violation.File != "" { + textContent += fmt.Sprintf(" File: %s", violation.File) + if violation.Line > 0 { + textContent += fmt.Sprintf(":%d", violation.Line) + if violation.Column > 0 { + textContent += fmt.Sprintf(":%d", violation.Column) + } + } + textContent += "\n" + } + textContent += fmt.Sprintf(" Message: %s\n\n", violation.Message) + } + } + + // Return MCP-compliant response with content array return map[string]interface{}{ - "valid": !hasErrors, - "violations": allViolations, - "total": len(allViolations), - "message": message, + "content": []map[string]interface{}{ + { + "type": "text", + "text": textContent, + }, + }, + "isError": hasErrors, }, nil } @@ -562,17 +742,12 @@ Example: Before implementing authentication, query security conventions first.`, "properties": map[string]interface{}{ "category": map[string]interface{}{ "type": "string", - "description": "Filter by category (security, style, documentation, error_handling, architecture, performance, testing)", - }, - "files": map[string]interface{}{ - "type": "array", - "items": map[string]string{"type": "string"}, - "description": "File paths to check conventions for", + "description": "Filter by category (optional). Leave empty or use 'all' to fetch all categories. Options: security, style, documentation, error_handling, architecture, performance, testing", }, "languages": map[string]interface{}{ "type": "array", "items": map[string]string{"type": "string"}, - "description": "Programming languages to filter by (go, javascript, python, java, etc.)", + "description": "Programming languages to filter by (optional). Leave empty to get conventions for all languages. Examples: go, javascript, typescript, python, java", }, }, }, diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..30093a6 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,260 @@ +package mcp + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQueryConventions(t *testing.T) { + // Setup: Create a temporary user policy + tmpDir := t.TempDir() + userPolicyPath := filepath.Join(tmpDir, "user-policy.json") + + userPolicyJSON := `{ + "version": "1.0.0", + "defaults": { + "languages": ["javascript", "typescript"], + "severity": "error", + "autofix": true + }, + "rules": [ + { + "id": "DOC-001", + "say": "주석에는 항상 순서 번호가 달려있어야 함", + "category": "documentation", + "languages": ["javascript"], + "severity": "warning", + "message": "Comments must include sequence numbers" + }, + { + "id": "SEC-001", + "say": "환경변수를 사용해서 API 키를 관리해야 함", + "category": "security", + "languages": ["javascript", "typescript"], + "severity": "error", + "message": "Use environment variables for API keys" + }, + { + "id": "STYLE-001", + "say": "함수는 camelCase를 사용해야 함", + "category": "style", + "languages": ["javascript", "typescript"] + } + ] +}` + + err := os.WriteFile(userPolicyPath, []byte(userPolicyJSON), 0644) + require.NoError(t, err) + + // Create server + server := &Server{ + configPath: userPolicyPath, + loader: policy.NewLoader(false), + } + + // Load user policy + userPolicy, err := server.loader.LoadUserPolicy(userPolicyPath) + require.NoError(t, err) + server.userPolicy = userPolicy + + t.Run("query all categories for javascript", func(t *testing.T) { + params := map[string]interface{}{ + "category": "all", + "languages": []interface{}{"javascript"}, + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should find conventions + assert.NotContains(t, text, "No conventions found") + assert.Contains(t, text, "DOC-001") + assert.Contains(t, text, "SEC-001") + assert.Contains(t, text, "STYLE-001") + }) + + t.Run("query documentation category for javascript", func(t *testing.T) { + params := map[string]interface{}{ + "category": "documentation", + "languages": []interface{}{"javascript"}, + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should find only documentation conventions + assert.Contains(t, text, "DOC-001") + assert.NotContains(t, text, "SEC-001") + assert.NotContains(t, text, "STYLE-001") + }) + + t.Run("query security category for typescript", func(t *testing.T) { + params := map[string]interface{}{ + "category": "security", + "languages": []interface{}{"typescript"}, + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should find SEC-001 (supports typescript) + assert.Contains(t, text, "SEC-001") + assert.NotContains(t, text, "DOC-001") // javascript only + }) + + t.Run("query with unsupported language", func(t *testing.T) { + params := map[string]interface{}{ + "category": "all", + "languages": []interface{}{"python"}, + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should return no conventions + assert.Contains(t, text, "No conventions found") + }) + + t.Run("rule without severity uses defaults", func(t *testing.T) { + params := map[string]interface{}{ + "category": "style", + "languages": []interface{}{"javascript"}, + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // STYLE-001 doesn't have explicit severity, should use default "error" + assert.Contains(t, text, "STYLE-001") + assert.Contains(t, text, "[error]") // Should use default from policy + }) + + t.Run("empty parameters returns all conventions", func(t *testing.T) { + params := map[string]interface{}{} + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should return all conventions when no filters specified + assert.Contains(t, text, "DOC-001") + assert.Contains(t, text, "SEC-001") + assert.Contains(t, text, "STYLE-001") + }) + + t.Run("only category specified", func(t *testing.T) { + params := map[string]interface{}{ + "category": "security", + } + + result, rpcErr := server.handleQueryConventions(params) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + t.Logf("Result: %s", text) + + // Should return only security conventions + assert.Contains(t, text, "SEC-001") + assert.NotContains(t, text, "DOC-001") + }) +} + +func TestFilterConventionsWithDefaults(t *testing.T) { + // Create test server with user policy that has defaults + userPolicy := &UserPolicyForTest{ + Defaults: DefaultsForTest{ + Severity: "error", + }, + Rules: []UserRuleForTest{ + { + ID: "TEST-001", + Say: "Test rule without severity", + Category: "testing", + Languages: []string{"go"}, + // No severity or message specified + }, + { + ID: "TEST-002", + Say: "Test rule with severity", + Category: "testing", + Languages: []string{"go"}, + Severity: "warning", + Message: "Custom message", + }, + }, + } + + // Convert to JSON and back to ensure proper structure + data, _ := json.Marshal(userPolicy) + t.Logf("User policy: %s", string(data)) +} + +// Test helper types to match schema.UserPolicy structure +type UserPolicyForTest struct { + Defaults DefaultsForTest `json:"defaults"` + Rules []UserRuleForTest `json:"rules"` +} + +type DefaultsForTest struct { + Severity string `json:"severity"` +} + +type UserRuleForTest struct { + ID string `json:"id"` + Say string `json:"say"` + Category string `json:"category"` + Languages []string `json:"languages"` + Severity string `json:"severity,omitempty"` + Message string `json:"message,omitempty"` +} From 1ad1ffe51ec48da0a4fde7d13feba1119c06e042 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 12 Nov 2025 17:10:45 +0900 Subject: [PATCH 9/9] refactor: streamline MCP server policy handling and extract conversion logic for reuse --- internal/cmd/mcp.go | 101 ++------------- internal/mcp/server.go | 283 ++++++++++++++++++++++------------------- 2 files changed, 164 insertions(+), 220 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 680f818..e38c4db 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -1,18 +1,13 @@ package cmd import ( - "context" - "encoding/json" "fmt" "os" "path/filepath" "time" - "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/git" - "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/mcp" - "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -55,19 +50,22 @@ func runMCP(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a git repository: %w", err) } - userPolicyPath := filepath.Join(repoRoot, ".sym", "user-policy.json") - codePolicyPath := filepath.Join(repoRoot, ".sym", "code-policy.json") + symDir := filepath.Join(repoRoot, ".sym") + userPolicyPath := filepath.Join(symDir, "user-policy.json") // If custom config path is specified, use it directly + var configPath string if mcpConfig != "" { - codePolicyPath = mcpConfig + configPath = mcpConfig + } else { + // Use .sym directory as config path for auto-detection + configPath = symDir } // Check if user-policy.json exists userPolicyExists := fileExists(userPolicyPath) - codePolicyExists := fileExists(codePolicyPath) - // Case 1: No user-policy.json → Launch dashboard + // If no user-policy.json → Launch dashboard if !userPolicyExists { fmt.Println("❌ User policy not found at:", userPolicyPath) fmt.Println("📝 Opening dashboard to create policy...") @@ -82,21 +80,8 @@ func runMCP(cmd *cobra.Command, args []string) error { return nil } - // Case 2: user-policy.json exists but code-policy.json doesn't → Auto-convert - if userPolicyExists && !codePolicyExists { - fmt.Println("✓ User policy found at:", userPolicyPath) - fmt.Println("⚙️ Code policy not found. Converting user policy...") - - if err := autoConvertPolicy(userPolicyPath, codePolicyPath); err != nil { - return fmt.Errorf("failed to convert policy: %w", err) - } - - fmt.Println("✓ Policy converted successfully:", codePolicyPath) - } - - // Case 3: Both exist → Start MCP server normally - fmt.Println("✓ Policy loaded from:", codePolicyPath) - server := mcp.NewServer(mcpHost, mcpPort, codePolicyPath) + // Start MCP server - it will handle conversion automatically if needed + server := mcp.NewServer(mcpHost, mcpPort, configPath) return server.Start() } @@ -123,69 +108,3 @@ func launchDashboard() error { return nil } - -// autoConvertPolicy converts user-policy.json to code-policy.json -func autoConvertPolicy(userPolicyPath, codePolicyPath string) error { - // Load user policy - data, err := os.ReadFile(userPolicyPath) - if err != nil { - return fmt.Errorf("failed to read user policy: %w", err) - } - - var userPolicy schema.UserPolicy - if err := json.Unmarshal(data, &userPolicy); err != nil { - return fmt.Errorf("failed to parse user policy: %w", err) - } - - // Setup LLM client - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - return fmt.Errorf("OPENAI_API_KEY environment variable not set") - } - - llmClient := llm.NewClient(apiKey, - llm.WithModel("gpt-4o-mini"), - llm.WithTimeout(30*time.Second), - ) - - // Create converter - conv := converter.NewConverter(converter.WithLLMClient(llmClient)) - - // Setup context with timeout - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(userPolicy.Rules)*30)*time.Second) - defer cancel() - - fmt.Printf("Converting %d rules...\n", len(userPolicy.Rules)) - - // Convert to all targets - result, err := conv.ConvertMultiTarget(ctx, &userPolicy, converter.MultiTargetConvertOptions{ - Targets: []string{"all"}, - OutputDir: filepath.Dir(codePolicyPath), - ConfidenceThreshold: 0.7, - }) - if err != nil { - return fmt.Errorf("conversion failed: %w", err) - } - - // Write code policy - codePolicyJSON, err := json.MarshalIndent(result.CodePolicy, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize code policy: %w", err) - } - - if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { - return fmt.Errorf("failed to write code policy: %w", err) - } - - // Write linter configs - for linterName, config := range result.LinterConfigs { - outputPath := filepath.Join(filepath.Dir(codePolicyPath), config.Filename) - if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { - fmt.Printf("Warning: failed to write %s config: %v\n", linterName, err) - } else { - fmt.Printf(" ✓ Generated %s: %s\n", linterName, outputPath) - } - } - - return nil -} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1a2593e..8555b5d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1,24 +1,91 @@ package mcp import ( - "bufio" "context" "encoding/json" "fmt" - "io" "net/http" "os" "path/filepath" - "strconv" "strings" "time" + "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/git" + "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) +// ConvertPolicyWithLLM converts user policy to code policy using LLM. +// This is extracted from cmd/mcp.go's autoConvertPolicy for reuse. +func ConvertPolicyWithLLM(userPolicyPath, codePolicyPath string) error { + // Load user policy + data, err := os.ReadFile(userPolicyPath) + if err != nil { + return fmt.Errorf("failed to read user policy: %w", err) + } + + var userPolicy schema.UserPolicy + if err := json.Unmarshal(data, &userPolicy); err != nil { + return fmt.Errorf("failed to parse user policy: %w", err) + } + + // Setup LLM client + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return fmt.Errorf("OPENAI_API_KEY environment variable not set") + } + + llmClient := llm.NewClient(apiKey, + llm.WithModel("gpt-4o-mini"), + llm.WithTimeout(30*time.Second), + ) + + // Create converter + conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + + // Setup context with timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(userPolicy.Rules)*30)*time.Second) + defer cancel() + + fmt.Fprintf(os.Stderr, "Converting %d rules...\n", len(userPolicy.Rules)) + + // Convert to all targets + result, err := conv.ConvertMultiTarget(ctx, &userPolicy, converter.MultiTargetConvertOptions{ + Targets: []string{"all"}, + OutputDir: filepath.Dir(codePolicyPath), + ConfidenceThreshold: 0.7, + }) + if err != nil { + return fmt.Errorf("conversion failed: %w", err) + } + + // Write code policy + codePolicyJSON, err := json.MarshalIndent(result.CodePolicy, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize code policy: %w", err) + } + + if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { + return fmt.Errorf("failed to write code policy: %w", err) + } + + // Write linter configs + for linterName, config := range result.LinterConfigs { + outputPath := filepath.Join(filepath.Dir(codePolicyPath), config.Filename) + if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write %s config: %v\n", linterName, err) + } else { + fmt.Fprintf(os.Stderr, " ✓ Generated %s: %s\n", linterName, outputPath) + } + } + + return nil +} + // Server is a MCP (Model Context Protocol) server. // It communicates via JSON-RPC over stdio or HTTP. type Server struct { @@ -43,10 +110,31 @@ func NewServer(host string, port int, configPath string) *Server { // Start starts the MCP server. // It communicates via JSON-RPC over stdio or HTTP. func (s *Server) Start() error { + // Determine the directory to look for policy files + var dir string + if s.configPath != "" { - // Determine the directory and try to load both user-policy and code-policy - dir := filepath.Dir(s.configPath) + // If configPath is provided, check if it's a directory or file + fileInfo, err := os.Stat(s.configPath) + if err == nil && fileInfo.IsDir() { + // If it's a directory, use it directly + dir = s.configPath + } else { + // If it's a file, use its parent directory + dir = filepath.Dir(s.configPath) + } + } else { + // No configPath provided, auto-detect .sym folder from git root + repoRoot, err := git.GetRepoRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Warning: Not in a git repository, MCP server starting without policies\n") + } else { + dir = filepath.Join(repoRoot, ".sym") + } + } + // Only try to load policies if we have a directory + if dir != "" { // Try to load user-policy.json for natural language descriptions userPolicyPath := filepath.Join(dir, "user-policy.json") if userPolicy, err := s.loader.LoadUserPolicy(userPolicyPath); err == nil { @@ -61,6 +149,24 @@ func (s *Server) Start() error { fmt.Fprintf(os.Stderr, "✓ Code policy loaded: %s (%d rules)\n", codePolicyPath, len(codePolicy.Rules)) } + // Check if conversion is needed + if s.userPolicy != nil { + needsConversion := s.needsConversion(codePolicyPath) + if needsConversion { + fmt.Fprintf(os.Stderr, "⚙️ User policy has been updated. Converting to code policy...\n") + if err := s.convertUserPolicy(userPolicyPath, codePolicyPath); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Warning: Failed to convert policy: %v\n", err) + fmt.Fprintf(os.Stderr, " Continuing with existing policies...\n") + } else { + // Reload code policy after conversion + if codePolicy, err := s.loader.LoadCodePolicy(codePolicyPath); err == nil { + s.codePolicy = codePolicy + fmt.Fprintf(os.Stderr, "✓ Code policy updated: %s (%d rules)\n", codePolicyPath, len(codePolicy.Rules)) + } + } + } + } + // At least one policy must be loaded if s.userPolicy == nil && s.codePolicy == nil { return fmt.Errorf("no policy found in %s", dir) @@ -257,129 +363,6 @@ func (s *Server) runStdioWithSDK(ctx context.Context) error { return server.Run(ctx, &sdkmcp.StdioTransport{}) } -// handleRequests handles incoming requests via stdio. -func (s *Server) handleRequests(in io.Reader, out io.Writer) error { - // MCP over stdio uses LSP-style framing: - // Content-Length: \r\n - // [other headers...]\r\n - // \r\n - // - reader := bufio.NewReader(in) - writer := bufio.NewWriter(out) - - for { - // Parse headers - var contentLength int = -1 - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - _ = writer.Flush() - } - return err - } - line = strings.TrimRight(line, "\r\n") - if line == "" { - // End of headers - break - } - lower := strings.ToLower(line) - if strings.HasPrefix(lower, "content-length:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - nStr := strings.TrimSpace(parts[1]) - if n, err := strconv.Atoi(nStr); err == nil { - contentLength = n - } - } - } - // Ignore other headers - } - - if contentLength < 0 { - // No content-length provided → ignore and continue - // (Some clients might send keep-alive pings without body) - continue - } - - // Read body - body := make([]byte, contentLength) - if _, err := io.ReadFull(reader, body); err != nil { - return fmt.Errorf("failed to read request body: %w", err) - } - - // Decode JSON-RPC request - var req JSONRPCRequest - if err := json.Unmarshal(body, &req); err != nil { - // Send parse error - resp := JSONRPCResponse{ - JSONRPC: "2.0", - Error: &RPCError{ - Code: -32700, - Message: "parse error", - }, - ID: nil, - } - respBytes, _ := json.Marshal(resp) - if _, err := fmt.Fprintf(writer, "Content-Length: %d\r\n\r\n", len(respBytes)); err != nil { - return err - } - if _, err := writer.Write(respBytes); err != nil { - return err - } - if err := writer.Flush(); err != nil { - return err - } - continue - } - - var result interface{} - var rpcErr *RPCError - - switch req.Method { - case "initialize": - result, rpcErr = s.handleInitialize(req.Params) - case "initialized": - // Notification - no response needed, but we'll send empty result - result = nil - case "tools/list": - result, rpcErr = s.handleToolsList(req.Params) - case "tools/call": - result, rpcErr = s.handleToolsCall(req.Params) - case "query_conventions": - result, rpcErr = s.handleQueryConventions(req.Params) - case "validate_code": - result, rpcErr = s.handleValidateCode(req.Params) - default: - rpcErr = &RPCError{ - Code: -32601, - Message: fmt.Sprintf("method not found: %s", req.Method), - } - } - - // Encode response - resp := JSONRPCResponse{ - JSONRPC: "2.0", - Result: result, - Error: rpcErr, - ID: req.ID, - } - respBytes, err := json.Marshal(resp) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - if _, err := fmt.Fprintf(writer, "Content-Length: %d\r\n\r\n", len(respBytes)); err != nil { - return err - } - if _, err := writer.Write(respBytes); err != nil { - return err - } - if err := writer.Flush(); err != nil { - return err - } - } -} - // QueryConventionsRequest is a request to query conventions. type QueryConventionsRequest struct { Category string `json:"category"` // optional; use "all" or empty to fetch all categories @@ -482,7 +465,6 @@ func (s *Server) filterConventions(req QueryConventionsRequest) []ConventionItem } // If req.Languages is empty, include all rules (more user-friendly) - // Apply defaults if not specified in rule severity := rule.Severity if severity == "" && s.userPolicy.Defaults.Severity != "" { severity = s.userPolicy.Defaults.Severity @@ -823,3 +805,46 @@ func (s *Server) handleToolsCall(params map[string]interface{}) (interface{}, *R } } } + +// needsConversion checks if user policy needs to be converted to code policy. +// Returns true if: +// 1. code-policy.json doesn't exist, OR +// 2. user policy has more rules than code policy (indicating new rules added), OR +// 3. user policy has rule IDs that don't exist in code policy +func (s *Server) needsConversion(codePolicyPath string) bool { + // If no code policy exists, conversion is needed + if s.codePolicy == nil { + return true + } + + // If no user policy, no conversion needed + if s.userPolicy == nil { + return false + } + + // Check if user policy has more rules + if len(s.userPolicy.Rules) > len(s.codePolicy.Rules) { + return true + } + + // Check if all user policy rule IDs exist in code policy + codePolicyRuleIDs := make(map[string]bool) + for _, rule := range s.codePolicy.Rules { + codePolicyRuleIDs[rule.ID] = true + } + + for _, userRule := range s.userPolicy.Rules { + if !codePolicyRuleIDs[userRule.ID] { + // Found a user rule that doesn't exist in code policy + return true + } + } + + return false +} + +// convertUserPolicy converts user policy to code policy using LLM. +// This is a wrapper that calls the shared conversion logic. +func (s *Server) convertUserPolicy(userPolicyPath, codePolicyPath string) error { + return ConvertPolicyWithLLM(userPolicyPath, codePolicyPath) +}