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())
+ }
+ }
+}