diff --git a/go.mod b/go.mod index 4d24d41..5c093de 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/spf13/cobra v1.10.1 ) +require github.com/bmatcuk/doublestar/v4 v4.9.1 + require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/go.sum b/go.sum index 9b270cf..926b457 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= diff --git a/internal/adapter/eslint/adapter_test.go b/internal/adapter/eslint/adapter_test.go new file mode 100644 index 0000000..9fdbd24 --- /dev/null +++ b/internal/adapter/eslint/adapter_test.go @@ -0,0 +1,224 @@ +package eslint + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestNewAdapter(t *testing.T) { + adapter := NewAdapter("", "") + if adapter == nil { + t.Fatal("NewAdapter() returned nil") + } + + if adapter.ToolsDir == "" { + t.Error("ToolsDir should not be empty") + } +} + +func TestNewAdapter_CustomDirs(t *testing.T) { + toolsDir := "/custom/tools" + workDir := "/custom/work" + + a := NewAdapter(toolsDir, workDir) + + if a.ToolsDir != toolsDir { + t.Errorf("ToolsDir = %q, want %q", a.ToolsDir, toolsDir) + } + + if a.WorkDir != workDir { + t.Errorf("WorkDir = %q, want %q", a.WorkDir, workDir) + } +} + +func TestName(t *testing.T) { + a := NewAdapter("", "") + if a.Name() != "eslint" { + t.Errorf("Name() = %q, want %q", a.Name(), "eslint") + } +} + +func TestGetESLintPath(t *testing.T) { + a := NewAdapter("/test/tools", "") + expected := filepath.Join("/test/tools", "node_modules", ".bin", "eslint") + + got := a.getESLintPath() + if got != expected { + t.Errorf("getESLintPath() = %q, want %q", got, expected) + } +} + +func TestInitPackageJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "eslint-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter(tmpDir, "") + + if err := a.initPackageJSON(); err != nil { + t.Fatalf("initPackageJSON() error = %v", err) + } + + packagePath := filepath.Join(tmpDir, "package.json") + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + t.Error("package.json was not created") + } + + content, err := os.ReadFile(packagePath) + if err != nil { + t.Fatalf("Failed to read package.json: %v", err) + } + + expectedFields := []string{`"name"`, `"symphony-tools"`} + for _, field := range expectedFields { + if !contains(string(content), field) { + t.Errorf("package.json missing expected field: %s", field) + } + } +} + +func TestCheckAvailability_NotFound(t *testing.T) { + a := NewAdapter("/nonexistent/path", "") + + ctx := context.Background() + err := a.CheckAvailability(ctx) + + if err == nil { + t.Log("ESLint found globally, test skipped") + } +} + +func TestInstall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "eslint-install-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter(tmpDir, "") + + ctx := context.Background() + config := adapter.InstallConfig{ + ToolsDir: tmpDir, + } + + err = a.Install(ctx, config) + if err != nil { + t.Logf("Install failed (expected if npm unavailable): %v", err) + } +} + +func TestGenerateConfig(t *testing.T) { + a := NewAdapter("", "") + + rule := &core.Rule{ + ID: "TEST-RULE", + Category: "naming", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z]", + }, + } + + config, err := a.GenerateConfig(rule) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + if len(config) == 0 { + t.Error("GenerateConfig() returned empty config") + } +} + +func TestExecute_InvalidConfig(t *testing.T) { + a := NewAdapter("", t.TempDir()) + + ctx := context.Background() + config := []byte(`{"rules": {}}`) + files := []string{"test.js"} + + _, err := a.Execute(ctx, config, files) + if err == nil { + t.Log("Execute succeeded (ESLint may be available)") + } +} + +func TestParseOutput(t *testing.T) { + a := NewAdapter("", "") + + output := &adapter.ToolOutput{ + Stdout: `[{"filePath":"test.js","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'x' is defined but never used","line":1,"column":5}]}]`, + Stderr: "", + ExitCode: 1, + } + + violations, err := a.ParseOutput(output) + if err != nil { + t.Fatalf("ParseOutput() error = %v", err) + } + + if len(violations) == 0 { + t.Error("Expected violations to be parsed") + } +} + +func TestMapSeverity(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"error", "error"}, + {"warning", "warn"}, + {"info", "off"}, + {"unknown", "error"}, // default + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := MapSeverity(tt.input) + if got != tt.want { + t.Errorf("MapSeverity(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestMarshalConfig(t *testing.T) { + config := map[string]interface{}{ + "rules": map[string]interface{}{ + "semi": []interface{}{2, "always"}, + }, + } + + data, err := MarshalConfig(config) + if err != nil { + t.Fatalf("MarshalConfig() error = %v", err) + } + + if len(data) == 0 { + t.Error("MarshalConfig() returned empty data") + } +} + +// Helper function +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/adapter/eslint/config_test.go b/internal/adapter/eslint/config_test.go index 20dad1c..637acdd 100644 --- a/internal/adapter/eslint/config_test.go +++ b/internal/adapter/eslint/config_test.go @@ -96,3 +96,139 @@ func TestGenerateConfig_Style(t *testing.T) { t.Error("expected semi rule to be set") } } + +func TestGenerateConfig_PatternContent(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-PATTERN-CONTENT", + Category: "security", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + "target": "content", + "pattern": "password", + }, + Message: "No hardcoded passwords", + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var eslintConfig ESLintConfig + if err := json.Unmarshal(config, &eslintConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if _, ok := eslintConfig.Rules["no-restricted-syntax"]; !ok { + t.Error("expected no-restricted-syntax rule to be set") + } +} + +func TestGenerateConfig_PatternImport(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-PATTERN-IMPORT", + Category: "dependency", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + "target": "import", + "pattern": "^forbidden-package", + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var eslintConfig ESLintConfig + if err := json.Unmarshal(config, &eslintConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if _, ok := eslintConfig.Rules["no-restricted-imports"]; !ok { + t.Error("expected no-restricted-imports rule to be set") + } +} + +func TestGenerateConfig_LengthFile(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-LENGTH-FILE", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "file", + "max": 500, + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var eslintConfig ESLintConfig + if err := json.Unmarshal(config, &eslintConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if _, ok := eslintConfig.Rules["max-lines"]; !ok { + t.Error("expected max-lines rule to be set") + } +} + +func TestGenerateConfig_LengthFunction(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-LENGTH-FUNCTION", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "function", + "max": 50, + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var eslintConfig ESLintConfig + if err := json.Unmarshal(config, &eslintConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if _, ok := eslintConfig.Rules["max-lines-per-function"]; !ok { + t.Error("expected max-lines-per-function rule to be set") + } +} + +func TestGenerateConfig_LengthParams(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-LENGTH-PARAMS", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "params", + "max": 4, + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var eslintConfig ESLintConfig + if err := json.Unmarshal(config, &eslintConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if _, ok := eslintConfig.Rules["max-params"]; !ok { + t.Error("expected max-params rule to be set") + } +} diff --git a/internal/adapter/eslint/executor_test.go b/internal/adapter/eslint/executor_test.go new file mode 100644 index 0000000..a9668a4 --- /dev/null +++ b/internal/adapter/eslint/executor_test.go @@ -0,0 +1,200 @@ +package eslint + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +func TestExecute_FileCreation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "eslint-exec-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter("", tmpDir) + + ctx := context.Background() + config := []byte(`{"rules": {"semi": [2, "always"]}}`) + files := []string{"test.js"} + + _, err = a.execute(ctx, config, files) + + configPath := filepath.Join(tmpDir, ".symphony-eslintrc.json") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("Config file should have been cleaned up") + } +} + +func TestGetESLintCommand(t *testing.T) { + tests := []struct { + name string + toolsDir string + wantContain string + }{ + { + name: "local installation", + toolsDir: "/home/user/.symphony/tools", + wantContain: "node_modules", + }, + { + name: "empty tools dir", + toolsDir: "", + wantContain: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAdapter(tt.toolsDir, "") + cmd := a.getESLintCommand() + + if tt.wantContain != "" && len(cmd) > 0 { + contains := false + if len(cmd) > 0 && findSubstring(cmd, tt.wantContain) { + contains = true + } + if !contains && tt.toolsDir != "" { + t.Logf("Command %q doesn't contain %q (may use global)", cmd, tt.wantContain) + } + } + }) + } +} + +func TestGetExecutionArgs(t *testing.T) { + a := NewAdapter("", "/work/dir") + + configPath := "/tmp/config.json" + files := []string{"file1.js", "file2.js"} + + cmd, args := a.getExecutionArgs(configPath, files) + + if cmd == "" { + t.Error("Expected non-empty command") + } + + if len(args) == 0 { + t.Error("Expected non-empty args") + } + + // Check for essential flags + foundConfig := false + foundFormat := false + for _, arg := range args { + if arg == "--config" { + foundConfig = true + } + if arg == "--format" { + foundFormat = true + } + } + + if !foundConfig { + t.Error("Expected --config flag in args") + } + + if !foundFormat { + t.Error("Expected --format flag in args") + } +} + +func TestWriteConfigFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "eslint-config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter("", tmpDir) + + config := []byte(`{"rules": {"semi": [2, "always"]}}`) + + configPath, err := a.writeConfigFile(config) + if err != nil { + t.Fatalf("writeConfigFile() error = %v", err) + } + defer os.Remove(configPath) + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("Config file was not created") + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + if len(content) == 0 { + t.Error("Config file is empty") + } +} + +func TestExecute_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir, err := os.MkdirTemp("", "eslint-integration-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a test file + testFile := filepath.Join(tmpDir, "test.js") + testCode := []byte("var x = 1\n") // Missing semicolon + if err := os.WriteFile(testFile, testCode, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + a := NewAdapter("", tmpDir) + + ctx := context.Background() + config := []byte(`{"rules": {"semi": [2, "always"]}}`) + files := []string{testFile} + + output, err := a.execute(ctx, config, files) + if err != nil { + t.Logf("Execute failed (expected if ESLint not available): %v", err) + return + } + + if output == nil { + t.Error("Expected non-nil output") + } +} + +func TestParseOutput_EmptyOutput(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: "", + Stderr: "", + ExitCode: 0, + } + + violations, err := parseOutput(output) + if err != nil { + t.Errorf("parseOutput() error = %v, want nil", err) + } + + if len(violations) != 0 { + t.Errorf("Expected 0 violations, got %d", len(violations)) + } +} + +func TestParseOutput_InvalidJSON(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: "invalid json", + Stderr: "", + ExitCode: 1, + } + + _, err := parseOutput(output) + if err == nil { + t.Error("Expected error for invalid JSON") + } +} diff --git a/internal/adapter/prettier/adapter_test.go b/internal/adapter/prettier/adapter_test.go new file mode 100644 index 0000000..75f715e --- /dev/null +++ b/internal/adapter/prettier/adapter_test.go @@ -0,0 +1,200 @@ +package prettier + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestNewAdapter(t *testing.T) { + a := NewAdapter("", "") + if a == nil { + t.Fatal("NewAdapter() returned nil") + } + + if a.ToolsDir == "" { + t.Error("ToolsDir should not be empty") + } +} + +func TestNewAdapter_CustomDirs(t *testing.T) { + toolsDir := "/custom/tools" + workDir := "/custom/work" + + a := NewAdapter(toolsDir, workDir) + + if a.ToolsDir != toolsDir { + t.Errorf("ToolsDir = %q, want %q", a.ToolsDir, toolsDir) + } + + if a.WorkDir != workDir { + t.Errorf("WorkDir = %q, want %q", a.WorkDir, workDir) + } +} + +func TestName(t *testing.T) { + a := NewAdapter("", "") + if a.Name() != "prettier" { + t.Errorf("Name() = %q, want %q", a.Name(), "prettier") + } +} + +func TestGetPrettierPath(t *testing.T) { + a := NewAdapter("/test/tools", "") + expected := filepath.Join("/test/tools", "node_modules", ".bin", "prettier") + + got := a.getPrettierPath() + if got != expected { + t.Errorf("getPrettierPath() = %q, want %q", got, expected) + } +} + +func TestInitPackageJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "prettier-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter(tmpDir, "") + + if err := a.initPackageJSON(); err != nil { + t.Fatalf("initPackageJSON() error = %v", err) + } + + packagePath := filepath.Join(tmpDir, "package.json") + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + t.Error("package.json was not created") + } +} + +func TestCheckAvailability_NotFound(t *testing.T) { + a := NewAdapter("/nonexistent/path", "") + + ctx := context.Background() + err := a.CheckAvailability(ctx) + + if err == nil { + t.Log("Prettier found globally, test skipped") + } +} + +func TestInstall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "prettier-install-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter(tmpDir, "") + + ctx := context.Background() + config := adapter.InstallConfig{ + ToolsDir: tmpDir, + } + + err = a.Install(ctx, config) + if err != nil { + t.Logf("Install failed (expected if npm unavailable): %v", err) + } +} + +func TestGenerateConfig(t *testing.T) { + a := NewAdapter("", "") + + rule := &core.Rule{ + Check: map[string]interface{}{ + "indent": 2, + "quote": "single", + }, + } + + config, err := a.GenerateConfig(rule) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + if len(config) == 0 { + t.Error("GenerateConfig() returned empty config") + } +} + +func TestExecute(t *testing.T) { + a := NewAdapter("", t.TempDir()) + + ctx := context.Background() + config := []byte(`{"semi": true}`) + files := []string{"test.js"} + + _, err := a.Execute(ctx, config, files) + if err == nil { + t.Log("Execute succeeded (Prettier may be available)") + } +} + +func TestExecuteWithMode(t *testing.T) { + a := NewAdapter("", t.TempDir()) + + ctx := context.Background() + config := []byte(`{"semi": true}`) + files := []string{"test.js"} + + for _, mode := range []string{"check", "write"} { + t.Run(mode, func(t *testing.T) { + _, err := a.ExecuteWithMode(ctx, config, files, mode) + if err == nil { + t.Logf("ExecuteWithMode(%s) succeeded", mode) + } + }) + } +} + +func TestParseOutput(t *testing.T) { + a := NewAdapter("", "") + + tests := []struct { + name string + output *adapter.ToolOutput + wantLen int + wantErr bool + }{ + { + name: "no violations", + output: &adapter.ToolOutput{ + Stdout: "", + Stderr: "", + ExitCode: 0, + }, + wantLen: 0, + wantErr: false, + }, + { + name: "with violations", + output: &adapter.ToolOutput{ + Stdout: "file1.js\nfile2.js\n", + Stderr: "", + ExitCode: 1, + }, + wantLen: 2, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + violations, err := a.ParseOutput(tt.output) + if (err != nil) != tt.wantErr { + t.Errorf("ParseOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(violations) != tt.wantLen { + t.Errorf("ParseOutput() returned %d violations, want %d", len(violations), tt.wantLen) + } + }) + } +} diff --git a/internal/adapter/prettier/executor_test.go b/internal/adapter/prettier/executor_test.go new file mode 100644 index 0000000..8ec994c --- /dev/null +++ b/internal/adapter/prettier/executor_test.go @@ -0,0 +1,122 @@ +package prettier + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestExecute_FileCreation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "prettier-exec-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter("", tmpDir) + + ctx := context.Background() + config := []byte(`{"semi": true}`) + files := []string{"test.js"} + + _, err = a.execute(ctx, config, files, "check") + + configPath := filepath.Join(tmpDir, ".symphony-prettierrc.json") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("Config file should have been cleaned up") + } +} + +func TestGetPrettierCommand(t *testing.T) { + tests := []struct { + name string + toolsDir string + }{ + { + name: "local installation", + toolsDir: "/home/user/.symphony/tools", + }, + { + name: "empty tools dir", + toolsDir: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAdapter(tt.toolsDir, "") + cmd := a.getPrettierCommand() + + if cmd == "" { + t.Error("Expected non-empty command") + } + }) + } +} + +func TestWriteConfigFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "prettier-config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter("", tmpDir) + + config := []byte(`{"semi": true, "singleQuote": true}`) + + configPath, err := a.writeConfigFile(config) + if err != nil { + t.Fatalf("writeConfigFile() error = %v", err) + } + defer os.Remove(configPath) + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("Config file was not created") + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + if len(content) == 0 { + t.Error("Config file is empty") + } +} + +func TestExecute_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir, err := os.MkdirTemp("", "prettier-integration-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a test file with bad formatting + testFile := filepath.Join(tmpDir, "test.js") + testCode := []byte("const x=1;const y=2;") // Bad formatting + if err := os.WriteFile(testFile, testCode, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + a := NewAdapter("", tmpDir) + + ctx := context.Background() + config := []byte(`{"semi": true, "singleQuote": true}`) + files := []string{testFile} + + output, err := a.execute(ctx, config, files, "check") + if err != nil { + t.Logf("Execute failed (expected if Prettier not available): %v", err) + return + } + + if output == nil { + t.Error("Expected non-nil output") + } +} diff --git a/internal/adapter/tsc/adapter.go b/internal/adapter/tsc/adapter.go new file mode 100644 index 0000000..037f696 --- /dev/null +++ b/internal/adapter/tsc/adapter.go @@ -0,0 +1,136 @@ +package tsc + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// Adapter wraps TypeScript Compiler (tsc) for type checking. +// +// TSC provides: +// - Type checking for TypeScript and JavaScript files +// - Compilation errors and warnings +// - Interface and type validation +type Adapter struct { + // ToolsDir is where TypeScript is installed + // Default: ~/.symphony/tools/node_modules + ToolsDir string + + // WorkDir is the project root + WorkDir string + + // executor runs tsc subprocess + executor *adapter.SubprocessExecutor +} + +// NewAdapter creates a new TSC adapter. +func NewAdapter(toolsDir, workDir string) *Adapter { + if toolsDir == "" { + home, _ := os.UserHomeDir() + toolsDir = filepath.Join(home, ".symphony", "tools") + } + + return &Adapter{ + ToolsDir: toolsDir, + WorkDir: workDir, + executor: adapter.NewSubprocessExecutor(), + } +} + +// Name returns the adapter name. +func (a *Adapter) Name() string { + return "tsc" +} + +// CheckAvailability checks if tsc is installed. +func (a *Adapter) CheckAvailability(ctx context.Context) error { + // Try local installation first + tscPath := a.getTSCPath() + if _, err := os.Stat(tscPath); err == nil { + return nil // Found in tools dir + } + + // Try global installation + cmd := exec.CommandContext(ctx, "tsc", "--version") + if err := cmd.Run(); err == nil { + return nil // Found globally + } + + return fmt.Errorf("tsc not found (checked: %s and global PATH)", tscPath) +} + +// Install installs TypeScript via npm. +func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { + // Ensure tools directory exists + if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + return fmt.Errorf("failed to create tools dir: %w", err) + } + + // Check if npm is available + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm not found: please install Node.js first") + } + + // Determine version + version := config.Version + if version == "" { + version = "^5.0.0" // Default to TypeScript 5.x + } + + // Initialize package.json if needed + packageJSON := filepath.Join(a.ToolsDir, "package.json") + if _, err := os.Stat(packageJSON); os.IsNotExist(err) { + if err := a.initPackageJSON(); err != nil { + return fmt.Errorf("failed to init package.json: %w", err) + } + } + + // Install TypeScript + a.executor.WorkDir = a.ToolsDir + _, err := a.executor.Execute(ctx, "npm", "install", fmt.Sprintf("typescript@%s", version)) + if err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + + return nil +} + +// Execute runs tsc with the given config and files. +// Returns type checking results. +func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { + return a.execute(ctx, config, files) +} + +// ParseOutput converts tsc output to violations. +func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + return parseOutput(output) +} + +// getTSCPath returns the path to local tsc binary. +func (a *Adapter) getTSCPath() string { + return filepath.Join(a.ToolsDir, "node_modules", ".bin", "tsc") +} + +// initPackageJSON creates a minimal package.json. +func (a *Adapter) initPackageJSON() error { + pkg := map[string]interface{}{ + "name": "symphony-tools", + "version": "1.0.0", + "description": "Symphony validation tools", + "private": true, + } + + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return err + } + + path := filepath.Join(a.ToolsDir, "package.json") + return os.WriteFile(path, data, 0644) +} diff --git a/internal/adapter/tsc/adapter_test.go b/internal/adapter/tsc/adapter_test.go new file mode 100644 index 0000000..aadcec1 --- /dev/null +++ b/internal/adapter/tsc/adapter_test.go @@ -0,0 +1,192 @@ +package tsc + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +func TestNewAdapter(t *testing.T) { + adapter := NewAdapter("", "") + if adapter == nil { + t.Fatal("NewAdapter() returned nil") + } + + // Should have default ToolsDir + if adapter.ToolsDir == "" { + t.Error("ToolsDir should not be empty") + } +} + +func TestNewAdapter_CustomDirs(t *testing.T) { + toolsDir := "/custom/tools" + workDir := "/custom/work" + + adapter := NewAdapter(toolsDir, workDir) + + if adapter.ToolsDir != toolsDir { + t.Errorf("ToolsDir = %q, want %q", adapter.ToolsDir, toolsDir) + } + + if adapter.WorkDir != workDir { + t.Errorf("WorkDir = %q, want %q", adapter.WorkDir, workDir) + } +} + +func TestName(t *testing.T) { + adapter := NewAdapter("", "") + if adapter.Name() != "tsc" { + t.Errorf("Name() = %q, want %q", adapter.Name(), "tsc") + } +} + +func TestGetTSCPath(t *testing.T) { + adapter := NewAdapter("/test/tools", "") + expected := filepath.Join("/test/tools", "node_modules", ".bin", "tsc") + + got := adapter.getTSCPath() + if got != expected { + t.Errorf("getTSCPath() = %q, want %q", got, expected) + } +} + +func TestInitPackageJSON(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "tsc-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + adapter := NewAdapter(tmpDir, "") + + if err := adapter.initPackageJSON(); err != nil { + t.Fatalf("initPackageJSON() error = %v", err) + } + + // Verify package.json was created + packagePath := filepath.Join(tmpDir, "package.json") + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + t.Error("package.json was not created") + } + + // Read and verify content + content, err := os.ReadFile(packagePath) + if err != nil { + t.Fatalf("Failed to read package.json: %v", err) + } + + // Verify it contains expected fields + expectedFields := []string{ + `"name"`, + `"version"`, + `"symphony-tools"`, + } + + for _, field := range expectedFields { + if !contains(string(content), field) { + t.Errorf("package.json missing expected field: %s", field) + } + } +} + +func TestCheckAvailability_NotFound(t *testing.T) { + // Use a non-existent directory + adapter := NewAdapter("/nonexistent/path", "") + + ctx := context.Background() + err := adapter.CheckAvailability(ctx) + + // Should return error when tsc is not found + if err == nil { + // This might pass if tsc is installed globally, which is okay + t.Log("tsc found globally, test skipped") + } +} + +func TestInstall_MissingNPM(t *testing.T) { + // This test will fail if npm is not available, which is expected + tmpDir, err := os.MkdirTemp("", "tsc-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + a := NewAdapter(tmpDir, "") + + ctx := context.Background() + config := adapter.InstallConfig{ + ToolsDir: tmpDir, + } + + // This will fail if npm is not in PATH + // We're just testing that the function handles this gracefully + err = a.Install(ctx, config) + + // We don't assert error here because npm might be available in CI + if err != nil { + // Expected when npm is not available + t.Logf("Install failed as expected when npm unavailable: %v", err) + } +} + +func TestExecute_FileCreation(t *testing.T) { + // Create temporary work directory + tmpDir, err := os.MkdirTemp("", "tsc-exec-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + adapter := NewAdapter("", tmpDir) + + ctx := context.Background() + config := []byte(`{"compilerOptions": {"strict": true}}`) + files := []string{"test.ts"} + + // Execute (will fail because tsc not installed, but we can test config file creation) + _, err = adapter.Execute(ctx, config, files) + + // Config file should have been created and cleaned up + configPath := filepath.Join(tmpDir, ".symphony-tsconfig.json") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("Config file should have been cleaned up") + } +} + +func TestParseOutput_Integration(t *testing.T) { + a := NewAdapter("", "") + + output := &adapter.ToolOutput{ + Stdout: `src/main.ts(10,5): error TS2304: Cannot find name 'foo'. +src/app.ts(20,10): error TS2339: Property 'bar' does not exist on type 'Object'.`, + Stderr: "", + ExitCode: 2, + } + + violations, err := a.ParseOutput(output) + if err != nil { + t.Fatalf("ParseOutput() error = %v", err) + } + + if len(violations) != 2 { + t.Errorf("Expected 2 violations, got %d", len(violations)) + } +} + +// Helper function +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/adapter/tsc/config.go b/internal/adapter/tsc/config.go new file mode 100644 index 0000000..42c655f --- /dev/null +++ b/internal/adapter/tsc/config.go @@ -0,0 +1,127 @@ +package tsc + +import ( + "encoding/json" + "fmt" +) + +// TSConfig represents TypeScript compiler configuration. +type TSConfig struct { + CompilerOptions CompilerOptions `json:"compilerOptions"` + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` +} + +// CompilerOptions represents TypeScript compiler options. +type CompilerOptions struct { + Target string `json:"target,omitempty"` + Module string `json:"module,omitempty"` + Lib []string `json:"lib,omitempty"` + Strict bool `json:"strict,omitempty"` + NoImplicitAny bool `json:"noImplicitAny,omitempty"` + StrictNullChecks bool `json:"strictNullChecks,omitempty"` + StrictFunctionTypes bool `json:"strictFunctionTypes,omitempty"` + StrictBindCallApply bool `json:"strictBindCallApply,omitempty"` + StrictPropertyInit bool `json:"strictPropertyInitialization,omitempty"` + NoImplicitThis bool `json:"noImplicitThis,omitempty"` + AlwaysStrict bool `json:"alwaysStrict,omitempty"` + NoUnusedLocals bool `json:"noUnusedLocals,omitempty"` + NoUnusedParameters bool `json:"noUnusedParameters,omitempty"` + NoImplicitReturns bool `json:"noImplicitReturns,omitempty"` + NoFallthroughCasesInSwitch bool `json:"noFallthroughCasesInSwitch,omitempty"` + SkipLibCheck bool `json:"skipLibCheck,omitempty"` + ESModuleInterop bool `json:"esModuleInterop,omitempty"` + AllowJS bool `json:"allowJs,omitempty"` + CheckJS bool `json:"checkJs,omitempty"` +} + +// GenerateConfig generates a tsconfig.json from a rule. +func (a *Adapter) GenerateConfig(rule interface{}) ([]byte, error) { + // Default configuration for type checking + config := TSConfig{ + CompilerOptions: CompilerOptions{ + Target: "ES2020", + Module: "commonjs", + Lib: []string{"ES2020"}, + Strict: true, + NoImplicitAny: true, + StrictNullChecks: true, + StrictFunctionTypes: true, + StrictBindCallApply: true, + StrictPropertyInit: true, + NoImplicitThis: true, + AlwaysStrict: true, + NoUnusedLocals: false, // Don't fail on unused locals + NoUnusedParameters: false, // Don't fail on unused params + NoImplicitReturns: true, + NoFallthroughCasesInSwitch: true, + SkipLibCheck: true, // Skip type checking of declaration files + ESModuleInterop: true, + AllowJS: false, // Only check TypeScript by default + CheckJS: false, + }, + } + + // If rule is a map, extract type checking options + if ruleMap, ok := rule.(map[string]interface{}); ok { + if check, ok := ruleMap["check"].(map[string]interface{}); ok { + applyRuleConfig(&config, check) + } + } + + return json.MarshalIndent(config, "", " ") +} + +// applyRuleConfig applies rule-specific configuration to TSConfig. +func applyRuleConfig(config *TSConfig, check map[string]interface{}) { + // Extract compiler options from rule + if strict, ok := check["strict"].(bool); ok { + config.CompilerOptions.Strict = strict + } + + if noImplicitAny, ok := check["noImplicitAny"].(bool); ok { + config.CompilerOptions.NoImplicitAny = noImplicitAny + } + + if strictNullChecks, ok := check["strictNullChecks"].(bool); ok { + config.CompilerOptions.StrictNullChecks = strictNullChecks + } + + if allowJS, ok := check["allowJs"].(bool); ok { + config.CompilerOptions.AllowJS = allowJS + } + + if checkJS, ok := check["checkJs"].(bool); ok { + config.CompilerOptions.CheckJS = checkJS + } + + // Extract file patterns + if include, ok := check["include"].([]interface{}); ok { + patterns := make([]string, 0, len(include)) + for _, p := range include { + if str, ok := p.(string); ok { + patterns = append(patterns, str) + } + } + config.Include = patterns + } + + if exclude, ok := check["exclude"].([]interface{}); ok { + patterns := make([]string, 0, len(exclude)) + for _, p := range exclude { + if str, ok := p.(string); ok { + patterns = append(patterns, str) + } + } + config.Exclude = patterns + } +} + +// MarshalConfig marshals a config map to JSON. +func MarshalConfig(config interface{}) ([]byte, error) { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + return data, nil +} diff --git a/internal/adapter/tsc/config_test.go b/internal/adapter/tsc/config_test.go new file mode 100644 index 0000000..278d212 --- /dev/null +++ b/internal/adapter/tsc/config_test.go @@ -0,0 +1,212 @@ +package tsc + +import ( + "encoding/json" + "testing" +) + +func TestGenerateConfig_Default(t *testing.T) { + adapter := NewAdapter("", "/test/project") + + config, err := adapter.GenerateConfig(nil) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + var tsconfig TSConfig + if err := json.Unmarshal(config, &tsconfig); err != nil { + t.Fatalf("Failed to unmarshal config: %v", err) + } + + // Verify default options + if !tsconfig.CompilerOptions.Strict { + t.Error("Expected strict mode to be enabled by default") + } + + if !tsconfig.CompilerOptions.NoImplicitAny { + t.Error("Expected noImplicitAny to be enabled by default") + } + + if !tsconfig.CompilerOptions.StrictNullChecks { + t.Error("Expected strictNullChecks to be enabled by default") + } + + if tsconfig.CompilerOptions.Target != "ES2020" { + t.Errorf("Target = %q, want %q", tsconfig.CompilerOptions.Target, "ES2020") + } + + if tsconfig.CompilerOptions.AllowJS { + t.Error("Expected allowJs to be false by default") + } +} + +func TestGenerateConfig_WithRuleOptions(t *testing.T) { + adapter := NewAdapter("", "/test/project") + + rule := map[string]interface{}{ + "check": map[string]interface{}{ + "strict": false, + "noImplicitAny": false, + "allowJs": true, + "checkJs": true, + "strictNullChecks": false, + }, + } + + config, err := adapter.GenerateConfig(rule) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + var tsconfig TSConfig + if err := json.Unmarshal(config, &tsconfig); err != nil { + t.Fatalf("Failed to unmarshal config: %v", err) + } + + // Verify custom options + if tsconfig.CompilerOptions.Strict { + t.Error("Expected strict mode to be disabled") + } + + if tsconfig.CompilerOptions.NoImplicitAny { + t.Error("Expected noImplicitAny to be disabled") + } + + if !tsconfig.CompilerOptions.AllowJS { + t.Error("Expected allowJs to be enabled") + } + + if !tsconfig.CompilerOptions.CheckJS { + t.Error("Expected checkJs to be enabled") + } + + if tsconfig.CompilerOptions.StrictNullChecks { + t.Error("Expected strictNullChecks to be disabled") + } +} + +func TestGenerateConfig_WithIncludeExclude(t *testing.T) { + adapter := NewAdapter("", "/test/project") + + rule := map[string]interface{}{ + "check": map[string]interface{}{ + "include": []interface{}{"src/**/*.ts", "lib/**/*.ts"}, + "exclude": []interface{}{"**/*.test.ts", "dist/**"}, + }, + } + + config, err := adapter.GenerateConfig(rule) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + var tsconfig TSConfig + if err := json.Unmarshal(config, &tsconfig); err != nil { + t.Fatalf("Failed to unmarshal config: %v", err) + } + + // Verify include patterns + if len(tsconfig.Include) != 2 { + t.Errorf("Include has %d patterns, want 2", len(tsconfig.Include)) + } + + if tsconfig.Include[0] != "src/**/*.ts" { + t.Errorf("Include[0] = %q, want %q", tsconfig.Include[0], "src/**/*.ts") + } + + // Verify exclude patterns + if len(tsconfig.Exclude) != 2 { + t.Errorf("Exclude has %d patterns, want 2", len(tsconfig.Exclude)) + } + + if tsconfig.Exclude[0] != "**/*.test.ts" { + t.Errorf("Exclude[0] = %q, want %q", tsconfig.Exclude[0], "**/*.test.ts") + } +} + +func TestGenerateConfig_ValidJSON(t *testing.T) { + adapter := NewAdapter("", "/test/project") + + config, err := adapter.GenerateConfig(nil) + if err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + + // Verify it's valid JSON + var result map[string]interface{} + if err := json.Unmarshal(config, &result); err != nil { + t.Errorf("Generated config is not valid JSON: %v", err) + } + + // Verify it has compilerOptions + if _, ok := result["compilerOptions"]; !ok { + t.Error("Config missing compilerOptions field") + } +} + +func TestMarshalConfig(t *testing.T) { + config := map[string]interface{}{ + "compilerOptions": map[string]interface{}{ + "target": "ES2020", + "strict": true, + }, + } + + data, err := MarshalConfig(config) + if err != nil { + t.Fatalf("MarshalConfig() error = %v", err) + } + + // Verify it's valid JSON + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Errorf("Marshaled config is not valid JSON: %v", err) + } +} + +func TestApplyRuleConfig_AllOptions(t *testing.T) { + config := &TSConfig{ + CompilerOptions: CompilerOptions{ + Strict: true, + NoImplicitAny: true, + StrictNullChecks: true, + AllowJS: false, + CheckJS: false, + }, + } + + check := map[string]interface{}{ + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "allowJs": true, + "checkJs": true, + "include": []interface{}{"src/**"}, + "exclude": []interface{}{"dist/**"}, + } + + applyRuleConfig(config, check) + + // Verify all options were applied + if config.CompilerOptions.Strict { + t.Error("strict should be false") + } + if config.CompilerOptions.NoImplicitAny { + t.Error("noImplicitAny should be false") + } + if config.CompilerOptions.StrictNullChecks { + t.Error("strictNullChecks should be false") + } + if !config.CompilerOptions.AllowJS { + t.Error("allowJs should be true") + } + if !config.CompilerOptions.CheckJS { + t.Error("checkJs should be true") + } + if len(config.Include) != 1 || config.Include[0] != "src/**" { + t.Error("include pattern not applied correctly") + } + if len(config.Exclude) != 1 || config.Exclude[0] != "dist/**" { + t.Error("exclude pattern not applied correctly") + } +} diff --git a/internal/adapter/tsc/executor.go b/internal/adapter/tsc/executor.go new file mode 100644 index 0000000..fa08aeb --- /dev/null +++ b/internal/adapter/tsc/executor.go @@ -0,0 +1,53 @@ +package tsc + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// execute runs tsc with the given configuration. +func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { + // Write tsconfig.json to a temporary location + configPath := filepath.Join(a.WorkDir, ".symphony-tsconfig.json") + if err := os.WriteFile(configPath, config, 0644); err != nil { + return nil, fmt.Errorf("failed to write tsconfig: %w", err) + } + defer os.Remove(configPath) + + // Determine tsc binary path + tscPath := a.getTSCPath() + if _, err := os.Stat(tscPath); os.IsNotExist(err) { + // Try global tsc + tscPath = "tsc" + } + + // Build tsc command + // Use --noEmit to only check types without generating output + // Use --pretty false to get machine-readable output + args := []string{ + "--project", configPath, + "--noEmit", + "--pretty", "false", + } + + // If specific files are provided, add them + if len(files) > 0 { + args = append(args, files...) + } + + // Execute tsc + a.executor.WorkDir = a.WorkDir + output, err := a.executor.Execute(ctx, tscPath, args...) + + // TSC returns non-zero exit code when there are type errors + // This is expected, so we don't treat it as an error + if output != nil { + return output, nil + } + + return nil, err +} diff --git a/internal/adapter/tsc/parser.go b/internal/adapter/tsc/parser.go new file mode 100644 index 0000000..570f50f --- /dev/null +++ b/internal/adapter/tsc/parser.go @@ -0,0 +1,133 @@ +package tsc + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// TSCDiagnostic represents a TypeScript diagnostic in JSON format. +type TSCDiagnostic struct { + File struct { + FileName string `json:"fileName"` + } `json:"file"` + Start int `json:"start"` + Length int `json:"length"` + Category int `json:"category"` // 0=message, 1=error, 2=warning, 3=suggestion + Code int `json:"code"` + Message string `json:"messageText"` + Line int `json:"line"` // Custom field we add + Column int `json:"column"` // Custom field we add +} + +// parseOutput parses tsc output and converts it to violations. +// TSC output format (without --pretty): +// file.ts(line,col): error TS2304: Message here. +func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + if output == nil { + return []adapter.Violation{}, nil + } + + // Try JSON format first (if we use --diagnostics or custom formatter) + if strings.HasPrefix(strings.TrimSpace(output.Stdout), "[") { + return parseJSONOutput(output.Stdout) + } + + // Fall back to text format parsing + return parseTextOutput(output.Stdout) +} + +// parseTextOutput parses tsc text output. +// Format: src/main.ts(10,5): error TS2304: Cannot find name 'foo'. +func parseTextOutput(text string) ([]adapter.Violation, error) { + lines := strings.Split(text, "\n") + violations := make([]adapter.Violation, 0) + + // Regex to match tsc output: + // file.ts(line,col): severity TScode: message + re := regexp.MustCompile(`^(.+?)\((\d+),(\d+)\):\s+(error|warning|suggestion)\s+TS(\d+):\s+(.+)$`) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + matches := re.FindStringSubmatch(line) + if len(matches) != 7 { + continue + } + + file := matches[1] + lineNum, _ := strconv.Atoi(matches[2]) + col, _ := strconv.Atoi(matches[3]) + severity := matches[4] + code := matches[5] + message := matches[6] + + violations = append(violations, adapter.Violation{ + File: file, + Line: lineNum, + Column: col, + Message: message, + Severity: mapSeverity(severity), + RuleID: fmt.Sprintf("TS%s", code), + }) + } + + return violations, nil +} + +// parseJSONOutput parses tsc JSON output (if we implement custom formatter). +func parseJSONOutput(jsonStr string) ([]adapter.Violation, error) { + var diagnostics []TSCDiagnostic + if err := json.Unmarshal([]byte(jsonStr), &diagnostics); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + violations := make([]adapter.Violation, len(diagnostics)) + for i, diag := range diagnostics { + violations[i] = adapter.Violation{ + File: diag.File.FileName, + Line: diag.Line, + Column: diag.Column, + Message: diag.Message, + Severity: mapCategory(diag.Category), + RuleID: fmt.Sprintf("TS%d", diag.Code), + } + } + + return violations, nil +} + +// mapSeverity maps tsc severity string to standard severity. +func mapSeverity(severity string) string { + switch severity { + case "error": + return "error" + case "warning": + return "warning" + case "suggestion": + return "info" + default: + return "error" + } +} + +// mapCategory maps tsc category number to severity. +func mapCategory(category int) string { + switch category { + case 1: // Error + return "error" + case 2: // Warning + return "warning" + case 3: // Suggestion + return "info" + default: // Message + return "info" + } +} diff --git a/internal/adapter/tsc/parser_test.go b/internal/adapter/tsc/parser_test.go new file mode 100644 index 0000000..6c06cf4 --- /dev/null +++ b/internal/adapter/tsc/parser_test.go @@ -0,0 +1,227 @@ +package tsc + +import ( + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +func TestParseTextOutput(t *testing.T) { + tests := []struct { + name string + input string + want int // number of violations + wantErr bool + }{ + { + name: "single error", + input: "src/main.ts(10,5): error TS2304: Cannot find name 'foo'.", + want: 1, + }, + { + name: "multiple errors", + input: `src/main.ts(10,5): error TS2304: Cannot find name 'foo'. +src/app.ts(20,10): error TS2339: Property 'bar' does not exist on type 'Object'.`, + want: 2, + }, + { + name: "warning", + input: "src/util.ts(5,1): warning TS6133: 'unused' is declared but its value is never read.", + want: 1, + }, + { + name: "suggestion", + input: "src/index.ts(1,1): suggestion TS80001: File is a CommonJS module.", + want: 1, + }, + { + name: "empty output", + input: "", + want: 0, + }, + { + name: "no violations", + input: "Compilation complete. No errors.", + want: 0, + }, + { + name: "mixed severity", + input: `src/main.ts(10,5): error TS2304: Cannot find name 'foo'. +src/app.ts(20,10): warning TS6133: 'bar' is declared but never used. +src/util.ts(30,15): suggestion TS80001: Consider using const.`, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTextOutput(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseTextOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.want { + t.Errorf("parseTextOutput() returned %d violations, want %d", len(got), tt.want) + } + + // Verify first violation details if present + if len(got) > 0 && len(tt.input) > 0 { + first := got[0] + if first.File == "" { + t.Error("First violation should have a file") + } + if first.Line == 0 { + t.Error("First violation should have a line number") + } + if first.Message == "" { + t.Error("First violation should have a message") + } + } + }) + } +} + +func TestParseTextOutput_Details(t *testing.T) { + input := "src/main.ts(10,5): error TS2304: Cannot find name 'foo'." + violations, err := parseTextOutput(input) + + if err != nil { + t.Fatalf("parseTextOutput() unexpected error: %v", err) + } + + if len(violations) != 1 { + t.Fatalf("Expected 1 violation, got %d", len(violations)) + } + + v := violations[0] + + if v.File != "src/main.ts" { + t.Errorf("File = %q, want %q", v.File, "src/main.ts") + } + + if v.Line != 10 { + t.Errorf("Line = %d, want %d", v.Line, 10) + } + + if v.Column != 5 { + t.Errorf("Column = %d, want %d", v.Column, 5) + } + + if v.Severity != "error" { + t.Errorf("Severity = %q, want %q", v.Severity, "error") + } + + if v.RuleID != "TS2304" { + t.Errorf("RuleID = %q, want %q", v.RuleID, "TS2304") + } + + if v.Message != "Cannot find name 'foo'." { + t.Errorf("Message = %q, want %q", v.Message, "Cannot find name 'foo'.") + } +} + +func TestMapSeverity(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"error", "error"}, + {"warning", "warning"}, + {"suggestion", "info"}, + {"unknown", "error"}, // default + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := mapSeverity(tt.input) + if got != tt.want { + t.Errorf("mapSeverity(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestMapCategory(t *testing.T) { + tests := []struct { + input int + want string + }{ + {0, "info"}, // Message + {1, "error"}, // Error + {2, "warning"}, // Warning + {3, "info"}, // Suggestion + {99, "info"}, // Unknown (default) + } + + for _, tt := range tests { + t.Run(string(rune(tt.input)), func(t *testing.T) { + got := mapCategory(tt.input) + if got != tt.want { + t.Errorf("mapCategory(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseOutput_EmptyOutput(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: "", + Stderr: "", + ExitCode: 0, + } + + violations, err := parseOutput(output) + if err != nil { + t.Errorf("parseOutput() error = %v, want nil", err) + } + + if len(violations) != 0 { + t.Errorf("parseOutput() returned %d violations, want 0", len(violations)) + } +} + +func TestParseOutput_NilOutput(t *testing.T) { + violations, err := parseOutput(nil) + if err != nil { + t.Errorf("parseOutput() error = %v, want nil", err) + } + + if len(violations) != 0 { + t.Errorf("parseOutput() returned %d violations, want 0", len(violations)) + } +} + +func TestParseOutput_RealWorldExample(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: `src/index.ts(15,7): error TS2322: Type 'string' is not assignable to type 'number'. +src/utils/helper.ts(42,15): error TS2339: Property 'nonExistent' does not exist on type 'MyType'. +src/components/Button.tsx(8,3): warning TS6133: 'props' is declared but its value is never read.`, + Stderr: "", + ExitCode: 2, + } + + violations, err := parseOutput(output) + if err != nil { + t.Fatalf("parseOutput() unexpected error: %v", err) + } + + if len(violations) != 3 { + t.Fatalf("Expected 3 violations, got %d", len(violations)) + } + + // Verify first violation + if violations[0].File != "src/index.ts" { + t.Errorf("First violation file = %q, want %q", violations[0].File, "src/index.ts") + } + if violations[0].Severity != "error" { + t.Errorf("First violation severity = %q, want %q", violations[0].Severity, "error") + } + + // Verify third violation (warning) + if violations[2].Severity != "warning" { + t.Errorf("Third violation severity = %q, want %q", violations[2].Severity, "warning") + } + if violations[2].RuleID != "TS6133" { + t.Errorf("Third violation RuleID = %q, want %q", violations[2].RuleID, "TS6133") + } +} diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index 83401d5..89144db 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -26,8 +26,6 @@ into a structured schema (Schema B) that the validation engine can read.`, } func init() { - rootCmd.AddCommand(convertCmd) - convertCmd.Flags().StringVarP(&convertInputFile, "input", "i", "user-policy.json", "input user policy file") convertCmd.Flags().StringVarP(&convertOutputFile, "output", "o", "code-policy.json", "output code policy file") } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d63f8c1..bf783ad 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,6 +7,10 @@ import ( "github.com/spf13/cobra" ) +// verbose is a global flag for verbose output +// Used by convert and validate commands +var verbose bool + // symphonyclient integration: Updated root command from symphony to sym var rootCmd = &cobra.Command{ Use: "sym", @@ -29,6 +33,9 @@ func Execute() { } func init() { + // Global flags + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") + // symphonyclient integration: Added symphonyclient commands rootCmd.AddCommand(configCmd) rootCmd.AddCommand(loginCmd) @@ -38,9 +45,11 @@ func init() { rootCmd.AddCommand(myRoleCmd) rootCmd.AddCommand(whoamiCmd) rootCmd.AddCommand(policyCmd) + rootCmd.AddCommand(mcpCmd) - // sym-cli core commands + // sym-cli core commands (in development) rootCmd.AddCommand(convertCmd) rootCmd.AddCommand(validateCmd) - rootCmd.AddCommand(exportCmd) + // TODO: implement export command + // rootCmd.AddCommand(exportCmd) } diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index 4693259..ef45f12 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -26,8 +26,6 @@ Validation results are returned to standard output, and a non-zero exit code is } func init() { - rootCmd.AddCommand(validateCmd) - validateCmd.Flags().StringVarP(&validatePolicyFile, "policy", "p", "code-policy.json", "code policy file path") validateCmd.Flags().StringSliceVarP(&validateTargetPaths, "target", "t", []string{"."}, "files or directories to validate") validateCmd.Flags().StringVarP(&validateRole, "role", "r", "contributor", "user role for RBAC validation") diff --git a/internal/engine/ast/engine.go b/internal/engine/ast/engine.go index 9b2c277..4ecc9bb 100644 --- a/internal/engine/ast/engine.go +++ b/internal/engine/ast/engine.go @@ -3,7 +3,6 @@ package ast import ( "context" "fmt" - "path/filepath" "github.com/DevSymphony/sym-cli/internal/adapter" "github.com/DevSymphony/sym-cli/internal/adapter/eslint" @@ -119,93 +118,9 @@ func (e *Engine) Close() error { return nil } -// filterFiles filters files based on the when selector. +// filterFiles filters files based on the when selector using proper glob matching. func (e *Engine) filterFiles(files []string, when *core.Selector) []string { - if when == nil { - return files - } - - var filtered []string - for _, file := range files { - if e.matchesSelector(file, when) { - filtered = append(filtered, file) - } - } - return filtered -} - -// matchesSelector checks if a file matches the selector criteria. -func (e *Engine) matchesSelector(file string, when *core.Selector) bool { - if when == nil { - return true - } - - // Language filter - if len(when.Languages) > 0 { - ext := filepath.Ext(file) - matched := false - for _, lang := range when.Languages { - if matchesLanguage(ext, lang) { - matched = true - break - } - } - if !matched { - return false - } - } - - // Include/Exclude filters - if len(when.Include) > 0 { - matched := false - for _, pattern := range when.Include { - if matchGlob(file, pattern) { - matched = true - break - } - } - if !matched { - return false - } - } - - if len(when.Exclude) > 0 { - for _, pattern := range when.Exclude { - if matchGlob(file, pattern) { - return false - } - } - } - - return true -} - -// matchesLanguage checks if file extension matches language. -func matchesLanguage(ext, lang string) bool { - langExtMap := map[string][]string{ - "javascript": {".js", ".mjs", ".cjs"}, - "typescript": {".ts", ".mts", ".cts"}, - "jsx": {".jsx"}, - "tsx": {".tsx"}, - } - - exts, ok := langExtMap[lang] - if !ok { - return false - } - - for _, e := range exts { - if ext == e { - return true - } - } - return false -} - -// matchGlob is a simple glob matcher (simplified version). -func matchGlob(path, pattern string) bool { - matched, _ := filepath.Match(pattern, path) - return matched + return core.FilterFiles(files, when) } // generateESLintConfigWithSelector generates ESLint config using no-restricted-syntax. diff --git a/internal/engine/ast/engine_test.go b/internal/engine/ast/engine_test.go index b4251b8..0644548 100644 --- a/internal/engine/ast/engine_test.go +++ b/internal/engine/ast/engine_test.go @@ -1,6 +1,7 @@ package ast import ( + "context" "testing" "github.com/DevSymphony/sym-cli/internal/engine/core" @@ -34,87 +35,252 @@ func TestGetCapabilities(t *testing.T) { } } -func TestMatchesSelector(t *testing.T) { - engine := &Engine{} - - tests := []struct { - name string - file string - selector *core.Selector - want bool - }{ - { - name: "nil selector", - file: "src/main.js", - selector: nil, - want: true, +func TestInit(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: false, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init failed (expected if ESLint not available): %v", err) + } +} + +func TestClose(t *testing.T) { + engine := NewEngine() + if err := engine.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } +} + +func TestValidate_NoFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "error_handling", + Severity: "error", + Check: map[string]interface{}{ + "engine": "ast", + "node": "CallExpression", }, - { - name: "matches javascript", - file: "src/main.js", - selector: &core.Selector{ - Languages: []string{"javascript"}, - }, - want: true, + } + + result, err := engine.Validate(ctx, rule, []string{}) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Error("Expected validation to pass for empty file list") + } +} + +func TestValidate_WithInitialization(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "error_handling", + Severity: "error", + Check: map[string]interface{}{ + "engine": "ast", + "node": "CallExpression", }, - { - name: "doesn't match typescript", - file: "src/main.js", - selector: &core.Selector{ - Languages: []string{"typescript"}, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithWhereClause(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-WHERE", + Category: "error_handling", + Severity: "error", + Check: map[string]interface{}{ + "engine": "ast", + "node": "CallExpression", + "where": map[string]interface{}{ + "func": map[string]interface{}{ + "in": []string{"open", "readFile"}, + }, }, - want: false, }, - { - name: "matches include pattern", - file: "src/main.js", - selector: &core.Selector{ - Include: []string{"src/*"}, - }, - want: true, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithHasClause(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-HAS", + Category: "error_handling", + Severity: "error", + Check: map[string]interface{}{ + "engine": "ast", + "node": "CallExpression", + "has": []string{"TryStatement"}, }, - { - name: "excluded by pattern", - file: "test/main.js", - selector: &core.Selector{ - Exclude: []string{"test/*"}, - }, - want: false, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithNotHasClause(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-NOT-HAS", + Category: "error_handling", + Severity: "error", + Check: map[string]interface{}{ + "engine": "ast", + "node": "FunctionDeclaration", + "notHas": []string{"JSDocComment"}, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := engine.matchesSelector(tt.file, tt.selector) - if got != tt.want { - t.Errorf("matchesSelector() = %v, want %v", got, tt.want) - } - }) + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } } } -func TestMatchesLanguage(t *testing.T) { - tests := []struct { - ext string - lang string - want bool - }{ - {".js", "javascript", true}, - {".jsx", "jsx", true}, - {".ts", "typescript", true}, - {".tsx", "tsx", true}, - {".mjs", "javascript", true}, - {".py", "javascript", false}, - } - - for _, tt := range tests { - got := matchesLanguage(tt.ext, tt.lang) - if got != tt.want { - t.Errorf("matchesLanguage(%q, %q) = %v, want %v", tt.ext, tt.lang, got, tt.want) +func TestValidate_WithCustomMessage(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-CUSTOM", + Category: "error_handling", + Severity: "error", + Message: "Custom AST violation", + Check: map[string]interface{}{ + "engine": "ast", + "node": "CallExpression", + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) } } } +// TestMatchesSelector has been moved to core package tests. +// File filtering logic is now centralized in core.FilterFiles. + +// TestMatchesLanguage has been moved to core package tests. +// Language matching logic is now centralized in core.MatchesLanguage. + // Helper functions func contains(slice []string, item string) bool { diff --git a/internal/engine/core/selector.go b/internal/engine/core/selector.go new file mode 100644 index 0000000..83459ca --- /dev/null +++ b/internal/engine/core/selector.go @@ -0,0 +1,127 @@ +package core + +import ( + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// MatchGlob checks if a file path matches a glob pattern. +// Supports doublestar patterns (e.g., "**/*.js", "src/**/test_*.go"). +func MatchGlob(filePath, pattern string) (bool, error) { + // Normalize paths for consistent matching + filePath = filepath.ToSlash(filePath) + pattern = filepath.ToSlash(pattern) + + return doublestar.Match(pattern, filePath) +} + +// MatchesLanguage checks if a file extension matches a language. +func MatchesLanguage(filePath, language string) bool { + ext := strings.ToLower(filepath.Ext(filePath)) + + // Language to extension mapping + langExtMap := map[string][]string{ + "javascript": {".js", ".mjs", ".cjs"}, + "js": {".js", ".mjs", ".cjs"}, + "typescript": {".ts", ".mts", ".cts"}, + "ts": {".ts", ".mts", ".cts"}, + "jsx": {".jsx"}, + "tsx": {".tsx"}, + "python": {".py", ".pyi", ".pyw"}, + "py": {".py", ".pyi", ".pyw"}, + "go": {".go"}, + "golang": {".go"}, + "java": {".java"}, + "c": {".c", ".h"}, + "cpp": {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"}, + "c++": {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"}, + "rust": {".rs"}, + "ruby": {".rb"}, + "php": {".php"}, + "swift": {".swift"}, + "kotlin": {".kt", ".kts"}, + "scala": {".scala"}, + "shell": {".sh", ".bash", ".zsh"}, + "sh": {".sh", ".bash", ".zsh"}, + } + + exts, ok := langExtMap[strings.ToLower(language)] + if !ok { + return false + } + + for _, e := range exts { + if ext == e { + return true + } + } + return false +} + +// MatchesSelector checks if a file matches the selector criteria. +// Returns true if the file passes all selector filters. +func MatchesSelector(filePath string, selector *Selector) bool { + if selector == nil { + return true + } + + // Normalize file path + filePath = filepath.ToSlash(filePath) + + // Language filter + if len(selector.Languages) > 0 { + matched := false + for _, lang := range selector.Languages { + if MatchesLanguage(filePath, lang) { + matched = true + break + } + } + if !matched { + return false + } + } + + // Include filter (if specified, file must match at least one pattern) + if len(selector.Include) > 0 { + matched := false + for _, pattern := range selector.Include { + if m, err := MatchGlob(filePath, pattern); err == nil && m { + matched = true + break + } + } + if !matched { + return false + } + } + + // Exclude filter (if file matches any pattern, exclude it) + if len(selector.Exclude) > 0 { + for _, pattern := range selector.Exclude { + if m, err := MatchGlob(filePath, pattern); err == nil && m { + return false + } + } + } + + return true +} + +// FilterFiles filters a list of files based on the selector criteria. +// Returns a new slice containing only the files that match. +func FilterFiles(files []string, selector *Selector) []string { + if selector == nil { + return files + } + + filtered := make([]string, 0, len(files)) + for _, file := range files { + if MatchesSelector(file, selector) { + filtered = append(filtered, file) + } + } + return filtered +} diff --git a/internal/engine/core/selector_test.go b/internal/engine/core/selector_test.go new file mode 100644 index 0000000..4ec9ade --- /dev/null +++ b/internal/engine/core/selector_test.go @@ -0,0 +1,581 @@ +package core + +import ( + "testing" +) + +func TestMatchGlob(t *testing.T) { + tests := []struct { + name string + filePath string + pattern string + want bool + wantErr bool + }{ + // Simple patterns + { + name: "exact match", + filePath: "main.go", + pattern: "main.go", + want: true, + }, + { + name: "wildcard extension", + filePath: "main.go", + pattern: "*.go", + want: true, + }, + { + name: "wildcard name", + filePath: "main.go", + pattern: "main.*", + want: true, + }, + { + name: "no match", + filePath: "main.go", + pattern: "*.js", + want: false, + }, + + // Doublestar patterns + { + name: "doublestar all files", + filePath: "src/main.go", + pattern: "**/*.go", + want: true, + }, + { + name: "doublestar nested", + filePath: "src/foo/bar/test.js", + pattern: "src/**/*.js", + want: true, + }, + { + name: "doublestar no match", + filePath: "test/main.go", + pattern: "src/**/*.go", + want: false, + }, + { + name: "doublestar middle", + filePath: "src/foo/bar/baz/test.ts", + pattern: "src/**/test.ts", + want: true, + }, + + // Path-specific patterns + { + name: "specific directory", + filePath: "src/components/Button.tsx", + pattern: "src/components/*.tsx", + want: true, + }, + { + name: "exclude test files", + filePath: "src/main_test.go", + pattern: "**/*_test.go", + want: true, + }, + { + name: "exclude test directory", + filePath: "tests/unit/main.go", + pattern: "tests/**/*.go", + want: true, + }, + + // Windows-style paths are normalized to forward slashes + // Note: In actual usage, filepath operations will handle OS-specific separators + { + name: "mixed separators", + filePath: "src/subdir/main.go", + pattern: "src/**/*.go", + want: true, + }, + + // Edge cases + { + name: "empty pattern", + filePath: "main.go", + pattern: "", + want: false, + }, + { + name: "root level file", + filePath: "main.go", + pattern: "*.go", + want: true, + }, + { + name: "multiple wildcards", + filePath: "src/foo/bar/test_main.go", + pattern: "src/**/test_*.go", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchGlob(tt.filePath, tt.pattern) + if (err != nil) != tt.wantErr { + t.Errorf("MatchGlob() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MatchGlob(%q, %q) = %v, want %v", tt.filePath, tt.pattern, got, tt.want) + } + }) + } +} + +func TestMatchesLanguage(t *testing.T) { + tests := []struct { + name string + filePath string + language string + want bool + }{ + // JavaScript variants + {"js standard", "main.js", "javascript", true}, + {"js module", "main.mjs", "javascript", true}, + {"js commonjs", "main.cjs", "javascript", true}, + {"js short", "main.js", "js", true}, + {"jsx", "Component.jsx", "jsx", true}, + + // TypeScript variants + {"ts standard", "main.ts", "typescript", true}, + {"ts module", "main.mts", "typescript", true}, + {"ts commonjs", "main.cts", "typescript", true}, + {"ts short", "main.ts", "ts", true}, + {"tsx", "Component.tsx", "tsx", true}, + + // Python variants + {"py standard", "main.py", "python", true}, + {"py interface", "main.pyi", "python", true}, + {"py windows", "main.pyw", "python", true}, + {"py short", "main.py", "py", true}, + + // Go + {"go standard", "main.go", "go", true}, + {"go long name", "main.go", "golang", true}, + + // Other languages + {"java", "Main.java", "java", true}, + {"c", "main.c", "c", true}, + {"c header", "main.h", "c", true}, + {"cpp", "main.cpp", "cpp", true}, + {"cpp alt", "main.cc", "cpp", true}, + {"cpp header", "main.hpp", "cpp", true}, + {"rust", "main.rs", "rust", true}, + {"ruby", "main.rb", "ruby", true}, + {"php", "index.php", "php", true}, + {"swift", "Main.swift", "swift", true}, + {"kotlin", "Main.kt", "kotlin", true}, + {"kotlin script", "build.kts", "kotlin", true}, + {"scala", "Main.scala", "scala", true}, + {"shell", "script.sh", "shell", true}, + {"bash", "script.bash", "shell", true}, + {"shell short", "script.sh", "sh", true}, + + // Case insensitivity + {"uppercase ext", "Main.GO", "go", true}, + {"uppercase lang", "main.js", "JAVASCRIPT", true}, + {"mixed case", "Main.JS", "JavaScript", true}, + + // No match + {"wrong extension", "main.go", "javascript", false}, + {"unknown language", "main.xyz", "xyz", false}, + {"empty extension", "README", "go", false}, + {"no extension match", "main.txt", "javascript", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchesLanguage(tt.filePath, tt.language) + if got != tt.want { + t.Errorf("MatchesLanguage(%q, %q) = %v, want %v", tt.filePath, tt.language, got, tt.want) + } + }) + } +} + +func TestMatchesSelector(t *testing.T) { + tests := []struct { + name string + filePath string + selector *Selector + want bool + }{ + // Nil selector (should match all) + { + name: "nil selector", + filePath: "any/file.go", + selector: nil, + want: true, + }, + + // Language only + { + name: "language match", + filePath: "main.go", + selector: &Selector{Languages: []string{"go"}}, + want: true, + }, + { + name: "language no match", + filePath: "main.go", + selector: &Selector{Languages: []string{"javascript"}}, + want: false, + }, + { + name: "multiple languages match first", + filePath: "main.js", + selector: &Selector{Languages: []string{"javascript", "typescript"}}, + want: true, + }, + { + name: "multiple languages match second", + filePath: "main.ts", + selector: &Selector{Languages: []string{"javascript", "typescript"}}, + want: true, + }, + + // Include only + { + name: "include match", + filePath: "src/main.go", + selector: &Selector{Include: []string{"src/**/*.go"}}, + want: true, + }, + { + name: "include no match", + filePath: "test/main.go", + selector: &Selector{Include: []string{"src/**/*.go"}}, + want: false, + }, + { + name: "multiple includes match first", + filePath: "src/main.go", + selector: &Selector{Include: []string{"src/**/*.go", "lib/**/*.go"}}, + want: true, + }, + { + name: "multiple includes match second", + filePath: "lib/util.go", + selector: &Selector{Include: []string{"src/**/*.go", "lib/**/*.go"}}, + want: true, + }, + { + name: "multiple includes no match", + filePath: "test/main.go", + selector: &Selector{Include: []string{"src/**/*.go", "lib/**/*.go"}}, + want: false, + }, + + // Exclude only + { + name: "exclude match - should reject", + filePath: "src/main_test.go", + selector: &Selector{Exclude: []string{"**/*_test.go"}}, + want: false, + }, + { + name: "exclude no match - should accept", + filePath: "src/main.go", + selector: &Selector{Exclude: []string{"**/*_test.go"}}, + want: true, + }, + { + name: "multiple excludes match first", + filePath: "node_modules/pkg/index.js", + selector: &Selector{Exclude: []string{"node_modules/**", "dist/**"}}, + want: false, + }, + { + name: "multiple excludes match second", + filePath: "dist/bundle.js", + selector: &Selector{Exclude: []string{"node_modules/**", "dist/**"}}, + want: false, + }, + + // Combined filters + { + name: "language + include both match", + filePath: "src/main.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + }, + want: true, + }, + { + name: "language match but include no match", + filePath: "test/main.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + }, + want: false, + }, + { + name: "include match but language no match", + filePath: "src/main.js", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*"}, + }, + want: false, + }, + { + name: "language + include match, exclude no match", + filePath: "src/main.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + Exclude: []string{"**/*_test.go"}, + }, + want: true, + }, + { + name: "language + include match, but excluded", + filePath: "src/main_test.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + Exclude: []string{"**/*_test.go"}, + }, + want: false, + }, + + // Complex real-world scenarios + { + name: "source files only, no tests, no vendor", + filePath: "src/components/Button.tsx", + selector: &Selector{ + Languages: []string{"tsx", "typescript"}, + Include: []string{"src/**/*.{ts,tsx}"}, + Exclude: []string{"**/*.test.ts", "**/*.test.tsx", "**/vendor/**"}, + }, + want: true, + }, + { + name: "exclude test file", + filePath: "src/components/Button.test.tsx", + selector: &Selector{ + Languages: []string{"tsx", "typescript"}, + Include: []string{"src/**/*.{ts,tsx}"}, + Exclude: []string{"**/*.test.ts", "**/*.test.tsx"}, + }, + want: false, + }, + { + name: "public API files only", + filePath: "src/public/api.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/public/**/*.go"}, + Exclude: []string{"**/*_internal.go"}, + }, + want: true, + }, + { + name: "exclude internal file", + filePath: "src/public/api_internal.go", + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/public/**/*.go"}, + Exclude: []string{"**/*_internal.go"}, + }, + want: false, + }, + + // Edge cases + { + name: "empty languages list - should match all", + filePath: "main.go", + selector: &Selector{Languages: []string{}}, + want: true, + }, + { + name: "empty include list - should match all", + filePath: "any/path/file.go", + selector: &Selector{Include: []string{}}, + want: true, + }, + { + name: "empty exclude list - should not exclude", + filePath: "any/path/file.go", + selector: &Selector{Exclude: []string{}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchesSelector(tt.filePath, tt.selector) + if got != tt.want { + t.Errorf("MatchesSelector(%q, %+v) = %v, want %v", tt.filePath, tt.selector, got, tt.want) + } + }) + } +} + +func TestFilterFiles(t *testing.T) { + files := []string{ + "src/main.go", + "src/util.go", + "src/main_test.go", + "test/integration.go", + "lib/helper.js", + "lib/index.ts", + "dist/bundle.js", + "node_modules/pkg/index.js", + } + + tests := []struct { + name string + files []string + selector *Selector + want []string + }{ + { + name: "nil selector - all files", + files: files, + selector: nil, + want: files, + }, + { + name: "go files only", + files: files, + selector: &Selector{ + Languages: []string{"go"}, + }, + want: []string{ + "src/main.go", + "src/util.go", + "src/main_test.go", + "test/integration.go", + }, + }, + { + name: "src directory only", + files: files, + selector: &Selector{ + Include: []string{"src/**/*"}, + }, + want: []string{ + "src/main.go", + "src/util.go", + "src/main_test.go", + }, + }, + { + name: "exclude test files", + files: files, + selector: &Selector{ + Exclude: []string{"**/*_test.go", "**/test/**"}, + }, + want: []string{ + "src/main.go", + "src/util.go", + "lib/helper.js", + "lib/index.ts", + "dist/bundle.js", + "node_modules/pkg/index.js", + }, + }, + { + name: "go files in src, no tests", + files: files, + selector: &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + Exclude: []string{"**/*_test.go"}, + }, + want: []string{ + "src/main.go", + "src/util.go", + }, + }, + { + name: "js/ts but exclude dist and node_modules", + files: files, + selector: &Selector{ + Languages: []string{"javascript", "typescript"}, + Exclude: []string{"dist/**", "node_modules/**"}, + }, + want: []string{ + "lib/helper.js", + "lib/index.ts", + }, + }, + { + name: "empty file list", + files: []string{}, + selector: &Selector{Languages: []string{"go"}}, + want: []string{}, + }, + { + name: "no matches", + files: files, + selector: &Selector{ + Languages: []string{"python"}, + }, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FilterFiles(tt.files, tt.selector) + if len(got) != len(tt.want) { + t.Errorf("FilterFiles() returned %d files, want %d", len(got), len(tt.want)) + t.Errorf("got: %v", got) + t.Errorf("want: %v", tt.want) + return + } + for i, file := range got { + if file != tt.want[i] { + t.Errorf("FilterFiles()[%d] = %q, want %q", i, file, tt.want[i]) + } + } + }) + } +} + +// Benchmark tests +func BenchmarkMatchGlob(b *testing.B) { + for i := 0; i < b.N; i++ { + MatchGlob("src/foo/bar/baz/test.go", "src/**/*.go") + } +} + +func BenchmarkMatchesSelector(b *testing.B) { + selector := &Selector{ + Languages: []string{"go"}, + Include: []string{"src/**/*.go"}, + Exclude: []string{"**/*_test.go"}, + } + for i := 0; i < b.N; i++ { + MatchesSelector("src/foo/bar/main.go", selector) + } +} + +func BenchmarkFilterFiles(b *testing.B) { + files := make([]string, 1000) + for i := 0; i < 1000; i++ { + if i%2 == 0 { + files[i] = "src/file.go" + } else { + files[i] = "test/file_test.go" + } + } + selector := &Selector{ + Languages: []string{"go"}, + Exclude: []string{"**/*_test.go"}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + FilterFiles(files, selector) + } +} diff --git a/internal/engine/length/engine.go b/internal/engine/length/engine.go index b41abe6..892662d 100644 --- a/internal/engine/length/engine.go +++ b/internal/engine/length/engine.go @@ -143,23 +143,7 @@ func (e *Engine) Close() error { return nil } -// filterFiles filters files based on selector. +// filterFiles filters files based on selector using proper glob matching. func (e *Engine) filterFiles(files []string, selector *core.Selector) []string { - if selector == nil { - return files - } - - // Simple extension-based filter - var filtered []string - for _, file := range files { - // Accept .js, .ts, .jsx, .tsx - if len(file) > 3 { - ext := file[len(file)-3:] - if ext == ".js" || ext == ".ts" || ext == "jsx" || ext == "tsx" { - filtered = append(filtered, file) - } - } - } - - return filtered + return core.FilterFiles(files, selector) } diff --git a/internal/engine/length/engine_test.go b/internal/engine/length/engine_test.go index 530591a..7ba8392 100644 --- a/internal/engine/length/engine_test.go +++ b/internal/engine/length/engine_test.go @@ -1,6 +1,7 @@ package length import ( + "context" "testing" "github.com/DevSymphony/sym-cli/internal/engine/core" @@ -34,6 +35,73 @@ func TestGetCapabilities(t *testing.T) { } } +func TestInit(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: false, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init failed (expected if ESLint not available): %v", err) + } +} + +func TestClose(t *testing.T) { + engine := NewEngine() + if err := engine.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } +} + +func TestValidate_NoFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "formatting", + Severity: "error", + Check: map[string]interface{}{ + "engine": "length", + "scope": "line", + "max": 100, + }, + } + + result, err := engine.Validate(ctx, rule, []string{}) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Error("Expected validation to pass for empty file list") + } +} + +func TestValidate_NotInitialized(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "formatting", + Severity: "error", + Check: map[string]interface{}{ + "engine": "length", + }, + } + + _, err := engine.Validate(ctx, rule, []string{"test.js"}) + if err == nil { + t.Error("Expected error for uninitialized engine") + } +} + func TestFilterFiles(t *testing.T) { engine := &Engine{} @@ -57,7 +125,7 @@ func TestFilterFiles(t *testing.T) { { name: "with selector - filters JS/TS only", selector: &core.Selector{ - Languages: []string{"javascript"}, + Languages: []string{"javascript", "typescript"}, }, want: []string{"src/main.js", "src/app.ts", "test/test.js"}, }, @@ -73,6 +141,179 @@ func TestFilterFiles(t *testing.T) { } } +func TestValidate_WithCustomMessage(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-CUSTOM-MSG", + Category: "formatting", + Severity: "error", + Message: "Line is too long", + Check: map[string]interface{}{ + "engine": "length", + "scope": "line", + "max": 80, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_FileScope(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-FILE-LENGTH", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "file", + "max": 500, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_FunctionScope(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-FUNCTION-LENGTH", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "function", + "max": 50, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_ParamsScope(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-PARAMS-LENGTH", + Category: "formatting", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "length", + "scope": "params", + "max": 4, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestInit_WithDebug(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: true, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init with debug failed (expected if ESLint not available): %v", err) + } + + if !engine.config.Debug { + t.Error("Expected debug to be true") + } +} + // Helper functions func contains(slice []string, item string) bool { diff --git a/internal/engine/pattern/engine.go b/internal/engine/pattern/engine.go index e3807b5..e3ccff6 100644 --- a/internal/engine/pattern/engine.go +++ b/internal/engine/pattern/engine.go @@ -146,53 +146,9 @@ func (e *Engine) Close() error { return nil } -// filterFiles filters files based on selector. +// filterFiles filters files based on selector using proper glob matching. func (e *Engine) filterFiles(files []string, selector *core.Selector) []string { - if selector == nil { - return files - } - - // Simple filter by extension - // TODO: Implement proper glob matching - var filtered []string - for _, file := range files { - if e.matchesLanguage(file, selector.Languages) { - filtered = append(filtered, file) - } - } - - return filtered -} - -// matchesLanguage checks if file matches language selector. -func (e *Engine) matchesLanguage(file string, languages []string) bool { - if len(languages) == 0 { - return true // No filter - } - - // Check by extension - for _, lang := range languages { - switch lang { - case "javascript", "js": - if len(file) > 3 && file[len(file)-3:] == ".js" { - return true - } - case "typescript", "ts": - if len(file) > 3 && file[len(file)-3:] == ".ts" { - return true - } - case "jsx": - if len(file) > 4 && file[len(file)-4:] == ".jsx" { - return true - } - case "tsx": - if len(file) > 4 && file[len(file)-4:] == ".tsx" { - return true - } - } - } - - return false + return core.FilterFiles(files, selector) } // detectLanguage detects the language from file extensions. diff --git a/internal/engine/pattern/engine_test.go b/internal/engine/pattern/engine_test.go index ee9f305..f76ff28 100644 --- a/internal/engine/pattern/engine_test.go +++ b/internal/engine/pattern/engine_test.go @@ -1,6 +1,7 @@ package pattern import ( + "context" "testing" "github.com/DevSymphony/sym-cli/internal/engine/core" @@ -34,31 +35,81 @@ func TestGetCapabilities(t *testing.T) { } } -func TestMatchesLanguage(t *testing.T) { - engine := &Engine{} +func TestInit(t *testing.T) { + engine := NewEngine() + ctx := context.Background() - tests := []struct { - file string - langs []string - want bool - }{ - {"main.js", []string{"javascript"}, true}, - {"app.jsx", []string{"jsx"}, true}, - {"server.ts", []string{"typescript"}, true}, - {"component.tsx", []string{"tsx"}, true}, - {"main.js", []string{"typescript"}, false}, - {"app.py", []string{"javascript"}, false}, - {"main.js", []string{"javascript", "typescript"}, true}, + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: false, } - for _, tt := range tests { - got := engine.matchesLanguage(tt.file, tt.langs) - if got != tt.want { - t.Errorf("matchesLanguage(%q, %v) = %v, want %v", tt.file, tt.langs, got, tt.want) - } + // Init might fail if eslint is not available, which is okay for unit tests + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init failed (expected if ESLint not available): %v", err) + } +} + +func TestClose(t *testing.T) { + engine := NewEngine() + if err := engine.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } +} + +func TestValidate_NoFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "naming", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + }, + } + + // Validate with empty file list + result, err := engine.Validate(ctx, rule, []string{}) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Error("Expected validation to pass for empty file list") + } + + if len(result.Violations) != 0 { + t.Errorf("Expected 0 violations, got %d", len(result.Violations)) } } +func TestValidate_NotInitialized(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "naming", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + }, + } + + // Validate without initialization + _, err := engine.Validate(ctx, rule, []string{"test.js"}) + if err == nil { + t.Error("Expected error for uninitialized engine") + } +} + +// TestMatchesLanguage has been moved to core package tests. +// Language matching logic is now centralized in core.MatchesLanguage. + func TestFilterFiles(t *testing.T) { engine := &Engine{} @@ -135,6 +186,116 @@ func TestDetectLanguage(t *testing.T) { } } +func TestValidate_WithCustomMessage(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-CUSTOM-MSG", + Category: "naming", + Severity: "error", + Message: "Custom violation message", + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z][a-zA-Z0-9]*$", + }, + } + + // Create a test file with a violation + testFile := t.TempDir() + "/test.js" + // Since we can't guarantee ESLint will find violations in a real file, + // we'll just test that the function handles the rule correctly + result, err := engine.Validate(ctx, rule, []string{testFile}) + + // Should not error even if file doesn't exist or has no violations + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + // If result is returned, check basic properties + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithFilteredFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-FILTERED", + Category: "naming", + Severity: "error", + When: &core.Selector{ + Languages: []string{"typescript"}, + }, + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z]", + }, + } + + // Provide JS and TS files - only TS should be validated + files := []string{"test.js", "test.ts"} + + result, err := engine.Validate(ctx, rule, files) + + // Should not error + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestInit_WithDebug(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: true, + } + + // Init might fail if eslint is not available + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init with debug failed (expected if ESLint not available): %v", err) + } + + // Check that config was set + if !engine.config.Debug { + t.Error("Expected debug to be true") + } +} + // Helper functions func contains(slice []string, item string) bool { diff --git a/internal/engine/registry/builtin.go b/internal/engine/registry/builtin.go index 40d1e78..f326de4 100644 --- a/internal/engine/registry/builtin.go +++ b/internal/engine/registry/builtin.go @@ -6,6 +6,7 @@ import ( "github.com/DevSymphony/sym-cli/internal/engine/length" "github.com/DevSymphony/sym-cli/internal/engine/pattern" "github.com/DevSymphony/sym-cli/internal/engine/style" + "github.com/DevSymphony/sym-cli/internal/engine/typechecker" ) // init registers all built-in engines. @@ -29,4 +30,9 @@ func init() { MustRegister("ast", func() (core.Engine, error) { return ast.NewEngine(), nil }) + + // Register type checker engine + MustRegister("typechecker", func() (core.Engine, error) { + return typechecker.NewEngine(), nil + }) } diff --git a/internal/engine/style/autofix.go b/internal/engine/style/autofix.go deleted file mode 100644 index de20c10..0000000 --- a/internal/engine/style/autofix.go +++ /dev/null @@ -1,135 +0,0 @@ -package style - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/DevSymphony/sym-cli/internal/engine/core" -) - -// Autofix applies style fixes to files using Prettier. -func (e *Engine) Autofix(ctx context.Context, rule core.Rule, files []string) ([]string, error) { - if e.prettier == nil { - return nil, fmt.Errorf("prettier not available for autofix") - } - - files = e.filterFiles(files, rule.When) - if len(files) == 0 { - return nil, nil - } - - // Generate Prettier config - config, err := e.prettier.GenerateConfig(&rule) - if err != nil { - return nil, fmt.Errorf("failed to generate Prettier config: %w", err) - } - - // Execute Prettier --write - _, err = e.prettier.ExecuteWithMode(ctx, config, files, "write") - if err != nil { - return nil, fmt.Errorf("prettier --write failed: %w", err) - } - - return files, nil -} - -// GenerateDiff generates a diff preview without modifying files. -func (e *Engine) GenerateDiff(ctx context.Context, rule core.Rule, files []string) (map[string]string, error) { - diffs := make(map[string]string) - - for _, file := range files { - // Read original - original, err := os.ReadFile(file) - if err != nil { - continue - } - - // Format with Prettier (write to temp file) - formatted, err := e.formatWithPrettier(ctx, rule, file) - if err != nil { - continue - } - - // Generate diff - diff := generateUnifiedDiff(file, string(original), formatted) - if diff != "" { - diffs[file] = diff - } - } - - return diffs, nil -} - -// formatWithPrettier formats a file and returns the result. -func (e *Engine) formatWithPrettier(ctx context.Context, rule core.Rule, file string) (string, error) { - config, err := e.prettier.GenerateConfig(&rule) - if err != nil { - return "", err - } - - // Write config to temp file - tmpConfig, err := os.CreateTemp("", "prettierrc-*.json") - if err != nil { - return "", err - } - defer os.Remove(tmpConfig.Name()) - - if _, err := tmpConfig.Write(config); err != nil { - return "", err - } - tmpConfig.Close() - - // Run prettier - cmd := exec.CommandContext(ctx, "prettier", "--config", tmpConfig.Name(), file) - output, err := cmd.Output() - if err != nil { - return "", err - } - - return string(output), nil -} - -// generateUnifiedDiff generates a unified diff between original and formatted. -func generateUnifiedDiff(filename, original, formatted string) string { - if original == formatted { - return "" - } - - var diff strings.Builder - diff.WriteString(fmt.Sprintf("--- %s\n", filename)) - diff.WriteString(fmt.Sprintf("+++ %s (formatted)\n", filename)) - - origLines := strings.Split(original, "\n") - formattedLines := strings.Split(formatted, "\n") - - // Simple line-by-line diff (not optimal, but works) - maxLines := len(origLines) - if len(formattedLines) > maxLines { - maxLines = len(formattedLines) - } - - for i := 0; i < maxLines; i++ { - var origLine, formattedLine string - - if i < len(origLines) { - origLine = origLines[i] - } - if i < len(formattedLines) { - formattedLine = formattedLines[i] - } - - if origLine != formattedLine { - if origLine != "" { - diff.WriteString(fmt.Sprintf("-%s\n", origLine)) - } - if formattedLine != "" { - diff.WriteString(fmt.Sprintf("+%s\n", formattedLine)) - } - } - } - - return diff.String() -} diff --git a/internal/engine/style/engine.go b/internal/engine/style/engine.go index 4719e92..ba397f4 100644 --- a/internal/engine/style/engine.go +++ b/internal/engine/style/engine.go @@ -6,7 +6,6 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/adapter/eslint" - "github.com/DevSymphony/sym-cli/internal/adapter/prettier" "github.com/DevSymphony/sym-cli/internal/engine/core" ) @@ -14,12 +13,10 @@ import ( // // Strategy: // - Validation: Use ESLint (indent, quotes, semi rules) -// - Autofix: Use Prettier (--write mode) -// - Prettier is opinionated, so we validate with ESLint to respect user config +// - Autofix is not supported (removed by design) type Engine struct { - eslint *eslint.Adapter - prettier *prettier.Adapter - config core.EngineConfig + eslint *eslint.Adapter + config core.EngineConfig } // NewEngine creates a new style engine. @@ -31,9 +28,8 @@ func NewEngine() *Engine { func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { e.config = config - // Initialize adapters + // Initialize ESLint adapter e.eslint = eslint.NewAdapter(config.ToolsDir, config.WorkDir) - e.prettier = prettier.NewAdapter(config.ToolsDir, config.WorkDir) // Check ESLint availability if err := e.eslint.CheckAvailability(ctx); err != nil { @@ -52,13 +48,6 @@ func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { } } - // Prettier is optional (can validate with ESLint only) - if err := e.prettier.CheckAvailability(ctx); err != nil { - if config.Debug { - fmt.Printf("Prettier not found, autofix will be limited\n") - } - } - return nil } @@ -110,13 +99,6 @@ func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) ( if rule.Message != "" { violations[i].Message = rule.Message } - - // Add autofix suggestion if Prettier available and remedy enabled - if rule.Remedy != nil && rule.Remedy.Autofix { - violations[i].Suggestion = &core.Suggestion{ - Desc: "Run prettier --write to auto-fix style issues", - } - } } return &core.ValidationResult{ @@ -135,7 +117,7 @@ func (e *Engine) GetCapabilities() core.EngineCapabilities { Name: "style", SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, SupportedCategories: []string{"style", "formatting"}, - SupportsAutofix: true, + SupportsAutofix: false, // Autofix removed by design RequiresCompilation: false, ExternalTools: []core.ToolRequirement{ { @@ -144,12 +126,6 @@ func (e *Engine) GetCapabilities() core.EngineCapabilities { Optional: false, InstallCommand: "npm install -g eslint", }, - { - Name: "prettier", - Version: "^3.0.0", - Optional: true, - InstallCommand: "npm install -g prettier", - }, }, } } @@ -159,20 +135,7 @@ func (e *Engine) Close() error { return nil } +// filterFiles filters files based on selector using proper glob matching. func (e *Engine) filterFiles(files []string, selector *core.Selector) []string { - if selector == nil { - return files - } - - var filtered []string - for _, file := range files { - if len(file) > 3 { - ext := file[len(file)-3:] - if ext == ".js" || ext == ".ts" || ext == "jsx" || ext == "tsx" { - filtered = append(filtered, file) - } - } - } - - return filtered + return core.FilterFiles(files, selector) } diff --git a/internal/engine/style/engine_test.go b/internal/engine/style/engine_test.go index 57e97d6..be76003 100644 --- a/internal/engine/style/engine_test.go +++ b/internal/engine/style/engine_test.go @@ -1,6 +1,7 @@ package style import ( + "context" "testing" "github.com/DevSymphony/sym-cli/internal/engine/core" @@ -29,8 +30,340 @@ func TestGetCapabilities(t *testing.T) { t.Error("Expected formatting in supported categories") } - if !caps.SupportsAutofix { - t.Error("Style engine should support autofix") + if caps.SupportsAutofix { + t.Error("Style engine should not support autofix (removed by design)") + } +} + +func TestInit(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: false, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init failed (expected if ESLint not available): %v", err) + } +} + +func TestClose(t *testing.T) { + engine := NewEngine() + if err := engine.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } +} + +func TestValidate_NoFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "style", + Severity: "error", + Check: map[string]interface{}{ + "engine": "style", + }, + } + + result, err := engine.Validate(ctx, rule, []string{}) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Error("Expected validation to pass for empty file list") + } +} + +func TestValidate_WithInitialization(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "style", + Severity: "error", + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_IndentRule(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-INDENT", + Category: "style", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "style", + "indent": 4, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_QuoteRule(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-QUOTE", + Category: "style", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "style", + "quote": "double", + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_SemiRule(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-SEMI", + Category: "style", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "style", + "semi": true, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithCustomMessage(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-CUSTOM", + Category: "style", + Severity: "warning", + Message: "Custom style violation", + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestInit_WithDebug(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: true, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init with debug failed (expected if ESLint not available): %v", err) + } + + if !engine.config.Debug { + t.Error("Expected debug to be true") + } +} + +func TestValidate_BraceStyleRule(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-BRACE", + Category: "style", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "style", + "brace_style": "1tbs", + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_MultipleRules(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - ESLint not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-MULTI", + Category: "style", + Severity: "warning", + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + "semi": true, + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + if result.Engine != "style" { + t.Errorf("Engine = %s, want style", result.Engine) + } } } @@ -55,9 +388,9 @@ func TestFilterFiles(t *testing.T) { want: files, }, { - name: "javascript only - selector ignored by style engine", + name: "javascript and typescript files", selector: &core.Selector{ - Languages: []string{"javascript"}, + Languages: []string{"javascript", "typescript"}, }, want: []string{"src/main.js", "src/app.ts", "test/test.js"}, }, diff --git a/internal/engine/typechecker/engine.go b/internal/engine/typechecker/engine.go new file mode 100644 index 0000000..3583d05 --- /dev/null +++ b/internal/engine/typechecker/engine.go @@ -0,0 +1,162 @@ +package typechecker + +import ( + "context" + "fmt" + "time" + + "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/adapter/tsc" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// Engine validates TypeScript/JavaScript type correctness using tsc. +// +// Strategy: +// - Uses TypeScript Compiler (tsc) for type checking +// - Supports strict mode and various compiler options +// - Works with TypeScript (.ts, .tsx) and optionally JavaScript (.js, .jsx) +type Engine struct { + tsc *tsc.Adapter + config core.EngineConfig +} + +// NewEngine creates a new type checker engine. +func NewEngine() *Engine { + return &Engine{} +} + +// Init initializes the engine. +func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { + e.config = config + + // Initialize tsc adapter + e.tsc = tsc.NewAdapter(config.ToolsDir, config.WorkDir) + + // Check tsc availability + if err := e.tsc.CheckAvailability(ctx); err != nil { + if config.Debug { + fmt.Printf("TSC not found, attempting install...\n") + } + + installConfig := adapter.InstallConfig{ + ToolsDir: config.ToolsDir, + } + + if err := e.tsc.Install(ctx, installConfig); err != nil { + return fmt.Errorf("failed to install TypeScript: %w", err) + } + } + + return nil +} + +// Validate validates files against type checking rules. +func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + start := time.Now() + + files = core.FilterFiles(files, rule.When) + if len(files) == 0 { + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Engine: "typechecker", + Duration: time.Since(start), + }, nil + } + + // Generate tsc config + tscConfig, err := e.tsc.GenerateConfig(&rule) + if err != nil { + return nil, fmt.Errorf("failed to generate tsc config: %w", err) + } + + // Execute tsc + output, err := e.tsc.Execute(ctx, tscConfig, files) + if err != nil { + return nil, fmt.Errorf("failed to execute tsc: %w", err) + } + + // Parse violations + adapterViolations, err := e.tsc.ParseOutput(output) + if err != nil { + return nil, fmt.Errorf("failed to parse tsc output: %w", err) + } + + // Convert to core violations + violations := make([]core.Violation, len(adapterViolations)) + for i, av := range adapterViolations { + violations[i] = core.Violation{ + File: av.File, + Line: av.Line, + Column: av.Column, + Message: av.Message, + Severity: av.Severity, + RuleID: av.RuleID, + Category: rule.Category, + } + + // Use custom message if provided + if rule.Message != "" { + violations[i].Message = rule.Message + } + } + + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: len(violations) == 0, + Violations: violations, + Duration: time.Since(start), + Engine: "typechecker", + Language: e.detectLanguage(files), + }, nil +} + +// GetCapabilities returns engine capabilities. +func (e *Engine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: "typechecker", + SupportedLanguages: []string{"typescript", "javascript", "tsx", "jsx"}, + SupportedCategories: []string{"type_safety", "correctness", "custom"}, + SupportsAutofix: false, + RequiresCompilation: false, + ExternalTools: []core.ToolRequirement{ + { + Name: "typescript", + Version: "^5.0.0", + Optional: false, + InstallCommand: "npm install -g typescript", + }, + }, + } +} + +// Close cleans up resources. +func (e *Engine) Close() error { + return nil +} + +// detectLanguage detects the language from file extensions. +func (e *Engine) detectLanguage(files []string) string { + if len(files) == 0 { + return "typescript" + } + + // Check first file + file := files[0] + if len(file) > 3 { + ext := file[len(file)-3:] + switch ext { + case ".ts": + return "typescript" + case ".js": + return "javascript" + case "jsx": + return "jsx" + case "tsx": + return "tsx" + } + } + + return "typescript" +} diff --git a/internal/engine/typechecker/engine_test.go b/internal/engine/typechecker/engine_test.go new file mode 100644 index 0000000..ac57622 --- /dev/null +++ b/internal/engine/typechecker/engine_test.go @@ -0,0 +1,374 @@ +package typechecker + +import ( + "context" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestNewEngine(t *testing.T) { + engine := NewEngine() + if engine == nil { + t.Fatal("NewEngine() returned nil") + } +} + +func TestGetCapabilities(t *testing.T) { + engine := NewEngine() + caps := engine.GetCapabilities() + + if caps.Name != "typechecker" { + t.Errorf("Name = %s, want typechecker", caps.Name) + } + + if !contains(caps.SupportedLanguages, "typescript") { + t.Error("Expected typescript in supported languages") + } + + if !contains(caps.SupportedLanguages, "javascript") { + t.Error("Expected javascript in supported languages") + } + + if !contains(caps.SupportedCategories, "type_safety") { + t.Error("Expected type_safety in supported categories") + } + + if caps.SupportsAutofix { + t.Error("Type checker engine should not support autofix") + } + + // Verify external tools + if len(caps.ExternalTools) == 0 { + t.Error("Expected external tools to be listed") + } + + foundTSC := false + for _, tool := range caps.ExternalTools { + if tool.Name == "typescript" { + foundTSC = true + if tool.Optional { + t.Error("TypeScript should not be optional") + } + } + } + + if !foundTSC { + t.Error("Expected typescript in external tools") + } +} + +func TestDetectLanguage(t *testing.T) { + engine := &Engine{} + + tests := []struct { + name string + files []string + want string + }{ + { + name: "typescript file", + files: []string{"src/main.ts"}, + want: "typescript", + }, + { + name: "javascript file", + files: []string{"src/main.js"}, + want: "javascript", + }, + { + name: "jsx file", + files: []string{"src/Component.jsx"}, + want: "jsx", + }, + { + name: "tsx file", + files: []string{"src/Component.tsx"}, + want: "tsx", + }, + { + name: "empty files", + files: []string{}, + want: "typescript", // default + }, + { + name: "multiple files - first determines language", + files: []string{"src/main.js", "src/app.ts"}, + want: "javascript", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := engine.detectLanguage(tt.files) + if got != tt.want { + t.Errorf("detectLanguage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClose(t *testing.T) { + engine := NewEngine() + if err := engine.Close(); err != nil { + t.Errorf("Close() error = %v, want nil", err) + } +} + +func TestInit(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: false, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init failed (expected if TSC not available): %v", err) + } +} + +func TestInit_WithDebug(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + Debug: true, + } + + err := engine.Init(ctx, config) + if err != nil { + t.Logf("Init with debug failed (expected if TSC not available): %v", err) + } + + if !engine.config.Debug { + t.Error("Expected debug to be true") + } +} + +func TestValidate_NoFiles(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "type_safety", + Severity: "error", + Check: map[string]interface{}{ + "engine": "typechecker", + }, + } + + result, err := engine.Validate(ctx, rule, []string{}) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Error("Expected validation to pass for empty file list") + } +} + +func TestValidate_WithInitialization(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - TSC not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-RULE", + Category: "type_safety", + Severity: "error", + Check: map[string]interface{}{ + "engine": "typechecker", + "strict": true, + }, + } + + testFile := t.TempDir() + "/test.ts" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithStrictMode(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - TSC not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-STRICT", + Category: "type_safety", + Severity: "error", + Check: map[string]interface{}{ + "engine": "typechecker", + "strict": true, + }, + } + + testFile := t.TempDir() + "/test.ts" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_WithCustomMessage(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - TSC not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-CUSTOM", + Category: "type_safety", + Severity: "error", + Message: "Custom type error", + Check: map[string]interface{}{ + "engine": "typechecker", + }, + } + + testFile := t.TempDir() + "/test.ts" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +func TestValidate_JavaScriptFile(t *testing.T) { + engine := NewEngine() + ctx := context.Background() + + config := core.EngineConfig{ + ToolsDir: t.TempDir(), + WorkDir: t.TempDir(), + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test - TSC not available: %v", err) + } + + rule := core.Rule{ + ID: "TEST-JS", + Category: "type_safety", + Severity: "error", + Check: map[string]interface{}{ + "engine": "typechecker", + }, + } + + testFile := t.TempDir() + "/test.js" + result, err := engine.Validate(ctx, rule, []string{testFile}) + + if err != nil { + t.Logf("Validate returned error (may be expected): %v", err) + } + + if result != nil { + if result.RuleID != rule.ID { + t.Errorf("RuleID = %s, want %s", result.RuleID, rule.ID) + } + } +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// Integration-like test (but mocked to avoid requiring tsc installation) +func TestValidate_FileFiltering(t *testing.T) { + files := []string{ + "src/main.ts", + "src/util.js", + "test/main_test.ts", + "README.md", + } + + rule := core.Rule{ + ID: "TYPE-CHECK", + Category: "type_safety", + Severity: "error", + When: &core.Selector{ + Languages: []string{"typescript"}, + Exclude: []string{"**/*_test.ts"}, + }, + Check: map[string]interface{}{ + "engine": "typechecker", + }, + } + + // Filter files using the selector (same logic as in engine) + filtered := core.FilterFiles(files, rule.When) + + // Should only include .ts files, excluding test files + expected := []string{"src/main.ts"} + + if len(filtered) != len(expected) { + t.Errorf("Filtered %d files, want %d", len(filtered), len(expected)) + t.Errorf("Got: %v", filtered) + t.Errorf("Want: %v", expected) + } + + for i, file := range filtered { + if i < len(expected) && file != expected[i] { + t.Errorf("Filtered[%d] = %q, want %q", i, file, expected[i]) + } + } +} diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..a30fe25 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,65 @@ +# Testdata Directory + +This directory contains test files for integration and unit tests. + +## Structure + +``` +testdata/ +├── javascript/ # JavaScript test files +├── typescript/ # TypeScript test files +└── mixed/ # JSX/TSX test files +``` + +## JavaScript Test Files + +### Naming Violations +- `naming-violations.js` - Contains snake_case, lowercase class names +- `bad-naming.js` - Additional naming convention violations + +### Style Violations +- `style-violations.js` - Indentation, quote, semicolon issues +- `bad-style.js` - Additional style violations + +### Length Violations +- `length-violations.js` - General length violations +- `long-lines.js` - Lines exceeding max length +- `long-function.js` - Functions exceeding max lines +- `long-file.js` - Files exceeding max lines +- `many-params.js` - Functions with too many parameters + +### Security Violations +- `security-violations.js` - Hardcoded credentials +- `hardcoded-secrets.js` - API keys, passwords + +### AST/Error Handling +- `async-with-try.js` - Async code with proper try/catch +- `async-without-try.js` - Async code without error handling + +### Import Violations +- `bad-imports.js` - Restricted import patterns + +### Valid Code +- `valid.js` - Well-formatted, compliant code +- `good-code.js` - Additional valid examples +- `good-style.js` - Properly styled code + +## TypeScript Test Files + +- `type-errors.ts` - Type assignment errors +- `strict-mode-errors.ts` - Strict mode violations +- `valid.ts` - Valid TypeScript code + +## Mixed Files + +- `component.jsx` - React JSX component +- `component.tsx` - React TypeScript component + +## Usage + +Integration tests reference these files via: +```go +filepath.Join(workDir, "testdata/javascript/naming-violations.js") +``` + +Where `workDir` is the project root directory. diff --git a/tests/testdata/javascript/async-with-try.js b/testdata/javascript/async-with-try.js similarity index 100% rename from tests/testdata/javascript/async-with-try.js rename to testdata/javascript/async-with-try.js diff --git a/tests/testdata/javascript/async-without-try.js b/testdata/javascript/async-without-try.js similarity index 100% rename from tests/testdata/javascript/async-without-try.js rename to testdata/javascript/async-without-try.js diff --git a/tests/testdata/javascript/bad-imports.js b/testdata/javascript/bad-imports.js similarity index 100% rename from tests/testdata/javascript/bad-imports.js rename to testdata/javascript/bad-imports.js diff --git a/tests/testdata/javascript/bad-naming.js b/testdata/javascript/bad-naming.js similarity index 100% rename from tests/testdata/javascript/bad-naming.js rename to testdata/javascript/bad-naming.js diff --git a/tests/testdata/javascript/bad-style.js b/testdata/javascript/bad-style.js similarity index 100% rename from tests/testdata/javascript/bad-style.js rename to testdata/javascript/bad-style.js diff --git a/tests/testdata/javascript/good-code.js b/testdata/javascript/good-code.js similarity index 100% rename from tests/testdata/javascript/good-code.js rename to testdata/javascript/good-code.js diff --git a/tests/testdata/javascript/good-style.js b/testdata/javascript/good-style.js similarity index 100% rename from tests/testdata/javascript/good-style.js rename to testdata/javascript/good-style.js diff --git a/tests/testdata/javascript/hardcoded-secrets.js b/testdata/javascript/hardcoded-secrets.js similarity index 100% rename from tests/testdata/javascript/hardcoded-secrets.js rename to testdata/javascript/hardcoded-secrets.js diff --git a/testdata/javascript/length-violations.js b/testdata/javascript/length-violations.js new file mode 100644 index 0000000..0ef2874 --- /dev/null +++ b/testdata/javascript/length-violations.js @@ -0,0 +1,65 @@ +// File with length violations + +// Bad: line too long (over 100 characters) +const reallyLongVariableNameThatExceedsTheMaximumLineLengthSetByOurLinterConfigurationAndShouldBeReported = 'test'; + +// Bad: function with too many parameters (over 4) +function tooManyParameters(param1, param2, param3, param4, param5, param6) { + return param1 + param2 + param3 + param4 + param5 + param6; +} + +// Bad: function with too many lines (over 50) +function veryLongFunction() { + const line1 = 1; + const line2 = 2; + const line3 = 3; + const line4 = 4; + const line5 = 5; + const line6 = 6; + const line7 = 7; + const line8 = 8; + const line9 = 9; + const line10 = 10; + const line11 = 11; + const line12 = 12; + const line13 = 13; + const line14 = 14; + const line15 = 15; + const line16 = 16; + const line17 = 17; + const line18 = 18; + const line19 = 19; + const line20 = 20; + const line21 = 21; + const line22 = 22; + const line23 = 23; + const line24 = 24; + const line25 = 25; + const line26 = 26; + const line27 = 27; + const line28 = 28; + const line29 = 29; + const line30 = 30; + const line31 = 31; + const line32 = 32; + const line33 = 33; + const line34 = 34; + const line35 = 35; + const line36 = 36; + const line37 = 37; + const line38 = 38; + const line39 = 39; + const line40 = 40; + const line41 = 41; + const line42 = 42; + const line43 = 43; + const line44 = 44; + const line45 = 45; + const line46 = 46; + const line47 = 47; + const line48 = 48; + const line49 = 49; + const line50 = 50; + const line51 = 51; + return line51; +} diff --git a/tests/testdata/javascript/long-file.js b/testdata/javascript/long-file.js similarity index 100% rename from tests/testdata/javascript/long-file.js rename to testdata/javascript/long-file.js diff --git a/tests/testdata/javascript/long-function.js b/testdata/javascript/long-function.js similarity index 100% rename from tests/testdata/javascript/long-function.js rename to testdata/javascript/long-function.js diff --git a/tests/testdata/javascript/long-lines.js b/testdata/javascript/long-lines.js similarity index 100% rename from tests/testdata/javascript/long-lines.js rename to testdata/javascript/long-lines.js diff --git a/tests/testdata/javascript/many-params.js b/testdata/javascript/many-params.js similarity index 100% rename from tests/testdata/javascript/many-params.js rename to testdata/javascript/many-params.js diff --git a/testdata/javascript/naming-violations.js b/testdata/javascript/naming-violations.js new file mode 100644 index 0000000..196df75 --- /dev/null +++ b/testdata/javascript/naming-violations.js @@ -0,0 +1,29 @@ +// File with naming convention violations + +// Bad: snake_case function name (should be camelCase) +function bad_function_name() { + return 'test'; +} + +// Bad: lowercase class name (should be PascalCase) +class lowercase_class { + constructor() { + this.value = 0; + } +} + +// Bad: variable with uppercase +var BAD_VARIABLE = 'test'; + +// Good examples for comparison +function goodFunctionName() { + return 'test'; +} + +class GoodClassName { + constructor() { + this.value = 0; + } +} + +const goodVariable = 'test'; diff --git a/testdata/javascript/security-violations.js b/testdata/javascript/security-violations.js new file mode 100644 index 0000000..414ced5 --- /dev/null +++ b/testdata/javascript/security-violations.js @@ -0,0 +1,18 @@ +// File with security violations + +// Bad: hardcoded API key +const API_KEY = 'sk-1234567890abcdef1234567890abcdef'; + +// Bad: hardcoded password +const password = 'mySecretPassword123'; + +// Bad: hardcoded secret +const client_secret = 'super-secret-value-12345'; + +// Bad: hardcoded token +const access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + +// Good: using environment variables +const apiKey = process.env.API_KEY; +const userPassword = process.env.PASSWORD; +const clientSecret = process.env.CLIENT_SECRET; diff --git a/testdata/javascript/style-violations.js b/testdata/javascript/style-violations.js new file mode 100644 index 0000000..73ab57c --- /dev/null +++ b/testdata/javascript/style-violations.js @@ -0,0 +1,31 @@ +// File with style violations + +// Bad: inconsistent indentation +function badIndentation() { +const x = 1; + const y = 2; + const z = 3; + return x + y + z; +} + +// Bad: double quotes (should be single) +const message = "Hello World"; + +// Bad: missing semicolons +const a = 1 +const b = 2 +const c = 3 + +// Bad: long line exceeding 100 characters +const veryLongLineHereThisIsWayTooLongAndShouldBeReportedByTheLinterAsAViolationOfTheLineLength = true; + +// Bad: multiple statements on one line +const x = 1; const y = 2; const z = 3; + +// Good examples +function goodIndentation() { + const x = 1; + const y = 2; + const z = 3; + return x + y + z; +} diff --git a/testdata/javascript/valid.js b/testdata/javascript/valid.js new file mode 100644 index 0000000..edebf0f --- /dev/null +++ b/testdata/javascript/valid.js @@ -0,0 +1,28 @@ +// Valid JavaScript file for testing + +class Calculator { + constructor() { + this.result = 0; + } + + add(a, b) { + return a + b; + } + + subtract(a, b) { + return a - b; + } + + multiply(a, b) { + return a * b; + } + + divide(a, b) { + if (b === 0) { + throw new Error('Division by zero'); + } + return a / b; + } +} + +module.exports = Calculator; diff --git a/testdata/mixed/component.jsx b/testdata/mixed/component.jsx new file mode 100644 index 0000000..ba795ec --- /dev/null +++ b/testdata/mixed/component.jsx @@ -0,0 +1,36 @@ +// React component for testing JSX + +import React from 'react'; + +class Button extends React.Component { + constructor(props) { + super(props); + this.state = { + clicked: false + }; + } + + handleClick = () => { + this.setState({ clicked: true }); + if (this.props.onClick) { + this.props.onClick(); + } + } + + render() { + const { label, disabled } = this.props; + const { clicked } = this.state; + + return ( + + ); + } +} + +export default Button; diff --git a/testdata/mixed/component.tsx b/testdata/mixed/component.tsx new file mode 100644 index 0000000..e6cb76e --- /dev/null +++ b/testdata/mixed/component.tsx @@ -0,0 +1,46 @@ +// TypeScript React component for testing TSX + +import React, { Component } from 'react'; + +interface ButtonProps { + label: string; + onClick?: () => void; + disabled?: boolean; +} + +interface ButtonState { + clicked: boolean; +} + +class Button extends Component { + constructor(props: ButtonProps) { + super(props); + this.state = { + clicked: false + }; + } + + handleClick = (): void => { + this.setState({ clicked: true }); + if (this.props.onClick) { + this.props.onClick(); + } + } + + render() { + const { label, disabled } = this.props; + const { clicked } = this.state; + + return ( + + ); + } +} + +export default Button; diff --git a/testdata/typescript/strict-mode-errors.ts b/testdata/typescript/strict-mode-errors.ts new file mode 100644 index 0000000..e650f08 --- /dev/null +++ b/testdata/typescript/strict-mode-errors.ts @@ -0,0 +1,29 @@ +// File with strict mode violations + +// Error: Variable 'x' implicitly has an 'any' type +let x; +x = 10; +x = 'string'; + +// Error: Parameter 'input' implicitly has an 'any' type +function processInput(input) { + return input.toUpperCase(); +} + +// Error: Function lacks return type annotation +function calculate(a: number, b: number) { + return a + b; +} + +// Error: Object is possibly 'undefined' +function getUserName(user: { name?: string }) { + return user.name.toUpperCase(); // name might be undefined +} + +// Error: Not all code paths return a value +function getValue(condition: boolean): string { + if (condition) { + return 'yes'; + } + // Missing return for false case +} diff --git a/testdata/typescript/type-errors.ts b/testdata/typescript/type-errors.ts new file mode 100644 index 0000000..4ee1a68 --- /dev/null +++ b/testdata/typescript/type-errors.ts @@ -0,0 +1,41 @@ +// File with TypeScript type errors + +interface Person { + name: string; + age: number; +} + +// Error: Type 'string' is not assignable to type 'number' +const person: Person = { + name: 'John', + age: 'thirty' // Should be number +}; + +// Error: Property 'email' does not exist on type 'Person' +function printEmail(p: Person) { + console.log(p.email); // email doesn't exist +} + +// Error: Cannot find name 'undefinedVariable' +const result = undefinedVariable + 10; + +// Error: Argument of type 'number' is not assignable to parameter of type 'string' +function greet(name: string): string { + return `Hello, ${name}`; +} +greet(123); // Should be string + +// Error: Object is possibly 'null' +function getLength(str: string | null) { + return str.length; // str might be null +} + +// Error: 'this' implicitly has type 'any' +const obj = { + value: 10, + getValue: function() { + return function() { + return this.value; // 'this' has wrong context + }; + } +}; diff --git a/testdata/typescript/valid.ts b/testdata/typescript/valid.ts new file mode 100644 index 0000000..02eb210 --- /dev/null +++ b/testdata/typescript/valid.ts @@ -0,0 +1,34 @@ +// Valid TypeScript file for testing + +interface User { + id: number; + name: string; + email: string; +} + +class UserService { + private users: User[] = []; + + addUser(user: User): void { + this.users.push(user); + } + + getUser(id: number): User | undefined { + return this.users.find(u => u.id === id); + } + + getAllUsers(): User[] { + return [...this.users]; + } + + removeUser(id: number): boolean { + const index = this.users.findIndex(u => u.id === id); + if (index !== -1) { + this.users.splice(index, 1); + return true; + } + return false; + } +} + +export { User, UserService }; diff --git a/tests/integration/ast_integration_test.go b/tests/integration/ast_integration_test.go new file mode 100644 index 0000000..5c42bcd --- /dev/null +++ b/tests/integration/ast_integration_test.go @@ -0,0 +1,115 @@ +package integration + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/ast" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestASTEngine_CallExpression_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := ast.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: detect console.log calls + rule := core.Rule{ + ID: "AST-NO-CONSOLE-LOG", + Category: "custom", + Severity: "warning", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "ast", + "language": "javascript", + "node": "CallExpression", + "where": map[string]interface{}{ + "callee.object.name": "console", + "callee.property.name": "log", + }, + }, + Message: "Avoid using console.log in production code", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/valid.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + t.Logf("AST validation result: passed=%v, violations=%d", result.Passed, len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestASTEngine_ClassDeclaration_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := ast.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: detect class declarations + rule := core.Rule{ + ID: "AST-CLASS-EXISTS", + Category: "custom", + Severity: "info", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "ast", + "language": "javascript", + "node": "ClassDeclaration", + }, + Message: "Class declaration found", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/valid.js"), + filepath.Join(workDir, "testdata/javascript/naming-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + // Should find class declarations in both files + t.Logf("Found %d class declarations", len(result.Violations)) +} diff --git a/tests/integration/ast_test.go b/tests/integration/ast_test.go deleted file mode 100644 index 26ebea0..0000000 --- a/tests/integration/ast_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package integration - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/DevSymphony/sym-cli/internal/engine/ast" - "github.com/DevSymphony/sym-cli/internal/engine/core" -) - -func TestASTEngine_AsyncWithTry(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := ast.NewEngine() - - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Timeout: 30 * time.Second, - Debug: true, - } - - ctx := context.Background() - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint init failed: %v", err) - } - defer engine.Close() - - // Rule: Async functions must have try-catch - rule := core.Rule{ - ID: "AST-ASYNC-TRY", - Category: "error_handling", - Severity: "error", - Check: map[string]interface{}{ - "engine": "ast", - "node": "FunctionDeclaration", - "where": map[string]interface{}{ - "async": true, - }, - "has": []interface{}{"TryStatement"}, - }, - Message: "Async functions must use try-catch for error handling", - } - - // Test bad file (async without try-catch) - badFile := filepath.Join("testdata", "javascript", "async-without-try.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - if err != nil { - t.Fatalf("Validation failed: %v", err) - } - - t.Logf("Bad file result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - - if result.Passed { - t.Error("Expected validation to fail for async functions without try-catch") - } - - if len(result.Violations) == 0 { - t.Error("Expected violations for async functions without try-catch") - } - - // Should find 3 violations (3 async functions without try-catch) - if len(result.Violations) < 3 { - t.Errorf("Expected at least 3 violations, got %d", len(result.Violations)) - } - - // Verify violation details - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - if v.Severity != "error" { - t.Errorf("Violation severity = %s, want error", v.Severity) - } - } -} - -func TestASTEngine_AsyncWithTryGood(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := ast.NewEngine() - - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Timeout: 30 * time.Second, - Debug: true, - } - - ctx := context.Background() - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint init failed: %v", err) - } - defer engine.Close() - - rule := core.Rule{ - ID: "AST-ASYNC-TRY", - Category: "error_handling", - Severity: "error", - Check: map[string]interface{}{ - "engine": "ast", - "node": "FunctionDeclaration", - "where": map[string]interface{}{ - "async": true, - }, - "has": []interface{}{"TryStatement"}, - }, - Message: "Async functions must use try-catch for error handling", - } - - // Test good file (async with try-catch) - goodFile := filepath.Join("testdata", "javascript", "async-with-try.js") - result, err := engine.Validate(ctx, rule, []string{goodFile}) - if err != nil { - t.Fatalf("Validation failed: %v", err) - } - - t.Logf("Good file result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - - if !result.Passed { - t.Errorf("Expected validation to pass for async functions with try-catch") - for i, v := range result.Violations { - t.Logf("Unexpected violation %d: %s", i+1, v.String()) - } - } - - if len(result.Violations) > 0 { - t.Errorf("Expected no violations, got %d", len(result.Violations)) - } -} diff --git a/tests/integration/helper.go b/tests/integration/helper.go new file mode 100644 index 0000000..6fe22a4 --- /dev/null +++ b/tests/integration/helper.go @@ -0,0 +1,35 @@ +package integration + +import ( + "os" + "path/filepath" + "testing" +) + +// getTestdataDir returns the path to the testdata directory +func getTestdataDir(t *testing.T) string { + t.Helper() + + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + // Go up two levels from tests/integration to project root + projectRoot := filepath.Join(cwd, "../..") + + return projectRoot +} + +// getToolsDir returns the path to tools directory for test +func getToolsDir(t *testing.T) string { + t.Helper() + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("Failed to get home directory: %v", err) + } + + return filepath.Join(home, ".symphony", "tools") +} diff --git a/tests/integration/length_integration_test.go b/tests/integration/length_integration_test.go new file mode 100644 index 0000000..96ab1c9 --- /dev/null +++ b/tests/integration/length_integration_test.go @@ -0,0 +1,180 @@ +package integration + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/engine/length" +) + +func TestLengthEngine_LineLengthViolations_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := length.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: max line length 100 + rule := core.Rule{ + ID: "FMT-LINE-100", + Category: "formatting", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "length", + "scope": "line", + "max": 100, + }, + Message: "Line length must not exceed 100 characters", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/length-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for line length violations") + } + + if len(result.Violations) == 0 { + t.Error("Expected violations to be detected") + } + + t.Logf("Found %d line length violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestLengthEngine_MaxParams_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := length.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: max 4 parameters + rule := core.Rule{ + ID: "FUNC-MAX-PARAMS", + Category: "formatting", + Severity: "warning", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "length", + "scope": "params", + "max": 4, + }, + Message: "Functions should have at most 4 parameters", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/length-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for too many parameters") + } + + if len(result.Violations) == 0 { + t.Error("Expected violations to be detected") + } + + t.Logf("Found %d parameter violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestLengthEngine_ValidFile_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := length.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: max line length 100 + rule := core.Rule{ + ID: "FMT-LINE-100", + Category: "formatting", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "length", + "scope": "line", + "max": 100, + }, + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/valid.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Errorf("Expected validation to pass for valid file, got %d violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } + } +} diff --git a/tests/integration/length_test.go b/tests/integration/length_test.go deleted file mode 100644 index 07237aa..0000000 --- a/tests/integration/length_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package integration - -import ( - "context" - "path/filepath" - "testing" - - "github.com/DevSymphony/sym-cli/internal/engine/core" - "github.com/DevSymphony/sym-cli/internal/engine/length" -) - -func TestLengthEngine_LineScope(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := length.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Max 80 characters per line - rule := core.Rule{ - ID: "FMT-LINE-80", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "length", - "scope": "line", - "max": 80, - }, - Message: "Line exceeds 80 characters", - } - - badFile := filepath.Join("testdata", "javascript", "long-lines.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for long lines") - } - - // Should find at least 2 long lines - if len(result.Violations) < 2 { - t.Errorf("Expected at least 2 violations, got %d", len(result.Violations)) - } -} - -func TestLengthEngine_FileScope(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := length.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Max 50 lines per file - rule := core.Rule{ - ID: "FMT-FILE-50", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "length", - "scope": "file", - "max": 50, - }, - Message: "File exceeds 50 lines", - } - - badFile := filepath.Join("testdata", "javascript", "long-file.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for long file") - } - - if len(result.Violations) == 0 { - t.Error("Expected violations for long file") - } -} - -func TestLengthEngine_FunctionScope(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := length.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Max 30 lines per function - rule := core.Rule{ - ID: "FMT-FUNC-30", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "length", - "scope": "function", - "max": 30, - }, - Message: "Function exceeds 30 lines", - } - - badFile := filepath.Join("testdata", "javascript", "long-function.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for long function") - } - - if len(result.Violations) == 0 { - t.Error("Expected violations for long function") - } -} - -func TestLengthEngine_ParamsScope(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := length.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Max 4 parameters per function - rule := core.Rule{ - ID: "FMT-PARAMS-4", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "length", - "scope": "params", - "max": 4, - }, - Message: "Function has too many parameters (max 4)", - } - - badFile := filepath.Join("testdata", "javascript", "many-params.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for too many params") - } - - // Should find 2 functions with too many params - if len(result.Violations) < 2 { - t.Errorf("Expected at least 2 violations, got %d", len(result.Violations)) - } -} diff --git a/tests/integration/pattern_integration_test.go b/tests/integration/pattern_integration_test.go new file mode 100644 index 0000000..fbe334b --- /dev/null +++ b/tests/integration/pattern_integration_test.go @@ -0,0 +1,182 @@ +package integration + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/engine/pattern" +) + +func TestPatternEngine_NamingViolations_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := pattern.NewEngine() + ctx := context.Background() + + // Initialize engine + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: class names must be PascalCase + rule := core.Rule{ + ID: "NAMING-CLASS-PASCAL", + Category: "naming", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z][a-zA-Z0-9]*$", + }, + Message: "Class names must be PascalCase", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/naming-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for naming violations") + } + + if len(result.Violations) == 0 { + t.Error("Expected violations to be detected") + } + + t.Logf("Found %d violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestPatternEngine_SecurityViolations_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := pattern.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: no hardcoded secrets + rule := core.Rule{ + ID: "SEC-NO-SECRETS", + Category: "security", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "pattern", + "target": "content", + "pattern": "(api[_-]?key|password|secret|token)\\s*=\\s*['\"][^'\"]+['\"]", + "flags": "i", + }, + Message: "No hardcoded secrets allowed", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/security-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for security violations") + } + + if len(result.Violations) == 0 { + t.Error("Expected violations to be detected") + } + + t.Logf("Found %d security violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestPatternEngine_ValidFile_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := pattern.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: class names must be PascalCase + rule := core.Rule{ + ID: "NAMING-CLASS-PASCAL", + Category: "naming", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z][a-zA-Z0-9]*$", + }, + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/valid.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Errorf("Expected validation to pass for valid file, got %d violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } + } +} diff --git a/tests/integration/pattern_test.go b/tests/integration/pattern_test.go deleted file mode 100644 index 993cf29..0000000 --- a/tests/integration/pattern_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package integration - -import ( - "context" - "path/filepath" - "testing" - - "github.com/DevSymphony/sym-cli/internal/engine/core" - "github.com/DevSymphony/sym-cli/internal/engine/pattern" -) - -func TestPatternEngine_BadNaming(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - // Create engine - engine := pattern.NewEngine() - - // Init (this will try to install ESLint if not found) - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Define rule: class names must be PascalCase - rule := core.Rule{ - ID: "NAMING-CLASS-PASCAL", - Category: "naming", - Severity: "error", - Check: map[string]interface{}{ - "engine": "pattern", - "target": "identifier", - "pattern": "^[A-Z][a-zA-Z0-9]*$", - }, - Message: "Class names must be PascalCase", - } - - // Validate bad file - badFile := filepath.Join("testdata", "javascript", "bad-naming.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: %+v", result) - - // Note: This test requires ESLint to be installed - // If not installed, Init will try to install it - // In CI, we should pre-install ESLint -} - -func TestPatternEngine_GoodCode(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := pattern.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - rule := core.Rule{ - ID: "NAMING-CLASS-PASCAL", - Category: "naming", - Severity: "error", - Check: map[string]interface{}{ - "engine": "pattern", - "target": "identifier", - "pattern": "^[A-Z][a-zA-Z0-9]*$", - }, - } - - goodFile := filepath.Join("testdata", "javascript", "good-code.js") - result, err := engine.Validate(ctx, rule, []string{goodFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - // Good file should pass (though ESLint id-match might still flag some things) - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) -} - -func TestPatternEngine_ContentPattern_Secrets(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := pattern.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Detect hardcoded secrets - rule := core.Rule{ - ID: "SEC-NO-HARDCODED-SECRETS", - Category: "security", - Severity: "error", - Check: map[string]interface{}{ - "engine": "pattern", - "target": "content", - "pattern": "(api[_-]?key|secret|password).*=.*[\"'][^\"']+[\"']", - }, - Message: "Hardcoded secrets detected", - } - - badFile := filepath.Join("testdata", "javascript", "hardcoded-secrets.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for hardcoded secrets") - } - - if len(result.Violations) == 0 { - t.Error("Expected violations for hardcoded secrets") - } -} - -func TestPatternEngine_ImportPattern_RestrictedModules(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := pattern.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: Restrict lodash imports - rule := core.Rule{ - ID: "DEP-NO-LODASH", - Category: "dependency", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "pattern", - "target": "import", - "pattern": "lodash", - }, - Message: "Lodash imports are restricted, use native alternatives", - } - - badFile := filepath.Join("testdata", "javascript", "bad-imports.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for lodash imports") - } - - // Should find at least 2 lodash imports - if len(result.Violations) < 2 { - t.Errorf("Expected at least 2 violations, got %d", len(result.Violations)) - } -} diff --git a/tests/integration/style_integration_test.go b/tests/integration/style_integration_test.go new file mode 100644 index 0000000..94cc44e --- /dev/null +++ b/tests/integration/style_integration_test.go @@ -0,0 +1,123 @@ +package integration + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/engine/style" +) + +func TestStyleEngine_IndentViolations_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := style.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: indent with 2 spaces + rule := core.Rule{ + ID: "STYLE-INDENT-2", + Category: "style", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + "semi": true, + }, + Message: "Code must use 2-space indentation", + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/style-violations.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for style violations") + } + + if len(result.Violations) == 0 { + t.Error("Expected violations to be detected") + } + + t.Logf("Found %d style violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestStyleEngine_ValidFile_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := style.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: ESLint not available: %v", err) + } + defer engine.Close() + + // Test rule: indent with 2 spaces + rule := core.Rule{ + ID: "STYLE-INDENT-2", + Category: "style", + Severity: "error", + When: &core.Selector{ + Languages: []string{"javascript"}, + }, + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + "semi": true, + }, + } + + files := []string{ + filepath.Join(workDir, "testdata/javascript/valid.js"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Errorf("Expected validation to pass for valid file, got %d violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } + } +} diff --git a/tests/integration/style_test.go b/tests/integration/style_test.go deleted file mode 100644 index e622042..0000000 --- a/tests/integration/style_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package integration - -import ( - "context" - "path/filepath" - "testing" - - "github.com/DevSymphony/sym-cli/internal/engine/core" - "github.com/DevSymphony/sym-cli/internal/engine/style" -) - -func TestStyleEngine_Validation(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := style.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("Prettier/ESLint not available: %v", err) - } - defer engine.Close() - - // Rule: 2-space indentation, single quotes, semicolons - rule := core.Rule{ - ID: "STYLE-STANDARD", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "style", - "indent": 2, - "quote": "single", - "semi": true, - }, - Message: "Code style violations detected", - } - - badFile := filepath.Join("testdata", "javascript", "bad-style.js") - result, err := engine.Validate(ctx, rule, []string{badFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - - if result.Passed { - t.Error("Expected validation to fail for bad style") - } - - if len(result.Violations) == 0 { - t.Error("Expected violations for style issues") - } -} - -func TestStyleEngine_GoodStyle(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - engine := style.NewEngine() - ctx := context.Background() - config := core.EngineConfig{ - WorkDir: "../../tests/testdata/javascript", - Debug: true, - } - - if err := engine.Init(ctx, config); err != nil { - t.Skipf("Prettier/ESLint not available: %v", err) - } - defer engine.Close() - - rule := core.Rule{ - ID: "STYLE-STANDARD", - Category: "formatting", - Severity: "warning", - Check: map[string]interface{}{ - "engine": "style", - "indent": 2, - "quote": "single", - "semi": true, - }, - Message: "Code style violations detected", - } - - goodFile := filepath.Join("testdata", "javascript", "good-style.js") - result, err := engine.Validate(ctx, rule, []string{goodFile}) - - if err != nil { - t.Fatalf("Validate failed: %v", err) - } - - t.Logf("Result: passed=%v, violations=%d", result.Passed, len(result.Violations)) - - // Good file should have minimal or no violations - if len(result.Violations) > 5 { - t.Errorf("Expected few violations for good style file, got %d", len(result.Violations)) - for i, v := range result.Violations { - t.Logf("Violation %d: %s", i+1, v.String()) - } - } -} diff --git a/tests/integration/typechecker_integration_test.go b/tests/integration/typechecker_integration_test.go new file mode 100644 index 0000000..dbb746f --- /dev/null +++ b/tests/integration/typechecker_integration_test.go @@ -0,0 +1,179 @@ +package integration + +import ( + "context" + "path/filepath" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/engine/typechecker" +) + +func TestTypeChecker_TypeErrors_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := typechecker.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: TypeScript not available: %v", err) + } + defer engine.Close() + + // Test rule: type checking with strict mode + rule := core.Rule{ + ID: "TYPE-CHECK-STRICT", + Category: "type_safety", + Severity: "error", + When: &core.Selector{ + Languages: []string{"typescript"}, + }, + Check: map[string]interface{}{ + "engine": "typechecker", + "strict": true, + }, + Message: "TypeScript type errors detected", + } + + files := []string{ + filepath.Join(workDir, "testdata/typescript/type-errors.ts"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for type errors") + } + + if len(result.Violations) == 0 { + t.Error("Expected type errors to be detected") + } + + t.Logf("Found %d type errors", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestTypeChecker_StrictModeErrors_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := typechecker.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: TypeScript not available: %v", err) + } + defer engine.Close() + + // Test rule: strict mode violations + rule := core.Rule{ + ID: "TYPE-STRICT-MODE", + Category: "type_safety", + Severity: "error", + When: &core.Selector{ + Languages: []string{"typescript"}, + }, + Check: map[string]interface{}{ + "engine": "typechecker", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + }, + Message: "Strict mode violations detected", + } + + files := []string{ + filepath.Join(workDir, "testdata/typescript/strict-mode-errors.ts"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if result.Passed { + t.Error("Expected validation to fail for strict mode violations") + } + + if len(result.Violations) == 0 { + t.Error("Expected strict mode violations to be detected") + } + + t.Logf("Found %d strict mode violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } +} + +func TestTypeChecker_ValidFile_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + engine := typechecker.NewEngine() + ctx := context.Background() + + workDir := getTestdataDir(t) + config := core.EngineConfig{ + ToolsDir: getToolsDir(t), + WorkDir: workDir, + Debug: true, + } + + if err := engine.Init(ctx, config); err != nil { + t.Skipf("Skipping test: TypeScript not available: %v", err) + } + defer engine.Close() + + // Test rule: type checking + rule := core.Rule{ + ID: "TYPE-CHECK", + Category: "type_safety", + Severity: "error", + When: &core.Selector{ + Languages: []string{"typescript"}, + }, + Check: map[string]interface{}{ + "engine": "typechecker", + "strict": true, + }, + } + + files := []string{ + filepath.Join(workDir, "testdata/typescript/valid.ts"), + } + + result, err := engine.Validate(ctx, rule, files) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !result.Passed { + t.Errorf("Expected validation to pass for valid file, got %d violations", len(result.Violations)) + for _, v := range result.Violations { + t.Logf(" %s", v.String()) + } + } +}