Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.claude/
bin/
coverage.out
76 changes: 76 additions & 0 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions internal/adapter/eslint/adapter.go
Original file line number Diff line number Diff line change
@@ -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)
}
119 changes: 119 additions & 0 deletions internal/adapter/eslint/ast.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading