From e04cbb6150c59176d9d4445d37cb97e8552aaa1d Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Tue, 18 Nov 2025 23:11:54 +0900 Subject: [PATCH 1/6] feat: implement parallel processing in LLM validation --- internal/validator/llm_validator.go | 48 ++++++++++----- internal/validator/llm_validator_test.go | 32 +++++----- internal/validator/validator.go | 75 ++++++++++++++++-------- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/internal/validator/llm_validator.go b/internal/validator/llm_validator.go index c77d698..408cae0 100644 --- a/internal/validator/llm_validator.go +++ b/internal/validator/llm_validator.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" @@ -49,7 +50,10 @@ func (v *LLMValidator) Validate(ctx context.Context, changes []GitChange) (*Vali return result, nil } - // Check each change against LLM rules + // Check each change against LLM rules using goroutines for parallel processing + var wg sync.WaitGroup + var mu sync.Mutex + for _, change := range changes { if change.Status == "D" { continue // Skip deleted files @@ -65,26 +69,38 @@ func (v *LLMValidator) Validate(ctx context.Context, changes []GitChange) (*Vali continue } - // Validate against each LLM rule + // Validate against each LLM rule in parallel for _, rule := range llmRules { + mu.Lock() result.Checked++ - - violation, err := v.CheckRule(ctx, change, addedLines, rule) - if err != nil { - // Log error but continue - fmt.Printf("Warning: failed to check rule %s: %v\n", rule.ID, err) - continue - } - - if violation != nil { - result.Failed++ - result.Violations = append(result.Violations, *violation) - } else { - result.Passed++ - } + mu.Unlock() + + wg.Add(1) + go func(ch GitChange, lines []string, r schema.PolicyRule) { + defer wg.Done() + + violation, err := v.CheckRule(ctx, ch, lines, r) + if err != nil { + // Log error but continue + fmt.Printf("Warning: failed to check rule %s: %v\n", r.ID, err) + return + } + + mu.Lock() + defer mu.Unlock() + if violation != nil { + result.Failed++ + result.Violations = append(result.Violations, *violation) + } else { + result.Passed++ + } + }(change, addedLines, rule) } } + // Wait for all goroutines to complete + wg.Wait() + return result, nil } diff --git a/internal/validator/llm_validator_test.go b/internal/validator/llm_validator_test.go index 577bf29..49b0ab8 100644 --- a/internal/validator/llm_validator_test.go +++ b/internal/validator/llm_validator_test.go @@ -57,28 +57,28 @@ func TestParseValidationResponse_NoViolation(t *testing.T) { func TestParseValidationResponse_WithViolation(t *testing.T) { tests := []struct { - name string - response string - expectDesc bool - expectSugg bool + name string + response string + expectDesc bool + expectSugg bool }{ { - name: "with description and suggestion", - response: `{"violates": true, "description": "Hardcoded API key found", "suggestion": "Use environment variables"}`, - expectDesc: true, - expectSugg: true, + name: "with description and suggestion", + response: `{"violates": true, "description": "Hardcoded API key found", "suggestion": "Use environment variables"}`, + expectDesc: true, + expectSugg: true, }, { - name: "with description only", - response: `{"violates": true, "description": "Security issue detected", "suggestion": ""}`, - expectDesc: true, - expectSugg: false, + name: "with description only", + response: `{"violates": true, "description": "Security issue detected", "suggestion": ""}`, + expectDesc: true, + expectSugg: false, }, { - name: "minimal violation", - response: `{"violates": true}`, - expectDesc: true, // Should have default description - expectSugg: false, + name: "minimal violation", + response: `{"violates": true}`, + expectDesc: true, // Should have default description + expectSugg: false, }, } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 72a9885..dbdead3 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "time" + "sync" "github.com/DevSymphony/sym-cli/internal/engine/core" "github.com/DevSymphony/sym-cli/internal/engine/registry" @@ -467,23 +468,23 @@ func (v *Validator) ValidateChanges(ctx context.Context, changes []GitChange) (* coreRule := convertToCoreRule(rule) // Execute validation on each change - for _, change := range relevantChanges { - if change.Status == "D" { - continue // Skip deleted files + // For LLM engine, use goroutines for parallel processing + if engineName == "llm-validator" { + if v.llmClient == nil { + fmt.Printf("⚠️ LLM client not configured for rule %s\n", rule.ID) + continue } - result.Checked++ + // Use goroutines for parallel LLM validation + var wg sync.WaitGroup + var mu sync.Mutex + llmValidator := NewLLMValidator(v.llmClient, v.policy) - // For LLM engine, validate the diff using LLMValidator - if engineName == "llm-validator" { - if v.llmClient == nil { - fmt.Printf("⚠️ LLM client not configured for rule %s\n", rule.ID) - continue + for _, change := range relevantChanges { + if change.Status == "D" { + continue // Skip deleted files } - // Create LLMValidator to use its CheckRule method - llmValidator := NewLLMValidator(v.llmClient, v.policy) - // Extract added lines from diff addedLines := ExtractAddedLines(change.Diff) if len(addedLines) == 0 && strings.TrimSpace(change.Diff) != "" { @@ -491,22 +492,50 @@ func (v *Validator) ValidateChanges(ctx context.Context, changes []GitChange) (* } if len(addedLines) == 0 { + mu.Lock() + result.Checked++ result.Passed++ + mu.Unlock() continue } - violation, err := llmValidator.CheckRule(ctx, change, addedLines, rule) - if err != nil { - fmt.Printf("⚠️ Validation failed for rule %s: %v\n", rule.ID, err) - continue - } - if violation != nil { - result.Failed++ - result.Violations = append(result.Violations, *violation) - } else { - result.Passed++ + // Increment counter and launch goroutine + mu.Lock() + result.Checked++ + mu.Unlock() + + wg.Add(1) + go func(ch GitChange, lines []string, r schema.PolicyRule) { + defer wg.Done() + + violation, err := llmValidator.CheckRule(ctx, ch, lines, r) + if err != nil { + fmt.Printf("⚠️ Validation failed for rule %s: %v\n", r.ID, err) + return + } + + mu.Lock() + defer mu.Unlock() + if violation != nil { + result.Failed++ + result.Violations = append(result.Violations, *violation) + } else { + result.Passed++ + } + }(change, addedLines, rule) + } + + // Wait for all goroutines to complete + wg.Wait() + } else { + // For other engines, process sequentially + for _, change := range relevantChanges { + if change.Status == "D" { + continue // Skip deleted files } - } else { + + result.Checked++ + // For other engines, validate the file validationResult, err := engine.Validate(ctx, coreRule, []string{change.FilePath}) if err != nil { From f3fe72f09e6e4ad2eee9fa602dc93467116ada24 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 19 Nov 2025 01:23:19 +0900 Subject: [PATCH 2/6] refactor: streamline linter converters and enhance LLM integration --- internal/cmd/convert.go | 150 +---- internal/converter/converter.go | 713 ++++++++------------- internal/converter/linters/checkstyle.go | 464 +++++--------- internal/converter/linters/converter.go | 49 -- internal/converter/linters/eslint.go | 439 +++++-------- internal/converter/linters/eslint_test.go | 172 ----- internal/converter/linters/interface.go | 28 + internal/converter/linters/pmd.go | 414 ++++-------- internal/converter/linters/prettier_tsc.go | 283 ++++++++ internal/converter/linters/registry.go | 115 ---- 10 files changed, 1050 insertions(+), 1777 deletions(-) delete mode 100644 internal/converter/linters/converter.go delete mode 100644 internal/converter/linters/eslint_test.go create mode 100644 internal/converter/linters/interface.go create mode 100644 internal/converter/linters/prettier_tsc.go delete mode 100644 internal/converter/linters/registry.go diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index 63e8aed..3174b9a 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -16,13 +16,13 @@ import ( ) var ( - convertInputFile string - convertOutputFile string - convertTargets []string - convertOutputDir string - convertOpenAIModel string + convertInputFile string + convertOutputFile string + convertTargets []string + convertOutputDir string + convertOpenAIModel string convertConfidenceThreshold float64 - convertTimeout int + convertTimeout int ) var convertCmd = &cobra.Command{ @@ -86,13 +86,8 @@ func runConvert(cmd *cobra.Command, args []string) error { fmt.Printf("Loaded user policy with %d rules\n", len(userPolicy.Rules)) - // Check mode: multi-target or legacy - if len(convertTargets) > 0 { - return runMultiTargetConvert(&userPolicy) - } - - // Legacy mode: generate only internal code-policy.json - return runLegacyConvert(&userPolicy) + // Use new converter by default (language-based routing with parallel LLM) + return runNewConverter(&userPolicy) } // loadPolicyPathFromEnv reads POLICY_PATH from .sym/.env @@ -121,65 +116,17 @@ func loadPolicyPathFromEnv() string { return "" } -func runLegacyConvert(userPolicy *schema.UserPolicy) error { - outputFile := convertOutputFile - if outputFile == "" { - // Use same directory as input file - inputDir := filepath.Dir(convertInputFile) - outputFile = filepath.Join(inputDir, "code-policy.json") - } - - conv := converter.NewConverter() - - fmt.Printf("Converting %d natural language rules into structured policy...\n", len(userPolicy.Rules)) - - codePolicy, err := conv.Convert(userPolicy) - if err != nil { - return fmt.Errorf("conversion failed: %w", err) - } - - output, err := json.MarshalIndent(codePolicy, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize code policy: %w", err) - } - - // Create directory if needed - outputDir := filepath.Dir(outputFile) - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - if err := os.WriteFile(outputFile, output, 0644); err != nil { - return fmt.Errorf("failed to write output file: %w", err) - } - - fmt.Printf("✓ Conversion completed: %s\n", outputFile) - fmt.Printf(" - Processed rules: %d\n", len(codePolicy.Rules)) - if codePolicy.RBAC != nil { - fmt.Printf(" - RBAC roles: %d\n", len(codePolicy.RBAC.Roles)) - } - - return nil -} - -func runMultiTargetConvert(userPolicy *schema.UserPolicy) error { +func runNewConverter(userPolicy *schema.UserPolicy) error { // Determine output directory if convertOutputDir == "" { - // Use same directory as input file - convertOutputDir = filepath.Dir(convertInputFile) - fmt.Printf("Using output directory: %s (same as input file)\n", convertOutputDir) - } - - // Create output directory if it doesn't exist - if err := os.MkdirAll(convertOutputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + // Default to .sym directory + convertOutputDir = ".sym" } // Setup OpenAI client apiKey, err := getAPIKey() if err != nil { - fmt.Printf("Warning: %v, using fallback inference\n", err) - apiKey = "" + return fmt.Errorf("OpenAI API key required: %w", err) } timeout := time.Duration(convertTimeout) * time.Second @@ -189,70 +136,41 @@ func runMultiTargetConvert(userPolicy *schema.UserPolicy) error { llm.WithTimeout(timeout), ) - // Create converter with LLM client - conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + // Create new converter + conv := converter.NewConverter(llmClient, convertOutputDir) - // Setup context with timeout - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(convertTimeout*len(userPolicy.Rules))*time.Second) + // Setup context with generous timeout for parallel processing + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(convertTimeout*10)*time.Second) defer cancel() - fmt.Printf("\nConverting with OpenAI model: %s\n", convertOpenAIModel) - fmt.Printf("Confidence threshold: %.2f\n", convertConfidenceThreshold) - fmt.Printf("Output directory: %s\n\n", convertOutputDir) + fmt.Printf("\n🚀 Converting with language-based routing and parallel LLM inference\n") + fmt.Printf("📝 Model: %s\n", convertOpenAIModel) + fmt.Printf("📂 Output: %s\n\n", convertOutputDir) - // Convert for multiple targets - result, err := conv.ConvertMultiTarget(ctx, userPolicy, converter.MultiTargetConvertOptions{ - Targets: convertTargets, - OutputDir: convertOutputDir, - ConfidenceThreshold: convertConfidenceThreshold, - }) + // Convert + result, err := conv.Convert(ctx, userPolicy) if err != nil { - return fmt.Errorf("multi-target conversion failed: %w", err) - } - - // Write linter configuration files - filesWritten := 0 - for linterName, config := range result.LinterConfigs { - outputPath := filepath.Join(convertOutputDir, config.Filename) - - if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { - return fmt.Errorf("failed to write %s config: %w", linterName, err) - } - - fmt.Printf("✓ Generated %s configuration: %s\n", linterName, outputPath) - - // Print rule count - if convResult, ok := result.Results[linterName]; ok { - fmt.Printf(" - Rules: %d\n", len(convResult.Rules)) - if len(convResult.Warnings) > 0 { - fmt.Printf(" - Warnings: %d\n", len(convResult.Warnings)) - } - } - - filesWritten++ + return fmt.Errorf("conversion failed: %w", err) } - // Write internal code policy - codePolicyPath := filepath.Join(convertOutputDir, "code-policy.json") - codePolicyJSON, err := json.MarshalIndent(result.CodePolicy, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize code policy: %w", err) + // Print results + fmt.Printf("\n✅ Conversion completed successfully!\n") + fmt.Printf("📦 Generated %d configuration file(s):\n", len(result.GeneratedFiles)) + for _, file := range result.GeneratedFiles { + fmt.Printf(" ✓ %s\n", file) } - if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { - return fmt.Errorf("failed to write code policy: %w", err) + if len(result.Errors) > 0 { + fmt.Printf("\n⚠️ Errors (%d):\n", len(result.Errors)) + for linter, err := range result.Errors { + fmt.Printf(" ✗ %s: %v\n", linter, err) + } } - fmt.Printf("✓ Generated internal policy: %s\n", codePolicyPath) - filesWritten++ - - // Print summary - fmt.Printf("\n✓ Conversion complete: %d files written\n", filesWritten) - if len(result.Warnings) > 0 { - fmt.Printf("\nWarnings (%d):\n", len(result.Warnings)) + fmt.Printf("\n⚠️ Warnings (%d):\n", len(result.Warnings)) for _, warning := range result.Warnings { - fmt.Printf(" ⚠ %s\n", warning) + fmt.Printf(" • %s\n", warning) } } diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 2d9821a..3b1cd2c 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -2,548 +2,391 @@ package converter import ( "context" + "encoding/json" "fmt" - "reflect" + "os" + "path/filepath" "strings" + "sync" "github.com/DevSymphony/sym-cli/internal/converter/linters" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) -// Converter converts user policy (A schema) to code policy (B schema) -type Converter struct { - llmClient *llm.Client - inferencer *llm.Inferencer +// LanguageLinterMapping defines which linters are available for each language +var LanguageLinterMapping = map[string][]string{ + "javascript": {"eslint", "prettier"}, + "js": {"eslint", "prettier"}, + "typescript": {"tsc", "eslint", "prettier"}, + "ts": {"tsc", "eslint", "prettier"}, + "tsx": {"tsc", "eslint", "prettier"}, + "jsx": {"eslint", "prettier"}, + "java": {"checkstyle", "pmd"}, } -// ConverterOption is a functional option for configuring the converter -type ConverterOption func(*Converter) +// Special linter for rules that don't fit any specific external linter +const LLMValidatorEngine = "llm-validator" -// WithLLMClient sets the LLM client for inference -func WithLLMClient(client *llm.Client) ConverterOption { - return func(c *Converter) { - c.llmClient = client - c.inferencer = llm.NewInferencer(client) - } +// Converter is the main converter with language-based routing +type Converter struct { + llmClient *llm.Client + outputDir string } -// NewConverter creates a new converter -func NewConverter(opts ...ConverterOption) *Converter { - c := &Converter{} - - for _, opt := range opts { - opt(c) +// NewConverter creates a new converter instance +func NewConverter(llmClient *llm.Client, outputDir string) *Converter { + if outputDir == "" { + outputDir = ".sym" } + return &Converter{ + llmClient: llmClient, + outputDir: outputDir, + } +} - return c +// ConvertResult represents the result of conversion +type ConvertResult struct { + GeneratedFiles []string // List of generated file paths (including code-policy.json) + CodePolicy *schema.CodePolicy // Generated code policy + Errors map[string]error // Errors per linter + Warnings []string // Conversion warnings } -// Convert converts user policy to code policy -func (c *Converter) Convert(userPolicy *schema.UserPolicy) (*schema.CodePolicy, error) { +// Convert is the main entry point for converting user policy to linter configs +func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy) (*ConvertResult, error) { if userPolicy == nil { return nil, fmt.Errorf("user policy is nil") } - codePolicy := &schema.CodePolicy{ - Version: userPolicy.Version, - Rules: make([]schema.PolicyRule, 0, len(userPolicy.Rules)), - Enforce: schema.EnforceSettings{ - Stages: []string{"pre-commit"}, - FailOn: []string{"error"}, - }, - } - - if codePolicy.Version == "" { - codePolicy.Version = "1.0.0" - } - - // Convert RBAC - if userPolicy.RBAC != nil { - codePolicy.RBAC = c.convertRBAC(userPolicy.RBAC) - } - - // Convert rules - for i, userRule := range userPolicy.Rules { - policyRule, err := c.convertRule(&userRule, userPolicy.Defaults, i) - if err != nil { - return nil, fmt.Errorf("failed to convert rule %d: %w", i, err) - } - codePolicy.Rules = append(codePolicy.Rules, *policyRule) - } - - return codePolicy, nil -} + // Step 1: Route rules by asking LLM which linters are appropriate + linterRules := c.routeRulesWithLLM(ctx, userPolicy) -// convertWithEngines converts user policy to code policy with engine mappings -func (c *Converter) convertWithEngines(userPolicy *schema.UserPolicy, engineMap map[string]string) (*schema.CodePolicy, error) { - if userPolicy == nil { - return nil, fmt.Errorf("user policy is nil") + // Step 2: Create output directory + if err := os.MkdirAll(c.outputDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create output directory: %w", err) } + // Step 3: Build CodePolicy with linter mappings codePolicy := &schema.CodePolicy{ - Version: userPolicy.Version, - Rules: make([]schema.PolicyRule, 0, len(userPolicy.Rules)), + Version: "1.0", + Rules: []schema.PolicyRule{}, Enforce: schema.EnforceSettings{ - Stages: []string{"pre-commit"}, + Stages: []string{"pre-commit", "pre-push"}, FailOn: []string{"error"}, }, } - if codePolicy.Version == "" { - codePolicy.Version = "1.0.0" - } + // Track which linters each rule maps to + ruleToLinters := make(map[string][]string) // rule ID -> linter names - // Convert RBAC - if userPolicy.RBAC != nil { - codePolicy.RBAC = c.convertRBAC(userPolicy.RBAC) - } - - // Convert rules with engine information - for i, userRule := range userPolicy.Rules { - policyRule, err := c.convertRule(&userRule, userPolicy.Defaults, i) - if err != nil { - return nil, fmt.Errorf("failed to convert rule %d: %w", i, err) + for linterName, rules := range linterRules { + for _, rule := range rules { + ruleToLinters[rule.ID] = append(ruleToLinters[rule.ID], linterName) } - - // Update engine field based on mapping - if engine, ok := engineMap[userRule.ID]; ok { - policyRule.Check["engine"] = engine - } else { - // Fallback to llm-validator if no mapping found - policyRule.Check["engine"] = "llm-validator" - } - - codePolicy.Rules = append(codePolicy.Rules, *policyRule) } - return codePolicy, nil -} - -// convertRBAC converts user RBAC to policy RBAC -func (c *Converter) convertRBAC(userRBAC *schema.UserRBAC) *schema.PolicyRBAC { - policyRBAC := &schema.PolicyRBAC{ - Roles: make(map[string]schema.PolicyRole), + // Step 4: Convert rules for each linter in parallel using goroutines + result := &ConvertResult{ + GeneratedFiles: []string{}, + CodePolicy: codePolicy, + Errors: make(map[string]error), + Warnings: []string{}, } - for roleName, userRole := range userRBAC.Roles { - permissions := make([]schema.Permission, 0) - - // Convert allowWrite - for _, path := range userRole.AllowWrite { - permissions = append(permissions, schema.Permission{ - Path: path, - Read: true, - Write: true, - Execute: false, - }) - } + var wg sync.WaitGroup + var mu sync.Mutex - // Convert denyWrite - for _, path := range userRole.DenyWrite { - permissions = append(permissions, schema.Permission{ - Path: path, - Read: true, - Write: false, - Execute: false, - }) + for linterName, rules := range linterRules { + if len(rules) == 0 { + continue } - // Convert allowExec - for _, path := range userRole.AllowExec { - permissions = append(permissions, schema.Permission{ - Path: path, - Read: true, - Write: false, - Execute: true, - }) + // Skip llm-validator - it will be handled in CodePolicy only + if linterName == LLMValidatorEngine { + continue } - policyRBAC.Roles[roleName] = schema.PolicyRole{ - Permissions: permissions, - } - } + wg.Add(1) + go func(linter string, ruleSet []schema.UserRule) { + defer wg.Done() + + // Get linter converter + converter := c.getLinterConverter(linter) + if converter == nil { + mu.Lock() + result.Errors[linter] = fmt.Errorf("unsupported linter: %s", linter) + mu.Unlock() + return + } - return policyRBAC -} + // Convert rules using LLM + configFile, err := converter.ConvertRules(ctx, ruleSet, c.llmClient) + if err != nil { + mu.Lock() + result.Errors[linter] = fmt.Errorf("conversion failed: %w", err) + mu.Unlock() + return + } -// convertRule converts a user rule to policy rule -func (c *Converter) convertRule(userRule *schema.UserRule, defaults *schema.UserDefaults, index int) (*schema.PolicyRule, error) { - // Generate ID if not provided - id := userRule.ID - if id == "" { - id = fmt.Sprintf("RULE-%d", index+1) - } + // Write config file to .sym directory + outputPath := filepath.Join(c.outputDir, configFile.Filename) + if err := os.WriteFile(outputPath, configFile.Content, 0644); err != nil { + mu.Lock() + result.Errors[linter] = fmt.Errorf("failed to write file: %w", err) + mu.Unlock() + return + } - // Determine severity - severity := userRule.Severity - if severity == "" && defaults != nil { - severity = defaults.Severity - } - if severity == "" { - severity = "error" - } + mu.Lock() + result.GeneratedFiles = append(result.GeneratedFiles, outputPath) + mu.Unlock() - // Build selector - var selector *schema.Selector - if len(userRule.Languages) > 0 || len(userRule.Include) > 0 || len(userRule.Exclude) > 0 { - selector = &schema.Selector{ - Languages: userRule.Languages, - Include: userRule.Include, - Exclude: userRule.Exclude, - } - } else if defaults != nil && (len(defaults.Languages) > 0 || len(defaults.Include) > 0 || len(defaults.Exclude) > 0) { - selector = &schema.Selector{ - Languages: defaults.Languages, - Include: defaults.Include, - Exclude: defaults.Exclude, - } + fmt.Printf("✓ Generated %s configuration: %s\n", linter, outputPath) + }(linterName, rules) } - // For now, create a basic check structure with llm-validator as default engine - check := map[string]any{ - "engine": "llm-validator", - "desc": userRule.Say, - } + // Wait for all goroutines to complete + wg.Wait() - // Merge params if provided - for k, v := range userRule.Params { - check[k] = v + // Check if we have any successful conversions + if len(result.GeneratedFiles) == 0 && len(result.Errors) > 0 { + return result, fmt.Errorf("all conversions failed") } - // Build remedy - var remedy *schema.Remedy - autofix := userRule.Autofix - if !autofix && defaults != nil { - autofix = defaults.Autofix - } - if autofix { - remedy = &schema.Remedy{ - Autofix: true, + // Step 5: Generate CodePolicy rules from UserPolicy + for _, userRule := range userPolicy.Rules { + linters := ruleToLinters[userRule.ID] + if len(linters) == 0 { + continue // Skip rules that didn't map to any linter } - } - - policyRule := &schema.PolicyRule{ - ID: id, - Enabled: true, - Category: userRule.Category, - Severity: severity, - Desc: userRule.Say, - When: selector, - Check: check, - Remedy: remedy, - Message: userRule.Message, - } - if policyRule.Category == "" { - policyRule.Category = "custom" - } + // Create a PolicyRule for each linter this rule applies to + for _, linterName := range linters { + policyRule := schema.PolicyRule{ + ID: fmt.Sprintf("%s-%s", userRule.ID, linterName), + Enabled: true, + Category: userRule.Category, + Severity: userRule.Severity, + Desc: userRule.Say, + Message: userRule.Message, + Check: map[string]any{ + "engine": linterName, // External linter name + "desc": userRule.Say, + }, + } - return policyRule, nil -} + // Add selector if languages are specified + if len(userRule.Languages) > 0 || len(userRule.Include) > 0 || len(userRule.Exclude) > 0 { + policyRule.When = &schema.Selector{ + Languages: userRule.Languages, + Include: userRule.Include, + Exclude: userRule.Exclude, + } + } -// MultiTargetConvertOptions represents options for multi-target conversion -type MultiTargetConvertOptions struct { - Targets []string // Linter targets (e.g., "eslint", "checkstyle", "pmd") - OutputDir string // Output directory for generated files - ConfidenceThreshold float64 // Minimum confidence for LLM inference -} + // Add remedy if autofix is enabled + if userRule.Autofix { + policyRule.Remedy = &schema.Remedy{ + Autofix: true, + Tool: linterName, + } + } -// 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 -} + // Use defaults if not specified + if policyRule.Severity == "" && userPolicy.Defaults != nil { + policyRule.Severity = userPolicy.Defaults.Severity + } + if policyRule.Severity == "" { + policyRule.Severity = "error" + } -// ConvertMultiTarget converts user policy to multiple linter configurations -func (c *Converter) ConvertMultiTarget(ctx context.Context, userPolicy *schema.UserPolicy, opts MultiTargetConvertOptions) (*MultiTargetConvertResult, error) { - if userPolicy == nil { - return nil, fmt.Errorf("user policy is nil") + codePolicy.Rules = append(codePolicy.Rules, policyRule) + } } - // Default options - if opts.ConfidenceThreshold == 0 { - opts.ConfidenceThreshold = 0.7 + // Step 6: Write code-policy.json + codePolicyPath := filepath.Join(c.outputDir, "code-policy.json") + codePolicyJSON, err := json.MarshalIndent(codePolicy, "", " ") + if err != nil { + return result, fmt.Errorf("failed to marshal code policy: %w", err) } - if len(opts.Targets) == 0 { - opts.Targets = []string{"all"} + if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { + return result, fmt.Errorf("failed to write code policy: %w", err) } - result := &MultiTargetConvertResult{ - CodePolicy: nil, // Will be generated after linter conversion - LinterConfigs: make(map[string]*linters.LinterConfig), - Results: make(map[string]*linters.ConversionResult), - Warnings: []string{}, - } + result.GeneratedFiles = append(result.GeneratedFiles, codePolicyPath) + fmt.Printf("✓ Generated code policy: %s\n", codePolicyPath) - // Resolve target linters - targetConverters, err := c.resolveTargets(opts.Targets) - if err != nil { - return nil, fmt.Errorf("failed to resolve targets: %w", err) - } + return result, nil +} - // Aggregate engine mappings: ruleID -> engine name - engineMap := make(map[string]string) +// routeRulesWithLLM uses LLM to determine which linters are appropriate for each rule +func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.UserPolicy) map[string][]schema.UserRule { + linterRules := make(map[string][]schema.UserRule) - // Convert rules for each target linter - for _, converter := range targetConverters { - linterName := converter.Name() + for _, rule := range userPolicy.Rules { + // Get languages for this rule + languages := rule.Languages + if len(languages) == 0 && userPolicy.Defaults != nil { + languages = userPolicy.Defaults.Languages + } - convResult, err := c.convertForLinter(ctx, userPolicy, converter, opts.ConfidenceThreshold) - if err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("%s: conversion failed: %v", linterName, err)) + // Get available linters for these languages + availableLinters := c.getAvailableLinters(languages) + if len(availableLinters) == 0 { + // No language-specific linters, use llm-validator + linterRules[LLMValidatorEngine] = append(linterRules[LLMValidatorEngine], rule) continue } - result.Results[linterName] = convResult - result.LinterConfigs[linterName] = convResult.Config + // Ask LLM which linters are appropriate for this rule + selectedLinters := c.selectLintersForRule(ctx, rule, availableLinters) - // Aggregate engine mappings - // Priority: first linter that successfully converts wins - for ruleID, engine := range convResult.RuleEngineMap { - if _, exists := engineMap[ruleID]; !exists || engine != "llm-validator" { - engineMap[ruleID] = engine + if len(selectedLinters) == 0 { + // LLM couldn't map to any linter, use llm-validator + linterRules[LLMValidatorEngine] = append(linterRules[LLMValidatorEngine], rule) + } else { + // Add rule to selected linters + for _, linter := range selectedLinters { + linterRules[linter] = append(linterRules[linter], rule) } } - - // Collect warnings - for _, warning := range convResult.Warnings { - result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %s", linterName, warning)) - } - } - - // Generate internal code policy with engine mappings - codePolicy, err := c.convertWithEngines(userPolicy, engineMap) - if err != nil { - return nil, fmt.Errorf("failed to convert to code policy: %w", err) } - result.CodePolicy = codePolicy - - return result, nil + return linterRules } -// 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{}, - RuleEngineMap: make(map[string]string), // Track which engine handles each rule +// getAvailableLinters returns available linters for given languages +func (c *Converter) getAvailableLinters(languages []string) []string { + if len(languages) == 0 { + // If no languages specified, include all linters + languages = []string{"javascript", "typescript", "java"} } - // Filter rules by language if needed - supportedLangs := converter.SupportedLanguages() - - // Collect applicable rules - type ruleWithIndex struct { - rule schema.UserRule - index int - } - var applicableRules []ruleWithIndex - - for i, userRule := range userPolicy.Rules { - if c.ruleAppliesToLanguages(userRule, supportedLangs, userPolicy.Defaults) { - applicableRules = append(applicableRules, ruleWithIndex{rule: userRule, index: i}) - } - } - - if len(applicableRules) == 0 { - // No applicable rules, return empty result - config, err := converter.GenerateConfig(result.Rules) - if err != nil { - return nil, fmt.Errorf("failed to generate config: %w", err) + linterSet := make(map[string]bool) + for _, lang := range languages { + if linters, ok := LanguageLinterMapping[lang]; ok { + for _, linter := range linters { + linterSet[linter] = true + } } - result.Config = config - return result, nil } - // Infer rule intents in parallel - if c.inferencer == nil { - return nil, fmt.Errorf("LLM client not configured") + result := []string{} + for linter := range linterSet { + result = append(result, linter) } + return result +} - type inferenceJob struct { - ruleWithIndex - intent *llm.RuleIntent - err error - warning string - } - - // Create worker pool with concurrency limit - maxWorkers := 5 // Limit concurrent LLM API calls - jobs := make(chan ruleWithIndex, len(applicableRules)) - results := make(chan inferenceJob, len(applicableRules)) - - // Start workers - for w := 0; w < maxWorkers; w++ { - go func() { - for job := range jobs { - inferResult, err := c.inferencer.InferFromUserRule(ctx, &job.rule) +// selectLintersForRule uses LLM to determine which linters are appropriate for a rule +func (c *Converter) selectLintersForRule(ctx context.Context, rule schema.UserRule, availableLinters []string) []string { + systemPrompt := fmt.Sprintf(`You are a code quality expert. Analyze the given coding rule and determine which linters can ACTUALLY enforce it using their NATIVE rules (without plugins). - jobResult := inferenceJob{ - ruleWithIndex: job, - } +Available linters and NATIVE capabilities: +- eslint: ONLY native ESLint rules (no-console, no-unused-vars, eqeqeq, no-var, camelcase, new-cap, max-len, max-lines, no-eval, etc.) + - CAN: Simple syntax checks, variable naming, console usage, basic patterns + - CANNOT: Complex business logic, context-aware rules, file naming, advanced async patterns +- prettier: Code formatting ONLY (quotes, semicolons, indentation, line length, trailing commas) +- tsc: TypeScript type checking ONLY (strict modes, noImplicitAny, strictNullChecks, type inference) +- checkstyle: Java style checks (naming, whitespace, imports, line length, complexity) +- pmd: Java code quality (unused code, empty blocks, naming conventions, design issues) - if err != nil { - jobResult.err = err - jobResult.warning = fmt.Sprintf("Rule %d (%s): %v", job.index+1, job.rule.ID, err) - } else { - jobResult.intent = inferResult.Intent - - // Check confidence threshold - if inferResult.Intent.Confidence < confidenceThreshold { - jobResult.warning = fmt.Sprintf("Rule %d (%s): low confidence %.2f", - job.index+1, job.rule.ID, inferResult.Intent.Confidence) - } - } +STRICT Rules for selection: +1. ONLY select if the linter has a NATIVE rule that can enforce this +2. If the rule requires understanding business logic or context → return [] +3. If the rule requires custom plugins → return [] +4. If the rule is about file naming → return [] +5. If the rule requires deep semantic analysis → return [] +6. When in doubt, return [] (better to use llm-validator than fail) - results <- jobResult - } - }() - } +Available linters for this rule: %s - // Send jobs - for _, rule := range applicableRules { - jobs <- rule - } - close(jobs) +Return ONLY a JSON array of linter names (no markdown): +["linter1", "linter2"] or [] - // Collect results - inferenceResults := make(map[int]inferenceJob) - for i := 0; i < len(applicableRules); i++ { - jobResult := <-results - inferenceResults[jobResult.index] = jobResult - } - close(results) +Examples: - // Process results in original order - for _, ruleInfo := range applicableRules { - jobResult := inferenceResults[ruleInfo.index] +Input: "Use single quotes for strings" +Output: ["prettier"] - if jobResult.err != nil { - result.Warnings = append(result.Warnings, jobResult.warning) - continue - } +Input: "No console.log allowed" +Output: ["eslint"] - if jobResult.warning != "" { - result.Warnings = append(result.Warnings, jobResult.warning) - } +Input: "Classes start with capital letter" +Output: ["eslint"] - // Convert to linter-specific rule - linterRule, err := converter.Convert(&ruleInfo.rule, jobResult.intent) - if err != nil { - result.Errors = append(result.Errors, fmt.Errorf("rule %d: %w", ruleInfo.index+1, err)) - continue - } +Input: "Maximum line length is 120" +Output: ["prettier"] - result.Rules = append(result.Rules, linterRule) - - // Track which engine will validate this rule - // Check if the rule has meaningful configuration content - hasContent := false - if len(linterRule.Config) > 0 { - // Check if config has actual rule content (not just empty nested structures) - for key, value := range linterRule.Config { - if key == "modules" && value != nil { - // For Checkstyle, check if modules slice is not empty - v := reflect.ValueOf(value) - if v.Kind() == reflect.Slice && v.Len() > 0 { - hasContent = true - break - } - } else if key == "rules" && value != nil { - // For PMD, check if rules array/slice is not empty - v := reflect.ValueOf(value) - if v.Kind() == reflect.Slice && v.Len() > 0 { - hasContent = true - break - } - } else if key != "" && value != nil { - // For ESLint and other formats with direct config - hasContent = true - break - } - } - } +Input: "No implicit any types" +Output: ["tsc"] - if hasContent { - result.RuleEngineMap[ruleInfo.rule.ID] = converter.Name() - } else { - result.RuleEngineMap[ruleInfo.rule.ID] = "llm-validator" - } - } +Input: "All async functions must have try-catch" +Output: [] +Reason: Requires semantic understanding of error handling - // Generate final configuration - config, err := converter.GenerateConfig(result.Rules) - if err != nil { - return nil, fmt.Errorf("failed to generate config: %w", err) - } +Input: "File names must be kebab-case" +Output: [] +Reason: File naming requires plugin - result.Config = config +Input: "API handlers must return proper status codes" +Output: [] +Reason: Requires business logic understanding - return result, nil -} +Input: "Database queries must use parameterized queries" +Output: [] +Reason: Requires understanding SQL injection context -// resolveTargets resolves target names to converters -func (c *Converter) resolveTargets(targets []string) ([]linters.LinterConverter, error) { - if len(targets) == 1 && strings.ToLower(targets[0]) == "all" { - // Return all registered converters - return linters.GetAll(), nil - } +Input: "No hardcoded API keys or passwords" +Output: [] +Reason: Requires semantic analysis of what constitutes secrets - converters := []linters.LinterConverter{} - for _, target := range targets { - converter, err := linters.Get(target) - if err != nil { - return nil, fmt.Errorf("target %s: %w", target, err) - } - converters = append(converters, converter) - } +Input: "Imports from large packages must be specific" +Output: [] +Reason: Requires knowing which packages are "large"`, availableLinters) - return converters, nil -} + userPrompt := fmt.Sprintf("Rule: %s\nCategory: %s", rule.Say, rule.Category) -// ruleAppliesToLanguages checks if a rule applies to any of the supported languages -func (c *Converter) ruleAppliesToLanguages(rule schema.UserRule, supportedLangs []string, defaults *schema.UserDefaults) bool { - // Get rule's target languages - targetLangs := rule.Languages - if len(targetLangs) == 0 && defaults != nil { - targetLangs = defaults.Languages + // Call LLM + response, err := c.llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + fmt.Printf("Warning: LLM routing failed for rule %s: %v\n", rule.ID, err) + return []string{} // Will fall back to llm-validator } - // If no target languages specified, apply to all - if len(targetLangs) == 0 { - return true - } + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) - // Check if any target language matches supported languages - for _, targetLang := range targetLangs { - targetLang = strings.ToLower(targetLang) - for _, supportedLang := range supportedLangs { - supportedLang = strings.ToLower(supportedLang) - if targetLang == supportedLang || strings.Contains(supportedLang, targetLang) || strings.Contains(targetLang, supportedLang) { - return true - } - } + var selectedLinters []string + if err := json.Unmarshal([]byte(response), &selectedLinters); err != nil { + fmt.Printf("Warning: Failed to parse LLM response for rule %s: %v\n", rule.ID, err) + return []string{} // Will fall back to llm-validator } - return false + return selectedLinters } -// GetAll is a helper to get all registered converters -func GetAll() []linters.LinterConverter { - registry := linters.List() - converters := make([]linters.LinterConverter, 0, len(registry)) - for _, name := range registry { - converter, err := linters.Get(name) - if err == nil { - converters = append(converters, converter) - } +// getLinterConverter returns the appropriate converter for a linter +func (c *Converter) getLinterConverter(linterName string) linters.LinterConverter { + switch linterName { + case "eslint": + return linters.NewESLintLinterConverter() + case "prettier": + return linters.NewPrettierLinterConverter() + case "tsc": + return linters.NewTSCLinterConverter() + case "checkstyle": + return linters.NewCheckstyleLinterConverter() + case "pmd": + return linters.NewPMDLinterConverter() + default: + return nil } - return converters } diff --git a/internal/converter/linters/checkstyle.go b/internal/converter/linters/checkstyle.go index f213964..d90bb57 100644 --- a/internal/converter/linters/checkstyle.go +++ b/internal/converter/linters/checkstyle.go @@ -1,370 +1,240 @@ package linters import ( + "context" + "encoding/json" "encoding/xml" "fmt" "strings" + "sync" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) -// CheckstyleConverter converts rules to Checkstyle XML configuration -type CheckstyleConverter struct { - verbose bool -} +// CheckstyleLinterConverter converts rules to Checkstyle XML using LLM +type CheckstyleLinterConverter struct{} -// NewCheckstyleConverter creates a new Checkstyle converter -func NewCheckstyleConverter(verbose bool) *CheckstyleConverter { - return &CheckstyleConverter{ - verbose: verbose, - } +// NewCheckstyleLinterConverter creates a new Checkstyle converter +func NewCheckstyleLinterConverter() *CheckstyleLinterConverter { + return &CheckstyleLinterConverter{} } // Name returns the linter name -func (c *CheckstyleConverter) Name() string { +func (c *CheckstyleLinterConverter) Name() string { return "checkstyle" } // SupportedLanguages returns supported languages -func (c *CheckstyleConverter) SupportedLanguages() []string { +func (c *CheckstyleLinterConverter) SupportedLanguages() []string { return []string{"java"} } -// SupportedCategories returns supported rule categories -func (c *CheckstyleConverter) SupportedCategories() []string { - return []string{ - "naming", - "formatting", - "style", - "length", - "complexity", - "whitespace", - "javadoc", - "imports", - } -} - -// CheckstyleModule represents a Checkstyle module in XML +// CheckstyleModule represents a Checkstyle module type CheckstyleModule struct { - XMLName xml.Name `xml:"module"` - Name string `xml:"name,attr"` - Properties []CheckstyleProperty `xml:"property,omitempty"` - Modules []CheckstyleModule `xml:"module,omitempty"` - Comment string `xml:",comment"` + XMLName xml.Name `xml:"module"` + Name string `xml:"name,attr"` + Properties []CheckstyleProperty `xml:"property,omitempty"` + Modules []CheckstyleModule `xml:"module,omitempty"` } -// CheckstyleProperty represents a property in Checkstyle XML +// CheckstyleProperty represents a property type CheckstyleProperty struct { XMLName xml.Name `xml:"property"` Name string `xml:"name,attr"` Value string `xml:"value,attr"` } -// CheckstyleConfig represents the root Checkstyle configuration +// CheckstyleConfig represents root configuration type CheckstyleConfig struct { - XMLName xml.Name `xml:"module"` - Name string `xml:"name,attr"` + XMLName xml.Name `xml:"module"` + Name string `xml:"name,attr"` Modules []CheckstyleModule `xml:"module"` } -// Convert converts a user rule with intent to Checkstyle module -func (c *CheckstyleConverter) Convert(userRule *schema.UserRule, intent *llm.RuleIntent) (*LinterRule, error) { - if userRule == nil { - return nil, fmt.Errorf("user rule is nil") +// ConvertRules converts user rules to Checkstyle configuration using LLM +func (c *CheckstyleLinterConverter) ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) { + if llmClient == nil { + return nil, fmt.Errorf("LLM client is required") } - if intent == nil { - return nil, fmt.Errorf("rule intent is nil") + // Convert rules in parallel + type moduleResult struct { + index int + module *CheckstyleModule + err error } - severity := c.mapSeverity(userRule.Severity) + results := make(chan moduleResult, len(rules)) + var wg sync.WaitGroup - var modules []CheckstyleModule - var err error - - switch intent.Engine { - case "pattern": - modules, err = c.convertPatternRule(intent, severity) - case "length": - modules, err = c.convertLengthRule(intent, severity) - case "style": - modules, err = c.convertStyleRule(intent, severity) - case "ast": - modules, err = c.convertASTRule(intent, severity) - default: - // Return empty config with comment for unsupported rules - return &LinterRule{ - ID: userRule.ID, - Severity: severity, - Config: make(map[string]any), - Comment: fmt.Sprintf("Unsupported rule (engine: %s): %s", intent.Engine, userRule.Say), - }, nil - } + for i, rule := range rules { + wg.Add(1) + go func(idx int, r schema.UserRule) { + defer wg.Done() - if err != nil { - return nil, fmt.Errorf("failed to convert rule: %w", err) + module, err := c.convertSingleRule(ctx, r, llmClient) + results <- moduleResult{ + index: idx, + module: module, + err: err, + } + }(i, rule) } - // Store modules in config map - config := map[string]any{ - "modules": modules, - } + go func() { + wg.Wait() + close(results) + }() - return &LinterRule{ - ID: userRule.ID, - Severity: severity, - Config: config, - Comment: userRule.Say, - }, nil -} + // Collect modules + var modules []CheckstyleModule + var errors []string -// GenerateConfig generates Checkstyle XML configuration from rules -func (c *CheckstyleConverter) GenerateConfig(rules []*LinterRule) (*LinterConfig, error) { - rootModule := CheckstyleConfig{ - Name: "Checker", - Modules: []CheckstyleModule{}, + for result := range results { + if result.err != nil { + errors = append(errors, fmt.Sprintf("Rule %d: %v", result.index+1, result.err)) + continue + } + + if result.module != nil { + modules = append(modules, *result.module) + } } - // TreeWalker module for most rules - treeWalker := CheckstyleModule{ - Name: "TreeWalker", - Modules: []CheckstyleModule{}, + if len(modules) == 0 { + return nil, fmt.Errorf("no rules converted: %v", errors) } - // Collect all modules from rules - for _, rule := range rules { - if modulesInterface, ok := rule.Config["modules"]; ok { - if modules, ok := modulesInterface.([]CheckstyleModule); ok { - for _, module := range modules { - // Add comment if available - if rule.Comment != "" { - module.Comment = " " + rule.Comment + " " - } - treeWalker.Modules = append(treeWalker.Modules, module) - } - } - } + // Build Checkstyle configuration + treeWalker := CheckstyleModule{ + Name: "TreeWalker", + Modules: modules, } - // Add TreeWalker to root if it has modules - if len(treeWalker.Modules) > 0 { - rootModule.Modules = append(rootModule.Modules, treeWalker) + config := CheckstyleConfig{ + Name: "Checker", + Modules: []CheckstyleModule{treeWalker}, } // Marshal to XML - output, err := xml.MarshalIndent(rootModule, "", " ") + content, err := xml.MarshalIndent(config, "", " ") if err != nil { - return nil, fmt.Errorf("failed to marshal Checkstyle config: %w", err) + return nil, fmt.Errorf("failed to marshal config: %w", err) } - // Add XML header and DOCTYPE + // Add XML header xmlHeader := ` ` - content := []byte(xmlHeader + string(output)) + fullContent := []byte(xmlHeader + string(content)) return &LinterConfig{ - Format: "xml", Filename: "checkstyle.xml", - Content: content, + Content: fullContent, + Format: "xml", }, nil } -// convertPatternRule converts pattern engine rules to Checkstyle modules -func (c *CheckstyleConverter) convertPatternRule(intent *llm.RuleIntent, severity string) ([]CheckstyleModule, error) { - modules := []CheckstyleModule{} - - switch intent.Target { - case "identifier", "variable", "class", "method", "function": - // Naming conventions - if caseStyle, ok := intent.Params["case"].(string); ok { - format := c.caseToRegex(caseStyle) - - switch intent.Target { - case "class": - modules = append(modules, CheckstyleModule{ - Name: "TypeName", - Properties: []CheckstyleProperty{ - {Name: "format", Value: format}, - {Name: "severity", Value: severity}, - }, - }) - - case "method", "function": - modules = append(modules, CheckstyleModule{ - Name: "MethodName", - Properties: []CheckstyleProperty{ - {Name: "format", Value: format}, - {Name: "severity", Value: severity}, - }, - }) - - case "variable": - modules = append(modules, CheckstyleModule{ - Name: "LocalVariableName", - Properties: []CheckstyleProperty{ - {Name: "format", Value: format}, - {Name: "severity", Value: severity}, - }, - }) - - default: - // Generic member name - modules = append(modules, CheckstyleModule{ - Name: "MemberName", - Properties: []CheckstyleProperty{ - {Name: "format", Value: format}, - {Name: "severity", Value: severity}, - }, - }) - } - } else if len(intent.Patterns) > 0 { - // Use the first pattern - pattern := intent.Patterns[0] - modules = append(modules, CheckstyleModule{ - Name: "MemberName", - Properties: []CheckstyleProperty{ - {Name: "format", Value: pattern}, - {Name: "severity", Value: severity}, - }, - }) - } +// convertSingleRule converts a single rule using LLM +func (c *CheckstyleLinterConverter) convertSingleRule(ctx context.Context, rule schema.UserRule, llmClient *llm.Client) (*CheckstyleModule, error) { + systemPrompt := `You are a Checkstyle configuration expert. Convert natural language Java coding rules to Checkstyle modules. - case "import", "dependency": - // Import control - if len(intent.Patterns) > 0 { - for _, pattern := range intent.Patterns { - modules = append(modules, CheckstyleModule{ - Name: "IllegalImport", - Properties: []CheckstyleProperty{ - {Name: "illegalPkgs", Value: pattern}, - {Name: "severity", Value: severity}, - }, - }) - } - } - } +Return ONLY a JSON object (no markdown fences): +{ + "module_name": "CheckstyleModuleName", + "severity": "error|warning|info", + "properties": {"key": "value", ...} +} + +Common Checkstyle modules: +- Naming: TypeName, MethodName, ParameterName, LocalVariableName, ConstantName +- Length: LineLength, MethodLength, ParameterNumber, FileLength +- Style: Indentation, WhitespaceAround, NeedBraces, LeftCurly, RightCurly +- Imports: AvoidStarImport, IllegalImport, UnusedImports +- Complexity: CyclomaticComplexity, NPathComplexity +- JavaDoc: JavadocMethod, JavadocType, MissingJavadocMethod - return modules, nil +If cannot convert, return: +{ + "module_name": "", + "severity": "error", + "properties": {} } -// convertLengthRule converts length engine rules to Checkstyle modules -func (c *CheckstyleConverter) convertLengthRule(intent *llm.RuleIntent, severity string) ([]CheckstyleModule, error) { - modules := []CheckstyleModule{} - - max := c.getIntParam(intent.Params, "max") - - switch intent.Scope { - case "line": - if max > 0 { - modules = append(modules, CheckstyleModule{ - Name: "LineLength", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", max)}, - {Name: "severity", Value: severity}, - }, - }) - } +Examples: - case "file": - if max > 0 { - modules = append(modules, CheckstyleModule{ - Name: "FileLength", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", max)}, - {Name: "severity", Value: severity}, - }, - }) - } +Input: "Methods must not exceed 50 lines" +Output: +{ + "module_name": "MethodLength", + "severity": "error", + "properties": {"max": "50"} +} - case "method", "function": - if max > 0 { - modules = append(modules, CheckstyleModule{ - Name: "MethodLength", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", max)}, - {Name: "severity", Value: severity}, - }, - }) - } +Input: "Use camelCase for local variables" +Output: +{ + "module_name": "LocalVariableName", + "severity": "error", + "properties": {"format": "^[a-z][a-zA-Z0-9]*$"} +}` - case "params", "parameters": - if max > 0 { - modules = append(modules, CheckstyleModule{ - Name: "ParameterNumber", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", max)}, - {Name: "severity", Value: severity}, - }, - }) - } + userPrompt := fmt.Sprintf("Convert this Java rule to Checkstyle module:\n\n%s", rule.Say) + + response, err := llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("LLM call failed: %w", err) } - return modules, nil -} + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) -// convertStyleRule converts style engine rules to Checkstyle modules -func (c *CheckstyleConverter) convertStyleRule(intent *llm.RuleIntent, severity string) ([]CheckstyleModule, error) { - modules := []CheckstyleModule{} - - // Indentation - if indent := c.getIntParam(intent.Params, "indent"); indent > 0 { - modules = append(modules, CheckstyleModule{ - Name: "Indentation", - Properties: []CheckstyleProperty{ - {Name: "basicOffset", Value: fmt.Sprintf("%d", indent)}, - {Name: "braceAdjustment", Value: "0"}, - {Name: "caseIndent", Value: fmt.Sprintf("%d", indent)}, - {Name: "severity", Value: severity}, - }, - }) + var result struct { + ModuleName string `json:"module_name"` + Severity string `json:"severity"` + Properties map[string]string `json:"properties"` } - // Whitespace around operators - modules = append(modules, CheckstyleModule{ - Name: "WhitespaceAround", - Properties: []CheckstyleProperty{ - {Name: "severity", Value: severity}, - }, - }) + if err := json.Unmarshal([]byte(response), &result); err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) + } - return modules, nil -} + if result.ModuleName == "" { + return nil, nil + } -// convertASTRule converts AST engine rules to Checkstyle modules -func (c *CheckstyleConverter) convertASTRule(intent *llm.RuleIntent, severity string) ([]CheckstyleModule, error) { - modules := []CheckstyleModule{} - - // Cyclomatic complexity - if complexity := c.getIntParam(intent.Params, "complexity"); complexity > 0 { - modules = append(modules, CheckstyleModule{ - Name: "CyclomaticComplexity", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", complexity)}, - {Name: "severity", Value: severity}, - }, - }) + // Build module + module := &CheckstyleModule{ + Name: result.ModuleName, + Properties: []CheckstyleProperty{}, } - // Nesting depth - if depth := c.getIntParam(intent.Params, "depth"); depth > 0 { - modules = append(modules, CheckstyleModule{ - Name: "NestedIfDepth", - Properties: []CheckstyleProperty{ - {Name: "max", Value: fmt.Sprintf("%d", depth)}, - {Name: "severity", Value: severity}, - }, + // Add severity + module.Properties = append(module.Properties, CheckstyleProperty{ + Name: "severity", + Value: mapCheckstyleSeverity(result.Severity), + }) + + // Add other properties + for key, value := range result.Properties { + module.Properties = append(module.Properties, CheckstyleProperty{ + Name: key, + Value: value, }) } - return modules, nil + return module, nil } -// mapSeverity maps Symphony severity to Checkstyle severity -func (c *CheckstyleConverter) mapSeverity(severity string) string { +// mapCheckstyleSeverity maps severity to Checkstyle severity +func mapCheckstyleSeverity(severity string) string { switch strings.ToLower(severity) { case "error": return "error" @@ -376,41 +246,3 @@ func (c *CheckstyleConverter) mapSeverity(severity string) string { return "error" } } - -// caseToRegex converts case style name to regex pattern (Java conventions) -func (c *CheckstyleConverter) caseToRegex(caseStyle string) string { - switch strings.ToLower(caseStyle) { - case "pascalcase": - return "^[A-Z][a-zA-Z0-9]*$" - case "camelcase": - return "^[a-z][a-zA-Z0-9]*$" - case "snake_case": - return "^[a-z][a-z0-9_]*$" - case "screaming_snake_case": - return "^[A-Z][A-Z0-9_]*$" - default: - return "^[a-zA-Z][a-zA-Z0-9]*$" - } -} - -// getIntParam safely extracts an integer parameter -func (c *CheckstyleConverter) getIntParam(params map[string]any, key string) int { - if val, ok := params[key]; ok { - switch v := val.(type) { - case int: - return v - case float64: - return int(v) - case string: - var i int - _, _ = fmt.Sscanf(v, "%d", &i) - return i - } - } - return 0 -} - -func init() { - // Register Checkstyle converter on package initialization - Register(NewCheckstyleConverter(false)) -} diff --git a/internal/converter/linters/converter.go b/internal/converter/linters/converter.go deleted file mode 100644 index e248851..0000000 --- a/internal/converter/linters/converter.go +++ /dev/null @@ -1,49 +0,0 @@ -package linters - -import ( - "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/pkg/schema" -) - -// LinterConverter converts user rules to linter-specific configurations -type LinterConverter interface { - // Name returns the linter name - Name() string - - // SupportedLanguages returns the list of supported programming languages - SupportedLanguages() []string - - // SupportedCategories returns the list of supported rule categories - SupportedCategories() []string - - // Convert converts a user rule with inferred intent to linter configuration - Convert(userRule *schema.UserRule, intent *llm.RuleIntent) (*LinterRule, error) - - // GenerateConfig generates the final linter configuration file from rules - GenerateConfig(rules []*LinterRule) (*LinterConfig, error) -} - -// LinterRule represents a single rule in linter-specific format -type LinterRule struct { - ID string // Rule identifier - Severity string // error/warning/info - Config map[string]any // Linter-specific configuration - Comment string // Optional comment (original "say") -} - -// LinterConfig represents a linter configuration file -type LinterConfig struct { - Format string // "json", "xml", "yaml", "ini", "properties" - Filename string // ".eslintrc.json", "checkstyle.xml", etc. - Content []byte // File content -} - -// ConversionResult represents the result of converting rules for a linter -type ConversionResult struct { - LinterName string - Config *LinterConfig - Rules []*LinterRule - Warnings []string // Conversion warnings - Errors []error // Non-fatal errors - RuleEngineMap map[string]string // Maps rule ID to engine name (eslint/checkstyle/pmd/llm-validator) -} diff --git a/internal/converter/linters/eslint.go b/internal/converter/linters/eslint.go index 1f630d0..8584584 100644 --- a/internal/converter/linters/eslint.go +++ b/internal/converter/linters/eslint.go @@ -1,318 +1,235 @@ package linters import ( + "context" "encoding/json" "fmt" "strings" + "sync" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) -// ESLintConverter converts rules to ESLint configuration -type ESLintConverter struct { - verbose bool -} +// ESLintLinterConverter converts rules to ESLint configuration using LLM +type ESLintLinterConverter struct{} -// NewESLintConverter creates a new ESLint converter -func NewESLintConverter(verbose bool) *ESLintConverter { - return &ESLintConverter{ - verbose: verbose, - } +// NewESLintLinterConverter creates a new ESLint converter +func NewESLintLinterConverter() *ESLintLinterConverter { + return &ESLintLinterConverter{} } // Name returns the linter name -func (c *ESLintConverter) Name() string { +func (c *ESLintLinterConverter) Name() string { return "eslint" } // SupportedLanguages returns supported languages -func (c *ESLintConverter) SupportedLanguages() []string { - return []string{"javascript", "typescript", "js", "ts", "jsx", "tsx"} +func (c *ESLintLinterConverter) SupportedLanguages() []string { + return []string{"javascript", "js", "typescript", "ts", "jsx", "tsx"} } -// SupportedCategories returns supported rule categories -func (c *ESLintConverter) SupportedCategories() []string { - return []string{ - "naming", - "formatting", - "style", - "length", - "security", - "error_handling", - "dependency", - "import", +// ConvertRules converts user rules to ESLint configuration using LLM +func (c *ESLintLinterConverter) ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) { + if llmClient == nil { + return nil, fmt.Errorf("LLM client is required") } -} -// Convert converts a user rule with intent to ESLint rule -func (c *ESLintConverter) Convert(userRule *schema.UserRule, intent *llm.RuleIntent) (*LinterRule, error) { - if userRule == nil { - return nil, fmt.Errorf("user rule is nil") + // Convert rules in parallel using goroutines + type ruleResult struct { + index int + ruleName string + config interface{} + err error } - if intent == nil { - return nil, fmt.Errorf("rule intent is nil") - } + results := make(chan ruleResult, len(rules)) + var wg sync.WaitGroup - severity := c.mapSeverity(userRule.Severity) - - // Map based on engine type - var config map[string]any - var err error - - switch intent.Engine { - case "pattern": - config, err = c.convertPatternRule(intent, severity) - case "length": - config, err = c.convertLengthRule(intent, severity) - case "style": - config, err = c.convertStyleRule(intent, severity) - case "ast": - config, err = c.convertASTRule(intent, severity) - default: - // Custom or unsupported engine - create generic comment - return &LinterRule{ - ID: userRule.ID, - Severity: severity, - Config: make(map[string]any), - Comment: fmt.Sprintf("Unsupported rule (engine: %s): %s", intent.Engine, userRule.Say), - }, nil - } + // Process each rule in parallel + for i, rule := range rules { + wg.Add(1) + go func(idx int, r schema.UserRule) { + defer wg.Done() - if err != nil { - return nil, fmt.Errorf("failed to convert rule: %w", err) + ruleName, config, err := c.convertSingleRule(ctx, r, llmClient) + results <- ruleResult{ + index: idx, + ruleName: ruleName, + config: config, + err: err, + } + }(i, rule) } - return &LinterRule{ - ID: userRule.ID, - Severity: severity, - Config: config, - Comment: userRule.Say, - }, nil -} + // Wait for all goroutines + go func() { + wg.Wait() + close(results) + }() -// GenerateConfig generates ESLint configuration from rules -func (c *ESLintConverter) GenerateConfig(rules []*LinterRule) (*LinterConfig, error) { - eslintConfig := map[string]any{ - "env": map[string]bool{ - "es2021": true, - "node": true, - "browser": true, - }, - "rules": make(map[string]any), - } + // Collect results + eslintRules := make(map[string]interface{}) + var errors []string + skippedCount := 0 - rulesMap := eslintConfig["rules"].(map[string]any) + for result := range results { + if result.err != nil { + errors = append(errors, fmt.Sprintf("Rule %d: %v", result.index+1, result.err)) + fmt.Printf("⚠️ ESLint rule %d conversion error: %v\n", result.index+1, result.err) + continue + } - // Merge all rule configs - for _, rule := range rules { - for ruleID, ruleConfig := range rule.Config { - rulesMap[ruleID] = ruleConfig + if result.ruleName != "" { + eslintRules[result.ruleName] = result.config + fmt.Printf("✓ ESLint rule %d → %s\n", result.index+1, result.ruleName) + } else { + skippedCount++ + fmt.Printf("⊘ ESLint rule %d skipped (cannot be enforced by ESLint)\n", result.index+1) } } - // Add comments as a separate field (not part of standard ESLint config) - comments := make(map[string]string) - for _, rule := range rules { - if rule.Comment != "" { - for ruleID := range rule.Config { - comments[ruleID] = rule.Comment - } - } + if skippedCount > 0 { + fmt.Printf("ℹ️ %d rule(s) skipped for ESLint (will use llm-validator)\n", skippedCount) + } + + if len(eslintRules) == 0 { + return nil, fmt.Errorf("no rules converted successfully: %v", errors) } - if len(comments) > 0 { - eslintConfig["_comments"] = comments + // Build ESLint configuration + eslintConfig := map[string]interface{}{ + "env": map[string]bool{ + "es2021": true, + "node": true, + "browser": true, + }, + "parserOptions": map[string]interface{}{ + "ecmaVersion": "latest", + "sourceType": "module", + }, + "rules": eslintRules, } + // Marshal to JSON content, err := json.MarshalIndent(eslintConfig, "", " ") if err != nil { - return nil, fmt.Errorf("failed to marshal ESLint config: %w", err) + return nil, fmt.Errorf("failed to marshal config: %w", err) } return &LinterConfig{ - Format: "json", Filename: ".eslintrc.json", Content: content, + Format: "json", }, nil } -// convertPatternRule converts pattern engine rules to ESLint rules -func (c *ESLintConverter) convertPatternRule(intent *llm.RuleIntent, severity string) (map[string]any, error) { - config := make(map[string]any) - - switch intent.Target { - case "identifier", "variable", "function", "class": - // Use id-match for identifier patterns - if len(intent.Patterns) > 0 { - pattern := intent.Patterns[0] - config["id-match"] = []any{ - severity, - pattern, - map[string]any{ - "properties": false, - "classFields": false, - "onlyDeclarations": true, - }, - } - } else if caseStyle, ok := intent.Params["case"].(string); ok { - // Convert case style to regex - pattern := c.caseToRegex(caseStyle) - config["id-match"] = []any{ - severity, - pattern, - map[string]any{ - "properties": false, - "classFields": false, - "onlyDeclarations": true, - }, - } - } +// convertSingleRule converts a single user rule to ESLint rule using LLM +func (c *ESLintLinterConverter) convertSingleRule(ctx context.Context, rule schema.UserRule, llmClient *llm.Client) (string, interface{}, error) { + systemPrompt := `You are an ESLint configuration expert. Convert natural language coding rules to ESLint rule configurations. - case "content": - // Use no-restricted-syntax for content patterns - if len(intent.Patterns) > 0 { - pattern := intent.Patterns[0] - config["no-restricted-syntax"] = []any{ - severity, - map[string]any{ - "selector": fmt.Sprintf("Literal[value=/%s/]", pattern), - "message": "Forbidden pattern detected", - }, - } - } - - case "import", "dependency": - // Use no-restricted-imports - if len(intent.Patterns) > 0 { - config["no-restricted-imports"] = []any{ - severity, - map[string]any{ - "patterns": intent.Patterns, - }, - } - } - - default: - return nil, fmt.Errorf("unsupported pattern target: %s", intent.Target) - } - - return config, nil +Return ONLY a JSON object (no markdown fences) with this structure: +{ + "rule_name": "eslint-rule-name", + "severity": "error|warn|off", + "options": {...} } -// convertLengthRule converts length engine rules to ESLint rules -func (c *ESLintConverter) convertLengthRule(intent *llm.RuleIntent, severity string) (map[string]any, error) { - config := make(map[string]any) - - max := c.getIntParam(intent.Params, "max") - min := c.getIntParam(intent.Params, "min") - - switch intent.Scope { - case "line": - if max > 0 { - config["max-len"] = []any{ - severity, - map[string]any{ - "code": max, - }, - } - } - - case "file": - if max > 0 { - config["max-lines"] = []any{ - severity, - map[string]any{ - "max": max, - "skipBlankLines": true, - "skipComments": true, - }, - } - } - - case "function", "method": - if max > 0 { - config["max-lines-per-function"] = []any{ - severity, - map[string]any{ - "max": max, - "skipBlankLines": true, - "skipComments": true, - }, - } - } - - case "params", "parameters": - if max > 0 { - config["max-params"] = []any{severity, max} - } +Common ESLint rules: +- Naming: camelcase, id-match, id-length, new-cap +- Console: no-console, no-debugger, no-alert +- Code Quality: no-unused-vars, no-undef, eqeqeq, prefer-const, no-var +- Complexity: complexity, max-depth, max-nested-callbacks, max-lines-per-function +- Length: max-len, max-lines, max-params, max-statements +- Style: indent, quotes, semi, comma-dangle, brace-style +- Imports: no-restricted-imports +- Security: no-eval, no-implied-eval, no-new-func + +If the rule cannot be expressed in ESLint, return: +{ + "rule_name": "", + "severity": "off", + "options": null +} - default: - return nil, fmt.Errorf("unsupported length scope: %s", intent.Scope) - } +Examples: - // Note: ESLint doesn't have min-len, so we ignore min for now - _ = min +Input: "No console.log allowed" +Output: +{ + "rule_name": "no-console", + "severity": "error", + "options": null +} - return config, nil +Input: "Functions must not exceed 50 lines" +Output: +{ + "rule_name": "max-lines-per-function", + "severity": "error", + "options": {"max": 50, "skipBlankLines": true, "skipComments": true} } -// convertStyleRule converts style engine rules to ESLint rules -func (c *ESLintConverter) convertStyleRule(intent *llm.RuleIntent, severity string) (map[string]any, error) { - config := make(map[string]any) +Input: "Use camelCase for variables" +Output: +{ + "rule_name": "camelcase", + "severity": "error", + "options": {"properties": "always"} +}` - // Indent - if indent := c.getIntParam(intent.Params, "indent"); indent > 0 { - config["indent"] = []any{severity, indent} + userPrompt := fmt.Sprintf("Convert this rule to ESLint configuration:\n\n%s", rule.Say) + if rule.Severity != "" { + userPrompt += fmt.Sprintf("\nSeverity: %s", rule.Severity) } - // Quote style - if quote, ok := intent.Params["quote"].(string); ok { - config["quotes"] = []any{severity, quote} + // Call LLM + response, err := llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + return "", nil, fmt.Errorf("LLM call failed: %w", err) } - // Semicolons - if semi, ok := intent.Params["semi"].(bool); ok { - if semi { - config["semi"] = []any{severity, "always"} - } else { - config["semi"] = []any{severity, "never"} - } - } + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) - // Trailing comma - if trailingComma, ok := intent.Params["trailingComma"].(string); ok { - config["comma-dangle"] = []any{severity, trailingComma} + var result struct { + RuleName string `json:"rule_name"` + Severity string `json:"severity"` + Options interface{} `json:"options"` } - return config, nil -} - -// convertASTRule converts AST engine rules to ESLint rules -func (c *ESLintConverter) convertASTRule(intent *llm.RuleIntent, severity string) (map[string]any, error) { - config := make(map[string]any) + if err := json.Unmarshal([]byte(response), &result); err != nil { + return "", nil, fmt.Errorf("failed to parse LLM response: %w", err) + } - // Cyclomatic complexity - if complexity := c.getIntParam(intent.Params, "complexity"); complexity > 0 { - config["complexity"] = []any{severity, complexity} + // If rule_name is empty, this rule cannot be converted + if result.RuleName == "" { + return "", nil, nil } - // Max depth - if depth := c.getIntParam(intent.Params, "depth"); depth > 0 { - config["max-depth"] = []any{severity, depth} + // Map user severity to ESLint severity if needed + severity := mapSeverity(rule.Severity) + if severity == "" { + severity = result.Severity } - // Max nested callbacks - if callbacks := c.getIntParam(intent.Params, "callbacks"); callbacks > 0 { - config["max-nested-callbacks"] = []any{severity, callbacks} + // Build rule configuration + var config interface{} + if result.Options != nil { + config = []interface{}{severity, result.Options} + } else { + config = severity } - return config, nil + return result.RuleName, config, nil } -// mapSeverity maps Symphony severity to ESLint severity -func (c *ESLintConverter) mapSeverity(severity string) string { +// mapSeverity maps user severity to ESLint severity +func mapSeverity(severity string) string { switch strings.ToLower(severity) { case "error": return "error" @@ -324,43 +241,3 @@ func (c *ESLintConverter) mapSeverity(severity string) string { return "error" } } - -// caseToRegex converts case style name to regex pattern -func (c *ESLintConverter) caseToRegex(caseStyle string) string { - switch strings.ToLower(caseStyle) { - case "pascalcase": - return "^[A-Z][a-zA-Z0-9]*$" - case "camelcase": - return "^[a-z][a-zA-Z0-9]*$" - case "snake_case": - return "^[a-z][a-z0-9_]*$" - case "screaming_snake_case": - return "^[A-Z][A-Z0-9_]*$" - case "kebab-case": - return "^[a-z][a-z0-9-]*$" - default: - return "^[a-zA-Z][a-zA-Z0-9]*$" - } -} - -// getIntParam safely extracts an integer parameter -func (c *ESLintConverter) getIntParam(params map[string]any, key string) int { - if val, ok := params[key]; ok { - switch v := val.(type) { - case int: - return v - case float64: - return int(v) - case string: - var i int - _, _ = fmt.Sscanf(v, "%d", &i) - return i - } - } - return 0 -} - -func init() { - // Register ESLint converter on package initialization - Register(NewESLintConverter(false)) -} diff --git a/internal/converter/linters/eslint_test.go b/internal/converter/linters/eslint_test.go deleted file mode 100644 index eb0ed6e..0000000 --- a/internal/converter/linters/eslint_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package linters - -import ( - "testing" - - "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/pkg/schema" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestESLintConverter_Convert_Pattern(t *testing.T) { - converter := NewESLintConverter(false) - - userRule := &schema.UserRule{ - ID: "test-naming", - Say: "Class names must be PascalCase", - Category: "naming", - Severity: "error", - } - - intent := &llm.RuleIntent{ - Engine: "pattern", - Category: "naming", - Target: "identifier", - Params: map[string]any{ - "case": "PascalCase", - }, - Confidence: 0.9, - } - - result, err := converter.Convert(userRule, intent) - require.NoError(t, err) - assert.NotNil(t, result) - assert.Equal(t, "error", result.Severity) - assert.NotEmpty(t, result.Config) - assert.Contains(t, result.Config, "id-match") -} - -func TestESLintConverter_Convert_Length(t *testing.T) { - converter := NewESLintConverter(false) - - userRule := &schema.UserRule{ - ID: "test-max-line", - Say: "Maximum line length is 100 characters", - Category: "length", - Severity: "error", - } - - intent := &llm.RuleIntent{ - Engine: "length", - Category: "length", - Scope: "line", - Params: map[string]any{ - "max": 100, - }, - Confidence: 0.9, - } - - result, err := converter.Convert(userRule, intent) - require.NoError(t, err) - assert.NotNil(t, result) - assert.Contains(t, result.Config, "max-len") -} - -func TestESLintConverter_Convert_Style(t *testing.T) { - converter := NewESLintConverter(false) - - userRule := &schema.UserRule{ - ID: "test-indent", - Say: "Use 4 spaces for indentation", - Category: "style", - Severity: "error", - } - - intent := &llm.RuleIntent{ - Engine: "style", - Category: "style", - Params: map[string]any{ - "indent": 4, - }, - Confidence: 0.9, - } - - result, err := converter.Convert(userRule, intent) - require.NoError(t, err) - assert.NotNil(t, result) - assert.Contains(t, result.Config, "indent") -} - -func TestESLintConverter_GenerateConfig(t *testing.T) { - converter := NewESLintConverter(false) - - rules := []*LinterRule{ - { - ID: "rule-1", - Severity: "error", - Config: map[string]any{ - "indent": []any{"error", 4}, - }, - Comment: "Use 4 spaces", - }, - { - ID: "rule-2", - Severity: "error", - Config: map[string]any{ - "max-len": []any{"error", map[string]any{"code": 100}}, - }, - Comment: "Max line length 100", - }, - } - - config, err := converter.GenerateConfig(rules) - require.NoError(t, err) - assert.NotNil(t, config) - assert.Equal(t, "json", config.Format) - assert.Equal(t, ".eslintrc.json", config.Filename) - assert.NotEmpty(t, config.Content) -} - -func TestESLintConverter_MapSeverity(t *testing.T) { - converter := NewESLintConverter(false) - - tests := []struct { - input string - expected string - }{ - {"error", "error"}, - {"warning", "warn"}, - {"warn", "warn"}, - {"info", "off"}, - {"off", "off"}, - {"unknown", "error"}, // default - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := converter.mapSeverity(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestESLintConverter_CaseToRegex(t *testing.T) { - converter := NewESLintConverter(false) - - tests := []struct { - caseStyle string - expected string - }{ - {"PascalCase", "^[A-Z][a-zA-Z0-9]*$"}, - {"camelCase", "^[a-z][a-zA-Z0-9]*$"}, - {"snake_case", "^[a-z][a-z0-9_]*$"}, - {"SCREAMING_SNAKE_CASE", "^[A-Z][A-Z0-9_]*$"}, - {"kebab-case", "^[a-z][a-z0-9-]*$"}, - } - - for _, tt := range tests { - t.Run(tt.caseStyle, func(t *testing.T) { - result := converter.caseToRegex(tt.caseStyle) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestESLintConverter_SupportedLanguages(t *testing.T) { - converter := NewESLintConverter(false) - langs := converter.SupportedLanguages() - - assert.Contains(t, langs, "javascript") - assert.Contains(t, langs, "typescript") -} diff --git a/internal/converter/linters/interface.go b/internal/converter/linters/interface.go new file mode 100644 index 0000000..a528262 --- /dev/null +++ b/internal/converter/linters/interface.go @@ -0,0 +1,28 @@ +package linters + +import ( + "context" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// LinterConverter converts user rules to native linter configuration using LLM +type LinterConverter interface { + // Name returns the linter name (e.g., "eslint", "checkstyle", "pmd") + Name() string + + // SupportedLanguages returns the languages this linter supports + SupportedLanguages() []string + + // ConvertRules converts user rules to native linter configuration using LLM + // This is the main entry point for parallel conversion + ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) +} + +// LinterConfig represents a generated configuration file +type LinterConfig struct { + Filename string // e.g., ".eslintrc.json", "checkstyle.xml" + Content []byte // File content + Format string // "json", "xml", "yaml" +} diff --git a/internal/converter/linters/pmd.go b/internal/converter/linters/pmd.go index 68a458f..ea7e577 100644 --- a/internal/converter/linters/pmd.go +++ b/internal/converter/linters/pmd.go @@ -1,368 +1,196 @@ package linters import ( + "context" + "encoding/json" "encoding/xml" "fmt" "strings" + "sync" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) -// PMDConverter converts rules to PMD ruleset XML configuration -type PMDConverter struct { - verbose bool -} +// PMDLinterConverter converts rules to PMD XML using LLM +type PMDLinterConverter struct{} -// NewPMDConverter creates a new PMD converter -func NewPMDConverter(verbose bool) *PMDConverter { - return &PMDConverter{ - verbose: verbose, - } +// NewPMDLinterConverter creates a new PMD converter +func NewPMDLinterConverter() *PMDLinterConverter { + return &PMDLinterConverter{} } // Name returns the linter name -func (c *PMDConverter) Name() string { +func (c *PMDLinterConverter) Name() string { return "pmd" } // SupportedLanguages returns supported languages -func (c *PMDConverter) SupportedLanguages() []string { +func (c *PMDLinterConverter) SupportedLanguages() []string { return []string{"java"} } -// SupportedCategories returns supported rule categories -func (c *PMDConverter) SupportedCategories() []string { - return []string{ - "naming", - "complexity", - "design", - "performance", - "security", - "error_handling", - "best_practices", - "code_style", - } -} - -// PMDRuleset represents the root PMD ruleset +// PMDRuleset represents PMD ruleset type PMDRuleset struct { - XMLName xml.Name `xml:"ruleset"` - Name string `xml:"name,attr"` - XMLNS string `xml:"xmlns,attr"` - XMLNSXSI string `xml:"xmlns:xsi,attr"` - XSISchema string `xml:"xsi:schemaLocation,attr"` - Description string `xml:"description"` + XMLName xml.Name `xml:"ruleset"` + Name string `xml:"name,attr"` + XMLNS string `xml:"xmlns,attr"` + XMLNSXSI string `xml:"xmlns:xsi,attr"` + XSISchema string `xml:"xsi:schemaLocation,attr"` + Description string `xml:"description"` Rules []PMDRule `xml:"rule"` } -// PMDRule represents a single PMD rule reference +// PMDRule represents a PMD rule type PMDRule struct { - XMLName xml.Name `xml:"rule"` - Ref string `xml:"ref,attr,omitempty"` - Name string `xml:"name,attr,omitempty"` - Message string `xml:"message,attr,omitempty"` - Class string `xml:"class,attr,omitempty"` - Priority int `xml:"priority,omitempty"` - Properties []PMDProperty `xml:"properties>property,omitempty"` - Comment string `xml:",comment"` + XMLName xml.Name `xml:"rule"` + Ref string `xml:"ref,attr"` + Priority int `xml:"priority,omitempty"` } -// PMDProperty represents a property in PMD rule -type PMDProperty struct { - XMLName xml.Name `xml:"property"` - Name string `xml:"name,attr"` - Value string `xml:"value,attr,omitempty"` - Type string `xml:"type,attr,omitempty"` -} - -// Convert converts a user rule with intent to PMD rule -func (c *PMDConverter) Convert(userRule *schema.UserRule, intent *llm.RuleIntent) (*LinterRule, error) { - if userRule == nil { - return nil, fmt.Errorf("user rule is nil") +// ConvertRules converts user rules to PMD configuration using LLM +func (c *PMDLinterConverter) ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) { + if llmClient == nil { + return nil, fmt.Errorf("LLM client is required") } - if intent == nil { - return nil, fmt.Errorf("rule intent is nil") + // Convert rules in parallel + type ruleResult struct { + index int + rule *PMDRule + err error } - priority := c.mapSeverityToPriority(userRule.Severity) - - var rules []PMDRule - var err error - - switch intent.Engine { - case "pattern": - rules, err = c.convertPatternRule(intent, priority) - case "length": - rules, err = c.convertLengthRule(intent, priority) - case "style": - rules, err = c.convertStyleRule(intent, priority) - case "ast": - rules, err = c.convertASTRule(intent, priority) - default: - // Return empty config with comment for unsupported rules - return &LinterRule{ - ID: userRule.ID, - Severity: userRule.Severity, - Config: make(map[string]any), - Comment: fmt.Sprintf("Unsupported rule (engine: %s): %s", intent.Engine, userRule.Say), - }, nil - } + results := make(chan ruleResult, len(rules)) + var wg sync.WaitGroup - if err != nil { - return nil, fmt.Errorf("failed to convert rule: %w", err) + for i, rule := range rules { + wg.Add(1) + go func(idx int, r schema.UserRule) { + defer wg.Done() + + pmdRule, err := c.convertSingleRule(ctx, r, llmClient) + results <- ruleResult{ + index: idx, + rule: pmdRule, + err: err, + } + }(i, rule) } - // Store rules in config map - config := map[string]any{ - "rules": rules, + go func() { + wg.Wait() + close(results) + }() + + // Collect rules + var pmdRules []PMDRule + var errors []string + + for result := range results { + if result.err != nil { + errors = append(errors, fmt.Sprintf("Rule %d: %v", result.index+1, result.err)) + continue + } + + if result.rule != nil { + pmdRules = append(pmdRules, *result.rule) + } } - return &LinterRule{ - ID: userRule.ID, - Severity: userRule.Severity, - Config: config, - Comment: userRule.Say, - }, nil -} + if len(pmdRules) == 0 { + return nil, fmt.Errorf("no rules converted: %v", errors) + } -// GenerateConfig generates PMD ruleset XML configuration from rules -func (c *PMDConverter) GenerateConfig(rules []*LinterRule) (*LinterConfig, error) { + // Build PMD ruleset ruleset := PMDRuleset{ - Name: "Symphony Convention Rules", + Name: "Symphony Rules", XMLNS: "http://pmd.sourceforge.net/ruleset/2.0.0", XMLNSXSI: "http://www.w3.org/2001/XMLSchema-instance", XSISchema: "http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd", - Description: "Generated PMD ruleset from Symphony user policy", - Rules: []PMDRule{}, - } - - // Collect all PMD rules - for _, rule := range rules { - if rulesInterface, ok := rule.Config["rules"]; ok { - if pmdRules, ok := rulesInterface.([]PMDRule); ok { - for _, pmdRule := range pmdRules { - // Add comment if available - if rule.Comment != "" { - pmdRule.Comment = " " + rule.Comment + " " - } - ruleset.Rules = append(ruleset.Rules, pmdRule) - } - } - } + Description: "Generated from Symphony user policy", + Rules: pmdRules, } // Marshal to XML - output, err := xml.MarshalIndent(ruleset, "", " ") + content, err := xml.MarshalIndent(ruleset, "", " ") if err != nil { - return nil, fmt.Errorf("failed to marshal PMD ruleset: %w", err) + return nil, fmt.Errorf("failed to marshal config: %w", err) } - // Add XML header - xmlHeader := ` -` - content := []byte(xmlHeader + string(output)) + xmlHeader := `` + "\n" + fullContent := []byte(xmlHeader + string(content)) return &LinterConfig{ + Filename: "pmd.xml", + Content: fullContent, Format: "xml", - Filename: "pmd-ruleset.xml", - Content: content, }, nil } -// convertPatternRule converts pattern engine rules to PMD rules -func (c *PMDConverter) convertPatternRule(intent *llm.RuleIntent, priority int) ([]PMDRule, error) { - rules := []PMDRule{} - - switch intent.Target { - case "class": - // Class naming convention - if caseStyle, ok := intent.Params["case"].(string); ok && strings.ToLower(caseStyle) == "pascalcase" { - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/ClassNamingConventions", - Priority: priority, - }) - } - - case "method", "function": - // Method naming convention - if caseStyle, ok := intent.Params["case"].(string); ok && strings.ToLower(caseStyle) == "camelcase" { - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/MethodNamingConventions", - Priority: priority, - }) - } - - case "variable": - // Variable naming convention - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/LocalVariableNamingConventions", - Priority: priority, - }) - - case "import", "dependency": - // Import restrictions - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/UnnecessaryImport", - Priority: priority, - }) - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/DuplicateImports", - Priority: priority, - }) - } +// convertSingleRule converts a single rule using LLM +func (c *PMDLinterConverter) convertSingleRule(ctx context.Context, rule schema.UserRule, llmClient *llm.Client) (*PMDRule, error) { + systemPrompt := `You are a PMD configuration expert. Convert natural language Java coding rules to PMD rule references. - return rules, nil +Return ONLY a JSON object (no markdown fences): +{ + "rule_ref": "category/java/ruleset.xml/RuleName", + "priority": 1-5 } -// convertLengthRule converts length engine rules to PMD rules -func (c *PMDConverter) convertLengthRule(intent *llm.RuleIntent, priority int) ([]PMDRule, error) { - rules := []PMDRule{} - - max := c.getIntParam(intent.Params, "max") - - switch intent.Scope { - case "method", "function": - if max > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/ExcessiveMethodLength", - Priority: priority, - Properties: []PMDProperty{ - {Name: "minimum", Value: fmt.Sprintf("%d", max), Type: "Integer"}, - }, - }) - } - - case "class": - if max > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/ExcessiveClassLength", - Priority: priority, - Properties: []PMDProperty{ - {Name: "minimum", Value: fmt.Sprintf("%d", max), Type: "Integer"}, - }, - }) - } +Common PMD rules: +- Best Practices: rulesets/java/bestpractices.xml/UnusedPrivateMethod +- Code Style: rulesets/java/codestyle.xml/ShortVariable +- Design: rulesets/java/design.xml/TooManyMethods +- Error Handling: rulesets/java/errorprone.xml/EmptyCatchBlock +- Security: rulesets/java/security.xml/HardCodedCryptoKey - case "params", "parameters": - if max > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/ExcessiveParameterList", - Priority: priority, - Properties: []PMDProperty{ - {Name: "minimum", Value: fmt.Sprintf("%d", max), Type: "Integer"}, - }, - }) - } - } +Priority levels: 1=High, 2=Medium-High, 3=Medium, 4=Low, 5=Info - return rules, nil +If cannot convert, return: +{ + "rule_ref": "", + "priority": 3 } -// convertStyleRule converts style engine rules to PMD rules -func (c *PMDConverter) convertStyleRule(intent *llm.RuleIntent, priority int) ([]PMDRule, error) { - rules := []PMDRule{} +Example: - // PMD has limited style rules compared to Checkstyle - // Add some common code style rules - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/UnnecessaryModifier", - Priority: priority, - }) +Input: "Avoid unused private methods" +Output: +{ + "rule_ref": "rulesets/java/bestpractices.xml/UnusedPrivateMethod", + "priority": 2 +}` - rules = append(rules, PMDRule{ - Ref: "category/java/codestyle.xml/UselessParentheses", - Priority: priority, - }) + userPrompt := fmt.Sprintf("Convert this Java rule to PMD rule reference:\n\n%s", rule.Say) - return rules, nil -} - -// convertASTRule converts AST engine rules to PMD rules -func (c *PMDConverter) convertASTRule(intent *llm.RuleIntent, priority int) ([]PMDRule, error) { - rules := []PMDRule{} - - // Cyclomatic complexity - if complexity := c.getIntParam(intent.Params, "complexity"); complexity > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/CyclomaticComplexity", - Priority: priority, - Properties: []PMDProperty{ - { - Name: "methodReportLevel", - Value: fmt.Sprintf("%d", complexity), - Type: "Integer", - }, - }, - }) + response, err := llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("LLM call failed: %w", err) } - // Nesting depth - if depth := c.getIntParam(intent.Params, "depth"); depth > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/AvoidDeeplyNestedIfStmts", - Priority: priority, - Properties: []PMDProperty{ - { - Name: "problemDepth", - Value: fmt.Sprintf("%d", depth), - Type: "Integer", - }, - }, - }) - } + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) - // Cognitive complexity - if cognitiveComplexity := c.getIntParam(intent.Params, "cognitiveComplexity"); cognitiveComplexity > 0 { - rules = append(rules, PMDRule{ - Ref: "category/java/design.xml/CognitiveComplexity", - Priority: priority, - Properties: []PMDProperty{ - { - Name: "reportLevel", - Value: fmt.Sprintf("%d", cognitiveComplexity), - Type: "Integer", - }, - }, - }) + var result struct { + RuleRef string `json:"rule_ref"` + Priority int `json:"priority"` } - return rules, nil -} - -// mapSeverityToPriority maps Symphony severity to PMD priority (1-5, lower is more severe) -func (c *PMDConverter) mapSeverityToPriority(severity string) int { - switch strings.ToLower(severity) { - case "error": - return 1 - case "warning", "warn": - return 3 - case "info": - return 5 - default: - return 1 + if err := json.Unmarshal([]byte(response), &result); err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) } -} -// getIntParam safely extracts an integer parameter -func (c *PMDConverter) getIntParam(params map[string]any, key string) int { - if val, ok := params[key]; ok { - switch v := val.(type) { - case int: - return v - case float64: - return int(v) - case string: - var i int - _, _ = fmt.Sscanf(v, "%d", &i) - return i - } + if result.RuleRef == "" { + return nil, nil } - return 0 -} -func init() { - // Register PMD converter on package initialization - Register(NewPMDConverter(false)) + return &PMDRule{ + Ref: result.RuleRef, + Priority: result.Priority, + }, nil } diff --git a/internal/converter/linters/prettier_tsc.go b/internal/converter/linters/prettier_tsc.go new file mode 100644 index 0000000..2b0ba51 --- /dev/null +++ b/internal/converter/linters/prettier_tsc.go @@ -0,0 +1,283 @@ +package linters + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// PrettierLinterConverter converts rules to Prettier configuration +type PrettierLinterConverter struct{} + +// NewPrettierLinterConverter creates a new Prettier converter +func NewPrettierLinterConverter() *PrettierLinterConverter { + return &PrettierLinterConverter{} +} + +// Name returns the linter name +func (c *PrettierLinterConverter) Name() string { + return "prettier" +} + +// SupportedLanguages returns supported languages +func (c *PrettierLinterConverter) SupportedLanguages() []string { + return []string{"javascript", "js", "typescript", "ts", "jsx", "tsx"} +} + +// ConvertRules converts formatting rules to Prettier config using LLM +func (c *PrettierLinterConverter) ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) { + if llmClient == nil { + return nil, fmt.Errorf("LLM client is required") + } + + // Start with default Prettier configuration + prettierConfig := map[string]interface{}{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "es5", + "printWidth": 80, + "arrowParens": "always", + } + + // Use LLM to infer settings from rules + for _, rule := range rules { + config, err := c.convertSingleRule(ctx, rule, llmClient) + if err != nil { + continue // Skip rules that cannot be converted + } + + // Merge LLM-generated config + for key, value := range config { + prettierConfig[key] = value + } + } + + content, err := json.MarshalIndent(prettierConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + return &LinterConfig{ + Filename: ".prettierrc", + Content: content, + Format: "json", + }, nil +} + +// convertSingleRule converts a single user rule to Prettier config using LLM +func (c *PrettierLinterConverter) convertSingleRule(ctx context.Context, rule schema.UserRule, llmClient *llm.Client) (map[string]interface{}, error) { + systemPrompt := `You are a Prettier configuration expert. Convert natural language formatting rules to Prettier configuration options. + +Return ONLY a JSON object (no markdown fences) with Prettier options. + +Available Prettier options: +- semi: true/false (use semicolons) +- singleQuote: true/false (use single quotes) +- tabWidth: number (spaces per indentation level) +- useTabs: true/false (use tabs instead of spaces) +- trailingComma: "none"/"es5"/"all" (trailing commas) +- printWidth: number (line length) +- arrowParens: "always"/"avoid" (arrow function parentheses) +- bracketSpacing: true/false (spaces in object literals) +- endOfLine: "lf"/"crlf"/"auto" + +If the rule is not about formatting, return empty object: {} + +Examples: + +Input: "Use single quotes for strings" +Output: +{ + "singleQuote": true +} + +Input: "No semicolons" +Output: +{ + "semi": false +} + +Input: "Use 4 spaces for indentation" +Output: +{ + "tabWidth": 4, + "useTabs": false +} + +Input: "Maximum line length is 120 characters" +Output: +{ + "printWidth": 120 +}` + + userPrompt := fmt.Sprintf("Convert this rule to Prettier configuration:\n\n%s", rule.Say) + + // Call LLM + response, err := llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("LLM call failed: %w", err) + } + + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) + + var config map[string]interface{} + if err := json.Unmarshal([]byte(response), &config); err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) + } + + return config, nil +} + +// TSCLinterConverter converts rules to TypeScript compiler configuration +type TSCLinterConverter struct{} + +// NewTSCLinterConverter creates a new TSC converter +func NewTSCLinterConverter() *TSCLinterConverter { + return &TSCLinterConverter{} +} + +// Name returns the linter name +func (c *TSCLinterConverter) Name() string { + return "tsc" +} + +// SupportedLanguages returns supported languages +func (c *TSCLinterConverter) SupportedLanguages() []string { + return []string{"typescript", "ts", "tsx"} +} + +// ConvertRules converts type-checking rules to tsconfig.json using LLM +func (c *TSCLinterConverter) ConvertRules(ctx context.Context, rules []schema.UserRule, llmClient *llm.Client) (*LinterConfig, error) { + if llmClient == nil { + return nil, fmt.Errorf("LLM client is required") + } + + // Start with strict TypeScript configuration + tsConfig := map[string]interface{}{ + "compilerOptions": map[string]interface{}{ + "target": "ES2020", + "module": "commonjs", + "lib": []string{"ES2020"}, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + }, + } + + compilerOpts := tsConfig["compilerOptions"].(map[string]interface{}) + + // Use LLM to infer settings from rules + for _, rule := range rules { + config, err := c.convertSingleRule(ctx, rule, llmClient) + if err != nil { + continue // Skip rules that cannot be converted + } + + // Merge LLM-generated compiler options + for key, value := range config { + compilerOpts[key] = value + } + } + + content, err := json.MarshalIndent(tsConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + return &LinterConfig{ + Filename: "tsconfig.json", + Content: content, + Format: "json", + }, nil +} + +// convertSingleRule converts a single user rule to TypeScript compiler option using LLM +func (c *TSCLinterConverter) convertSingleRule(ctx context.Context, rule schema.UserRule, llmClient *llm.Client) (map[string]interface{}, error) { + systemPrompt := `You are a TypeScript compiler configuration expert. Convert natural language type-checking rules to tsconfig.json compiler options. + +Return ONLY a JSON object (no markdown fences) with TypeScript compiler options. + +Available TypeScript compiler options: +- strict: true/false (enable all strict checks) +- noImplicitAny: true/false (error on implicit any) +- strictNullChecks: true/false (strict null checking) +- strictFunctionTypes: true/false (strict function types) +- strictBindCallApply: true/false (strict bind/call/apply) +- noUnusedLocals: true/false (error on unused locals) +- noUnusedParameters: true/false (error on unused parameters) +- noImplicitReturns: true/false (error on implicit returns) +- noFallthroughCasesInSwitch: true/false (error on fallthrough) +- noUncheckedIndexedAccess: true/false (undefined in index signatures) +- allowUnreachableCode: true/false (allow unreachable code) +- allowUnusedLabels: true/false (allow unused labels) + +If the rule is not about TypeScript type-checking, return empty object: {} + +Examples: + +Input: "No implicit any types allowed" +Output: +{ + "noImplicitAny": true +} + +Input: "Check for null and undefined strictly" +Output: +{ + "strictNullChecks": true +} + +Input: "Report unused variables" +Output: +{ + "noUnusedLocals": true, + "noUnusedParameters": true +} + +Input: "Enable all strict type checks" +Output: +{ + "strict": true +}` + + userPrompt := fmt.Sprintf("Convert this rule to TypeScript compiler configuration:\n\n%s", rule.Say) + + // Call LLM + response, err := llmClient.Complete(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, fmt.Errorf("LLM call failed: %w", err) + } + + // Parse response + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) + + var config map[string]interface{} + if err := json.Unmarshal([]byte(response), &config); err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) + } + + return config, nil +} diff --git a/internal/converter/linters/registry.go b/internal/converter/linters/registry.go deleted file mode 100644 index e7986d4..0000000 --- a/internal/converter/linters/registry.go +++ /dev/null @@ -1,115 +0,0 @@ -package linters - -import ( - "fmt" - "sort" - "strings" - "sync" -) - -var ( - globalRegistry = &Registry{ - converters: make(map[string]LinterConverter), - } -) - -// Registry manages available linter converters -type Registry struct { - mu sync.RWMutex - converters map[string]LinterConverter -} - -// Register registers a linter converter -func Register(converter LinterConverter) { - globalRegistry.Register(converter) -} - -// Get retrieves a linter converter by name -func Get(name string) (LinterConverter, error) { - return globalRegistry.Get(name) -} - -// List returns all registered linter names -func List() []string { - return globalRegistry.List() -} - -// GetByLanguage returns converters that support a specific language -func GetByLanguage(language string) []LinterConverter { - return globalRegistry.GetByLanguage(language) -} - -// Register registers a linter converter -func (r *Registry) Register(converter LinterConverter) { - r.mu.Lock() - defer r.mu.Unlock() - - name := strings.ToLower(converter.Name()) - r.converters[name] = converter -} - -// Get retrieves a linter converter by name -func (r *Registry) Get(name string) (LinterConverter, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - name = strings.ToLower(name) - converter, ok := r.converters[name] - if !ok { - return nil, fmt.Errorf("linter converter not found: %s", name) - } - - return converter, nil -} - -// List returns all registered linter names -func (r *Registry) List() []string { - r.mu.RLock() - defer r.mu.RUnlock() - - names := make([]string, 0, len(r.converters)) - for name := range r.converters { - names = append(names, name) - } - - sort.Strings(names) - return names -} - -// GetByLanguage returns converters that support a specific language -func (r *Registry) GetByLanguage(language string) []LinterConverter { - r.mu.RLock() - defer r.mu.RUnlock() - - language = strings.ToLower(language) - converters := make([]LinterConverter, 0) - - for _, converter := range r.converters { - for _, lang := range converter.SupportedLanguages() { - if strings.ToLower(lang) == language { - converters = append(converters, converter) - break - } - } - } - - return converters -} - -// GetAll returns all registered converters (package-level function) -func GetAll() []LinterConverter { - return globalRegistry.GetAll() -} - -// GetAll returns all registered converters -func (r *Registry) GetAll() []LinterConverter { - r.mu.RLock() - defer r.mu.RUnlock() - - converters := make([]LinterConverter, 0, len(r.converters)) - for _, converter := range r.converters { - converters = append(converters, converter) - } - - return converters -} From ca07270d8b68ac9c2323786bb93829ce67479373 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 19 Nov 2025 01:38:49 +0900 Subject: [PATCH 3/6] chore: remove deprecated inference and testing files --- internal/llm/inference.go | 217 --------------------------------- internal/llm/inference_test.go | 115 ----------------- internal/llm/types.go | 28 ----- 3 files changed, 360 deletions(-) delete mode 100644 internal/llm/inference.go delete mode 100644 internal/llm/inference_test.go delete mode 100644 internal/llm/types.go diff --git a/internal/llm/inference.go b/internal/llm/inference.go deleted file mode 100644 index 7bbc1af..0000000 --- a/internal/llm/inference.go +++ /dev/null @@ -1,217 +0,0 @@ -package llm - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - - "github.com/DevSymphony/sym-cli/pkg/schema" -) - -const systemPrompt = `You are a code linting rule analyzer. Extract structured information from natural language coding rules. - -Extract: -1. **engine**: pattern|length|style|ast|llm-validator - - Use "style" for code formatting rules (semicolons, quotes, indentation, spacing) - - Use "pattern" for naming conventions or content matching - - Use "length" for size/length constraints - - Use "ast" for structural complexity rules - - Use "llm-validator" for complex semantic rules that cannot be expressed with simple patterns - -2. **category**: naming|formatting|security|error_handling|testing|documentation|dependency|commit|performance|architecture|custom - -3. **target**: identifier|content|import|class|method|function|variable|file|line - -4. **scope**: line|file|function|method|class|module|project - -5. **patterns**: Array of regex patterns or keywords - -6. **params**: JSON object with rule parameters. Examples: - - For semicolons: {"semi": true} or {"semi": false} - - For quotes: {"quote": "single"} or {"quote": "double"} - - For indentation: {"indent": 2} or {"indent": 4} - - For trailing commas: {"trailingComma": "always"} or {"trailingComma": "never"} - - For case styles: {"case": "PascalCase"} or {"case": "camelCase"} or {"case": "snake_case"} - - For length limits: {"max": 80}, {"min": 10} - -7. **confidence**: 0.0-1.0 - -Examples: - -Input: "All statements should end with semicolons" -Output: -{ - "engine": "style", - "category": "formatting", - "target": "content", - "scope": "line", - "patterns": [], - "params": {"semi": true}, - "confidence": 0.95 -} - -Input: "Use single quotes for strings" -Output: -{ - "engine": "style", - "category": "formatting", - "target": "content", - "scope": "file", - "patterns": [], - "params": {"quote": "single"}, - "confidence": 0.95 -} - -Input: "Class names must be PascalCase" -Output: -{ - "engine": "pattern", - "category": "naming", - "target": "class", - "scope": "file", - "patterns": ["^[A-Z][a-zA-Z0-9]*$"], - "params": {"case": "PascalCase"}, - "confidence": 0.95 -} - -Input: "Lines should not exceed 80 characters" -Output: -{ - "engine": "length", - "category": "formatting", - "target": "line", - "scope": "line", - "patterns": [], - "params": {"max": 80}, - "confidence": 0.95 -} - -Respond with valid JSON only.` - -// Inferencer handles rule inference using LLM -type Inferencer struct { - client *Client - cache *inferenceCache -} - -// inferenceCache caches inference results -type inferenceCache struct { - mu sync.RWMutex - entries map[string]*RuleIntent -} - -func newInferenceCache() *inferenceCache { - return &inferenceCache{ - entries: make(map[string]*RuleIntent), - } -} - -func (c *inferenceCache) Get(key string) (*RuleIntent, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - intent, ok := c.entries[key] - return intent, ok -} - -func (c *inferenceCache) Set(key string, intent *RuleIntent) { - c.mu.Lock() - defer c.mu.Unlock() - c.entries[key] = intent -} - -// NewInferencer creates a new rule inferencer -func NewInferencer(client *Client) *Inferencer { - return &Inferencer{ - client: client, - cache: newInferenceCache(), - } -} - -// InferRuleIntent analyzes a rule and extracts structured intent -func (i *Inferencer) InferRuleIntent(ctx context.Context, req InferenceRequest) (*InferenceResult, error) { - if i.client == nil { - return nil, fmt.Errorf("LLM client not configured") - } - - // Check cache - cacheKey := strings.ToLower(strings.TrimSpace(req.Say)) - if cached, ok := i.cache.Get(cacheKey); ok { - return &InferenceResult{ - Intent: cached, - Success: true, - UsedCache: true, - }, nil - } - - // Build prompt - userPrompt := fmt.Sprintf("Rule: %s", req.Say) - if req.Category != "" { - userPrompt += fmt.Sprintf("\nCategory: %s", req.Category) - } - - // Call LLM - response, err := i.client.Complete(ctx, systemPrompt, userPrompt) - if err != nil { - return nil, fmt.Errorf("LLM inference failed: %w", err) - } - - // Parse response - intent, err := parseIntent(response) - if err != nil { - return nil, fmt.Errorf("failed to parse LLM response: %w", err) - } - - // Merge hint params - if intent.Params == nil { - intent.Params = make(map[string]any) - } - for k, v := range req.Params { - if _, exists := intent.Params[k]; !exists { - intent.Params[k] = v - } - } - - // Cache result - i.cache.Set(cacheKey, intent) - - return &InferenceResult{ - Intent: intent, - Success: true, - UsedCache: false, - }, nil -} - -// InferFromUserRule convenience method -func (i *Inferencer) InferFromUserRule(ctx context.Context, userRule *schema.UserRule) (*InferenceResult, error) { - req := InferenceRequest{ - Say: userRule.Say, - Category: userRule.Category, - Params: userRule.Params, - } - return i.InferRuleIntent(ctx, req) -} - -func parseIntent(response string) (*RuleIntent, error) { - // Extract JSON from markdown code blocks - jsonStr := strings.TrimSpace(response) - jsonStr = strings.TrimPrefix(jsonStr, "```json") - jsonStr = strings.TrimPrefix(jsonStr, "```") - jsonStr = strings.TrimSuffix(jsonStr, "```") - jsonStr = strings.TrimSpace(jsonStr) - - var intent RuleIntent - if err := json.Unmarshal([]byte(jsonStr), &intent); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - - if intent.Engine == "" { - return nil, fmt.Errorf("missing engine field") - } - if intent.Confidence == 0 { - intent.Confidence = 0.5 - } - - return &intent, nil -} diff --git a/internal/llm/inference_test.go b/internal/llm/inference_test.go deleted file mode 100644 index 7e9f45d..0000000 --- a/internal/llm/inference_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package llm - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestParseIntent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - response string - expectError bool - checkFunc func(*testing.T, *RuleIntent) - }{ - { - name: "valid JSON response", - response: `{ - "engine": "pattern", - "category": "naming", - "target": "identifier", - "scope": "file", - "patterns": ["^[A-Z][a-zA-Z0-9]*$"], - "params": {"case": "PascalCase"}, - "confidence": 0.95 - }`, - expectError: false, - checkFunc: func(t *testing.T, intent *RuleIntent) { - assert.Equal(t, "pattern", intent.Engine) - assert.Equal(t, "naming", intent.Category) - assert.Equal(t, 0.95, intent.Confidence) - }, - }, - { - name: "JSON in markdown code block", - response: "```json\n" + `{ - "engine": "style", - "category": "formatting", - "confidence": 0.8 - }` + "\n```", - expectError: false, - checkFunc: func(t *testing.T, intent *RuleIntent) { - assert.Equal(t, "style", intent.Engine) - assert.Equal(t, "formatting", intent.Category) - }, - }, - { - name: "missing engine field", - response: `{"category": "naming", "confidence": 0.9}`, - expectError: true, - }, - { - name: "invalid JSON", - response: `{invalid json}`, - expectError: true, - }, - { - name: "zero confidence sets default", - response: `{ - "engine": "pattern", - "category": "naming" - }`, - expectError: false, - checkFunc: func(t *testing.T, intent *RuleIntent) { - assert.Equal(t, 0.5, intent.Confidence) - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - intent, err := parseIntent(tt.response) - - if tt.expectError { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - assert.NotNil(t, intent) - if tt.checkFunc != nil { - tt.checkFunc(t, intent) - } - }) - } -} - -func TestInferenceCache(t *testing.T) { - t.Parallel() - - cache := newInferenceCache() - - // Cache miss - _, ok := cache.Get("test-key") - assert.False(t, ok) - - // Cache set - intent := &RuleIntent{ - Engine: "pattern", - Category: "naming", - Confidence: 0.9, - } - cache.Set("test-key", intent) - - // Cache hit - cached, ok := cache.Get("test-key") - assert.True(t, ok) - assert.Equal(t, "pattern", cached.Engine) - assert.Equal(t, 0.9, cached.Confidence) -} diff --git a/internal/llm/types.go b/internal/llm/types.go deleted file mode 100644 index 9ed33a9..0000000 --- a/internal/llm/types.go +++ /dev/null @@ -1,28 +0,0 @@ -package llm - -// RuleIntent represents the structured interpretation of a natural language rule -type RuleIntent struct { - Engine string // "pattern", "length", "style", "ast", "llm-validator" - Category string // "naming", "formatting", "security", "error_handling", etc. - Target string // "identifier", "content", "import", "class", "method", etc. - Scope string // "line", "file", "function", "method", "class", etc. - Patterns []string // Extracted regex patterns or keywords - Params map[string]any // Extracted parameters (e.g., max, min, indent, quote) - Confidence float64 // 0.0-1.0 confidence score from LLM - Reasoning string // Explanation of why this intent was inferred -} - -// InferenceResult represents the result of rule inference -type InferenceResult struct { - Intent *RuleIntent - Success bool - Error error - UsedCache bool // Whether result came from cache -} - -// InferenceRequest represents a request to infer rule intent -type InferenceRequest struct { - Say string // Natural language rule - Category string // Optional category hint - Params map[string]any // Optional parameter hints -} From 97dba2725a7621e95e9ead0fddacbc8ff536409d Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 19 Nov 2025 02:13:31 +0900 Subject: [PATCH 4/6] feat: implement RBAC conversion and enforcement in policy converter --- internal/converter/converter.go | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 3b1cd2c..462c66c 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -77,6 +77,18 @@ func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy) }, } + // Step 3.1: Convert RBAC if present + if userPolicy.RBAC != nil { + codePolicy.RBAC = c.convertRBAC(userPolicy.RBAC) + + // Enable RBAC enforcement + codePolicy.Enforce.RBACConfig = &schema.RBACEnforce{ + Enabled: true, + Stages: []string{"pre-commit", "pre-push"}, + OnViolation: "block", + } + } + // Track which linters each rule maps to ruleToLinters := make(map[string][]string) // rule ID -> linter names @@ -390,3 +402,73 @@ func (c *Converter) getLinterConverter(linterName string) linters.LinterConverte return nil } } + +// convertRBAC converts UserRBAC to PolicyRBAC +func (c *Converter) convertRBAC(userRBAC *schema.UserRBAC) *schema.PolicyRBAC { + if userRBAC == nil || len(userRBAC.Roles) == 0 { + return nil + } + + policyRBAC := &schema.PolicyRBAC{ + Roles: make(map[string]schema.PolicyRole), + } + + for roleName, userRole := range userRBAC.Roles { + policyRole := schema.PolicyRole{ + Permissions: []schema.Permission{}, + } + + // Convert allowWrite to write permissions + for _, path := range userRole.AllowWrite { + policyRole.Permissions = append(policyRole.Permissions, schema.Permission{ + Path: path, + Read: true, + Write: true, + Execute: false, + }) + } + + // Convert denyWrite to read-only permissions + for _, path := range userRole.DenyWrite { + policyRole.Permissions = append(policyRole.Permissions, schema.Permission{ + Path: path, + Read: true, + Write: false, + Execute: false, + }) + } + + // Convert allowExec to execute permissions + for _, path := range userRole.AllowExec { + policyRole.Permissions = append(policyRole.Permissions, schema.Permission{ + Path: path, + Read: true, + Write: false, + Execute: true, + }) + } + + // Add special permissions for policy/role editing + if userRole.CanEditPolicy { + policyRole.Permissions = append(policyRole.Permissions, schema.Permission{ + Path: ".sym/**", + Read: true, + Write: true, + Execute: false, + }) + } + + if userRole.CanEditRoles { + policyRole.Permissions = append(policyRole.Permissions, schema.Permission{ + Path: ".sym/user-policy.json", + Read: true, + Write: true, + Execute: false, + }) + } + + policyRBAC.Roles[roleName] = policyRole + } + + return policyRBAC +} From a05baa9d30a0cd9d23aafba149bef303a50bdeaa Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 19 Nov 2025 03:00:07 +0900 Subject: [PATCH 5/6] feat: update policy conversion and save result in .sym directory --- internal/mcp/server.go | 159 ++++++++++++++++++++++++++++---------- internal/server/server.go | 46 +++-------- 2 files changed, 128 insertions(+), 77 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 312fa86..8c927b7 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -46,42 +46,31 @@ func ConvertPolicyWithLLM(userPolicyPath, codePolicyPath string) error { llm.WithTimeout(30*time.Second), ) - // Create converter - conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + // Create converter with output directory + outputDir := filepath.Dir(codePolicyPath) + conv := converter.NewConverter(llmClient, outputDir) // Setup context with timeout - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(userPolicy.Rules)*30)*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*10)*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, - }) + // Convert using new API + result, err := conv.Convert(ctx, &userPolicy) 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) + // Files are already written by converter + for _, filePath := range result.GeneratedFiles { + fmt.Fprintf(os.Stderr, " ✓ Generated: %s\n", filePath) } - // 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) + // Report any errors + if len(result.Errors) > 0 { + for linter, err := range result.Errors { + fmt.Fprintf(os.Stderr, " ✗ %s: %v\n", linter, err) } } @@ -594,10 +583,8 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, var allViolations []ViolationItem var hasErrors bool - // Always validate git changes (staged + unstaged) - // This is the most efficient and relevant approach for AI coding workflows - - // Get unstaged changes + // Get all git changes (staged + unstaged + untracked) + // GetGitChanges already includes all types of changes changes, err := validator.GetGitChanges() if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to get git changes: %v\n", err) @@ -612,12 +599,6 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, }, nil } - // Also check staged changes - stagedChanges, err := validator.GetStagedChanges() - if err == nil { - changes = append(changes, stagedChanges...) - } - if len(changes) == 0 { return map[string]interface{}{ "content": []map[string]interface{}{ @@ -631,21 +612,18 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } // Setup LLM client for validation - apiKey := envutil.GetAPIKey("ANTHROPIC_API_KEY") - if apiKey == "" { - apiKey = envutil.GetAPIKey("OPENAI_API_KEY") - } + apiKey := envutil.GetAPIKey("OPENAI_API_KEY") if apiKey == "" { return nil, &RPCError{ Code: -32000, - Message: "LLM API key not found (ANTHROPIC_API_KEY or OPENAI_API_KEY required for validation in environment or .sym/.env)", + Message: "OPENAI_API_KEY not found in environment or .sym/.env", } } llmClient := llm.NewClient(apiKey) // Create unified validator that handles all engines + RBAC - v := validator.NewValidator(validationPolicy, false) // verbose=false for MCP + v := validator.NewValidator(validationPolicy, true) // verbose=true for debugging v.SetLLMClient(llmClient) defer func() { _ = v.Close() // Ignore close error in MCP context @@ -678,6 +656,11 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } } + // Save validation results to .sym/validation-results.json + if err := s.saveValidationResults(result, allViolations, hasErrors); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save validation results: %v\n", err) + } + // Format validation results as readable text for MCP response var textContent string if hasErrors { @@ -706,6 +689,9 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } } + // Add note about saved results + textContent += fmt.Sprintf("\n💾 Validation results saved to .sym/validation-results.json\n") + // Return MCP-compliant response with content array return map[string]interface{}{ "content": []map[string]interface{}{ @@ -985,3 +971,96 @@ func (s *Server) getRBACInfo() string { return rbacMsg.String() } + +// ValidationResultRecord represents a single validation result with timestamp +type ValidationResultRecord struct { + Timestamp string `json:"timestamp"` + Status string `json:"status"` // "passed", "warning", "failed" + TotalChecks int `json:"total_checks"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Violations []ViolationItem `json:"violations"` + FilesChecked []string `json:"files_checked"` +} + +// ValidationHistory represents the history of validation results +type ValidationHistory struct { + Results []ValidationResultRecord `json:"results"` +} + +// saveValidationResults saves validation results to .sym/validation-results.json +func (s *Server) saveValidationResults(result *validator.ValidationResult, violations []ViolationItem, hasErrors bool) error { + // Get git root to find .sym directory + repoRoot, err := git.GetRepoRoot() + if err != nil { + return fmt.Errorf("failed to get repository root: %w", err) + } + + symDir := filepath.Join(repoRoot, ".sym") + if err := os.MkdirAll(symDir, 0755); err != nil { + return fmt.Errorf("failed to create .sym directory: %w", err) + } + + resultsPath := filepath.Join(symDir, "validation-results.json") + + // Load existing history + var history ValidationHistory + if data, err := os.ReadFile(resultsPath); err == nil { + if err := json.Unmarshal(data, &history); err != nil { + // If unmarshal fails, start fresh + history = ValidationHistory{Results: []ValidationResultRecord{}} + } + } else { + history = ValidationHistory{Results: []ValidationResultRecord{}} + } + + // Determine status + status := "passed" + if hasErrors { + status = "failed" + } else if len(violations) > 0 { + status = "warning" + } + + // Collect files checked (from violations) + filesChecked := make(map[string]bool) + for _, v := range violations { + if v.File != "" { + filesChecked[v.File] = true + } + } + filesCheckedList := make([]string, 0, len(filesChecked)) + for file := range filesChecked { + filesCheckedList = append(filesCheckedList, file) + } + + // Create new record + record := ValidationResultRecord{ + Timestamp: time.Now().Format(time.RFC3339), + Status: status, + TotalChecks: result.Checked, + Passed: result.Passed, + Failed: result.Failed, + Violations: violations, + FilesChecked: filesCheckedList, + } + + // Add to history (keep last 50 results) + history.Results = append(history.Results, record) + if len(history.Results) > 50 { + history.Results = history.Results[len(history.Results)-50:] + } + + // Save to file + data, err := json.MarshalIndent(history, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal validation results: %w", err) + } + + if err := os.WriteFile(resultsPath, data, 0644); err != nil { + return fmt.Errorf("failed to write validation results: %w", err) + } + + fmt.Fprintf(os.Stderr, "✓ Validation results saved to %s\n", resultsPath) + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go index f22b7a9..d87a08f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -686,55 +686,27 @@ func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { llm.WithTimeout(timeout), ) - // Create converter with LLM client - conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + // Create converter with LLM client and output directory + conv := converter.NewConverter(llmClient, outputDir) // Setup context with timeout - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*len(userPolicy.Rules))*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*10)*time.Second) defer cancel() - // Convert for all targets - targets := []string{"all"} - convResult, err := conv.ConvertMultiTarget(ctx, userPolicy, converter.MultiTargetConvertOptions{ - Targets: targets, - OutputDir: outputDir, - ConfidenceThreshold: 0.7, - }) + // Convert using new API + convResult, err := conv.Convert(ctx, userPolicy) if err != nil { http.Error(w, fmt.Sprintf("Conversion failed: %v", err), http.StatusInternalServerError) return } - // Write linter configuration files + // Files are already written by converter filesWritten := []string{} - for linterName, config := range convResult.LinterConfigs { - outputPath := filepath.Join(outputDir, config.Filename) - - if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { - http.Error(w, fmt.Sprintf("Failed to write %s config: %v", linterName, err), http.StatusInternalServerError) - return - } - - fmt.Printf("✓ Generated %s configuration: %s\n", linterName, outputPath) - filesWritten = append(filesWritten, config.Filename) + for _, filePath := range convResult.GeneratedFiles { + // Extract just the filename + filesWritten = append(filesWritten, filepath.Base(filePath)) } - // Write internal code policy - codePolicyPath := filepath.Join(outputDir, "code-policy.json") - codePolicyJSON, err := json.MarshalIndent(convResult.CodePolicy, "", " ") - if err != nil { - http.Error(w, fmt.Sprintf("Failed to serialize code policy: %v", err), http.StatusInternalServerError) - return - } - - if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { - http.Error(w, fmt.Sprintf("Failed to write code policy: %v", err), http.StatusInternalServerError) - return - } - - fmt.Printf("✓ Generated internal policy: %s\n", codePolicyPath) - filesWritten = append(filesWritten, "code-policy.json") - result := map[string]interface{}{ "status": "success", "policyPath": policyPath, From a993b419bbff1a4b5cc04928052aa904a1077d39 Mon Sep 17 00:00:00 2001 From: sehwan505 Date: Wed, 19 Nov 2025 03:11:11 +0900 Subject: [PATCH 6/6] fix: update MCP validator to disable verbose logging and enhance end-to-end workflow tests --- internal/mcp/server.go | 2 +- tests/e2e/full_workflow_test.go | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8c927b7..f753f0a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -623,7 +623,7 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, llmClient := llm.NewClient(apiKey) // Create unified validator that handles all engines + RBAC - v := validator.NewValidator(validationPolicy, true) // verbose=true for debugging + v := validator.NewValidator(validationPolicy, false) // verbose=false for MCP v.SetLLMClient(llmClient) defer func() { _ = v.Close() // Ignore close error in MCP context diff --git a/tests/e2e/full_workflow_test.go b/tests/e2e/full_workflow_test.go index f9eaf96..de44d81 100644 --- a/tests/e2e/full_workflow_test.go +++ b/tests/e2e/full_workflow_test.go @@ -79,12 +79,22 @@ func TestE2E_FullWorkflow(t *testing.T) { llm.WithTimeout(30*time.Second), ) - conv := converter.NewConverter(converter.WithLLMClient(client)) + outputDir := filepath.Join(testDir, ".sym") + conv := converter.NewConverter(client, outputDir) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - convertedPolicy, err := conv.Convert(&userPolicy) + result, err := conv.Convert(ctx, &userPolicy) + require.NoError(t, err, "Conversion should succeed") + + // Load the generated code policy + codePolicyPath := filepath.Join(outputDir, "code-policy.json") + codePolicyData, err := os.ReadFile(codePolicyPath) + require.NoError(t, err, "Should be able to read generated code policy") + + var convertedPolicy schema.CodePolicy + err = json.Unmarshal(codePolicyData, &convertedPolicy) require.NoError(t, err, "Conversion should succeed") require.NotNil(t, convertedPolicy) @@ -96,16 +106,10 @@ func TestE2E_FullWorkflow(t *testing.T) { t.Logf(" Rule %d: %s (category: %s)", i+1, rule.ID, rule.Category) } - // Save converted policy - convertedPolicyPath := filepath.Join(testDir, ".sym", "code-policy.json") - err = os.MkdirAll(filepath.Dir(convertedPolicyPath), 0755) - require.NoError(t, err) - - convertedData, err := json.MarshalIndent(convertedPolicy, "", " ") - require.NoError(t, err) - err = os.WriteFile(convertedPolicyPath, convertedData, 0644) - require.NoError(t, err) - t.Logf("✓ Saved converted policy: %s", convertedPolicyPath) + // Files are already written by converter + for _, filePath := range result.GeneratedFiles { + t.Logf("✓ Generated: %s", filePath) + } // ========== STEP 3: LLM coding tool queries conventions via MCP ========== t.Log("STEP 3: Simulating LLM tool querying conventions") @@ -166,7 +170,7 @@ func ProcessData(data string) error { // ========== STEP 4: Validate generated code ========== t.Log("STEP 4: Validating generated code against conventions") - llmValidator := validator.NewLLMValidator(client, convertedPolicy) + llmValidator := validator.NewLLMValidator(client, &convertedPolicy) // Validate BAD code t.Log("STEP 4a: Validating BAD code (should find violations)")