diff --git a/.gitignore b/.gitignore index 53de309..85f9a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .claude/ bin/ +coverage.out diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..c10e29d --- /dev/null +++ b/internal/adapter/adapter.go @@ -0,0 +1,76 @@ +package adapter + +import ( + "context" +) + +// Adapter wraps external tools (ESLint, Prettier, etc.) for use by engines. +// +// Design: +// - Adapters handle tool installation, config generation, execution +// - Engines delegate to adapters for language-specific validation +// - One adapter per tool (ESLintAdapter, PrettierAdapter, etc.) +type Adapter interface { + // Name returns the adapter name (e.g., "eslint", "prettier"). + Name() string + + // CheckAvailability checks if the tool is installed and usable. + // Returns nil if available, error with details if not. + CheckAvailability(ctx context.Context) error + + // Install installs the tool if not available. + // Returns error if installation fails. + Install(ctx context.Context, config InstallConfig) error + + // GenerateConfig generates tool-specific config from a rule. + // Returns config content (JSON, XML, YAML, etc.). + GenerateConfig(rule interface{}) ([]byte, error) + + // Execute runs the tool with the given config and files. + // Returns raw tool output. + Execute(ctx context.Context, config []byte, files []string) (*ToolOutput, error) + + // ParseOutput converts tool output to standard violations. + ParseOutput(output *ToolOutput) ([]Violation, error) +} + +// InstallConfig holds tool installation settings. +type InstallConfig struct { + // ToolsDir is where to install the tool. + // Default: ~/.symphony/tools + ToolsDir string + + // Version is the tool version to install. + // Empty = latest + Version string + + // Force reinstalls even if already installed. + Force bool +} + +// ToolOutput is the raw output from a tool execution. +type ToolOutput struct { + // Stdout is the standard output. + Stdout string + + // Stderr is the error output. + Stderr string + + // ExitCode is the process exit code. + ExitCode int + + // Duration is how long the tool took to run. + Duration string +} + +// Violation represents a single violation found by a tool. +// This is a simplified version that adapters return. +// Engines convert this to core.Violation. +type Violation struct { + File string + Line int + Column int + Message string + Severity string // "error", "warning", "info" + RuleID string +} diff --git a/internal/adapter/eslint/adapter.go b/internal/adapter/eslint/adapter.go new file mode 100644 index 0000000..599c606 --- /dev/null +++ b/internal/adapter/eslint/adapter.go @@ -0,0 +1,145 @@ +package eslint + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// Adapter wraps ESLint for JavaScript/TypeScript validation. +// +// ESLint is the universal adapter for JavaScript: +// - Pattern rules: id-match, no-restricted-syntax, no-restricted-imports +// - Length rules: max-len, max-lines, max-params, max-lines-per-function +// - Style rules: indent, quotes, semi, comma-dangle +// - AST rules: Custom rule generation +type Adapter struct { + // ToolsDir is where ESLint is installed + // Default: ~/.symphony/tools/node_modules + ToolsDir string + + // WorkDir is the project root + WorkDir string + + // executor runs ESLint subprocess + executor *adapter.SubprocessExecutor +} + +// NewAdapter creates a new ESLint 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 "eslint" +} + +// CheckAvailability checks if ESLint is installed. +func (a *Adapter) CheckAvailability(ctx context.Context) error { + // Try local installation first + eslintPath := a.getESLintPath() + if _, err := os.Stat(eslintPath); err == nil { + return nil // Found in tools dir + } + + // Try global installation + cmd := exec.CommandContext(ctx, "eslint", "--version") + if err := cmd.Run(); err == nil { + return nil // Found globally + } + + return fmt.Errorf("eslint not found (checked: %s and global PATH)", eslintPath) +} + +// Install installs ESLint 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 = "^8.0.0" // Default to ESLint 8.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 ESLint + a.executor.WorkDir = a.ToolsDir + _, err := a.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version)) + if err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + + return nil +} + +// GenerateConfig generates ESLint config from a rule. +// Returns .eslintrc.json content. +func (a *Adapter) GenerateConfig(rule interface{}) ([]byte, error) { + // Implementation in config.go + return generateConfig(rule) +} + +// Execute runs ESLint with the given config and files. +func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { + // Implementation in executor.go + return a.execute(ctx, config, files) +} + +// ParseOutput converts ESLint JSON output to violations. +func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + // Implementation in parser.go + return parseOutput(output) +} + +// getESLintPath returns the path to local ESLint binary. +func (a *Adapter) getESLintPath() string { + return filepath.Join(a.ToolsDir, "node_modules", ".bin", "eslint") +} + +// 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/eslint/ast.go b/internal/adapter/eslint/ast.go new file mode 100644 index 0000000..cb666cd --- /dev/null +++ b/internal/adapter/eslint/ast.go @@ -0,0 +1,119 @@ +package eslint + +import ( + "fmt" + "strings" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// ASTQuery represents a parsed AST query from a rule. +type ASTQuery struct { + Node string `json:"node"` + Where map[string]interface{} `json:"where,omitempty"` + Has []string `json:"has,omitempty"` + NotHas []string `json:"notHas,omitempty"` + Language string `json:"language,omitempty"` +} + +// ParseASTQuery extracts AST query from a rule's check field. +func ParseASTQuery(rule *core.Rule) (*ASTQuery, error) { + node, ok := rule.Check["node"].(string) + if !ok || node == "" { + return nil, fmt.Errorf("AST rule requires 'node' field") + } + + query := &ASTQuery{ + Node: node, + } + + if where, ok := rule.Check["where"].(map[string]interface{}); ok { + query.Where = where + } + + if has, ok := rule.Check["has"].([]interface{}); ok { + query.Has = interfaceSliceToStringSlice(has) + } + + if notHas, ok := rule.Check["notHas"].([]interface{}); ok { + query.NotHas = interfaceSliceToStringSlice(notHas) + } + + if lang, ok := rule.Check["language"].(string); ok { + query.Language = lang + } + + return query, nil +} + +// GenerateESTreeSelector generates ESLint AST selector from AST query. +// Uses ESLint's no-restricted-syntax with ESTree selectors. +func GenerateESTreeSelector(query *ASTQuery) string { + var parts []string + + // Start with node type + parts = append(parts, query.Node) + + // Add where conditions as attribute selectors + if len(query.Where) > 0 { + for key, value := range query.Where { + selector := generateAttributeSelector(key, value) + if selector != "" { + parts = append(parts, selector) + } + } + } + + // Combine into single selector + selector := strings.Join(parts, "") + + // For "has" queries, use descendant combinator + if len(query.Has) > 0 { + // ESLint selector: "FunctionDeclaration:not(:has(TryStatement))" + for _, nodeType := range query.Has { + selector = fmt.Sprintf("%s:not(:has(%s))", selector, nodeType) + } + } + + // For "notHas" queries, check presence + if len(query.NotHas) > 0 { + for _, nodeType := range query.NotHas { + selector = fmt.Sprintf("%s:has(%s)", selector, nodeType) + } + } + + return selector +} + +// generateAttributeSelector creates an attribute selector for ESTree. +func generateAttributeSelector(key string, value interface{}) string { + switch v := value.(type) { + case bool: + if v { + return fmt.Sprintf("[%s=true]", key) + } + return fmt.Sprintf("[%s=false]", key) + case string: + return fmt.Sprintf("[%s=\"%s\"]", key, v) + case float64, int: + return fmt.Sprintf("[%s=%v]", key, v) + case map[string]interface{}: + // Handle operators + if eq, ok := v["eq"]; ok { + return generateAttributeSelector(key, eq) + } + // Other operators not supported in ESTree selectors + } + return "" +} + +// interfaceSliceToStringSlice converts []interface{} to []string. +func interfaceSliceToStringSlice(slice []interface{}) []string { + result := make([]string, 0, len(slice)) + for _, item := range slice { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} diff --git a/internal/adapter/eslint/ast_test.go b/internal/adapter/eslint/ast_test.go new file mode 100644 index 0000000..927f7cc --- /dev/null +++ b/internal/adapter/eslint/ast_test.go @@ -0,0 +1,144 @@ +package eslint + +import ( + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestParseASTQuery(t *testing.T) { + tests := []struct { + name string + rule *core.Rule + want *ASTQuery + wantErr bool + }{ + { + name: "simple node query", + rule: &core.Rule{ + Check: map[string]interface{}{ + "node": "FunctionDeclaration", + }, + }, + want: &ASTQuery{ + Node: "FunctionDeclaration", + }, + wantErr: false, + }, + { + name: "query with where clause", + rule: &core.Rule{ + Check: map[string]interface{}{ + "node": "FunctionDeclaration", + "where": map[string]interface{}{ + "async": true, + }, + }, + }, + want: &ASTQuery{ + Node: "FunctionDeclaration", + Where: map[string]interface{}{ + "async": true, + }, + }, + wantErr: false, + }, + { + name: "query with has clause", + rule: &core.Rule{ + Check: map[string]interface{}{ + "node": "FunctionDeclaration", + "has": []interface{}{"TryStatement"}, + }, + }, + want: &ASTQuery{ + Node: "FunctionDeclaration", + Has: []string{"TryStatement"}, + }, + wantErr: false, + }, + { + name: "missing node", + rule: &core.Rule{ + Check: map[string]interface{}{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseASTQuery(tt.rule) + if (err != nil) != tt.wantErr { + t.Errorf("ParseASTQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Node != tt.want.Node { + t.Errorf("Node = %v, want %v", got.Node, tt.want.Node) + } + if len(tt.want.Where) > 0 && got.Where == nil { + t.Errorf("Where is nil, want %v", tt.want.Where) + } + if len(tt.want.Has) > 0 && len(got.Has) != len(tt.want.Has) { + t.Errorf("Has = %v, want %v", got.Has, tt.want.Has) + } + }) + } +} + +func TestGenerateESTreeSelector(t *testing.T) { + tests := []struct { + name string + query *ASTQuery + want string + }{ + { + name: "simple node", + query: &ASTQuery{ + Node: "FunctionDeclaration", + }, + want: "FunctionDeclaration", + }, + { + name: "node with where clause", + query: &ASTQuery{ + Node: "FunctionDeclaration", + Where: map[string]interface{}{ + "async": true, + }, + }, + want: "FunctionDeclaration[async=true]", + }, + { + name: "node with has clause", + query: &ASTQuery{ + Node: "FunctionDeclaration", + Where: map[string]interface{}{ + "async": true, + }, + Has: []string{"TryStatement"}, + }, + want: "FunctionDeclaration[async=true]:not(:has(TryStatement))", + }, + { + name: "node with notHas clause", + query: &ASTQuery{ + Node: "FunctionDeclaration", + NotHas: []string{"ReturnStatement"}, + }, + want: "FunctionDeclaration:has(ReturnStatement)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateESTreeSelector(tt.query) + if got != tt.want { + t.Errorf("GenerateESTreeSelector() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/adapter/eslint/config.go b/internal/adapter/eslint/config.go new file mode 100644 index 0000000..b1f2538 --- /dev/null +++ b/internal/adapter/eslint/config.go @@ -0,0 +1,201 @@ +package eslint + +import ( + "encoding/json" + "fmt" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// ESLintConfig represents .eslintrc.json structure. +type ESLintConfig struct { + Env map[string]bool `json:"env,omitempty"` + Rules map[string]interface{} `json:"rules"` + Extra map[string]interface{} `json:"-"` // For extensions +} + +// generateConfig creates ESLint config from a Symphony rule. +func generateConfig(ruleInterface interface{}) ([]byte, error) { + rule, ok := ruleInterface.(*core.Rule) + if !ok { + return nil, fmt.Errorf("expected *core.Rule, got %T", ruleInterface) + } + + config := &ESLintConfig{ + Env: map[string]bool{ + "es2021": true, + "node": true, + "browser": true, + }, + Rules: make(map[string]interface{}), + } + + // Determine which ESLint rules to use based on engine type + engine := rule.GetString("engine") + + switch engine { + case "pattern": + if err := addPatternRules(config, rule); err != nil { + return nil, err + } + case "length": + if err := addLengthRules(config, rule); err != nil { + return nil, err + } + case "style": + if err := addStyleRules(config, rule); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported engine: %s", engine) + } + + return json.MarshalIndent(config, "", " ") +} + +// addPatternRules adds pattern validation rules. +func addPatternRules(config *ESLintConfig, rule *core.Rule) error { + target := rule.GetString("target") + pattern := rule.GetString("pattern") + + if pattern == "" { + return fmt.Errorf("pattern is required for pattern engine") + } + + switch target { + case "identifier": + // Use id-match rule for identifier patterns + config.Rules["id-match"] = []interface{}{ + rule.Severity, // "error", "warn", "off" + pattern, + map[string]interface{}{ + "properties": false, + "classFields": false, + "onlyDeclarations": true, + }, + } + + case "content": + // Use no-restricted-syntax for content patterns + config.Rules["no-restricted-syntax"] = []interface{}{ + rule.Severity, + map[string]interface{}{ + "selector": fmt.Sprintf("Literal[value=/%s/]", pattern), + "message": rule.Message, + }, + } + + case "import": + // Use no-restricted-imports for import patterns + config.Rules["no-restricted-imports"] = []interface{}{ + rule.Severity, + map[string]interface{}{ + "patterns": []string{pattern}, + }, + } + + default: + return fmt.Errorf("unsupported pattern target: %s", target) + } + + return nil +} + +// addLengthRules adds length constraint rules. +func addLengthRules(config *ESLintConfig, rule *core.Rule) error { + scope := rule.GetString("scope") + max := rule.GetInt("max") + min := rule.GetInt("min") + + if max == 0 && min == 0 { + return fmt.Errorf("max or min is required for length engine") + } + + switch scope { + case "line": + // Use max-len rule + opts := map[string]interface{}{ + "code": max, + } + if min > 0 { + // TODO: ESLint doesn't have min-len, so we'd need custom rule + // For now, just enforce max + _ = min // Explicitly ignore min for now + } + config.Rules["max-len"] = []interface{}{rule.Severity, opts} + + case "file": + // Use max-lines rule + opts := map[string]interface{}{ + "max": max, + "skipBlankLines": true, + "skipComments": true, + } + config.Rules["max-lines"] = []interface{}{rule.Severity, opts} + + case "function": + // Use max-lines-per-function rule + opts := map[string]interface{}{ + "max": max, + "skipBlankLines": true, + "skipComments": true, + } + config.Rules["max-lines-per-function"] = []interface{}{rule.Severity, opts} + + case "params": + // Use max-params rule + config.Rules["max-params"] = []interface{}{rule.Severity, max} + + default: + return fmt.Errorf("unsupported length scope: %s", scope) + } + + return nil +} + +// addStyleRules adds style formatting rules. +func addStyleRules(config *ESLintConfig, rule *core.Rule) error { + // Get style properties from rule.Check + indent := rule.GetInt("indent") + quote := rule.GetString("quote") + semi := rule.GetBool("semi") + + if indent > 0 { + config.Rules["indent"] = []interface{}{rule.Severity, indent} + } + + if quote != "" { + config.Rules["quotes"] = []interface{}{rule.Severity, quote} + } + + // Semi is boolean, but we need to handle it carefully + // If explicitly set, add the rule + if _, ok := rule.Check["semi"]; ok { + if semi { + config.Rules["semi"] = []interface{}{rule.Severity, "always"} + } else { + config.Rules["semi"] = []interface{}{rule.Severity, "never"} + } + } + + return nil +} + +// MarshalConfig converts a config map to JSON bytes. +func MarshalConfig(config map[string]interface{}) ([]byte, error) { + return json.MarshalIndent(config, "", " ") +} + +// MapSeverity converts severity string to ESLint severity level. +func MapSeverity(severity string) interface{} { + switch severity { + case "error": + return "error" + case "warning", "warn": + return "warn" + case "info", "off": + return "off" + default: + return "error" + } +} diff --git a/internal/adapter/eslint/config_test.go b/internal/adapter/eslint/config_test.go new file mode 100644 index 0000000..20dad1c --- /dev/null +++ b/internal/adapter/eslint/config_test.go @@ -0,0 +1,98 @@ +package eslint + +import ( + "encoding/json" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestGenerateConfig_Pattern(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-PATTERN", + Category: "naming", + Severity: "error", + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + "pattern": "^[A-Z][a-zA-Z0-9]*$", + }, + } + + 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["id-match"]; !ok { + t.Error("expected id-match rule to be set") + } +} + +func TestGenerateConfig_Length(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-LENGTH", + Category: "formatting", + Severity: "error", + Check: map[string]interface{}{ + "engine": "length", + "scope": "line", + "max": 100, + }, + } + + 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-len"]; !ok { + t.Error("expected max-len rule to be set") + } +} + +func TestGenerateConfig_Style(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-STYLE", + Category: "style", + Severity: "error", + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + "semi": true, + }, + } + + 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["indent"]; !ok { + t.Error("expected indent rule to be set") + } + + if _, ok := eslintConfig.Rules["quotes"]; !ok { + t.Error("expected quotes rule to be set") + } + + if _, ok := eslintConfig.Rules["semi"]; !ok { + t.Error("expected semi rule to be set") + } +} diff --git a/internal/adapter/eslint/executor.go b/internal/adapter/eslint/executor.go new file mode 100644 index 0000000..98b38e8 --- /dev/null +++ b/internal/adapter/eslint/executor.go @@ -0,0 +1,98 @@ +package eslint + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// execute runs ESLint with the given config and files. +func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { + if len(files) == 0 { + return &adapter.ToolOutput{ + Stdout: "[]", + ExitCode: 0, + }, nil + } + + // Write config to temp file + configPath, err := a.writeConfigFile(config) + if err != nil { + return nil, fmt.Errorf("failed to write config: %w", err) + } + defer os.Remove(configPath) + + // Get command and arguments + eslintCmd, args := a.getExecutionArgs(configPath, files) + + // Execute with environment variable to support both ESLint 8 and 9 + a.executor.WorkDir = a.WorkDir + a.executor.Env = map[string]string{ + "ESLINT_USE_FLAT_CONFIG": "false", + } + return a.executor.Execute(ctx, eslintCmd, args...) +} + +// getESLintCommand returns the ESLint command to use. +func (a *Adapter) getESLintCommand() string { + // Try local installation first + localPath := a.getESLintPath() + if _, err := os.Stat(localPath); err == nil { + return localPath + } + + // Try global eslint + if _, err := exec.LookPath("eslint"); err == nil { + return "eslint" + } + + // Fall back to npx with ESLint 8.x + return "npx" +} + +// getExecutionArgs returns the command and arguments for ESLint execution. +func (a *Adapter) getExecutionArgs(configPath string, files []string) (string, []string) { + eslintCmd := a.getESLintCommand() + + var args []string + + // If using npx, specify eslint@8 + if eslintCmd == "npx" { + args = []string{"eslint@8"} + } + + // Add ESLint arguments + args = append(args, + "--config", configPath, + "--format", "json", + "--no-eslintrc", // Don't load user's .eslintrc + ) + args = append(args, files...) + + return eslintCmd, args +} + +// writeConfigFile writes ESLint config to a temp file. +func (a *Adapter) writeConfigFile(config []byte) (string, error) { + tmpDir := filepath.Join(a.ToolsDir, ".tmp") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", err + } + + tmpFile, err := os.CreateTemp(tmpDir, "eslintrc-*.json") + if err != nil { + return "", err + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(config); err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} diff --git a/internal/adapter/eslint/parser.go b/internal/adapter/eslint/parser.go new file mode 100644 index 0000000..4634a94 --- /dev/null +++ b/internal/adapter/eslint/parser.go @@ -0,0 +1,70 @@ +package eslint + +import ( + "encoding/json" + "fmt" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// ESLintOutput represents ESLint JSON output format. +// ESLint outputs an array of file results. +type ESLintOutput []ESLintFileResult + +// ESLintFileResult represents results for a single file. +type ESLintFileResult struct { + FilePath string `json:"filePath"` + Messages []ESLintMessage `json:"messages"` +} + +// ESLintMessage represents a single violation. +type ESLintMessage struct { + RuleID string `json:"ruleId"` + Severity int `json:"severity"` // 0=off, 1=warn, 2=error + Message string `json:"message"` + Line int `json:"line"` + Column int `json:"column"` + EndLine int `json:"endLine,omitempty"` + EndColumn int `json:"endColumn,omitempty"` +} + +// parseOutput converts ESLint JSON output to violations. +func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + if output.Stdout == "" || output.Stdout == "[]" { + return nil, nil // No violations + } + + var eslintOutput ESLintOutput + if err := json.Unmarshal([]byte(output.Stdout), &eslintOutput); err != nil { + return nil, fmt.Errorf("failed to parse ESLint output: %w", err) + } + + var violations []adapter.Violation + + for _, fileResult := range eslintOutput { + for _, msg := range fileResult.Messages { + violations = append(violations, adapter.Violation{ + File: fileResult.FilePath, + Line: msg.Line, + Column: msg.Column, + Message: msg.Message, + Severity: severityToString(msg.Severity), + RuleID: msg.RuleID, + }) + } + } + + return violations, nil +} + +// severityToString converts ESLint severity to string. +func severityToString(severity int) string { + switch severity { + case 2: + return "error" + case 1: + return "warning" + default: + return "info" + } +} diff --git a/internal/adapter/eslint/parser_test.go b/internal/adapter/eslint/parser_test.go new file mode 100644 index 0000000..1ba59ae --- /dev/null +++ b/internal/adapter/eslint/parser_test.go @@ -0,0 +1,90 @@ +package eslint + +import ( + "testing" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +func TestParseOutput_Empty(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: "[]", + } + + violations, err := parseOutput(output) + if err != nil { + t.Fatalf("parseOutput failed: %v", err) + } + + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d", len(violations)) + } +} + +func TestParseOutput_WithViolations(t *testing.T) { + output := &adapter.ToolOutput{ + Stdout: `[ + { + "filePath": "src/app.js", + "messages": [ + { + "ruleId": "id-match", + "severity": 2, + "message": "Identifier 'myClass' does not match pattern", + "line": 10, + "column": 7 + }, + { + "ruleId": "max-len", + "severity": 2, + "message": "Line exceeds maximum length", + "line": 15, + "column": 1 + } + ] + } + ]`, + } + + violations, err := parseOutput(output) + if err != nil { + t.Fatalf("parseOutput failed: %v", err) + } + + if len(violations) != 2 { + t.Fatalf("expected 2 violations, got %d", len(violations)) + } + + // Check first violation + v := violations[0] + if v.File != "src/app.js" { + t.Errorf("file = %q, want %q", v.File, "src/app.js") + } + if v.Line != 10 { + t.Errorf("line = %d, want 10", v.Line) + } + if v.Severity != "error" { + t.Errorf("severity = %q, want %q", v.Severity, "error") + } + if v.RuleID != "id-match" { + t.Errorf("ruleId = %q, want %q", v.RuleID, "id-match") + } +} + +func TestSeverityToString(t *testing.T) { + tests := []struct { + severity int + want string + }{ + {0, "info"}, + {1, "warning"}, + {2, "error"}, + } + + for _, tt := range tests { + got := severityToString(tt.severity) + if got != tt.want { + t.Errorf("severityToString(%d) = %q, want %q", tt.severity, got, tt.want) + } + } +} diff --git a/internal/adapter/prettier/adapter.go b/internal/adapter/prettier/adapter.go new file mode 100644 index 0000000..337ce71 --- /dev/null +++ b/internal/adapter/prettier/adapter.go @@ -0,0 +1,128 @@ +package prettier + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// Adapter wraps Prettier for code formatting. +// +// Prettier handles: +// - Style validation (--check mode) +// - Auto-fixing (--write mode) +// - Config: indent, quote, semi, trailingComma, etc. +type Adapter struct { + ToolsDir string + WorkDir string + executor *adapter.SubprocessExecutor +} + +// NewAdapter creates a new Prettier 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 "prettier" +} + +// CheckAvailability checks if Prettier is installed. +func (a *Adapter) CheckAvailability(ctx context.Context) error { + prettierPath := a.getPrettierPath() + if _, err := os.Stat(prettierPath); err == nil { + return nil + } + + cmd := exec.CommandContext(ctx, "prettier", "--version") + if err := cmd.Run(); err == nil { + return nil + } + + return fmt.Errorf("prettier not found") +} + +// Install installs Prettier via npm. +func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { + if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + return fmt.Errorf("failed to create tools dir: %w", err) + } + + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm not found: please install Node.js first") + } + + version := config.Version + if version == "" { + version = "^3.0.0" + } + + // Init 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 err + } + } + + a.executor.WorkDir = a.ToolsDir + _, err := a.executor.Execute(ctx, "npm", "install", fmt.Sprintf("prettier@%s", version)) + return err +} + +// GenerateConfig generates Prettier config from a rule. +func (a *Adapter) GenerateConfig(rule interface{}) ([]byte, error) { + return generateConfig(rule) +} + +// Execute runs Prettier with the given config and files. +// mode: "check" or "write" +func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { + return a.execute(ctx, config, files, "check") +} + +// ExecuteWithMode runs Prettier with specified mode. +func (a *Adapter) ExecuteWithMode(ctx context.Context, config []byte, files []string, mode string) (*adapter.ToolOutput, error) { + return a.execute(ctx, config, files, mode) +} + +// ParseOutput converts Prettier output to violations. +func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + return parseOutput(output) +} + +func (a *Adapter) getPrettierPath() string { + return filepath.Join(a.ToolsDir, "node_modules", ".bin", "prettier") +} + +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/prettier/config.go b/internal/adapter/prettier/config.go new file mode 100644 index 0000000..a6416bf --- /dev/null +++ b/internal/adapter/prettier/config.go @@ -0,0 +1,60 @@ +package prettier + +import ( + "encoding/json" + "fmt" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// PrettierConfig represents .prettierrc.json structure. +type PrettierConfig struct { + TabWidth int `json:"tabWidth,omitempty"` + UseTabs bool `json:"useTabs,omitempty"` + Semi bool `json:"semi,omitempty"` + SingleQuote bool `json:"singleQuote,omitempty"` + TrailingComma string `json:"trailingComma,omitempty"` // "none", "es5", "all" + PrintWidth int `json:"printWidth,omitempty"` +} + +// generateConfig creates Prettier config from a Symphony rule. +func generateConfig(ruleInterface interface{}) ([]byte, error) { + rule, ok := ruleInterface.(*core.Rule) + if !ok { + return nil, fmt.Errorf("expected *core.Rule, got %T", ruleInterface) + } + + config := &PrettierConfig{} + + // Map Symphony style config to Prettier options + if indent := rule.GetInt("indent"); indent > 0 { + config.TabWidth = indent + config.UseTabs = false // Default to spaces + } + + if quote := rule.GetString("quote"); quote != "" { + config.SingleQuote = (quote == "single") + } + + // Semi is tricky - need to check if it exists in Check + if _, ok := rule.Check["semi"]; ok { + config.Semi = rule.GetBool("semi") + } + + if trailingComma := rule.GetString("trailingComma"); trailingComma != "" { + config.TrailingComma = trailingComma + } else { + config.TrailingComma = "es5" // Default + } + + // Line length + if printWidth := rule.GetInt("printWidth"); printWidth > 0 { + config.PrintWidth = printWidth + } else if maxLen := rule.GetInt("max"); maxLen > 0 { + config.PrintWidth = maxLen + } else { + config.PrintWidth = 100 // Default + } + + return json.MarshalIndent(config, "", " ") +} diff --git a/internal/adapter/prettier/config_test.go b/internal/adapter/prettier/config_test.go new file mode 100644 index 0000000..623ee75 --- /dev/null +++ b/internal/adapter/prettier/config_test.go @@ -0,0 +1,88 @@ +package prettier + +import ( + "encoding/json" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +func TestGenerateConfig_Basic(t *testing.T) { + rule := &core.Rule{ + ID: "TEST-STYLE", + Category: "style", + Severity: "error", + Check: map[string]interface{}{ + "engine": "style", + "indent": 2, + "quote": "single", + "semi": true, + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var prettierConfig PrettierConfig + if err := json.Unmarshal(config, &prettierConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if prettierConfig.TabWidth != 2 { + t.Errorf("tabWidth = %d, want 2", prettierConfig.TabWidth) + } + + if !prettierConfig.SingleQuote { + t.Error("singleQuote = false, want true") + } + + if !prettierConfig.Semi { + t.Error("semi = false, want true") + } +} + +func TestGenerateConfig_DoubleQuotes(t *testing.T) { + rule := &core.Rule{ + Check: map[string]interface{}{ + "quote": "double", + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var prettierConfig PrettierConfig + if err := json.Unmarshal(config, &prettierConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if prettierConfig.SingleQuote { + t.Error("singleQuote = true, want false (for double quotes)") + } +} + +func TestGenerateConfig_PrintWidth(t *testing.T) { + rule := &core.Rule{ + Check: map[string]interface{}{ + "printWidth": 120, + }, + } + + config, err := generateConfig(rule) + if err != nil { + t.Fatalf("generateConfig failed: %v", err) + } + + var prettierConfig PrettierConfig + if err := json.Unmarshal(config, &prettierConfig); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if prettierConfig.PrintWidth != 120 { + t.Errorf("printWidth = %d, want 120", prettierConfig.PrintWidth) + } +} diff --git a/internal/adapter/prettier/executor.go b/internal/adapter/prettier/executor.go new file mode 100644 index 0000000..bbff327 --- /dev/null +++ b/internal/adapter/prettier/executor.go @@ -0,0 +1,81 @@ +package prettier + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// execute runs Prettier with the given config and files. +// mode: "check" (validation only) or "write" (autofix) +func (a *Adapter) execute(ctx context.Context, config []byte, files []string, mode string) (*adapter.ToolOutput, error) { + if len(files) == 0 { + return &adapter.ToolOutput{ExitCode: 0}, nil + } + + // Write config to temp file + configPath, err := a.writeConfigFile(config) + if err != nil { + return nil, fmt.Errorf("failed to write config: %w", err) + } + defer os.Remove(configPath) + + // Determine Prettier command + prettierCmd := a.getPrettierCommand() + + // Build arguments + args := []string{ + "--config", configPath, + } + + if mode == "check" { + args = append(args, "--check") + } else if mode == "write" { + args = append(args, "--write") + } + + args = append(args, files...) + + // Execute + a.executor.WorkDir = a.WorkDir + output, err := a.executor.Execute(ctx, prettierCmd, args...) + + // Prettier returns non-zero exit code if files need formatting (in --check mode) + // This is expected, not an error + if err != nil { + return output, nil + } + + return output, nil +} + +func (a *Adapter) getPrettierCommand() string { + localPath := a.getPrettierPath() + if _, err := os.Stat(localPath); err == nil { + return localPath + } + return "prettier" +} + +func (a *Adapter) writeConfigFile(config []byte) (string, error) { + tmpDir := filepath.Join(a.ToolsDir, ".tmp") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", err + } + + tmpFile, err := os.CreateTemp(tmpDir, "prettierrc-*.json") + if err != nil { + return "", err + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(config); err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} diff --git a/internal/adapter/prettier/parser.go b/internal/adapter/prettier/parser.go new file mode 100644 index 0000000..ad12e90 --- /dev/null +++ b/internal/adapter/prettier/parser.go @@ -0,0 +1,52 @@ +package prettier + +import ( + "strings" + + "github.com/DevSymphony/sym-cli/internal/adapter" +) + +// parseOutput converts Prettier --check output to violations. +// +// Prettier --check output format: +// "Checking formatting... +// src/app.js +// src/utils.js +// [warn] Code style issues found in the above file(s). Forgot to run Prettier?" +// +// Non-zero exit code means files need formatting. +func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { + // Exit code 0 = all files formatted + if output.ExitCode == 0 { + return nil, nil + } + + // Parse stdout to find files that need formatting + var violations []adapter.Violation + lines := strings.Split(output.Stdout, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines and status messages + if line == "" || strings.HasPrefix(line, "Checking") || + strings.HasPrefix(line, "[warn]") || strings.HasPrefix(line, "Code style") { + continue + } + + // If line looks like a file path, it needs formatting + if strings.Contains(line, ".js") || strings.Contains(line, ".ts") || + strings.Contains(line, ".jsx") || strings.Contains(line, ".tsx") { + violations = append(violations, adapter.Violation{ + File: line, + Line: 0, // Prettier doesn't report line numbers in --check + Column: 0, + Message: "Code style issues found. Run prettier --write to fix.", + Severity: "warning", + RuleID: "prettier", + }) + } + } + + return violations, nil +} diff --git a/internal/adapter/subprocess.go b/internal/adapter/subprocess.go new file mode 100644 index 0000000..c9e6683 --- /dev/null +++ b/internal/adapter/subprocess.go @@ -0,0 +1,83 @@ +package adapter + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" +) + +// SubprocessExecutor runs external tools as subprocesses. +type SubprocessExecutor struct { + // Timeout is the max execution time. + // Default: 2 minutes + Timeout time.Duration + + // WorkDir is the working directory. + WorkDir string + + // Env is additional environment variables. + Env map[string]string +} + +// NewSubprocessExecutor creates a new executor. +func NewSubprocessExecutor() *SubprocessExecutor { + return &SubprocessExecutor{ + Timeout: 2 * time.Minute, + Env: make(map[string]string), + } +} + +// Execute runs a command and returns its output. +func (e *SubprocessExecutor) Execute(ctx context.Context, name string, args ...string) (*ToolOutput, error) { + // Apply timeout + if e.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, e.Timeout) + defer cancel() + } + + // Create command + cmd := exec.CommandContext(ctx, name, args...) + + if e.WorkDir != "" { + cmd.Dir = e.WorkDir + } + + // Add environment variables + if len(e.Env) > 0 { + cmd.Env = append(os.Environ(), e.envSlice()...) + } + + // Capture output + start := time.Now() + stdout, err := cmd.Output() + duration := time.Since(start) + + output := &ToolOutput{ + Stdout: string(stdout), + Duration: duration.String(), + } + + if err != nil { + // Check if it's an ExitError (non-zero exit code) + if exitErr, ok := err.(*exec.ExitError); ok { + output.Stderr = string(exitErr.Stderr) + output.ExitCode = exitErr.ExitCode() + return output, nil // Return output even on non-zero exit + } + return nil, fmt.Errorf("failed to execute %s: %w", name, err) + } + + output.ExitCode = 0 + return output, nil +} + +func (e *SubprocessExecutor) envSlice() []string { + result := make([]string, 0, len(e.Env)) + for k, v := range e.Env { + result = append(result, fmt.Sprintf("%s=%s", k, v)) + } + return result +} diff --git a/internal/adapter/subprocess_test.go b/internal/adapter/subprocess_test.go new file mode 100644 index 0000000..918b875 --- /dev/null +++ b/internal/adapter/subprocess_test.go @@ -0,0 +1,127 @@ +package adapter + +import ( + "context" + "testing" + "time" +) + +func TestNewSubprocessExecutor(t *testing.T) { + executor := NewSubprocessExecutor() + if executor == nil { + t.Fatal("NewSubprocessExecutor() returned nil") + } + + if executor.Timeout != 2*time.Minute { + t.Errorf("Default timeout = %v, want 2m", executor.Timeout) + } + + if executor.Env == nil { + t.Error("Env map should be initialized") + } +} + +func TestExecute_Success(t *testing.T) { + executor := NewSubprocessExecutor() + ctx := context.Background() + + output, err := executor.Execute(ctx, "echo", "hello") + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if output.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", output.ExitCode) + } + + if output.Stdout == "" { + t.Error("Expected stdout output") + } +} + +func TestExecute_WithWorkDir(t *testing.T) { + executor := NewSubprocessExecutor() + executor.WorkDir = "/tmp" + ctx := context.Background() + + output, err := executor.Execute(ctx, "pwd") + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if output.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", output.ExitCode) + } +} + +func TestExecute_WithEnv(t *testing.T) { + executor := NewSubprocessExecutor() + executor.Env = map[string]string{ + "TEST_VAR": "test_value", + } + ctx := context.Background() + + output, err := executor.Execute(ctx, "sh", "-c", "echo $TEST_VAR") + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if output.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", output.ExitCode) + } +} + +func TestExecute_NonZeroExit(t *testing.T) { + executor := NewSubprocessExecutor() + ctx := context.Background() + + output, err := executor.Execute(ctx, "sh", "-c", "exit 1") + if err != nil { + t.Fatalf("Execute should not return error for non-zero exit: %v", err) + } + + if output.ExitCode != 1 { + t.Errorf("ExitCode = %d, want 1", output.ExitCode) + } +} + +func TestExecute_Timeout(t *testing.T) { + executor := NewSubprocessExecutor() + executor.Timeout = 10 * time.Millisecond + ctx := context.Background() + + output, err := executor.Execute(ctx, "sleep", "1") + // Timeout can result in either error or killed exit code + if err == nil && (output == nil || output.ExitCode == 0) { + t.Error("Expected timeout error or non-zero exit code") + } +} + +func TestEnvSlice(t *testing.T) { + executor := &SubprocessExecutor{ + Env: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + } + + slice := executor.envSlice() + if len(slice) != 2 { + t.Errorf("envSlice() length = %d, want 2", len(slice)) + } + + // Check that both key-value pairs are present + found := make(map[string]bool) + for _, env := range slice { + if env == "KEY1=value1" { + found["KEY1"] = true + } + if env == "KEY2=value2" { + found["KEY2"] = true + } + } + + if !found["KEY1"] || !found["KEY2"] { + t.Errorf("envSlice() = %v, missing expected env vars", slice) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a7f0382..f04086d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,9 +1,6 @@ package cmd import ( - "fmt" - "os" - "github.com/spf13/cobra" ) @@ -32,11 +29,7 @@ func init() { func initConfig() { if cfgFile != "" { - // Use config file from the flag + // TODO: Use config file from the flag + _ = cfgFile // Placeholder to avoid unused variable warning } } - -func exitWithError(err error) { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) -} diff --git a/internal/engine/ast/engine.go b/internal/engine/ast/engine.go new file mode 100644 index 0000000..9b2c277 --- /dev/null +++ b/internal/engine/ast/engine.go @@ -0,0 +1,237 @@ +package ast + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/adapter/eslint" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// Engine validates code structure using AST queries. +type Engine struct { + eslint *eslint.Adapter + toolsDir string + workDir string +} + +// NewEngine creates a new AST engine. +func NewEngine() *Engine { + return &Engine{} +} + +// Init initializes the AST engine with ESLint adapter. +func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { + e.toolsDir = config.ToolsDir + e.workDir = config.WorkDir + + e.eslint = eslint.NewAdapter(e.toolsDir, e.workDir) + + // Check ESLint availability + if err := e.eslint.CheckAvailability(ctx); err != nil { + // Try to install + if installErr := e.eslint.Install(ctx, adapter.InstallConfig{}); installErr != nil { + return fmt.Errorf("eslint not available and installation failed: %w", installErr) + } + } + + return nil +} + +// Validate checks files against AST structure rules. +func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + if e.eslint == nil { + return nil, fmt.Errorf("AST engine not initialized") + } + + files = e.filterFiles(files, rule.When) + if len(files) == 0 { + return &core.ValidationResult{Violations: []core.Violation{}}, nil + } + + // Parse AST query + query, err := eslint.ParseASTQuery(&rule) + if err != nil { + return nil, fmt.Errorf("invalid AST query: %w", err) + } + + // Generate ESTree selector + selector := eslint.GenerateESTreeSelector(query) + + // Generate ESLint config using no-restricted-syntax + message := rule.Message + if message == "" { + message = fmt.Sprintf("AST rule %s violation", rule.ID) + } + + config, err := e.generateESLintConfigWithSelector(rule, selector, message) + if err != nil { + return nil, fmt.Errorf("failed to generate ESLint config: %w", err) + } + + // Execute ESLint + output, err := e.eslint.Execute(ctx, config, files) + if err != nil && output == nil { + return nil, fmt.Errorf("eslint execution failed: %w", err) + } + + // Parse violations + adapterViolations, err := e.eslint.ParseOutput(output) + if err != nil { + return nil, fmt.Errorf("failed to parse ESLint output: %w", err) + } + + // Convert adapter.Violation to core.Violation + violations := make([]core.Violation, len(adapterViolations)) + for i, v := range adapterViolations { + violations[i] = core.Violation{ + File: v.File, + Line: v.Line, + Column: v.Column, + Message: v.Message, + Severity: v.Severity, + RuleID: v.RuleID, + } + } + + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: len(violations) == 0, + Violations: violations, + Engine: "ast", + }, nil +} + +// GetCapabilities returns the engine's capabilities. +func (e *Engine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: "ast", + SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, + SupportedCategories: []string{"error_handling", "custom"}, + SupportsAutofix: false, + } +} + +// Close cleans up the engine resources. +func (e *Engine) Close() error { + return nil +} + +// filterFiles filters files based on the when selector. +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 +} + +// generateESLintConfigWithSelector generates ESLint config using no-restricted-syntax. +func (e *Engine) generateESLintConfigWithSelector(rule core.Rule, selector string, message string) ([]byte, error) { + severity := eslint.MapSeverity(rule.Severity) + + config := map[string]interface{}{ + "env": map[string]bool{ + "es2021": true, + "node": true, + "browser": true, + }, + "parserOptions": map[string]interface{}{ + "ecmaVersion": "latest", + "sourceType": "module", + }, + "rules": map[string]interface{}{ + "no-restricted-syntax": []interface{}{ + severity, + map[string]interface{}{ + "selector": selector, + "message": message, + }, + }, + }, + } + + return eslint.MarshalConfig(config) +} diff --git a/internal/engine/ast/engine_test.go b/internal/engine/ast/engine_test.go new file mode 100644 index 0000000..b4251b8 --- /dev/null +++ b/internal/engine/ast/engine_test.go @@ -0,0 +1,127 @@ +package ast + +import ( + "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 != "ast" { + t.Errorf("Name = %s, want ast", caps.Name) + } + + if !contains(caps.SupportedLanguages, "javascript") { + t.Error("Expected javascript in supported languages") + } + + if !contains(caps.SupportedCategories, "error_handling") { + t.Error("Expected error_handling in supported categories") + } + + if caps.SupportsAutofix { + t.Error("AST engine should not support autofix") + } +} + +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, + }, + { + name: "matches javascript", + file: "src/main.js", + selector: &core.Selector{ + Languages: []string{"javascript"}, + }, + want: true, + }, + { + name: "doesn't match typescript", + file: "src/main.js", + selector: &core.Selector{ + Languages: []string{"typescript"}, + }, + want: false, + }, + { + name: "matches include pattern", + file: "src/main.js", + selector: &core.Selector{ + Include: []string{"src/*"}, + }, + want: true, + }, + { + name: "excluded by pattern", + file: "test/main.js", + selector: &core.Selector{ + Exclude: []string{"test/*"}, + }, + want: false, + }, + } + + 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) + } + }) + } +} + +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) + } + } +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/engine/core/engine.go b/internal/engine/core/engine.go new file mode 100644 index 0000000..1a80421 --- /dev/null +++ b/internal/engine/core/engine.go @@ -0,0 +1,106 @@ +package core + +import ( + "context" + "time" +) + +// Engine is the interface that all validation engines must implement. +// Engines validate code against specific rule types (pattern, length, style, etc.). +// +// Design Philosophy: +// - Engines are language-agnostic at the interface level +// - Language-specific implementations are provided via LanguageProvider +// - External tools (ESLint, Prettier, etc.) are wrapped by adapters +type Engine interface { + // Init initializes the engine with configuration. + // Called once before validation begins. + Init(ctx context.Context, config EngineConfig) error + + // Validate validates files against a rule. + // Returns violations found, or nil if validation passed. + // + // The engine should: + // 1. Parse rule.Check configuration + // 2. Delegate to appropriate adapter (e.g., ESLint for JavaScript) + // 3. Collect and return violations + Validate(ctx context.Context, rule Rule, files []string) (*ValidationResult, error) + + // GetCapabilities returns engine capabilities (languages, features, etc.). + GetCapabilities() EngineCapabilities + + // Close cleans up resources (close connections, temp files, etc.). + Close() error +} + +// EngineConfig holds engine initialization settings. +type EngineConfig struct { + // WorkDir is the working directory (usually project root). + WorkDir string + + // ToolsDir is where external tools are installed. + // Default: ~/.symphony/tools + ToolsDir string + + // CacheDir is for caching validation results. + // Default: ~/.symphony/cache + CacheDir string + + // Timeout is the max time for a single validation. + // Default: 2 minutes + Timeout time.Duration + + // Parallelism is max concurrent validations. + // 0 = runtime.NumCPU() + Parallelism int + + // Debug enables verbose logging. + Debug bool + + // Extra holds engine-specific config. + Extra map[string]interface{} +} + +// EngineCapabilities describes what an engine supports. +type EngineCapabilities struct { + // Name is the engine identifier (e.g., "pattern", "style"). + Name string + + // SupportedLanguages lists supported languages. + // Empty = language-agnostic (e.g., commit engine). + // Example: ["javascript", "typescript", "jsx", "tsx"] + SupportedLanguages []string + + // SupportedCategories lists rule categories this engine handles. + // Example: ["naming", "security"] for pattern engine. + SupportedCategories []string + + // SupportsAutofix indicates if engine can auto-fix violations. + SupportsAutofix bool + + // RequiresCompilation indicates if compiled artifacts needed. + // Example: ArchUnit requires .class files (Java). + RequiresCompilation bool + + // ExternalTools lists required external tools. + // Example: ["eslint@^8.0.0", "prettier@^3.0.0"] + ExternalTools []ToolRequirement +} + +// ToolRequirement specifies an external tool dependency. +type ToolRequirement struct { + // Name is the tool name (e.g., "eslint"). + Name string + + // Version is the required version (e.g., "^8.0.0"). + // Empty = any version. + Version string + + // Optional indicates if tool is optional. + // If true, engine falls back to internal implementation. + Optional bool + + // InstallCommand is the command to install the tool. + // Example: "npm install -g eslint@^8.0.0" + InstallCommand string +} diff --git a/internal/engine/core/types.go b/internal/engine/core/types.go new file mode 100644 index 0000000..239490f --- /dev/null +++ b/internal/engine/core/types.go @@ -0,0 +1,162 @@ +package core + +import ( + "encoding/json" + "fmt" + "time" +) + +// Rule represents a validation rule from the policy. +// Maps to PolicyRule in pkg/schema/types.go. +type Rule struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Category string `json:"category"` + Severity string `json:"severity"` // "error", "warning", "info" + Desc string `json:"desc,omitempty"` + When *Selector `json:"when,omitempty"` + Check map[string]interface{} `json:"check"` // Engine-specific config + Remedy *Remedy `json:"remedy,omitempty"` + Message string `json:"message,omitempty"` +} + +// Selector defines when a rule applies. +type Selector struct { + Languages []string `json:"languages,omitempty"` // ["javascript", "typescript"] + Include []string `json:"include,omitempty"` // ["src/**/*.js"] + Exclude []string `json:"exclude,omitempty"` // ["**/*.test.js"] + Branches []string `json:"branches,omitempty"` // ["main", "develop"] + Roles []string `json:"roles,omitempty"` // ["dev", "reviewer"] + Tags []string `json:"tags,omitempty"` // ["critical", "style"] +} + +// Remedy contains auto-fix configuration. +type Remedy struct { + Autofix bool `json:"autofix"` + Tool string `json:"tool,omitempty"` // "prettier", "eslint" + Config map[string]interface{} `json:"config,omitempty"` // Tool-specific config +} + +// ValidationResult is the outcome of validating files against a rule. +type ValidationResult struct { + RuleID string `json:"ruleId"` + Passed bool `json:"passed"` + Violations []Violation `json:"violations,omitempty"` + Metrics *Metrics `json:"metrics,omitempty"` + Duration time.Duration `json:"-"` // Serialized separately + Engine string `json:"engine"` + Language string `json:"language,omitempty"` +} + +// Violation represents a single rule violation. +type Violation struct { + File string `json:"file"` + Line int `json:"line"` // 1-indexed, 0 if N/A + Column int `json:"column"` // 1-indexed, 0 if N/A + EndLine int `json:"endLine,omitempty"` // For multi-line + EndColumn int `json:"endColumn,omitempty"` + Message string `json:"message"` + Severity string `json:"severity"` // "error", "warning", "info" + RuleID string `json:"ruleId"` + Category string `json:"category,omitempty"` + Suggestion *Suggestion `json:"suggestion,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` // Extra info +} + +// Suggestion represents an auto-fix suggestion. +type Suggestion struct { + Desc string `json:"desc"` // "Change to single quotes" + Replacement string `json:"replacement,omitempty"` // Fixed text + Diff string `json:"diff,omitempty"` // Unified diff +} + +// Metrics contains validation metrics. +type Metrics struct { + FilesProcessed int `json:"filesProcessed"` + LinesProcessed int `json:"linesProcessed"` + Custom map[string]interface{} `json:"custom,omitempty"` // Engine-specific +} + +// String returns a human-readable violation description. +// Format: "path/to/file.js:10:5: message [RULE-ID]" +func (v *Violation) String() string { + loc := v.File + if v.Line > 0 { + loc = fmt.Sprintf("%s:%d", loc, v.Line) + if v.Column > 0 { + loc = fmt.Sprintf("%s:%d", loc, v.Column) + } + } + return fmt.Sprintf("%s: %s [%s]", loc, v.Message, v.RuleID) +} + +// MarshalJSON customizes JSON serialization for ValidationResult. +// Converts Duration to string (e.g., "1.5s"). +func (r *ValidationResult) MarshalJSON() ([]byte, error) { + type Alias ValidationResult + return json.Marshal(&struct { + Duration string `json:"duration"` + *Alias + }{ + Duration: r.Duration.String(), + Alias: (*Alias)(r), + }) +} + +// GetString safely extracts a string value from Check config. +// Returns empty string if key doesn't exist or type mismatch. +func (r *Rule) GetString(key string) string { + if v, ok := r.Check[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetInt safely extracts an int value from Check config. +// Returns 0 if key doesn't exist or type mismatch. +func (r *Rule) GetInt(key string) int { + if v, ok := r.Check[key]; ok { + switch val := v.(type) { + case int: + return val + case float64: // JSON numbers are float64 + return int(val) + } + } + return 0 +} + +// GetBool safely extracts a bool value from Check config. +// Returns false if key doesn't exist or type mismatch. +func (r *Rule) GetBool(key string) bool { + if v, ok := r.Check[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +// GetStringSlice safely extracts a []string from Check config. +// Returns nil if key doesn't exist or type mismatch. +func (r *Rule) GetStringSlice(key string) []string { + if v, ok := r.Check[key]; ok { + // Handle []interface{} from JSON unmarshaling + if arr, ok := v.([]interface{}); ok { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + } + // Handle native []string + if arr, ok := v.([]string); ok { + return arr + } + } + return nil +} diff --git a/internal/engine/core/types_test.go b/internal/engine/core/types_test.go new file mode 100644 index 0000000..0c498a6 --- /dev/null +++ b/internal/engine/core/types_test.go @@ -0,0 +1,131 @@ +package core + +import ( + "testing" +) + +func TestRule_GetString(t *testing.T) { + rule := &Rule{ + Check: map[string]interface{}{ + "engine": "pattern", + "target": "identifier", + }, + } + + if got := rule.GetString("engine"); got != "pattern" { + t.Errorf("GetString(engine) = %q, want %q", got, "pattern") + } + + if got := rule.GetString("missing"); got != "" { + t.Errorf("GetString(missing) = %q, want empty", got) + } +} + +func TestRule_GetInt(t *testing.T) { + rule := &Rule{ + Check: map[string]interface{}{ + "max": 100, + "float": 50.5, + }, + } + + if got := rule.GetInt("max"); got != 100 { + t.Errorf("GetInt(max) = %d, want 100", got) + } + + if got := rule.GetInt("float"); got != 50 { + t.Errorf("GetInt(float) = %d, want 50", got) + } + + if got := rule.GetInt("missing"); got != 0 { + t.Errorf("GetInt(missing) = %d, want 0", got) + } +} + +func TestRule_GetBool(t *testing.T) { + rule := &Rule{ + Check: map[string]interface{}{ + "enabled": true, + }, + } + + if got := rule.GetBool("enabled"); got != true { + t.Errorf("GetBool(enabled) = %v, want true", got) + } + + if got := rule.GetBool("missing"); got != false { + t.Errorf("GetBool(missing) = %v, want false", got) + } +} + +func TestRule_GetStringSlice(t *testing.T) { + rule := &Rule{ + Check: map[string]interface{}{ + "languages": []interface{}{"javascript", "typescript"}, + "native": []string{"go", "rust"}, + }, + } + + got := rule.GetStringSlice("languages") + want := []string{"javascript", "typescript"} + if len(got) != len(want) { + t.Fatalf("GetStringSlice(languages) length = %d, want %d", len(got), len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("GetStringSlice(languages)[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if got := rule.GetStringSlice("missing"); got != nil { + t.Errorf("GetStringSlice(missing) = %v, want nil", got) + } +} + +func TestViolation_String(t *testing.T) { + tests := []struct { + name string + v Violation + want string + }{ + { + name: "full location", + v: Violation{ + File: "src/app.js", + Line: 10, + Column: 5, + Message: "Missing semicolon", + RuleID: "STYLE-SEMI", + }, + want: "src/app.js:10:5: Missing semicolon [STYLE-SEMI]", + }, + { + name: "line only", + v: Violation{ + File: "src/utils.js", + Line: 42, + Message: "Line too long", + RuleID: "LENGTH-LINE", + }, + want: "src/utils.js:42: Line too long [LENGTH-LINE]", + }, + { + name: "file only", + v: Violation{ + File: "README.md", + Message: "File too long", + RuleID: "LENGTH-FILE", + }, + want: "README.md: File too long [LENGTH-FILE]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.v.String() + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/engine/length/engine.go b/internal/engine/length/engine.go new file mode 100644 index 0000000..b41abe6 --- /dev/null +++ b/internal/engine/length/engine.go @@ -0,0 +1,165 @@ +package length + +import ( + "context" + "fmt" + "time" + + "github.com/DevSymphony/sym-cli/internal/adapter/eslint" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// Engine validates length constraint rules (line, file, function, params). +// +// For JavaScript/TypeScript: +// - Uses ESLint max-len for line length +// - Uses ESLint max-lines for file length +// - Uses ESLint max-lines-per-function for function length +// - Uses ESLint max-params for parameter count +type Engine struct { + eslint *eslint.Adapter + config core.EngineConfig +} + +// NewEngine creates a new length 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 ESLint adapter + e.eslint = eslint.NewAdapter(config.ToolsDir, config.WorkDir) + + // Check availability (same as pattern engine) + if err := e.eslint.CheckAvailability(ctx); err != nil { + if config.Debug { + fmt.Printf("ESLint not found, attempting install...\n") + } + + installConfig := struct { + ToolsDir string + Version string + Force bool + }{ + ToolsDir: config.ToolsDir, + Version: "", + Force: false, + } + + if err := e.eslint.Install(ctx, installConfig); err != nil { + return fmt.Errorf("failed to install ESLint: %w", err) + } + } + + return nil +} + +// Validate validates files against a length rule. +func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + start := time.Now() + + // Filter files + files = e.filterFiles(files, rule.When) + + if len(files) == 0 { + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Engine: "length", + Duration: time.Since(start), + }, nil + } + + // Generate ESLint config + config, err := e.eslint.GenerateConfig(&rule) + if err != nil { + return nil, fmt.Errorf("failed to generate config: %w", err) + } + + // Execute ESLint + output, err := e.eslint.Execute(ctx, config, files) + if err != nil { + return nil, fmt.Errorf("failed to execute ESLint: %w", err) + } + + // Parse output + adapterViolations, err := e.eslint.ParseOutput(output) + if err != nil { + return nil, fmt.Errorf("failed to parse 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: rule.ID, + Category: rule.Category, + } + + 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: "length", + Language: "javascript", + }, nil +} + +// GetCapabilities returns engine capabilities. +func (e *Engine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: "length", + SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, + SupportedCategories: []string{"formatting", "style"}, + SupportsAutofix: false, + RequiresCompilation: false, + ExternalTools: []core.ToolRequirement{ + { + Name: "eslint", + Version: "^8.0.0", + Optional: false, + InstallCommand: "npm install -g eslint", + }, + }, + } +} + +// Close cleans up resources. +func (e *Engine) Close() error { + return nil +} + +// filterFiles filters files based on selector. +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 +} diff --git a/internal/engine/length/engine_test.go b/internal/engine/length/engine_test.go new file mode 100644 index 0000000..530591a --- /dev/null +++ b/internal/engine/length/engine_test.go @@ -0,0 +1,97 @@ +package length + +import ( + "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 != "length" { + t.Errorf("Name = %s, want length", caps.Name) + } + + if !contains(caps.SupportedLanguages, "javascript") { + t.Error("Expected javascript in supported languages") + } + + if !contains(caps.SupportedCategories, "formatting") { + t.Error("Expected formatting in supported categories") + } + + if caps.SupportsAutofix { + t.Error("Length engine should not support autofix") + } +} + +func TestFilterFiles(t *testing.T) { + engine := &Engine{} + + files := []string{ + "src/main.js", + "src/app.ts", + "test/test.js", + "README.md", + } + + tests := []struct { + name string + selector *core.Selector + want []string + }{ + { + name: "nil selector - all files", + selector: nil, + want: files, + }, + { + name: "with selector - filters JS/TS only", + selector: &core.Selector{ + Languages: []string{"javascript"}, + }, + want: []string{"src/main.js", "src/app.ts", "test/test.js"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := engine.filterFiles(files, tt.selector) + if !equalSlices(got, tt.want) { + t.Errorf("filterFiles() = %v, want %v", got, tt.want) + } + }) + } +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/engine/pattern/engine.go b/internal/engine/pattern/engine.go new file mode 100644 index 0000000..e3807b5 --- /dev/null +++ b/internal/engine/pattern/engine.go @@ -0,0 +1,219 @@ +package pattern + +import ( + "context" + "fmt" + "time" + + "github.com/DevSymphony/sym-cli/internal/adapter/eslint" + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// Engine validates pattern rules (naming, forbidden patterns, imports). +// +// For JavaScript/TypeScript: +// - Uses ESLint id-match for identifier patterns +// - Uses ESLint no-restricted-syntax for content patterns +// - Uses ESLint no-restricted-imports for import patterns +type Engine struct { + eslint *eslint.Adapter + config core.EngineConfig +} + +// NewEngine creates a new pattern 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 ESLint adapter + e.eslint = eslint.NewAdapter(config.ToolsDir, config.WorkDir) + + // Check if ESLint is available + if err := e.eslint.CheckAvailability(ctx); err != nil { + // Try to install + if config.Debug { + fmt.Printf("ESLint not found, attempting install...\n") + } + + installConfig := struct { + ToolsDir string + Version string + Force bool + }{ + ToolsDir: config.ToolsDir, + Version: "", + Force: false, + } + + // Convert to adapter.InstallConfig + // (Note: we'd need to import adapter package, but for now let's inline) + if err := e.eslint.Install(ctx, installConfig); err != nil { + return fmt.Errorf("failed to install ESLint: %w", err) + } + } + + return nil +} + +// Validate validates files against a pattern rule. +func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + start := time.Now() + + // Filter files by selector + files = e.filterFiles(files, rule.When) + + if len(files) == 0 { + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Engine: "pattern", + Duration: time.Since(start), + }, nil + } + + // Generate ESLint config + config, err := e.eslint.GenerateConfig(&rule) + if err != nil { + return nil, fmt.Errorf("failed to generate config: %w", err) + } + + // Execute ESLint + output, err := e.eslint.Execute(ctx, config, files) + if err != nil { + return nil, fmt.Errorf("failed to execute ESLint: %w", err) + } + + // Parse output + adapterViolations, err := e.eslint.ParseOutput(output) + if err != nil { + return nil, fmt.Errorf("failed to parse 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: rule.ID, + 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: "pattern", + Language: e.detectLanguage(files), + }, nil +} + +// GetCapabilities returns engine capabilities. +func (e *Engine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: "pattern", + SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, + SupportedCategories: []string{"naming", "security", "custom"}, + SupportsAutofix: false, + RequiresCompilation: false, + ExternalTools: []core.ToolRequirement{ + { + Name: "eslint", + Version: "^8.0.0", + Optional: false, + InstallCommand: "npm install -g eslint", + }, + }, + } +} + +// Close cleans up resources. +func (e *Engine) Close() error { + return nil +} + +// filterFiles filters files based on selector. +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 +} + +// detectLanguage detects the language from file extensions. +func (e *Engine) detectLanguage(files []string) string { + if len(files) == 0 { + return "javascript" + } + + // Check first file + file := files[0] + if len(file) > 3 { + ext := file[len(file)-3:] + switch ext { + case ".ts": + return "typescript" + case "jsx": + return "jsx" + case "tsx": + return "tsx" + } + } + + return "javascript" +} diff --git a/internal/engine/pattern/engine_test.go b/internal/engine/pattern/engine_test.go new file mode 100644 index 0000000..ee9f305 --- /dev/null +++ b/internal/engine/pattern/engine_test.go @@ -0,0 +1,159 @@ +package pattern + +import ( + "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 != "pattern" { + t.Errorf("Name = %s, want pattern", caps.Name) + } + + if !contains(caps.SupportedLanguages, "javascript") { + t.Error("Expected javascript in supported languages") + } + + if !contains(caps.SupportedCategories, "naming") { + t.Error("Expected naming in supported categories") + } + + if caps.SupportsAutofix { + t.Error("Pattern engine should not support autofix") + } +} + +func TestMatchesLanguage(t *testing.T) { + engine := &Engine{} + + 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}, + } + + 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) + } + } +} + +func TestFilterFiles(t *testing.T) { + engine := &Engine{} + + files := []string{ + "src/main.js", + "src/app.ts", + "test/test.js", + "README.md", + "src/styles.css", + } + + tests := []struct { + name string + selector *core.Selector + want []string + }{ + { + name: "nil selector - all files", + selector: nil, + want: files, + }, + { + name: "javascript only", + selector: &core.Selector{ + Languages: []string{"javascript"}, + }, + want: []string{"src/main.js", "test/test.js"}, + }, + { + name: "typescript only", + selector: &core.Selector{ + Languages: []string{"typescript"}, + }, + want: []string{"src/app.ts"}, + }, + { + name: "multiple languages", + selector: &core.Selector{ + Languages: []string{"javascript", "typescript"}, + }, + want: []string{"src/main.js", "src/app.ts", "test/test.js"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := engine.filterFiles(files, tt.selector) + if !equalSlices(got, tt.want) { + t.Errorf("filterFiles() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectLanguage(t *testing.T) { + engine := &Engine{} + + tests := []struct { + files []string + want string + }{ + {[]string{"main.js"}, "javascript"}, + {[]string{"app.jsx"}, "jsx"}, + {[]string{"server.ts"}, "typescript"}, + {[]string{"component.tsx"}, "tsx"}, + {[]string{}, "javascript"}, + } + + for _, tt := range tests { + got := engine.detectLanguage(tt.files) + if got != tt.want { + t.Errorf("detectLanguage(%v) = %q, want %q", tt.files, got, tt.want) + } + } +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/engine/registry/builtin.go b/internal/engine/registry/builtin.go new file mode 100644 index 0000000..40d1e78 --- /dev/null +++ b/internal/engine/registry/builtin.go @@ -0,0 +1,32 @@ +package registry + +import ( + "github.com/DevSymphony/sym-cli/internal/engine/ast" + "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/engine/length" + "github.com/DevSymphony/sym-cli/internal/engine/pattern" + "github.com/DevSymphony/sym-cli/internal/engine/style" +) + +// init registers all built-in engines. +func init() { + // Register pattern engine + MustRegister("pattern", func() (core.Engine, error) { + return pattern.NewEngine(), nil + }) + + // Register length engine + MustRegister("length", func() (core.Engine, error) { + return length.NewEngine(), nil + }) + + // Register style engine + MustRegister("style", func() (core.Engine, error) { + return style.NewEngine(), nil + }) + + // Register AST engine + MustRegister("ast", func() (core.Engine, error) { + return ast.NewEngine(), nil + }) +} diff --git a/internal/engine/registry/registry.go b/internal/engine/registry/registry.go new file mode 100644 index 0000000..bc08ebc --- /dev/null +++ b/internal/engine/registry/registry.go @@ -0,0 +1,99 @@ +package registry + +import ( + "fmt" + "sync" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// Registry manages available engines. +// Thread-safe for concurrent access. +type Registry struct { + mu sync.RWMutex + engines map[string]core.Engine + factories map[string]EngineFactory +} + +// EngineFactory creates engine instances. +type EngineFactory func() (core.Engine, error) + +var globalRegistry = &Registry{ + engines: make(map[string]core.Engine), + factories: make(map[string]EngineFactory), +} + +// Global returns the global engine registry. +func Global() *Registry { + return globalRegistry +} + +// Register registers an engine factory. +// The factory will be called lazily when Get() is first called. +func (r *Registry) Register(name string, factory EngineFactory) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.factories[name]; exists { + return fmt.Errorf("engine %q already registered", name) + } + + r.factories[name] = factory + return nil +} + +// Get retrieves an engine by name. +// Creates the engine on first access (lazy initialization). +func (r *Registry) Get(name string) (core.Engine, error) { + // Fast path: check if already created + r.mu.RLock() + if engine, ok := r.engines[name]; ok { + r.mu.RUnlock() + return engine, nil + } + r.mu.RUnlock() + + // Slow path: create engine + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check after acquiring write lock + if engine, ok := r.engines[name]; ok { + return engine, nil + } + + // Look up factory + factory, ok := r.factories[name] + if !ok { + return nil, fmt.Errorf("engine %q not registered", name) + } + + // Create engine + engine, err := factory() + if err != nil { + return nil, fmt.Errorf("failed to create engine %q: %w", name, err) + } + + r.engines[name] = engine + return engine, nil +} + +// List returns all registered engine names. +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.factories)) + for name := range r.factories { + names = append(names, name) + } + return names +} + +// MustRegister registers an engine factory and panics on error. +// Useful for init() functions. +func MustRegister(name string, factory EngineFactory) { + if err := Global().Register(name, factory); err != nil { + panic(err) + } +} diff --git a/internal/engine/registry/registry_test.go b/internal/engine/registry/registry_test.go new file mode 100644 index 0000000..b9b1ae6 --- /dev/null +++ b/internal/engine/registry/registry_test.go @@ -0,0 +1,126 @@ +package registry + +import ( + "context" + "testing" + + "github.com/DevSymphony/sym-cli/internal/engine/core" +) + +// mockEngine is a mock implementation for testing +type mockEngine struct { + name string +} + +func (m *mockEngine) Init(ctx context.Context, config core.EngineConfig) error { + return nil +} + +func (m *mockEngine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Engine: m.name, + }, nil +} + +func (m *mockEngine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: m.name, + } +} + +func (m *mockEngine) Close() error { + return nil +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + r := &Registry{ + engines: make(map[string]core.Engine), + factories: make(map[string]EngineFactory), + } + + // Register factory + err := r.Register("test", func() (core.Engine, error) { + return &mockEngine{name: "test"}, nil + }) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + // Get engine (should create it) + engine, err := r.Get("test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if engine == nil { + t.Fatal("Get returned nil engine") + } + + // Get again (should return same instance) + engine2, err := r.Get("test") + if err != nil { + t.Fatalf("Get (2nd) failed: %v", err) + } + + if engine != engine2 { + t.Error("Get returned different instances (should be same)") + } +} + +func TestRegistry_RegisterDuplicate(t *testing.T) { + r := &Registry{ + engines: make(map[string]core.Engine), + factories: make(map[string]EngineFactory), + } + + factory := func() (core.Engine, error) { + return &mockEngine{name: "test"}, nil + } + + // First registration should succeed + if err := r.Register("test", factory); err != nil { + t.Fatalf("First Register failed: %v", err) + } + + // Second registration should fail + err := r.Register("test", factory) + if err == nil { + t.Error("Register duplicate succeeded, want error") + } +} + +func TestRegistry_GetNonExistent(t *testing.T) { + r := &Registry{ + engines: make(map[string]core.Engine), + factories: make(map[string]EngineFactory), + } + + _, err := r.Get("nonexistent") + if err == nil { + t.Error("Get(nonexistent) succeeded, want error") + } +} + +func TestRegistry_List(t *testing.T) { + r := &Registry{ + engines: make(map[string]core.Engine), + factories: make(map[string]EngineFactory), + } + + names := []string{"pattern", "length", "style"} + for _, name := range names { + n := name // capture + if err := r.Register(n, func() (core.Engine, error) { + return &mockEngine{name: n}, nil + }); err != nil { + t.Fatalf("Register(%s) failed: %v", n, err) + } + } + + got := r.List() + if len(got) != len(names) { + t.Errorf("List() length = %d, want %d", len(got), len(names)) + } +} diff --git a/internal/engine/style/autofix.go b/internal/engine/style/autofix.go new file mode 100644 index 0000000..de20c10 --- /dev/null +++ b/internal/engine/style/autofix.go @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..4719e92 --- /dev/null +++ b/internal/engine/style/engine.go @@ -0,0 +1,178 @@ +package style + +import ( + "context" + "fmt" + "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" +) + +// Engine validates code style rules (indent, quotes, semicolons, etc.). +// +// 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 +type Engine struct { + eslint *eslint.Adapter + prettier *prettier.Adapter + config core.EngineConfig +} + +// NewEngine creates a new style 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 adapters + 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 { + if config.Debug { + fmt.Printf("ESLint not found, attempting install...\n") + } + + installConfig := struct { + ToolsDir string + Version string + Force bool + }{ToolsDir: config.ToolsDir} + + if err := e.eslint.Install(ctx, installConfig); err != nil { + return fmt.Errorf("failed to install ESLint: %w", err) + } + } + + // 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 +} + +// Validate validates files against a style rule. +func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { + start := time.Now() + + files = e.filterFiles(files, rule.When) + if len(files) == 0 { + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Engine: "style", + Duration: time.Since(start), + }, nil + } + + // Generate ESLint config for validation + eslintConfig, err := e.eslint.GenerateConfig(&rule) + if err != nil { + return nil, fmt.Errorf("failed to generate ESLint config: %w", err) + } + + // Execute ESLint + output, err := e.eslint.Execute(ctx, eslintConfig, files) + if err != nil { + return nil, fmt.Errorf("failed to execute ESLint: %w", err) + } + + // Parse violations + adapterViolations, err := e.eslint.ParseOutput(output) + if err != nil { + return nil, fmt.Errorf("failed to parse ESLint 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: rule.ID, + Category: rule.Category, + } + + 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{ + RuleID: rule.ID, + Passed: len(violations) == 0, + Violations: violations, + Duration: time.Since(start), + Engine: "style", + Language: "javascript", + }, nil +} + +// GetCapabilities returns engine capabilities. +func (e *Engine) GetCapabilities() core.EngineCapabilities { + return core.EngineCapabilities{ + Name: "style", + SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, + SupportedCategories: []string{"style", "formatting"}, + SupportsAutofix: true, + RequiresCompilation: false, + ExternalTools: []core.ToolRequirement{ + { + Name: "eslint", + Version: "^8.0.0", + Optional: false, + InstallCommand: "npm install -g eslint", + }, + { + Name: "prettier", + Version: "^3.0.0", + Optional: true, + InstallCommand: "npm install -g prettier", + }, + }, + } +} + +// Close cleans up resources. +func (e *Engine) Close() error { + return nil +} + +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 +} diff --git a/internal/engine/style/engine_test.go b/internal/engine/style/engine_test.go new file mode 100644 index 0000000..57e97d6 --- /dev/null +++ b/internal/engine/style/engine_test.go @@ -0,0 +1,97 @@ +package style + +import ( + "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 != "style" { + t.Errorf("Name = %s, want style", caps.Name) + } + + if !contains(caps.SupportedLanguages, "javascript") { + t.Error("Expected javascript in supported languages") + } + + if !contains(caps.SupportedCategories, "formatting") { + t.Error("Expected formatting in supported categories") + } + + if !caps.SupportsAutofix { + t.Error("Style engine should support autofix") + } +} + +func TestFilterFiles(t *testing.T) { + engine := &Engine{} + + files := []string{ + "src/main.js", + "src/app.ts", + "test/test.js", + "README.md", + } + + tests := []struct { + name string + selector *core.Selector + want []string + }{ + { + name: "nil selector - all files", + selector: nil, + want: files, + }, + { + name: "javascript only - selector ignored by style engine", + selector: &core.Selector{ + Languages: []string{"javascript"}, + }, + want: []string{"src/main.js", "src/app.ts", "test/test.js"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := engine.filterFiles(files, tt.selector) + if !equalSlices(got, tt.want) { + t.Errorf("filterFiles() = %v, want %v", got, tt.want) + } + }) + } +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/tests/integration/ast_test.go b/tests/integration/ast_test.go new file mode 100644 index 0000000..26ebea0 --- /dev/null +++ b/tests/integration/ast_test.go @@ -0,0 +1,132 @@ +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/length_test.go b/tests/integration/length_test.go new file mode 100644 index 0000000..07237aa --- /dev/null +++ b/tests/integration/length_test.go @@ -0,0 +1,216 @@ +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_test.go b/tests/integration/pattern_test.go new file mode 100644 index 0000000..993cf29 --- /dev/null +++ b/tests/integration/pattern_test.go @@ -0,0 +1,199 @@ +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_test.go b/tests/integration/style_test.go new file mode 100644 index 0000000..e622042 --- /dev/null +++ b/tests/integration/style_test.go @@ -0,0 +1,110 @@ +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/testdata/javascript/async-with-try.js b/tests/testdata/javascript/async-with-try.js new file mode 100644 index 0000000..0eb2c13 --- /dev/null +++ b/tests/testdata/javascript/async-with-try.js @@ -0,0 +1,24 @@ +// Good: Async function with try-catch + +async function fetchData() { + try { + const response = await fetch("https://api.example.com/data"); + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to fetch data:", error); + throw error; + } +} + +async function processFile(filename) { + try { + const content = await readFile(filename); + return JSON.parse(content); + } catch (error) { + console.error("Failed to process file:", error); + return null; + } +} + +module.exports = { fetchData, processFile }; diff --git a/tests/testdata/javascript/async-without-try.js b/tests/testdata/javascript/async-without-try.js new file mode 100644 index 0000000..be996e1 --- /dev/null +++ b/tests/testdata/javascript/async-without-try.js @@ -0,0 +1,20 @@ +// Bad: Async functions without try-catch + +async function fetchData() { + const response = await fetch("https://api.example.com/data"); + const data = await response.json(); + return data; +} + +async function processFile(filename) { + const content = await readFile(filename); + return JSON.parse(content); +} + +// This one is also bad - no try-catch +async function saveData(data) { + await writeFile("output.json", JSON.stringify(data)); + console.log("Data saved"); +} + +module.exports = { fetchData, processFile, saveData }; diff --git a/tests/testdata/javascript/bad-imports.js b/tests/testdata/javascript/bad-imports.js new file mode 100644 index 0000000..5e93b7b --- /dev/null +++ b/tests/testdata/javascript/bad-imports.js @@ -0,0 +1,13 @@ +// Bad: Importing from restricted modules + +import { something } from 'lodash'; +import _ from 'lodash'; +const moment = require('moment'); + +// These should be caught by no-restricted-imports +import React from 'react'; +import { useState } from 'react'; + +export default function Component() { + return null; +} diff --git a/tests/testdata/javascript/bad-naming.js b/tests/testdata/javascript/bad-naming.js new file mode 100644 index 0000000..51b7dad --- /dev/null +++ b/tests/testdata/javascript/bad-naming.js @@ -0,0 +1,31 @@ +// This file contains intentional violations for testing + +// ❌ Class name should be PascalCase +class myClass { + constructor() { + this.value = 42; + } +} + +// ❌ Function name should be camelCase +function MyFunction() { + return "hello"; +} + +// ❌ Variable with underscore (if pattern requires camelCase) +const my_variable = 123; + +// ❌ Line exceeds 100 characters +const veryLongLine = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore"; + +// ❌ Function with too many parameters (>5) +function tooManyParams(a, b, c, d, e, f, g) { + return a + b + c + d + e + f + g; +} + +// ✅ Good naming +class User { + getName() { + return "John"; + } +} diff --git a/tests/testdata/javascript/bad-style.js b/tests/testdata/javascript/bad-style.js new file mode 100644 index 0000000..a1b4522 --- /dev/null +++ b/tests/testdata/javascript/bad-style.js @@ -0,0 +1,21 @@ +// This file has bad style (no prettier formatting) + +const x={a:1,b:2,c:3}; + +function foo(a,b,c){ +return a+b+c +} + +const longObject = {name:"John",age:30,city:"New York",country:"USA",occupation:"Developer"}; + +class MyClass{ +constructor(){ +this.value=42; +} + +getValue(){return this.value;} +} + +const arr=[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]; + +module.exports={MyClass,foo,longObject}; diff --git a/tests/testdata/javascript/good-code.js b/tests/testdata/javascript/good-code.js new file mode 100644 index 0000000..f35170a --- /dev/null +++ b/tests/testdata/javascript/good-code.js @@ -0,0 +1,23 @@ +// This file follows all conventions + +class UserService { + constructor() { + this.users = []; + } + + addUser(user) { + this.users.push(user); + } + + getUsers() { + return this.users; + } +} + +function calculateTotal(items) { + return items.reduce((sum, item) => sum + item.price, 0); +} + +const apiEndpoint = "https://api.example.com"; + +module.exports = { UserService, calculateTotal }; diff --git a/tests/testdata/javascript/good-style.js b/tests/testdata/javascript/good-style.js new file mode 100644 index 0000000..2aaf57d --- /dev/null +++ b/tests/testdata/javascript/good-style.js @@ -0,0 +1,29 @@ +// This file has good style (prettier formatted) + +const x = { a: 1, b: 2, c: 3 }; + +function foo(a, b, c) { + return a + b + c; +} + +const longObject = { + name: "John", + age: 30, + city: "New York", + country: "USA", + occupation: "Developer", +}; + +class MyClass { + constructor() { + this.value = 42; + } + + getValue() { + return this.value; + } +} + +const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + +module.exports = { MyClass, foo, longObject }; diff --git a/tests/testdata/javascript/hardcoded-secrets.js b/tests/testdata/javascript/hardcoded-secrets.js new file mode 100644 index 0000000..e7522fe --- /dev/null +++ b/tests/testdata/javascript/hardcoded-secrets.js @@ -0,0 +1,15 @@ +// Bad: Hardcoded secrets (for content pattern testing) + +const API_KEY = "sk-1234567890abcdef"; +const apiKey = "my-secret-key-12345"; +const password = "admin123"; + +const config = { + secret: "my-secret-token", + apiKey: "hardcoded-api-key" +}; + +// This should also be caught +const SECRET_TOKEN = "secret_abc123"; + +module.exports = { API_KEY, config }; diff --git a/tests/testdata/javascript/long-file.js b/tests/testdata/javascript/long-file.js new file mode 100644 index 0000000..7b4001c --- /dev/null +++ b/tests/testdata/javascript/long-file.js @@ -0,0 +1,64 @@ +// File with too many lines for testing max-lines + +function func1() { return 1; } +function func2() { return 2; } +function func3() { return 3; } +function func4() { return 4; } +function func5() { return 5; } +function func6() { return 6; } +function func7() { return 7; } +function func8() { return 8; } +function func9() { return 9; } +function func10() { return 10; } +function func11() { return 11; } +function func12() { return 12; } +function func13() { return 13; } +function func14() { return 14; } +function func15() { return 15; } +function func16() { return 16; } +function func17() { return 17; } +function func18() { return 18; } +function func19() { return 19; } +function func20() { return 20; } +function func21() { return 21; } +function func22() { return 22; } +function func23() { return 23; } +function func24() { return 24; } +function func25() { return 25; } +function func26() { return 26; } +function func27() { return 27; } +function func28() { return 28; } +function func29() { return 29; } +function func30() { return 30; } +function func31() { return 31; } +function func32() { return 32; } +function func33() { return 33; } +function func34() { return 34; } +function func35() { return 35; } +function func36() { return 36; } +function func37() { return 37; } +function func38() { return 38; } +function func39() { return 39; } +function func40() { return 40; } +function func41() { return 41; } +function func42() { return 42; } +function func43() { return 43; } +function func44() { return 44; } +function func45() { return 45; } +function func46() { return 46; } +function func47() { return 47; } +function func48() { return 48; } +function func49() { return 49; } +function func50() { return 50; } +function func51() { return 51; } +function func52() { return 52; } +function func53() { return 53; } +function func54() { return 54; } +function func55() { return 55; } +function func56() { return 56; } +function func57() { return 57; } +function func58() { return 58; } +function func59() { return 59; } +function func60() { return 60; } + +module.exports = { func1, func60 }; diff --git a/tests/testdata/javascript/long-function.js b/tests/testdata/javascript/long-function.js new file mode 100644 index 0000000..ce5c3f6 --- /dev/null +++ b/tests/testdata/javascript/long-function.js @@ -0,0 +1,58 @@ +// File with a very long function for testing max-lines-per-function + +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; + + return line50; +} + +module.exports = { veryLongFunction }; diff --git a/tests/testdata/javascript/long-lines.js b/tests/testdata/javascript/long-lines.js new file mode 100644 index 0000000..8eed5a9 --- /dev/null +++ b/tests/testdata/javascript/long-lines.js @@ -0,0 +1,12 @@ +// File with long lines for testing max-len + +const shortLine = "ok"; + +const veryLongLine = "This is a very long line that exceeds 80 characters and should be flagged by the max-len rule for ESLint validation purposes."; + +function example() { + const anotherVeryLongLineHereThatDefinitelyExceedsTheMaximumLengthAndShouldBeFlaggedByOurLengthEngine = true; + return anotherVeryLongLineHereThatDefinitelyExceedsTheMaximumLengthAndShouldBeFlaggedByOurLengthEngine; +} + +module.exports = { example }; diff --git a/tests/testdata/javascript/many-params.js b/tests/testdata/javascript/many-params.js new file mode 100644 index 0000000..599b8c1 --- /dev/null +++ b/tests/testdata/javascript/many-params.js @@ -0,0 +1,15 @@ +// File with functions that have too many parameters + +function tooManyParams(a, b, c, d, e, f, g) { + return a + b + c + d + e + f + g; +} + +const arrowWithManyParams = (x1, x2, x3, x4, x5, x6, x7, x8) => { + return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8; +}; + +function goodFunction(a, b, c) { + return a + b + c; +} + +module.exports = { tooManyParams, arrowWithManyParams, goodFunction };