From 826dffea8e8fa9bf6a1c6004cee643e1ec5d8a4f Mon Sep 17 00:00:00 2001 From: ikjeong Date: Mon, 8 Dec 2025 08:48:08 +0000 Subject: [PATCH 1/4] refactor: rename adapter package to linter with new interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/adapter/ → internal/linter/ - adapter.go → linter.go - ConvertRules() → ConvertSingleRule() + BuildConfig() - Add SingleRuleResult type for single rule conversion - Add helpers.go with CleanJSONResponse - Add registry.go for global linter registry --- internal/adapter/README.md | 17 -- internal/adapter/adapter.go | 136 ---------- internal/adapter/checkstyle/register.go | 14 - internal/adapter/common.go | 13 - internal/adapter/eslint/register.go | 14 - internal/adapter/pmd/register.go | 14 - internal/adapter/prettier/register.go | 14 - internal/adapter/pylint/register.go | 14 - internal/adapter/registry/errors.go | 15 -- internal/adapter/registry/registry.go | 210 --------------- internal/adapter/tsc/register.go | 14 - internal/bootstrap/adapters.go | 16 -- internal/bootstrap/linters.go | 16 ++ internal/linter/README.md | 252 ++++++++++++++++++ .../checkstyle/converter.go | 107 +++----- .../checkstyle/executor.go | 18 +- .../checkstyle/linter.go} | 61 +++-- .../{adapter => linter}/checkstyle/parser.go | 10 +- internal/linter/checkstyle/register.go | 13 + internal/linter/converter.go | 64 +++++ .../{adapter => linter}/eslint/converter.go | 157 +++++------ .../{adapter => linter}/eslint/executor.go | 26 +- .../eslint/executor_test.go | 16 +- .../adapter.go => linter/eslint/linter.go} | 69 ++--- .../eslint/linter_test.go} | 36 +-- internal/{adapter => linter}/eslint/parser.go | 8 +- .../{adapter => linter}/eslint/parser_test.go | 6 +- internal/linter/eslint/register.go | 13 + internal/linter/helpers.go | 100 +++++++ internal/linter/linter.go | 94 +++++++ internal/{adapter => linter}/pmd/converter.go | 102 +++---- internal/{adapter => linter}/pmd/executor.go | 18 +- .../pmd/adapter.go => linter/pmd/linter.go} | 67 ++--- internal/{adapter => linter}/pmd/parser.go | 12 +- internal/linter/pmd/register.go | 13 + .../{adapter => linter}/prettier/converter.go | 107 ++++---- .../{adapter => linter}/prettier/executor.go | 20 +- .../prettier/executor_test.go | 8 +- .../adapter.go => linter/prettier/linter.go} | 67 ++--- .../prettier/linter_test.go} | 36 +-- .../{adapter => linter}/prettier/parser.go | 8 +- internal/linter/prettier/register.go | 13 + .../{adapter => linter}/pylint/converter.go | 140 +++++----- .../{adapter => linter}/pylint/executor.go | 20 +- .../pylint/executor_test.go | 8 +- .../adapter.go => linter/pylint/linter.go} | 89 ++++--- .../pylint/linter_test.go} | 38 +-- internal/{adapter => linter}/pylint/parser.go | 8 +- .../{adapter => linter}/pylint/parser_test.go | 12 +- internal/linter/pylint/register.go | 13 + internal/linter/registry.go | 170 ++++++++++++ internal/{adapter => linter}/subprocess.go | 2 +- .../{adapter => linter}/subprocess_test.go | 2 +- internal/{adapter => linter}/tsc/converter.go | 137 +++++----- internal/{adapter => linter}/tsc/executor.go | 10 +- .../tsc/adapter.go => linter/tsc/linter.go} | 63 ++--- .../tsc/linter_test.go} | 50 ++-- internal/{adapter => linter}/tsc/parser.go | 18 +- .../{adapter => linter}/tsc/parser_test.go | 6 +- internal/linter/tsc/register.go | 13 + 60 files changed, 1520 insertions(+), 1307 deletions(-) delete mode 100644 internal/adapter/README.md delete mode 100644 internal/adapter/adapter.go delete mode 100644 internal/adapter/checkstyle/register.go delete mode 100644 internal/adapter/common.go delete mode 100644 internal/adapter/eslint/register.go delete mode 100644 internal/adapter/pmd/register.go delete mode 100644 internal/adapter/prettier/register.go delete mode 100644 internal/adapter/pylint/register.go delete mode 100644 internal/adapter/registry/errors.go delete mode 100644 internal/adapter/registry/registry.go delete mode 100644 internal/adapter/tsc/register.go delete mode 100644 internal/bootstrap/adapters.go create mode 100644 internal/bootstrap/linters.go create mode 100644 internal/linter/README.md rename internal/{adapter => linter}/checkstyle/converter.go (84%) rename internal/{adapter => linter}/checkstyle/executor.go (74%) rename internal/{adapter/checkstyle/adapter.go => linter/checkstyle/linter.go} (68%) rename internal/{adapter => linter}/checkstyle/parser.go (90%) create mode 100644 internal/linter/checkstyle/register.go create mode 100644 internal/linter/converter.go rename internal/{adapter => linter}/eslint/converter.go (67%) rename internal/{adapter => linter}/eslint/executor.go (70%) rename internal/{adapter => linter}/eslint/executor_test.go (94%) rename internal/{adapter/eslint/adapter.go => linter/eslint/linter.go} (59%) rename internal/{adapter/eslint/adapter_test.go => linter/eslint/linter_test.go} (84%) rename internal/{adapter => linter}/eslint/parser.go (87%) rename internal/{adapter => linter}/eslint/parser_test.go (93%) create mode 100644 internal/linter/eslint/register.go create mode 100644 internal/linter/helpers.go create mode 100644 internal/linter/linter.go rename internal/{adapter => linter}/pmd/converter.go (75%) rename internal/{adapter => linter}/pmd/executor.go (76%) rename internal/{adapter/pmd/adapter.go => linter/pmd/linter.go} (71%) rename internal/{adapter => linter}/pmd/parser.go (89%) create mode 100644 internal/linter/pmd/register.go rename internal/{adapter => linter}/prettier/converter.go (64%) rename internal/{adapter => linter}/prettier/executor.go (69%) rename internal/{adapter => linter}/prettier/executor_test.go (96%) rename internal/{adapter/prettier/adapter.go => linter/prettier/linter.go} (52%) rename internal/{adapter/prettier/adapter_test.go => linter/prettier/linter_test.go} (84%) rename internal/{adapter => linter}/prettier/parser.go (90%) create mode 100644 internal/linter/prettier/register.go rename internal/{adapter => linter}/pylint/converter.go (71%) rename internal/{adapter => linter}/pylint/executor.go (64%) rename internal/{adapter => linter}/pylint/executor_test.go (96%) rename internal/{adapter/pylint/adapter.go => linter/pylint/linter.go} (72%) rename internal/{adapter/pylint/adapter_test.go => linter/pylint/linter_test.go} (86%) rename internal/{adapter => linter}/pylint/parser.go (92%) rename internal/{adapter => linter}/pylint/parser_test.go (95%) create mode 100644 internal/linter/pylint/register.go create mode 100644 internal/linter/registry.go rename internal/{adapter => linter}/subprocess.go (99%) rename internal/{adapter => linter}/subprocess_test.go (99%) rename internal/{adapter => linter}/tsc/converter.go (56%) rename internal/{adapter => linter}/tsc/executor.go (88%) rename internal/{adapter/tsc/adapter.go => linter/tsc/linter.go} (60%) rename internal/{adapter/tsc/adapter_test.go => linter/tsc/linter_test.go} (80%) rename internal/{adapter => linter}/tsc/parser.go (85%) rename internal/{adapter => linter}/tsc/parser_test.go (97%) create mode 100644 internal/linter/tsc/register.go diff --git a/internal/adapter/README.md b/internal/adapter/README.md deleted file mode 100644 index e00460a..0000000 --- a/internal/adapter/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# adapter - -외부 검증 도구를 표준 인터페이스로 통합하는 어댑터 레이어입니다. - -ESLint, Prettier, TypeScript Compiler(TSC), Checkstyle, PMD 등의 도구를 subprocess로 실행하고 결과를 파싱합니다. - -## 서브패키지 - -- `eslint`: JavaScript/TypeScript 린터 어댑터 -- `prettier`: 코드 포매터 어댑터 -- `tsc`: TypeScript 타입 체커 어댑터 -- `checkstyle`: Java 스타일 체커 어댑터 -- `pmd`: Java 정적 분석 도구 어댑터 -- `registry`: 어댑터 등록 및 검색 시스템 - -**사용자**: engine -**의존성**: engine/core diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go deleted file mode 100644 index 7e04c30..0000000 --- a/internal/adapter/adapter.go +++ /dev/null @@ -1,136 +0,0 @@ -package adapter - -import ( - "context" - - "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/pkg/schema" -) - -// 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 - - // GetCapabilities returns the adapter's capabilities. - // This includes supported languages, categories, and version info. - GetCapabilities() AdapterCapabilities - - // 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 - - // Execute runs the tool with the given config and files. - // Config is read from .sym directory (e.g., .sym/.eslintrc.json). - // 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) -} - -// AdapterCapabilities describes what an adapter can do. -type AdapterCapabilities struct { - // Name is the adapter identifier (e.g., "eslint", "checkstyle"). - Name string - - // SupportedLanguages lists languages this adapter can validate. - // Examples: ["javascript", "typescript", "java"] - SupportedLanguages []string - - // SupportedCategories lists rule categories this adapter can handle. - // Examples: ["pattern", "length", "style", "ast", "complexity"] - SupportedCategories []string - - // Version is the tool version (e.g., "8.0.0", "10.12.0"). - Version string -} - -// InstallConfig holds tool installation settings. -type InstallConfig struct { - // ToolsDir is where to install the tool. - // Default: ~/.sym/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 -} - -// LinterConverter converts user rules to native linter configuration using LLM. -// This interface is implemented by each linter's converter (e.g., ESLintConverter). -type LinterConverter interface { - // Name returns the linter name (e.g., "eslint", "checkstyle", "pmd") - Name() string - - // SupportedLanguages returns the languages this linter supports - SupportedLanguages() []string - - // GetLLMDescription returns a description of the linter's capabilities for LLM routing. - // This is used in the LLM prompt to help route rules to appropriate linters. - GetLLMDescription() string - - // GetRoutingHints returns routing rules for LLM to decide when to use this linter. - // Each hint is a rule like "For Java naming rules → ALWAYS use checkstyle". - // These hints are collected and included in the LLM prompt for rule routing. - GetRoutingHints() []string - - // ConvertRules converts user rules to native linter configuration using LLM. - // Returns ConversionResult with per-rule success/failure tracking for fallback support. - ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*ConversionResult, error) -} - -// LinterConfig represents a generated configuration file. -type LinterConfig struct { - Filename string // e.g., ".eslintrc.json", "checkstyle.xml" - Content []byte // File content - Format string // "json", "xml", "yaml" -} - -// ConversionResult contains the conversion output with per-rule tracking. -// This allows the main converter to know which rules succeeded vs failed, -// enabling fallback to llm-validator for failed rules. -type ConversionResult struct { - Config *LinterConfig // Generated config file (may be nil if all rules failed) - SuccessRules []string // Rule IDs that converted successfully - FailedRules []string // Rule IDs that couldn't be converted (fallback to llm-validator) -} diff --git a/internal/adapter/checkstyle/register.go b/internal/adapter/checkstyle/register.go deleted file mode 100644 index 44e4ba0..0000000 --- a/internal/adapter/checkstyle/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package checkstyle - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - "checkstyle.xml", - ) -} diff --git a/internal/adapter/common.go b/internal/adapter/common.go deleted file mode 100644 index ce87445..0000000 --- a/internal/adapter/common.go +++ /dev/null @@ -1,13 +0,0 @@ -package adapter - -import ( - "os" - "path/filepath" -) - -// DefaultToolsDir returns the standard tools directory (~/.sym/tools). -// Used by all adapters for consistent tool installation location. -func DefaultToolsDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".sym", "tools") -} diff --git a/internal/adapter/eslint/register.go b/internal/adapter/eslint/register.go deleted file mode 100644 index 40223c6..0000000 --- a/internal/adapter/eslint/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package eslint - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - ".eslintrc.json", - ) -} diff --git a/internal/adapter/pmd/register.go b/internal/adapter/pmd/register.go deleted file mode 100644 index e75669a..0000000 --- a/internal/adapter/pmd/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package pmd - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - "pmd.xml", - ) -} diff --git a/internal/adapter/prettier/register.go b/internal/adapter/prettier/register.go deleted file mode 100644 index de420ee..0000000 --- a/internal/adapter/prettier/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package prettier - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - ".prettierrc", - ) -} diff --git a/internal/adapter/pylint/register.go b/internal/adapter/pylint/register.go deleted file mode 100644 index 5b16dec..0000000 --- a/internal/adapter/pylint/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package pylint - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - ".pylintrc", - ) -} diff --git a/internal/adapter/registry/errors.go b/internal/adapter/registry/errors.go deleted file mode 100644 index 5dd7688..0000000 --- a/internal/adapter/registry/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -package registry - -import "fmt" - -// errAdapterNotFound is returned when no adapter is found for the given tool name. -type errAdapterNotFound struct { - ToolName string -} - -func (e *errAdapterNotFound) Error() string { - return fmt.Sprintf("adapter not found: %s", e.ToolName) -} - -// errNilAdapter is returned when trying to register a nil adapter. -var errNilAdapter = fmt.Errorf("cannot register nil adapter") diff --git a/internal/adapter/registry/registry.go b/internal/adapter/registry/registry.go deleted file mode 100644 index 0c6a76b..0000000 --- a/internal/adapter/registry/registry.go +++ /dev/null @@ -1,210 +0,0 @@ -package registry - -import ( - "log" - "sync" - - "github.com/DevSymphony/sym-cli/internal/adapter" -) - -// ToolRegistration contains all metadata for a linter tool. -type ToolRegistration struct { - Adapter adapter.Adapter // Adapter instance - Converter adapter.LinterConverter // LinterConverter instance (optional) - ConfigFile string // Config filename (e.g., ".eslintrc.json") -} - -// Registry manages tool registrations. -type Registry struct { - mu sync.RWMutex - tools map[string]*ToolRegistration - - // Legacy: adapters map for backward compatibility - adapters map[string]adapter.Adapter -} - -var ( - globalRegistry *Registry - once sync.Once -) - -// Global returns the singleton registry instance. -func Global() *Registry { - once.Do(func() { - globalRegistry = &Registry{ - tools: make(map[string]*ToolRegistration), - adapters: make(map[string]adapter.Adapter), - } - }) - return globalRegistry -} - -// NewRegistry creates a new empty adapter registry. -// Deprecated: Use Global() instead for the singleton pattern. -func NewRegistry() *Registry { - return &Registry{ - tools: make(map[string]*ToolRegistration), - adapters: make(map[string]adapter.Adapter), - } -} - -// RegisterTool registers a tool with adapter, converter, and config file. -func (r *Registry) RegisterTool( - adp adapter.Adapter, - converter adapter.LinterConverter, - configFile string, -) error { - if adp == nil { - return errNilAdapter - } - - name := adp.Name() - - r.mu.Lock() - defer r.mu.Unlock() - - // Warn on duplicate registration (init order issues) - if _, exists := r.tools[name]; exists { - log.Printf("warning: adapter already registered: %s (ignoring duplicate)", name) - return nil - } - - r.tools[name] = &ToolRegistration{ - Adapter: adp, - Converter: converter, - ConfigFile: configFile, - } - - // Also register in legacy adapters map for backward compatibility - r.adapters[name] = adp - - return nil -} - -// Register adds an adapter to the registry. -// Deprecated: Use RegisterTool() for new registrations. -func (r *Registry) Register(adp adapter.Adapter) error { - if adp == nil { - return errNilAdapter - } - - r.mu.Lock() - defer r.mu.Unlock() - - name := adp.Name() - - // Check if already registered via RegisterTool - if _, exists := r.tools[name]; exists { - return nil - } - - r.adapters[name] = adp - - return nil -} - -// GetAdapter finds an adapter by tool name (e.g., "eslint", "prettier", "tsc"). -func (r *Registry) GetAdapter(toolName string) (adapter.Adapter, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - // First check tools map - if reg, ok := r.tools[toolName]; ok { - return reg.Adapter, nil - } - - // Fallback to legacy adapters map - if adp, ok := r.adapters[toolName]; ok { - return adp, nil - } - - return nil, &errAdapterNotFound{ToolName: toolName} -} - -// GetConverter returns LinterConverter by tool name. -func (r *Registry) GetConverter(name string) (adapter.LinterConverter, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - - if reg, ok := r.tools[name]; ok && reg.Converter != nil { - return reg.Converter, true - } - return nil, false -} - -// GetConfigFile returns config filename by tool name. -func (r *Registry) GetConfigFile(name string) string { - r.mu.RLock() - defer r.mu.RUnlock() - - if reg, ok := r.tools[name]; ok { - return reg.ConfigFile - } - return "" -} - -// BuildLanguageMapping dynamically builds language->tools mapping from adapter capabilities. -func (r *Registry) BuildLanguageMapping() map[string][]string { - r.mu.RLock() - defer r.mu.RUnlock() - - mapping := make(map[string][]string) - for name, reg := range r.tools { - caps := reg.Adapter.GetCapabilities() - for _, lang := range caps.SupportedLanguages { - mapping[lang] = append(mapping[lang], name) - } - } - return mapping -} - -// GetAllToolNames returns all registered tool names. -func (r *Registry) GetAllToolNames() []string { - r.mu.RLock() - defer r.mu.RUnlock() - - names := make([]string, 0, len(r.tools)+len(r.adapters)) - seen := make(map[string]bool) - - for name := range r.tools { - names = append(names, name) - seen[name] = true - } - - // Add any legacy adapters not in tools - for name := range r.adapters { - if !seen[name] { - names = append(names, name) - } - } - - return names -} - -// GetAllConfigFiles returns all registered config file names. -func (r *Registry) GetAllConfigFiles() []string { - r.mu.RLock() - defer r.mu.RUnlock() - - files := make([]string, 0, len(r.tools)) - for _, reg := range r.tools { - if reg.ConfigFile != "" { - files = append(files, reg.ConfigFile) - } - } - return files -} - -// GetAllConverters returns all registered converters. -func (r *Registry) GetAllConverters() []adapter.LinterConverter { - r.mu.RLock() - defer r.mu.RUnlock() - - converters := make([]adapter.LinterConverter, 0, len(r.tools)) - for _, reg := range r.tools { - if reg.Converter != nil { - converters = append(converters, reg.Converter) - } - } - return converters -} diff --git a/internal/adapter/tsc/register.go b/internal/adapter/tsc/register.go deleted file mode 100644 index 0b02a81..0000000 --- a/internal/adapter/tsc/register.go +++ /dev/null @@ -1,14 +0,0 @@ -package tsc - -import ( - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" -) - -func init() { - _ = registry.Global().RegisterTool( - NewAdapter(adapter.DefaultToolsDir()), - NewConverter(), - "tsconfig.json", - ) -} diff --git a/internal/bootstrap/adapters.go b/internal/bootstrap/adapters.go deleted file mode 100644 index 44c1566..0000000 --- a/internal/bootstrap/adapters.go +++ /dev/null @@ -1,16 +0,0 @@ -package bootstrap - -import ( - // Import adapters for registration side-effects. - // Each adapter's register.go file contains an init() function - // that registers the adapter with the global registry. - _ "github.com/DevSymphony/sym-cli/internal/adapter/checkstyle" - _ "github.com/DevSymphony/sym-cli/internal/adapter/eslint" - _ "github.com/DevSymphony/sym-cli/internal/adapter/pmd" - _ "github.com/DevSymphony/sym-cli/internal/adapter/prettier" - _ "github.com/DevSymphony/sym-cli/internal/adapter/pylint" - _ "github.com/DevSymphony/sym-cli/internal/adapter/tsc" -) - -// This package only imports adapter packages for their init() side-effects. -// Import this package from main.go to ensure all adapters are registered. diff --git a/internal/bootstrap/linters.go b/internal/bootstrap/linters.go new file mode 100644 index 0000000..0eb4567 --- /dev/null +++ b/internal/bootstrap/linters.go @@ -0,0 +1,16 @@ +package bootstrap + +import ( + // Import linters for registration side-effects. + // Each linter's register.go file contains an init() function + // that registers the linter with the global registry. + _ "github.com/DevSymphony/sym-cli/internal/linter/checkstyle" + _ "github.com/DevSymphony/sym-cli/internal/linter/eslint" + _ "github.com/DevSymphony/sym-cli/internal/linter/pmd" + _ "github.com/DevSymphony/sym-cli/internal/linter/prettier" + _ "github.com/DevSymphony/sym-cli/internal/linter/pylint" + _ "github.com/DevSymphony/sym-cli/internal/linter/tsc" +) + +// This package only imports linter packages for their init() side-effects. +// Import this package from main.go to ensure all linters are registered. diff --git a/internal/linter/README.md b/internal/linter/README.md new file mode 100644 index 0000000..5873f80 --- /dev/null +++ b/internal/linter/README.md @@ -0,0 +1,252 @@ +# Linter Package + +Unified interface for static linting tools. + +## File Structure + +``` +internal/linter/ +├── linter.go # Linter interface + Capabilities, InstallConfig, ToolOutput, Violation +├── converter.go # Converter interface + LinterConfig, ConversionResult +├── registry.go # Registry, Global(), RegisterTool(), GetLinter() +├── helpers.go # DefaultToolsDir, WriteTempConfig, MapSeverity, FindTool +├── executor.go # SubprocessExecutor +├── eslint/ # JavaScript/TypeScript +├── prettier/ # Code formatting +├── pylint/ # Python +├── tsc/ # TypeScript type checking +├── checkstyle/ # Java style +└── pmd/ # Java static analysis +``` + +## Usage + +```go +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +// Get linter by name +l, err := linter.Global().GetLinter("eslint") +if err != nil { + return err +} + +// Check availability and install if needed +if err := l.CheckAvailability(ctx); err != nil { + if err := l.Install(ctx, linter.InstallConfig{}); err != nil { + return err + } +} + +// Execute linter +output, err := l.Execute(ctx, config, files) +if err != nil { + return err +} + +// Parse output +violations, err := l.ParseOutput(output) +``` + +## Linter List + +| Name | Languages | Categories | Config File | +|------|-----------|------------|-------------| +| `eslint` | JavaScript, TypeScript, JSX, TSX | pattern, length, style, ast | `.eslintrc.json` | +| `prettier` | JS, TS, JSON, CSS, HTML, Markdown | style | `.prettierrc.json` | +| `pylint` | Python | naming, style, docs, error_handling | `pylintrc` | +| `tsc` | TypeScript | typechecker | `tsconfig.json` | +| `checkstyle` | Java | naming, pattern, length, style | `checkstyle.xml` | +| `pmd` | Java | pattern, complexity, security, performance | `pmd-ruleset.xml` | + +## Adding New Linter + +### Step 1: Create Directory + +``` +internal/linter// +├── linter.go # Main struct + interface implementation +├── register.go # init() registration +├── converter.go # LLM rule conversion +├── executor.go # Tool execution +└── parser.go # Output parsing +``` + +### Step 2: Implement Linter Interface + +```go +package mylinter + +import ( + "context" + "github.com/DevSymphony/sym-cli/internal/linter" +) + +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + +type Linter struct { + ToolsDir string + executor *linter.SubprocessExecutor +} + +func New(toolsDir string) *Linter { + if toolsDir == "" { + toolsDir = linter.DefaultToolsDir() + } + return &Linter{ + ToolsDir: toolsDir, + executor: linter.NewSubprocessExecutor(), + } +} + +func (l *Linter) Name() string { + return "mylinter" +} + +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ + Name: "mylinter", + SupportedLanguages: []string{"ruby"}, + SupportedCategories: []string{"pattern", "style"}, + Version: "^1.0.0", + } +} + +func (l *Linter) CheckAvailability(ctx context.Context) error { + if path := linter.FindTool(l.localPath(), "mylinter"); path != "" { + return nil + } + return fmt.Errorf("mylinter not found") +} + +func (l *Linter) Install(ctx context.Context, cfg linter.InstallConfig) error { + // Install logic (gem install, pip install, etc.) +} + +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { + // Execution logic +} + +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { + // Parse tool-specific output to violations +} +``` + +### Step 3: Implement Converter Interface + +```go +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + +type Converter struct{} + +func NewConverter() *Converter { return &Converter{} } + +func (c *Converter) Name() string { return "mylinter" } + +func (c *Converter) SupportedLanguages() []string { + return []string{"ruby"} +} + +func (c *Converter) GetLLMDescription() string { + return "Ruby linter for style and quality" +} + +func (c *Converter) GetRoutingHints() []string { + return []string{"For Ruby code style → use mylinter"} +} + +func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*linter.ConversionResult, error) { + // Use helper for parallel conversion + results, successIDs, failedIDs := linter.ConvertRulesParallel(ctx, rules, c.convertSingle) + + // Build config from results + config := buildConfig(results) + + return &linter.ConversionResult{ + Config: config, + SuccessRules: successIDs, + FailedRules: failedIDs, + }, nil +} +``` + +### Step 4: Register in init() + +```go +package mylinter + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + ".mylinter.yml", + ) +} +``` + +### Step 5: Add Import to Bootstrap + +```go +// internal/bootstrap/linters.go +import ( + _ "github.com/DevSymphony/sym-cli/internal/linter/mylinter" +) +``` + +## Key Patterns + +### Compile-Time Interface Checks + +Every linter should include: +```go +var _ linter.Linter = (*Linter)(nil) +var _ linter.Converter = (*Converter)(nil) +``` + +### Helper Functions + +Use the provided helpers from `base.go`: + +```go +// Default tools directory (~/.sym/tools) +toolsDir := linter.DefaultToolsDir() + +// Write temp config file +configPath, err := linter.WriteTempConfig(toolsDir, "mylinter", configBytes) + +// Normalize severity +severity := linter.MapSeverity("warn") // returns "warning" + +// Find tool binary (local first, then PATH) +path := linter.FindTool(localPath, "mylinter") +``` + +### Parallel Rule Conversion + +Use `ConvertRulesParallel` for efficient parallel processing: + +```go +func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*linter.ConversionResult, error) { + results, successIDs, failedIDs := linter.ConvertRulesParallel(ctx, rules, + func(ctx context.Context, rule schema.UserRule) (*MyConfig, error) { + return c.convertSingleRule(ctx, rule, provider) + }, + ) + + // Build final config from successful conversions + // ... +} +``` + +## Error Handling + +- Return clear error messages if tool not installed +- Track per-rule success/failure in ConversionResult +- Failed rules automatically fall back to llm-validator diff --git a/internal/adapter/checkstyle/converter.go b/internal/linter/checkstyle/converter.go similarity index 84% rename from internal/adapter/checkstyle/converter.go rename to internal/linter/checkstyle/converter.go index d21abcd..8abb8e1 100644 --- a/internal/adapter/checkstyle/converter.go +++ b/internal/linter/checkstyle/converter.go @@ -6,13 +6,15 @@ import ( "encoding/xml" "fmt" "strings" - "sync" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to Checkstyle XML configuration using LLM type Converter struct{} @@ -64,76 +66,53 @@ type checkstyleConfig struct { Modules []checkstyleModule `xml:"module"` } -// ConvertRules converts user rules to Checkstyle configuration using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// ConvertSingleRule converts ONE user rule to Checkstyle module. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by Checkstyle (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } - // Convert rules in parallel - type moduleResult struct { - index int - ruleID string - module *checkstyleModule - err error + module, err := c.convertToCheckstyleModule(ctx, rule, provider) + if err != nil { + return nil, err } - results := make(chan moduleResult, len(rules)) - var wg sync.WaitGroup - - for i, rule := range rules { - wg.Add(1) - go func(idx int, r schema.UserRule) { - defer wg.Done() - - module, err := c.convertSingleRule(ctx, r, provider) - results <- moduleResult{ - index: idx, - ruleID: r.ID, - module: module, - err: err, - } - }(i, rule) + if module == nil { + return nil, nil } - go func() { - wg.Wait() - close(results) - }() + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: module, + }, nil +} + +// BuildConfig assembles Checkstyle XML configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } - // Collect modules with per-rule tracking var modules []checkstyleModule - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) - - for result := range results { - if result.err != nil { - failedRuleIDs = append(failedRuleIDs, result.ruleID) + for _, r := range results { + module, ok := r.Data.(*checkstyleModule) + if !ok { continue } - - if result.module != nil { - modules = append(modules, *result.module) - successRuleIDs = append(successRuleIDs, result.ruleID) - } else { - // Skipped = cannot be enforced by this linter - failedRuleIDs = append(failedRuleIDs, result.ruleID) - } - } - - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, + modules = append(modules, *module) } if len(modules) == 0 { - return convResult, nil + return nil, nil } // Separate modules into Checker-level and TreeWalker-level - // Checker-level modules (NOT under TreeWalker) checkerLevelModules := map[string]bool{ "LineLength": true, "FileLength": true, @@ -160,13 +139,11 @@ func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, p } // Build Checkstyle configuration - // TreeWalker contains TreeWalker-level modules treeWalker := checkstyleModule{ Name: "TreeWalker", Modules: treeWalkerModules, } - // Checker contains Checker-level modules + TreeWalker allModules := append(checkerModules, treeWalker) config := checkstyleConfig{ Name: "Checker", @@ -187,17 +164,15 @@ func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, p ` fullContent := []byte(xmlHeader + string(content)) - convResult.Config = &adapter.LinterConfig{ + return &linter.LinterConfig{ Filename: "checkstyle.xml", Content: fullContent, Format: "xml", - } - - return convResult, nil + }, nil } -// convertSingleRule converts a single rule using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*checkstyleModule, error) { +// convertToCheckstyleModule converts a single rule using LLM +func (c *Converter) convertToCheckstyleModule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*checkstyleModule, error) { systemPrompt := `You are a Checkstyle configuration expert. Convert natural language Java coding rules to Checkstyle modules. Return ONLY a JSON object (no markdown fences): @@ -279,11 +254,7 @@ Output: } // Parse response - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) if response == "" { return nil, fmt.Errorf("LLM returned empty response") @@ -347,7 +318,6 @@ func mapCheckstyleSeverity(severity string) string { } // validCheckstyleProperties defines valid properties for each Checkstyle module -// This prevents LLM from generating invalid properties that cause runtime errors var validCheckstyleProperties = map[string]map[string]bool{ "TypeName": { "severity": true, @@ -521,7 +491,6 @@ var validCheckstyleProperties = map[string]map[string]bool{ func filterValidProperties(moduleName string, properties map[string]string) map[string]string { validProps, hasDefinedProps := validCheckstyleProperties[moduleName] if !hasDefinedProps { - // If module is not in our whitelist, only allow severity result := make(map[string]string) if sev, ok := properties["severity"]; ok { result["severity"] = sev diff --git a/internal/adapter/checkstyle/executor.go b/internal/linter/checkstyle/executor.go similarity index 74% rename from internal/adapter/checkstyle/executor.go rename to internal/linter/checkstyle/executor.go index 056c344..cf6367c 100644 --- a/internal/adapter/checkstyle/executor.go +++ b/internal/linter/checkstyle/executor.go @@ -7,13 +7,13 @@ import ( "path/filepath" "time" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // execute runs Checkstyle with the given config and files. -func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { if len(files) == 0 { - return &adapter.ToolOutput{ + return &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 0, @@ -22,14 +22,14 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* } // Create temp config file - configFile, err := a.createTempConfig(config) + configFile, err := l.createTempConfig(config) if err != nil { return nil, fmt.Errorf("failed to create temp config: %w", err) } defer func() { _ = os.Remove(configFile) }() // Build command - jarPath := a.getJARPath() + jarPath := l.getJARPath() args := []string{ "-jar", jarPath, @@ -41,11 +41,11 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* // Execute (uses CWD by default) start := time.Now() - output, err := a.executor.Execute(ctx, a.JavaPath, args...) + output, err := l.executor.Execute(ctx, l.JavaPath, args...) duration := time.Since(start) if output == nil { - output = &adapter.ToolOutput{ + output = &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 1, @@ -71,9 +71,9 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* } // createTempConfig creates a temporary config file. -func (a *Adapter) createTempConfig(config []byte) (string, error) { +func (l *Linter) createTempConfig(config []byte) (string, error) { // Create temp file in tools directory - tempFile := filepath.Join(a.ToolsDir, "checkstyle-config-temp.xml") + tempFile := filepath.Join(l.ToolsDir, "checkstyle-config-temp.xml") if err := os.WriteFile(tempFile, config, 0644); err != nil { return "", err diff --git a/internal/adapter/checkstyle/adapter.go b/internal/linter/checkstyle/linter.go similarity index 68% rename from internal/adapter/checkstyle/adapter.go rename to internal/linter/checkstyle/linter.go index 323a3eb..4c3431a 100644 --- a/internal/adapter/checkstyle/adapter.go +++ b/internal/linter/checkstyle/linter.go @@ -9,9 +9,12 @@ import ( "os/exec" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + const ( // DefaultVersion is the default Checkstyle version. DefaultVersion = "10.26.1" @@ -20,7 +23,7 @@ const ( GitHubReleaseURL = "https://github.com/checkstyle/checkstyle/releases/download" ) -// Adapter wraps Checkstyle for Java validation. +// Linter wraps Checkstyle for Java validation. // // Checkstyle handles: // - Pattern rules: naming conventions, regex patterns @@ -28,9 +31,9 @@ const ( // - Style rules: indentation, whitespace // - Naming rules: class names, method names, variable names // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { // ToolsDir is where Checkstyle JAR is stored. // Default: ~/.sym/tools ToolsDir string @@ -40,11 +43,11 @@ type Adapter struct { JavaPath string // executor runs subprocess - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new Checkstyle adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new Checkstyle linter. +func New(toolsDir string) *Linter { if toolsDir == "" { home, _ := os.UserHomeDir() toolsDir = filepath.Join(home, ".sym", "tools") @@ -52,21 +55,21 @@ func NewAdapter(toolsDir string) *Adapter { javaPath, _ := exec.LookPath("java") - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, JavaPath: javaPath, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "checkstyle" } -// GetCapabilities returns the Checkstyle adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the Checkstyle linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "checkstyle", SupportedLanguages: []string{"java"}, SupportedCategories: []string{"pattern", "length", "style", "naming"}, @@ -75,20 +78,20 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if Java and Checkstyle JAR are available. -func (a *Adapter) CheckAvailability(ctx context.Context) error { +func (l *Linter) CheckAvailability(ctx context.Context) error { // Check Java - if a.JavaPath == "" { + if l.JavaPath == "" { return fmt.Errorf("java not found: please install Java") } // Verify Java version - cmd := exec.CommandContext(ctx, a.JavaPath, "-version") + cmd := exec.CommandContext(ctx, l.JavaPath, "-version") if err := cmd.Run(); err != nil { return fmt.Errorf("java execution failed: %w", err) } // Check Checkstyle JAR - jarPath := a.getJARPath() + jarPath := l.getJARPath() if _, err := os.Stat(jarPath); os.IsNotExist(err) { return fmt.Errorf("checkstyle JAR not found at %s: run Install first", jarPath) } @@ -97,9 +100,9 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // Install downloads Checkstyle JAR from Maven Central. -func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { // Ensure tools directory exists - if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } @@ -114,7 +117,7 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err url := fmt.Sprintf("%s/checkstyle-%s/%s", GitHubReleaseURL, version, jarName) // Destination path - jarPath := filepath.Join(a.ToolsDir, jarName) + jarPath := filepath.Join(l.ToolsDir, jarName) // Check if already exists and not forcing reinstall if !config.Force { @@ -124,7 +127,7 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Download - if err := a.downloadFile(ctx, url, jarPath); err != nil { + if err := l.downloadFile(ctx, url, jarPath); err != nil { return fmt.Errorf("failed to download checkstyle: %w", err) } @@ -133,22 +136,22 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err // Execute runs Checkstyle with the given config and files. -func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { - return a.execute(ctx, config, files) +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { + return l.execute(ctx, config, files) } // ParseOutput converts Checkstyle JSON output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { return parseOutput(output) } // getJARPath returns the path to Checkstyle JAR. -func (a *Adapter) getJARPath() string { - return filepath.Join(a.ToolsDir, fmt.Sprintf("checkstyle-%s-all.jar", DefaultVersion)) +func (l *Linter) getJARPath() string { + return filepath.Join(l.ToolsDir, fmt.Sprintf("checkstyle-%s-all.jar", DefaultVersion)) } // downloadFile downloads a file from URL to destPath. -func (a *Adapter) downloadFile(ctx context.Context, url, destPath string) error { +func (l *Linter) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err diff --git a/internal/adapter/checkstyle/parser.go b/internal/linter/checkstyle/parser.go similarity index 90% rename from internal/adapter/checkstyle/parser.go rename to internal/linter/checkstyle/parser.go index 7e2b3f7..79a3e99 100644 --- a/internal/adapter/checkstyle/parser.go +++ b/internal/linter/checkstyle/parser.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // CheckstyleOutput represents the XML output from Checkstyle. @@ -30,14 +30,14 @@ type CheckstyleError struct { } // parseOutput converts Checkstyle XML output to violations. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { if output == nil { return nil, fmt.Errorf("output is nil") } // If no output and exit code 0, no violations if output.Stdout == "" && output.ExitCode == 0 { - return []adapter.Violation{}, nil + return []linter.Violation{}, nil } // Parse XML output @@ -51,11 +51,11 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { } // Convert to violations - violations := make([]adapter.Violation, 0) + violations := make([]linter.Violation, 0) for _, file := range result.Files { for _, err := range file.Errors { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: file.Name, Line: err.Line, Column: err.Column, diff --git a/internal/linter/checkstyle/register.go b/internal/linter/checkstyle/register.go new file mode 100644 index 0000000..6a78e0a --- /dev/null +++ b/internal/linter/checkstyle/register.go @@ -0,0 +1,13 @@ +package checkstyle + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + "checkstyle.xml", + ) +} diff --git a/internal/linter/converter.go b/internal/linter/converter.go new file mode 100644 index 0000000..2bc9def --- /dev/null +++ b/internal/linter/converter.go @@ -0,0 +1,64 @@ +package linter + +import ( + "context" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Converter converts user rules to native linter configuration using LLM. +// This interface is implemented by each linter's converter (e.g., ESLintConverter). +// +// The main converter (internal/converter) handles all concurrency control. +// Individual linter converters only implement single-rule conversion logic. +type Converter interface { + // Name returns the linter name (e.g., "eslint", "checkstyle", "pmd") + Name() string + + // SupportedLanguages returns the languages this linter supports + SupportedLanguages() []string + + // GetLLMDescription returns a description of the linter's capabilities for LLM routing. + // This is used in the LLM prompt to help route rules to appropriate linters. + GetLLMDescription() string + + // GetRoutingHints returns routing rules for LLM to decide when to use this linter. + // Each hint is a rule like "For Java naming rules → ALWAYS use checkstyle". + // These hints are collected and included in the LLM prompt for rule routing. + GetRoutingHints() []string + + // ConvertSingleRule converts ONE user rule to linter-specific data. + // Returns (result, nil) on success, + // (nil, nil) if rule cannot be converted by this linter (skip), + // (nil, error) on actual conversion error. + // Note: Concurrency is handled by the main converter, not here. + ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*SingleRuleResult, error) + + // BuildConfig assembles final linter config from successful conversions. + // Called by main converter after collecting all successful SingleRuleResults. + BuildConfig(results []*SingleRuleResult) (*LinterConfig, error) +} + +// SingleRuleResult represents the conversion result for a single rule. +// The Data field contains linter-specific data that BuildConfig understands. +type SingleRuleResult struct { + RuleID string // Original user rule ID + Data interface{} // Linter-specific data (e.g., ESLint rule config, Checkstyle module) +} + +// LinterConfig represents a generated configuration file. +type LinterConfig struct { + Filename string // e.g., ".eslintrc.json", "checkstyle.xml" + Content []byte // File content + Format string // "json", "xml", "yaml" +} + +// ConversionResult contains the conversion output with per-rule tracking. +// This allows the main converter to know which rules succeeded vs failed, +// enabling fallback to llm-validator for failed rules. +type ConversionResult struct { + Config *LinterConfig // Generated config file (may be nil if all rules failed) + SuccessRules []string // Rule IDs that converted successfully + FailedRules []string // Rule IDs that couldn't be converted (fallback to llm-validator) +} diff --git a/internal/adapter/eslint/converter.go b/internal/linter/eslint/converter.go similarity index 67% rename from internal/adapter/eslint/converter.go rename to internal/linter/eslint/converter.go index 2b9ee42..86541d4 100644 --- a/internal/adapter/eslint/converter.go +++ b/internal/linter/eslint/converter.go @@ -4,15 +4,16 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" - "sync" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to ESLint configuration using LLM type Converter struct{} @@ -45,109 +46,89 @@ func (c *Converter) GetRoutingHints() []string { } } -// ConvertRules converts user rules to ESLint configuration using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// eslintRuleData holds ESLint-specific conversion data +type eslintRuleData struct { + RuleName string `json:"ruleName"` + Config interface{} `json:"config"` +} + +// ConvertSingleRule converts ONE user rule to ESLint rule configuration. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by ESLint (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } - // Convert rules in parallel using goroutines - type ruleResult struct { - index int - ruleID string - ruleName string - config interface{} - err error + ruleName, config, err := c.convertToESLintRule(ctx, rule, provider) + if err != nil { + return nil, err } - results := make(chan ruleResult, len(rules)) - var wg sync.WaitGroup - - // Process each rule in parallel - for i, rule := range rules { - wg.Add(1) - go func(idx int, r schema.UserRule) { - defer wg.Done() - - ruleName, config, err := c.convertSingleRule(ctx, r, provider) - results <- ruleResult{ - index: idx, - ruleID: r.ID, - ruleName: ruleName, - config: config, - err: err, - } - }(i, rule) + // If rule_name is empty, this rule cannot be converted by ESLint + if ruleName == "" { + return nil, nil } - // Wait for all goroutines - go func() { - wg.Wait() - close(results) - }() + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: eslintRuleData{ + RuleName: ruleName, + Config: config, + }, + }, nil +} - // Collect results with per-rule tracking - eslintRules := make(map[string]interface{}) - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) +// BuildConfig assembles ESLint configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } - for result := range results { - if result.err != nil { - failedRuleIDs = append(failedRuleIDs, result.ruleID) - fmt.Fprintf(os.Stderr, "⚠️ ESLint rule %s conversion error: %v\n", result.ruleID, result.err) + eslintRules := make(map[string]interface{}) + for _, r := range results { + data, ok := r.Data.(eslintRuleData) + if !ok { continue } - - if result.ruleName != "" { - eslintRules[result.ruleName] = result.config - successRuleIDs = append(successRuleIDs, result.ruleID) - fmt.Fprintf(os.Stderr, "✓ ESLint rule %s → %s\n", result.ruleID, result.ruleName) - } else { - // Skipped = cannot be enforced by this linter, fallback to llm-validator - failedRuleIDs = append(failedRuleIDs, result.ruleID) - fmt.Fprintf(os.Stderr, "⊘ ESLint rule %s skipped (cannot be enforced by ESLint)\n", result.ruleID) - } + eslintRules[data.RuleName] = data.Config } - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, + if len(eslintRules) == 0 { + return nil, nil } - // Generate config only if at least one rule succeeded - if len(eslintRules) > 0 { - eslintConfig := map[string]interface{}{ - "env": map[string]bool{ - "es2021": true, - "node": true, - "browser": true, - }, - "parserOptions": map[string]interface{}{ - "ecmaVersion": "latest", - "sourceType": "module", - }, - "rules": eslintRules, - } - - content, err := json.MarshalIndent(eslintConfig, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } + eslintConfig := map[string]interface{}{ + "env": map[string]bool{ + "es2021": true, + "node": true, + "browser": true, + }, + "parserOptions": map[string]interface{}{ + "ecmaVersion": "latest", + "sourceType": "module", + }, + "rules": eslintRules, + } - convResult.Config = &adapter.LinterConfig{ - Filename: ".eslintrc.json", - Content: content, - Format: "json", - } + content, err := json.MarshalIndent(eslintConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) } - return convResult, nil + return &linter.LinterConfig{ + Filename: ".eslintrc.json", + Content: content, + Format: "json", + }, nil } -// convertSingleRule converts a single user rule to ESLint rule using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (string, interface{}, error) { +// convertToESLintRule converts a single user rule to ESLint rule using LLM +func (c *Converter) convertToESLintRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (string, interface{}, error) { systemPrompt := `You are an ESLint configuration expert. Convert natural language coding rules to ESLint rule configurations. Return ONLY a JSON object (no markdown fences) with this structure: @@ -231,11 +212,7 @@ Output: } // Parse response - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) if response == "" { return "", nil, fmt.Errorf("LLM returned empty response") diff --git a/internal/adapter/eslint/executor.go b/internal/linter/eslint/executor.go similarity index 70% rename from internal/adapter/eslint/executor.go rename to internal/linter/eslint/executor.go index dd0d6d2..e393975 100644 --- a/internal/adapter/eslint/executor.go +++ b/internal/linter/eslint/executor.go @@ -7,40 +7,40 @@ import ( "os/exec" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // execute runs ESLint with the given config and files. -func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { if len(files) == 0 { - return &adapter.ToolOutput{ + return &linter.ToolOutput{ Stdout: "[]", ExitCode: 0, }, nil } // Write config to temp file - configPath, err := a.writeConfigFile(config) + configPath, err := l.writeConfigFile(config) if err != nil { return nil, fmt.Errorf("failed to write config: %w", err) } defer func() { _ = os.Remove(configPath) }() // Get command and arguments - eslintCmd, args := a.getExecutionArgs(configPath, files) + eslintCmd, args := l.getExecutionArgs(configPath, files) // Execute with environment variable to support both ESLint 8 and 9 // Uses CWD by default - a.executor.Env = map[string]string{ + l.executor.Env = map[string]string{ "ESLINT_USE_FLAT_CONFIG": "false", } - return a.executor.Execute(ctx, eslintCmd, args...) + return l.executor.Execute(ctx, eslintCmd, args...) } // getESLintCommand returns the ESLint command to use. -func (a *Adapter) getESLintCommand() string { +func (l *Linter) getESLintCommand() string { // Try local installation first - localPath := a.getESLintPath() + localPath := l.getESLintPath() if _, err := os.Stat(localPath); err == nil { return localPath } @@ -55,8 +55,8 @@ func (a *Adapter) getESLintCommand() string { } // getExecutionArgs returns the command and arguments for ESLint execution. -func (a *Adapter) getExecutionArgs(configPath string, files []string) (string, []string) { - eslintCmd := a.getESLintCommand() +func (l *Linter) getExecutionArgs(configPath string, files []string) (string, []string) { + eslintCmd := l.getESLintCommand() var args []string @@ -77,8 +77,8 @@ func (a *Adapter) getExecutionArgs(configPath string, files []string) (string, [ } // writeConfigFile writes ESLint config to a temp file. -func (a *Adapter) writeConfigFile(config []byte) (string, error) { - tmpDir := filepath.Join(a.ToolsDir, ".tmp") +func (l *Linter) writeConfigFile(config []byte) (string, error) { + tmpDir := filepath.Join(l.ToolsDir, ".tmp") if err := os.MkdirAll(tmpDir, 0755); err != nil { return "", err } diff --git a/internal/adapter/eslint/executor_test.go b/internal/linter/eslint/executor_test.go similarity index 94% rename from internal/adapter/eslint/executor_test.go rename to internal/linter/eslint/executor_test.go index 5388f78..b3958d9 100644 --- a/internal/adapter/eslint/executor_test.go +++ b/internal/linter/eslint/executor_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) func TestExecute_TempFileCleanup(t *testing.T) { @@ -17,7 +17,7 @@ func TestExecute_TempFileCleanup(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() config := []byte(`{"rules": {"semi": [2, "always"]}}`) @@ -53,7 +53,7 @@ func TestGetESLintCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - a := NewAdapter(tt.toolsDir) + a := New(tt.toolsDir) cmd := a.getESLintCommand() if tt.wantContain != "" && len(cmd) > 0 { @@ -67,7 +67,7 @@ func TestGetESLintCommand(t *testing.T) { } func TestGetExecutionArgs(t *testing.T) { - a := NewAdapter("") + a := New("") configPath := "/tmp/config.json" files := []string{"file1.js", "file2.js"} @@ -110,7 +110,7 @@ func TestWriteConfigFile(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) config := []byte(`{"rules": {"semi": [2, "always"]}}`) @@ -152,7 +152,7 @@ func TestExecute_Integration(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() config := []byte(`{"rules": {"semi": [2, "always"]}}`) @@ -170,7 +170,7 @@ func TestExecute_Integration(t *testing.T) { } func TestParseOutput_EmptyOutput(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 0, @@ -187,7 +187,7 @@ func TestParseOutput_EmptyOutput(t *testing.T) { } func TestParseOutput_InvalidJSON(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "invalid json", Stderr: "", ExitCode: 1, diff --git a/internal/adapter/eslint/adapter.go b/internal/linter/eslint/linter.go similarity index 59% rename from internal/adapter/eslint/adapter.go rename to internal/linter/eslint/linter.go index c90e740..29465f3 100644 --- a/internal/adapter/eslint/adapter.go +++ b/internal/linter/eslint/linter.go @@ -8,49 +8,51 @@ import ( "os/exec" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -// Adapter wraps ESLint for JavaScript/TypeScript validation. +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + +// Linter wraps ESLint for JavaScript/TypeScript validation. // -// ESLint is the universal adapter for JavaScript: +// ESLint is the universal linter 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 // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { // ToolsDir is where ESLint is installed // Default: ~/.sym/tools ToolsDir string // executor runs ESLint subprocess - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new ESLint adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new ESLint linter. +func New(toolsDir string) *Linter { if toolsDir == "" { - home, _ := os.UserHomeDir() - toolsDir = filepath.Join(home, ".sym", "tools") + toolsDir = linter.DefaultToolsDir() } - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "eslint" } -// GetCapabilities returns the ESLint adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the ESLint linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "eslint", SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx"}, SupportedCategories: []string{"pattern", "length", "style", "ast"}, @@ -59,9 +61,9 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if ESLint is installed. -func (a *Adapter) CheckAvailability(ctx context.Context) error { +func (l *Linter) CheckAvailability(ctx context.Context) error { // Try local installation first - eslintPath := a.getESLintPath() + eslintPath := l.getESLintPath() if _, err := os.Stat(eslintPath); err == nil { return nil // Found in tools dir } @@ -76,9 +78,9 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // Install installs ESLint via npm. -func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { // Ensure tools directory exists - if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } @@ -94,16 +96,16 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Initialize package.json if needed - packageJSON := filepath.Join(a.ToolsDir, "package.json") + packageJSON := filepath.Join(l.ToolsDir, "package.json") if _, err := os.Stat(packageJSON); os.IsNotExist(err) { - if err := a.initPackageJSON(); err != nil { + if err := l.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)) + l.executor.WorkDir = l.ToolsDir + _, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version)) if err != nil { return fmt.Errorf("npm install failed: %w", err) } @@ -111,26 +113,25 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err return nil } - // Execute runs ESLint with the given config and files. -func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { // Implementation in executor.go - return a.execute(ctx, config, files) + return l.execute(ctx, config, files) } // ParseOutput converts ESLint JSON output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.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") +func (l *Linter) getESLintPath() string { + return filepath.Join(l.ToolsDir, "node_modules", ".bin", "eslint") } // initPackageJSON creates a minimal package.json. -func (a *Adapter) initPackageJSON() error { +func (l *Linter) initPackageJSON() error { pkg := map[string]interface{}{ "name": "symphony-tools", "version": "1.0.0", @@ -143,6 +144,6 @@ func (a *Adapter) initPackageJSON() error { return err } - path := filepath.Join(a.ToolsDir, "package.json") + path := filepath.Join(l.ToolsDir, "package.json") return os.WriteFile(path, data, 0644) } diff --git a/internal/adapter/eslint/adapter_test.go b/internal/linter/eslint/linter_test.go similarity index 84% rename from internal/adapter/eslint/adapter_test.go rename to internal/linter/eslint/linter_test.go index 088b840..337e45b 100644 --- a/internal/adapter/eslint/adapter_test.go +++ b/internal/linter/eslint/linter_test.go @@ -7,24 +7,24 @@ import ( "strings" "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -func TestNewAdapter(t *testing.T) { - adapter := NewAdapter("") - if adapter == nil { - t.Fatal("NewAdapter() returned nil") +func TestNew(t *testing.T) { + l := New("") + if l == nil { + t.Fatal("New() returned nil") } - if adapter.ToolsDir == "" { + if l.ToolsDir == "" { t.Error("ToolsDir should not be empty") } } -func TestNewAdapter_CustomToolsDir(t *testing.T) { +func TestNew_CustomToolsDir(t *testing.T) { toolsDir := "/custom/tools" - a := NewAdapter(toolsDir) + a := New(toolsDir) if a.ToolsDir != toolsDir { t.Errorf("ToolsDir = %q, want %q", a.ToolsDir, toolsDir) @@ -32,14 +32,14 @@ func TestNewAdapter_CustomToolsDir(t *testing.T) { } func TestName(t *testing.T) { - a := NewAdapter("") + a := New("") if a.Name() != "eslint" { t.Errorf("Name() = %q, want %q", a.Name(), "eslint") } } func TestGetCapabilities(t *testing.T) { - a := NewAdapter("") + a := New("") caps := a.GetCapabilities() if caps.Name != "eslint" { @@ -62,7 +62,7 @@ func TestGetCapabilities(t *testing.T) { } func TestGetESLintPath(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") expected := filepath.Join("/test/tools", "node_modules", ".bin", "eslint") got := a.getESLintPath() @@ -78,7 +78,7 @@ func TestInitPackageJSON(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) if err := a.initPackageJSON(); err != nil { t.Fatalf("initPackageJSON() error = %v", err) @@ -103,7 +103,7 @@ func TestInitPackageJSON(t *testing.T) { } func TestCheckAvailability_NotFound(t *testing.T) { - a := NewAdapter("/nonexistent/path") + a := New("/nonexistent/path") ctx := context.Background() err := a.CheckAvailability(ctx) @@ -120,10 +120,10 @@ func TestInstall(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() - config := adapter.InstallConfig{ + config := linter.InstallConfig{ ToolsDir: tmpDir, } @@ -134,7 +134,7 @@ func TestInstall(t *testing.T) { } func TestExecute_InvalidConfig(t *testing.T) { - a := NewAdapter(t.TempDir()) + a := New(t.TempDir()) ctx := context.Background() config := []byte(`{"rules": {}}`) @@ -147,9 +147,9 @@ func TestExecute_InvalidConfig(t *testing.T) { } func TestParseOutput(t *testing.T) { - a := NewAdapter("") + a := New("") - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `[{"filePath":"test.js","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'x' is defined but never used","line":1,"column":5}]}]`, Stderr: "", ExitCode: 1, diff --git a/internal/adapter/eslint/parser.go b/internal/linter/eslint/parser.go similarity index 87% rename from internal/adapter/eslint/parser.go rename to internal/linter/eslint/parser.go index 4634a94..6d1b326 100644 --- a/internal/adapter/eslint/parser.go +++ b/internal/linter/eslint/parser.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // ESLintOutput represents ESLint JSON output format. @@ -29,7 +29,7 @@ type ESLintMessage struct { } // parseOutput converts ESLint JSON output to violations. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { if output.Stdout == "" || output.Stdout == "[]" { return nil, nil // No violations } @@ -39,11 +39,11 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { return nil, fmt.Errorf("failed to parse ESLint output: %w", err) } - var violations []adapter.Violation + var violations []linter.Violation for _, fileResult := range eslintOutput { for _, msg := range fileResult.Messages { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: fileResult.FilePath, Line: msg.Line, Column: msg.Column, diff --git a/internal/adapter/eslint/parser_test.go b/internal/linter/eslint/parser_test.go similarity index 93% rename from internal/adapter/eslint/parser_test.go rename to internal/linter/eslint/parser_test.go index 1ba59ae..c437349 100644 --- a/internal/adapter/eslint/parser_test.go +++ b/internal/linter/eslint/parser_test.go @@ -3,11 +3,11 @@ package eslint import ( "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) func TestParseOutput_Empty(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "[]", } @@ -22,7 +22,7 @@ func TestParseOutput_Empty(t *testing.T) { } func TestParseOutput_WithViolations(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `[ { "filePath": "src/app.js", diff --git a/internal/linter/eslint/register.go b/internal/linter/eslint/register.go new file mode 100644 index 0000000..c347935 --- /dev/null +++ b/internal/linter/eslint/register.go @@ -0,0 +1,13 @@ +package eslint + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + ".eslintrc.json", + ) +} diff --git a/internal/linter/helpers.go b/internal/linter/helpers.go new file mode 100644 index 0000000..1d765ef --- /dev/null +++ b/internal/linter/helpers.go @@ -0,0 +1,100 @@ +package linter + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ===== Path/Directory Helpers ===== + +// DefaultToolsDir returns the standard tools directory (~/.sym/tools). +// Used by all linters for consistent tool installation location. +func DefaultToolsDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".sym", "tools") +} + +// EnsureDir creates directory if it doesn't exist. +func EnsureDir(path string) error { + return os.MkdirAll(path, 0755) +} + +// FindTool locates a tool binary, checking local path first, then global PATH. +// Returns empty string if not found. +func FindTool(localPath, globalName string) string { + if localPath != "" { + if _, err := os.Stat(localPath); err == nil { + return localPath + } + } + if path, err := exec.LookPath(globalName); err == nil { + return path + } + return "" +} + +// ===== Config File Helpers ===== + +// WriteTempConfig writes config content to a temp file in the tools directory. +// Returns the path to the created temp file. +func WriteTempConfig(toolsDir string, prefix string, content []byte) (string, error) { + tmpDir := filepath.Join(toolsDir, ".tmp") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", err + } + + tmpFile, err := os.CreateTemp(tmpDir, prefix+"-*.json") + if err != nil { + return "", err + } + defer func() { _ = tmpFile.Close() }() + + if _, err := tmpFile.Write(content); err != nil { + _ = os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} + +// ===== Severity Helpers ===== + +// MapSeverity normalizes severity strings to standard values. +// Returns "error", "warning", or "info". +func MapSeverity(s string) string { + switch strings.ToLower(s) { + case "error", "err", "fatal", "critical": + return "error" + case "warning", "warn": + return "warning" + default: + return "info" + } +} + +// MapPriority converts numeric priority to severity string. +// Priority 1 = error, 2-3 = warning, 4+ = info. +func MapPriority(priority int) string { + switch priority { + case 1: + return "error" + case 2, 3: + return "warning" + default: + return "info" + } +} + +// ===== Response Parsing Helpers ===== + +// CleanJSONResponse removes markdown code block markers from LLM responses. +// This is commonly needed when parsing JSON responses from language models. +func CleanJSONResponse(response string) string { + response = strings.TrimSpace(response) + response = strings.TrimPrefix(response, "```json") + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + return strings.TrimSpace(response) +} diff --git a/internal/linter/linter.go b/internal/linter/linter.go new file mode 100644 index 0000000..7635232 --- /dev/null +++ b/internal/linter/linter.go @@ -0,0 +1,94 @@ +package linter + +import ( + "context" +) + +// Linter wraps external linting tools (ESLint, Prettier, etc.) for use by engines. +// +// Design: +// - Linters handle tool installation, config generation, execution +// - Engines delegate to linters for language-specific validation +// - One linter per tool (ESLint, Prettier, etc.) +type Linter interface { + // Name returns the linter name (e.g., "eslint", "prettier"). + Name() string + + // GetCapabilities returns the linter's capabilities. + // This includes supported languages, categories, and version info. + GetCapabilities() Capabilities + + // 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 + + // Execute runs the tool with the given config and files. + // Config is read from .sym directory (e.g., .sym/.eslintrc.json). + // 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) +} + +// Capabilities describes what a linter can do. +type Capabilities struct { + // Name is the linter identifier (e.g., "eslint", "checkstyle"). + Name string + + // SupportedLanguages lists languages this linter can validate. + // Examples: ["javascript", "typescript", "java"] + SupportedLanguages []string + + // SupportedCategories lists rule categories this linter can handle. + // Examples: ["pattern", "length", "style", "ast", "complexity"] + SupportedCategories []string + + // Version is the tool version (e.g., "8.0.0", "10.12.0"). + Version string +} + +// InstallConfig holds tool installation settings. +type InstallConfig struct { + // ToolsDir is where to install the tool. + // Default: ~/.sym/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 linters 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/pmd/converter.go b/internal/linter/pmd/converter.go similarity index 75% rename from internal/adapter/pmd/converter.go rename to internal/linter/pmd/converter.go index 334eddd..4c86e2c 100644 --- a/internal/adapter/pmd/converter.go +++ b/internal/linter/pmd/converter.go @@ -6,13 +6,15 @@ import ( "encoding/xml" "fmt" "strings" - "sync" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to PMD XML configuration using LLM type Converter struct{} @@ -63,72 +65,50 @@ type pmdRule struct { Priority int `xml:"priority,omitempty"` } -// ConvertRules converts user rules to PMD configuration using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// ConvertSingleRule converts ONE user rule to PMD rule. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by PMD (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } - // Convert rules in parallel - type ruleResult struct { - index int - ruleID string - rule *pmdRule - err error + pmdRule, err := c.convertToPMDRule(ctx, rule, provider) + if err != nil { + return nil, err } - results := make(chan ruleResult, len(rules)) - var wg sync.WaitGroup - - for i, rule := range rules { - wg.Add(1) - go func(idx int, r schema.UserRule) { - defer wg.Done() - - pmdRule, err := c.convertSingleRule(ctx, r, provider) - results <- ruleResult{ - index: idx, - ruleID: r.ID, - rule: pmdRule, - err: err, - } - }(i, rule) + if pmdRule == nil { + return nil, nil } - go func() { - wg.Wait() - close(results) - }() + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: pmdRule, + }, nil +} - // Collect rules with per-rule tracking - var pmdRules []pmdRule - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) +// BuildConfig assembles PMD XML configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } - for result := range results { - if result.err != nil { - failedRuleIDs = append(failedRuleIDs, result.ruleID) + var pmdRules []pmdRule + for _, r := range results { + rule, ok := r.Data.(*pmdRule) + if !ok { continue } - - if result.rule != nil { - pmdRules = append(pmdRules, *result.rule) - successRuleIDs = append(successRuleIDs, result.ruleID) - } else { - // Skipped = cannot be enforced by this linter - failedRuleIDs = append(failedRuleIDs, result.ruleID) - } - } - - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, + pmdRules = append(pmdRules, *rule) } if len(pmdRules) == 0 { - return convResult, nil + return nil, nil } // Build PMD ruleset @@ -150,17 +130,15 @@ func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, p xmlHeader := `` + "\n" fullContent := []byte(xmlHeader + string(content)) - convResult.Config = &adapter.LinterConfig{ + return &linter.LinterConfig{ Filename: "pmd.xml", Content: fullContent, Format: "xml", - } - - return convResult, nil + }, nil } -// convertSingleRule converts a single rule using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*pmdRule, error) { +// convertToPMDRule converts a single rule using LLM +func (c *Converter) convertToPMDRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*pmdRule, error) { systemPrompt := `You are a PMD 7.x configuration expert. Convert natural language Java coding rules to PMD rule references. Return ONLY a JSON object with exactly these two fields (no other fields): @@ -212,11 +190,7 @@ IMPORTANT: Return ONLY the JSON object. Do NOT include description, message, or } // Parse response - extract JSON object - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) // Find JSON object boundaries to handle extra text startIdx := strings.Index(response, "{") diff --git a/internal/adapter/pmd/executor.go b/internal/linter/pmd/executor.go similarity index 76% rename from internal/adapter/pmd/executor.go rename to internal/linter/pmd/executor.go index cc63e7b..0718dff 100644 --- a/internal/adapter/pmd/executor.go +++ b/internal/linter/pmd/executor.go @@ -8,13 +8,13 @@ import ( "strings" "time" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // execute runs PMD with the given config and files. -func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { if len(files) == 0 { - return &adapter.ToolOutput{ + return &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 0, @@ -23,14 +23,14 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* } // Create temp ruleset file - rulesetFile, err := a.createTempRuleset(config) + rulesetFile, err := l.createTempRuleset(config) if err != nil { return nil, fmt.Errorf("failed to create temp ruleset: %w", err) } defer func() { _ = os.Remove(rulesetFile) }() // Build command - pmdPath := a.getPMDPath() + pmdPath := l.getPMDPath() // PMD command format: pmd check -d -R -f json args := []string{ @@ -44,11 +44,11 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* // Execute (uses CWD by default) start := time.Now() - output, err := a.executor.Execute(ctx, pmdPath, args...) + output, err := l.executor.Execute(ctx, pmdPath, args...) duration := time.Since(start) if output == nil { - output = &adapter.ToolOutput{ + output = &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 1, @@ -77,9 +77,9 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* } // createTempRuleset creates a temporary ruleset file. -func (a *Adapter) createTempRuleset(config []byte) (string, error) { +func (l *Linter) createTempRuleset(config []byte) (string, error) { // Create temp file in tools directory - tempFile := filepath.Join(a.ToolsDir, "pmd-ruleset-temp.xml") + tempFile := filepath.Join(l.ToolsDir, "pmd-ruleset-temp.xml") if err := os.WriteFile(tempFile, config, 0644); err != nil { return "", err diff --git a/internal/adapter/pmd/adapter.go b/internal/linter/pmd/linter.go similarity index 71% rename from internal/adapter/pmd/adapter.go rename to internal/linter/pmd/linter.go index e402526..8551a35 100644 --- a/internal/adapter/pmd/adapter.go +++ b/internal/linter/pmd/linter.go @@ -10,9 +10,12 @@ import ( "path/filepath" "runtime" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + const ( // DefaultVersion is the default PMD version. DefaultVersion = "7.0.0" @@ -21,7 +24,7 @@ const ( GitHubReleaseURL = "https://github.com/pmd/pmd/releases/download" ) -// Adapter wraps PMD for Java validation. +// Linter wraps PMD for Java validation. // // PMD handles: // - Pattern rules: custom XPath rules @@ -30,9 +33,9 @@ const ( // - Security rules: hardcoded credentials, SQL injection // - Error handling rules: empty catch blocks, exception handling // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { // ToolsDir is where PMD is installed. // Default: ~/.sym/tools ToolsDir string @@ -42,30 +45,30 @@ type Adapter struct { PMDPath string // executor runs subprocess - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new PMD adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new PMD linter. +func New(toolsDir string) *Linter { if toolsDir == "" { home, _ := os.UserHomeDir() toolsDir = filepath.Join(home, ".sym", "tools") } - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "pmd" } -// GetCapabilities returns the PMD adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the PMD linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "pmd", SupportedLanguages: []string{"java"}, SupportedCategories: []string{"pattern", "complexity", "performance", "security", "error_handling", "ast"}, @@ -74,8 +77,8 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if PMD is available. -func (a *Adapter) CheckAvailability(ctx context.Context) error { - pmdPath := a.getPMDPath() +func (l *Linter) CheckAvailability(ctx context.Context) error { + pmdPath := l.getPMDPath() // Check if PMD binary exists if _, err := os.Stat(pmdPath); os.IsNotExist(err) { @@ -92,9 +95,9 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // Install downloads and extracts PMD from GitHub releases. -func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { // Ensure tools directory exists - if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } @@ -109,8 +112,8 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err url := fmt.Sprintf("%s/pmd_releases%%2F%s/%s", GitHubReleaseURL, version, distName) // Destination paths - zipPath := filepath.Join(a.ToolsDir, distName) - extractDir := filepath.Join(a.ToolsDir, fmt.Sprintf("pmd-bin-%s", version)) + zipPath := filepath.Join(l.ToolsDir, distName) + extractDir := filepath.Join(l.ToolsDir, fmt.Sprintf("pmd-bin-%s", version)) // Check if already exists if !config.Force { @@ -120,20 +123,20 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Download - if err := a.downloadFile(ctx, url, zipPath); err != nil { + if err := l.downloadFile(ctx, url, zipPath); err != nil { return fmt.Errorf("failed to download PMD: %w", err) } defer func() { _ = os.Remove(zipPath) }() // Extract (simplified - in production use archive/zip) // For now, assume unzip command is available - cmd := exec.CommandContext(ctx, "unzip", "-q", "-o", zipPath, "-d", a.ToolsDir) + cmd := exec.CommandContext(ctx, "unzip", "-q", "-o", zipPath, "-d", l.ToolsDir) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to extract PMD: %w (try installing unzip)", err) } // Make PMD binary executable - pmdBin := a.getPMDPath() + pmdBin := l.getPMDPath() if err := os.Chmod(pmdBin, 0755); err != nil { return fmt.Errorf("failed to make PMD executable: %w", err) } @@ -143,22 +146,22 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err // Execute runs PMD with the given config and files. -func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { - return a.execute(ctx, config, files) +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { + return l.execute(ctx, config, files) } // ParseOutput converts PMD JSON output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { return parseOutput(output) } // getPMDPath returns the path to PMD binary. -func (a *Adapter) getPMDPath() string { - if a.PMDPath != "" { - return a.PMDPath +func (l *Linter) getPMDPath() string { + if l.PMDPath != "" { + return l.PMDPath } - pmdDir := filepath.Join(a.ToolsDir, fmt.Sprintf("pmd-bin-%s", DefaultVersion)) + pmdDir := filepath.Join(l.ToolsDir, fmt.Sprintf("pmd-bin-%s", DefaultVersion)) // PMD binary name depends on OS binName := "pmd" @@ -170,7 +173,7 @@ func (a *Adapter) getPMDPath() string { } // downloadFile downloads a file from URL to destPath. -func (a *Adapter) downloadFile(ctx context.Context, url, destPath string) error { +func (l *Linter) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err diff --git a/internal/adapter/pmd/parser.go b/internal/linter/pmd/parser.go similarity index 89% rename from internal/adapter/pmd/parser.go rename to internal/linter/pmd/parser.go index 3c25023..dd6c493 100644 --- a/internal/adapter/pmd/parser.go +++ b/internal/linter/pmd/parser.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // PMDOutput represents the JSON output from PMD. @@ -41,14 +41,14 @@ type PMDProcessingError struct { } // parseOutput converts PMD JSON output to violations. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { if output == nil { return nil, fmt.Errorf("output is nil") } // If no output and exit code 0, no violations if output.Stdout == "" && output.ExitCode == 0 { - return []adapter.Violation{}, nil + return []linter.Violation{}, nil } // Parse JSON output @@ -62,11 +62,11 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { } // Convert to violations - violations := make([]adapter.Violation, 0) + violations := make([]linter.Violation, 0) for _, file := range result.Files { for _, v := range file.Violations { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: file.Filename, Line: v.BeginLine, Column: v.BeginColumn, @@ -79,7 +79,7 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { // Add processing errors as violations for _, err := range result.ProcessingErrors { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: err.Filename, Line: 0, Column: 0, diff --git a/internal/linter/pmd/register.go b/internal/linter/pmd/register.go new file mode 100644 index 0000000..bf66b29 --- /dev/null +++ b/internal/linter/pmd/register.go @@ -0,0 +1,13 @@ +package pmd + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + "pmd.xml", + ) +} diff --git a/internal/adapter/prettier/converter.go b/internal/linter/prettier/converter.go similarity index 64% rename from internal/adapter/prettier/converter.go rename to internal/linter/prettier/converter.go index 5c21f0c..80e583a 100644 --- a/internal/adapter/prettier/converter.go +++ b/internal/linter/prettier/converter.go @@ -4,13 +4,15 @@ import ( "context" "encoding/json" "fmt" - "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to Prettier configuration using LLM type Converter struct{} @@ -43,13 +45,47 @@ func (c *Converter) GetRoutingHints() []string { } } -// ConvertRules converts formatting rules to Prettier config using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// prettierRuleData holds Prettier-specific conversion data +type prettierRuleData struct { + Options map[string]interface{} +} + +// ConvertSingleRule converts ONE user rule to Prettier option. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by Prettier (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } + config, err := c.convertToPrettierOption(ctx, rule, provider) + if err != nil { + return nil, err + } + + // Check if LLM returned empty config (rule cannot be enforced by Prettier) + if len(config) == 0 { + return nil, nil + } + + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: prettierRuleData{ + Options: config, + }, + }, nil +} + +// BuildConfig assembles Prettier configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } + // Start with default Prettier configuration prettierConfig := map[string]interface{}{ "semi": true, @@ -61,56 +97,31 @@ func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, p "arrowParens": "always", } - // Track rule conversion results - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) - - // Use LLM to infer settings from rules - for _, rule := range rules { - config, err := c.convertSingleRule(ctx, rule, provider) - if err != nil { - failedRuleIDs = append(failedRuleIDs, rule.ID) - continue - } - - // Check if LLM returned empty config (rule cannot be enforced by Prettier) - if len(config) == 0 { - failedRuleIDs = append(failedRuleIDs, rule.ID) + // Merge all rule options + for _, r := range results { + data, ok := r.Data.(prettierRuleData) + if !ok { continue } - - // Merge LLM-generated config - for key, value := range config { + for key, value := range data.Options { prettierConfig[key] = value } - successRuleIDs = append(successRuleIDs, rule.ID) } - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, - } - - // Generate config only if at least one rule succeeded - if len(successRuleIDs) > 0 { - content, err := json.MarshalIndent(prettierConfig, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } - - convResult.Config = &adapter.LinterConfig{ - Filename: ".prettierrc", - Content: content, - Format: "json", - } + content, err := json.MarshalIndent(prettierConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) } - return convResult, nil + return &linter.LinterConfig{ + Filename: ".prettierrc", + Content: content, + Format: "json", + }, nil } -// convertSingleRule converts a single user rule to Prettier config using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (map[string]interface{}, error) { +// convertToPrettierOption converts a single user rule to Prettier config using LLM +func (c *Converter) convertToPrettierOption(ctx context.Context, rule schema.UserRule, provider llm.Provider) (map[string]interface{}, error) { systemPrompt := `You are a Prettier configuration expert. Convert natural language formatting rules to Prettier configuration options. Return ONLY a JSON object (no markdown fences) with Prettier options. @@ -165,11 +176,7 @@ Output: } // Parse response - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) if response == "" { return nil, fmt.Errorf("LLM returned empty response") diff --git a/internal/adapter/prettier/executor.go b/internal/linter/prettier/executor.go similarity index 69% rename from internal/adapter/prettier/executor.go rename to internal/linter/prettier/executor.go index 6fa2eb4..b94b7fa 100644 --- a/internal/adapter/prettier/executor.go +++ b/internal/linter/prettier/executor.go @@ -6,25 +6,25 @@ import ( "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // 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) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string, mode string) (*linter.ToolOutput, error) { if len(files) == 0 { - return &adapter.ToolOutput{ExitCode: 0}, nil + return &linter.ToolOutput{ExitCode: 0}, nil } // Write config to temp file - configPath, err := a.writeConfigFile(config) + configPath, err := l.writeConfigFile(config) if err != nil { return nil, fmt.Errorf("failed to write config: %w", err) } defer func() { _ = os.Remove(configPath) }() // Determine Prettier command - prettierCmd := a.getPrettierCommand() + prettierCmd := l.getPrettierCommand() // Build arguments args := []string{ @@ -41,7 +41,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string, mo args = append(args, files...) // Execute (uses CWD by default) - output, err := a.executor.Execute(ctx, prettierCmd, args...) + output, err := l.executor.Execute(ctx, prettierCmd, args...) // Prettier returns non-zero exit code if files need formatting (in --check mode) // This is expected, not an error @@ -52,16 +52,16 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string, mo return output, nil } -func (a *Adapter) getPrettierCommand() string { - localPath := a.getPrettierPath() +func (l *Linter) getPrettierCommand() string { + localPath := l.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") +func (l *Linter) writeConfigFile(config []byte) (string, error) { + tmpDir := filepath.Join(l.ToolsDir, ".tmp") if err := os.MkdirAll(tmpDir, 0755); err != nil { return "", err } diff --git a/internal/adapter/prettier/executor_test.go b/internal/linter/prettier/executor_test.go similarity index 96% rename from internal/adapter/prettier/executor_test.go rename to internal/linter/prettier/executor_test.go index 85c9b40..b5b4a18 100644 --- a/internal/adapter/prettier/executor_test.go +++ b/internal/linter/prettier/executor_test.go @@ -14,7 +14,7 @@ func TestExecute_TempFileCleanup(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() config := []byte(`{"semi": true}`) @@ -47,7 +47,7 @@ func TestGetPrettierCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - a := NewAdapter(tt.toolsDir) + a := New(tt.toolsDir) cmd := a.getPrettierCommand() if cmd == "" { @@ -64,7 +64,7 @@ func TestWriteConfigFile(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) config := []byte(`{"semi": true, "singleQuote": true}`) @@ -106,7 +106,7 @@ func TestExecute_Integration(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() config := []byte(`{"semi": true, "singleQuote": true}`) diff --git a/internal/adapter/prettier/adapter.go b/internal/linter/prettier/linter.go similarity index 52% rename from internal/adapter/prettier/adapter.go rename to internal/linter/prettier/linter.go index 13d671f..a2b2f10 100644 --- a/internal/adapter/prettier/adapter.go +++ b/internal/linter/prettier/linter.go @@ -8,44 +8,47 @@ import ( "os/exec" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -// Adapter wraps Prettier for code formatting. +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + +// Linter wraps Prettier for code formatting. // // Prettier handles: // - Style validation (--check mode) // - Auto-fixing (--write mode) // - Config: indent, quote, semi, trailingComma, etc. // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { ToolsDir string - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new Prettier adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new Prettier linter. +func New(toolsDir string) *Linter { if toolsDir == "" { home, _ := os.UserHomeDir() toolsDir = filepath.Join(home, ".sym", "tools") } - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "prettier" } -// GetCapabilities returns the Prettier adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the Prettier linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "prettier", SupportedLanguages: []string{"javascript", "typescript", "jsx", "tsx", "json", "yaml", "css", "html", "markdown"}, SupportedCategories: []string{"style"}, @@ -54,8 +57,8 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if Prettier is installed. -func (a *Adapter) CheckAvailability(ctx context.Context) error { - prettierPath := a.getPrettierPath() +func (l *Linter) CheckAvailability(ctx context.Context) error { + prettierPath := l.getPrettierPath() if _, err := os.Stat(prettierPath); err == nil { return nil } @@ -69,8 +72,8 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // 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 { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } @@ -84,40 +87,40 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Init package.json if needed - packageJSON := filepath.Join(a.ToolsDir, "package.json") + packageJSON := filepath.Join(l.ToolsDir, "package.json") if _, err := os.Stat(packageJSON); os.IsNotExist(err) { - if err := a.initPackageJSON(); err != nil { + if err := l.initPackageJSON(); err != nil { return err } } - a.executor.WorkDir = a.ToolsDir - _, err := a.executor.Execute(ctx, "npm", "install", fmt.Sprintf("prettier@%s", version)) + l.executor.WorkDir = l.ToolsDir + _, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("prettier@%s", version)) return err } // 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") +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { + return l.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) +func (l *Linter) ExecuteWithMode(ctx context.Context, config []byte, files []string, mode string) (*linter.ToolOutput, error) { + return l.execute(ctx, config, files, mode) } // ParseOutput converts Prettier output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { return parseOutput(output) } -func (a *Adapter) getPrettierPath() string { - return filepath.Join(a.ToolsDir, "node_modules", ".bin", "prettier") +func (l *Linter) getPrettierPath() string { + return filepath.Join(l.ToolsDir, "node_modules", ".bin", "prettier") } -func (a *Adapter) initPackageJSON() error { +func (l *Linter) initPackageJSON() error { pkg := map[string]interface{}{ "name": "symphony-tools", "version": "1.0.0", @@ -130,6 +133,6 @@ func (a *Adapter) initPackageJSON() error { return err } - path := filepath.Join(a.ToolsDir, "package.json") + path := filepath.Join(l.ToolsDir, "package.json") return os.WriteFile(path, data, 0644) } diff --git a/internal/adapter/prettier/adapter_test.go b/internal/linter/prettier/linter_test.go similarity index 84% rename from internal/adapter/prettier/adapter_test.go rename to internal/linter/prettier/linter_test.go index e48a38c..69c651b 100644 --- a/internal/adapter/prettier/adapter_test.go +++ b/internal/linter/prettier/linter_test.go @@ -6,13 +6,13 @@ import ( "path/filepath" "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -func TestNewAdapter(t *testing.T) { - a := NewAdapter("") +func TestNew(t *testing.T) { + a := New("") if a == nil { - t.Fatal("NewAdapter() returned nil") + t.Fatal("New() returned nil") } if a.ToolsDir == "" { @@ -20,10 +20,10 @@ func TestNewAdapter(t *testing.T) { } } -func TestNewAdapter_CustomToolsDir(t *testing.T) { +func TestNew_CustomToolsDir(t *testing.T) { toolsDir := "/custom/tools" - a := NewAdapter(toolsDir) + a := New(toolsDir) if a.ToolsDir != toolsDir { t.Errorf("ToolsDir = %q, want %q", a.ToolsDir, toolsDir) @@ -31,14 +31,14 @@ func TestNewAdapter_CustomToolsDir(t *testing.T) { } func TestName(t *testing.T) { - a := NewAdapter("") + a := New("") if a.Name() != "prettier" { t.Errorf("Name() = %q, want %q", a.Name(), "prettier") } } func TestGetPrettierPath(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") expected := filepath.Join("/test/tools", "node_modules", ".bin", "prettier") got := a.getPrettierPath() @@ -54,7 +54,7 @@ func TestInitPackageJSON(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) if err := a.initPackageJSON(); err != nil { t.Fatalf("initPackageJSON() error = %v", err) @@ -67,7 +67,7 @@ func TestInitPackageJSON(t *testing.T) { } func TestCheckAvailability_NotFound(t *testing.T) { - a := NewAdapter("/nonexistent/path") + a := New("/nonexistent/path") ctx := context.Background() err := a.CheckAvailability(ctx) @@ -84,10 +84,10 @@ func TestInstall(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() - config := adapter.InstallConfig{ + config := linter.InstallConfig{ ToolsDir: tmpDir, } @@ -98,7 +98,7 @@ func TestInstall(t *testing.T) { } func TestExecute(t *testing.T) { - a := NewAdapter(t.TempDir()) + a := New(t.TempDir()) ctx := context.Background() config := []byte(`{"semi": true}`) @@ -111,7 +111,7 @@ func TestExecute(t *testing.T) { } func TestExecuteWithMode(t *testing.T) { - a := NewAdapter(t.TempDir()) + a := New(t.TempDir()) ctx := context.Background() config := []byte(`{"semi": true}`) @@ -128,17 +128,17 @@ func TestExecuteWithMode(t *testing.T) { } func TestParseOutput(t *testing.T) { - a := NewAdapter("") + a := New("") tests := []struct { name string - output *adapter.ToolOutput + output *linter.ToolOutput wantLen int wantErr bool }{ { name: "no violations", - output: &adapter.ToolOutput{ + output: &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 0, @@ -148,7 +148,7 @@ func TestParseOutput(t *testing.T) { }, { name: "with violations", - output: &adapter.ToolOutput{ + output: &linter.ToolOutput{ Stdout: "file1.js\nfile2.js\n", Stderr: "", ExitCode: 1, diff --git a/internal/adapter/prettier/parser.go b/internal/linter/prettier/parser.go similarity index 90% rename from internal/adapter/prettier/parser.go rename to internal/linter/prettier/parser.go index 238f163..a9fe360 100644 --- a/internal/adapter/prettier/parser.go +++ b/internal/linter/prettier/parser.go @@ -3,7 +3,7 @@ package prettier import ( "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // parseOutput converts Prettier --check output to violations. @@ -21,7 +21,7 @@ import ( // [warn] Code style issues found in the above file(s). Run Prettier with --write to fix." // // Non-zero exit code means files need formatting. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { // Exit code 0 = all files formatted if output.ExitCode == 0 { return nil, nil @@ -29,7 +29,7 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { // Parse both stdout and stderr to find files that need formatting // Prettier 3.x outputs [warn] messages to stderr - var violations []adapter.Violation + var violations []linter.Violation combined := output.Stdout + "\n" + output.Stderr lines := strings.Split(combined, "\n") @@ -56,7 +56,7 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { strings.Contains(line, ".json") || strings.Contains(line, ".css") || strings.Contains(line, ".md") || strings.Contains(line, ".yaml") || strings.Contains(line, ".yml") { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: line, Line: 0, // Prettier doesn't report line numbers in --check Column: 0, diff --git a/internal/linter/prettier/register.go b/internal/linter/prettier/register.go new file mode 100644 index 0000000..4f5577b --- /dev/null +++ b/internal/linter/prettier/register.go @@ -0,0 +1,13 @@ +package prettier + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + ".prettierrc", + ) +} diff --git a/internal/adapter/pylint/converter.go b/internal/linter/pylint/converter.go similarity index 71% rename from internal/adapter/pylint/converter.go rename to internal/linter/pylint/converter.go index 9c7ac3f..5626236 100644 --- a/internal/adapter/pylint/converter.go +++ b/internal/linter/pylint/converter.go @@ -4,15 +4,16 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" - "sync" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to Pylint configuration using LLM type Converter struct{} @@ -49,103 +50,86 @@ func (c *Converter) GetRoutingHints() []string { } } -// ConvertRules converts user rules to Pylint configuration using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// pylintRuleData holds Pylint-specific conversion data +type pylintRuleData struct { + Symbol string + Options map[string]interface{} +} + +// ConvertSingleRule converts ONE user rule to Pylint rule. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by Pylint (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } - // Convert rules in parallel using goroutines - type ruleResult struct { - index int - ruleID string - symbol string - options map[string]interface{} - err error + symbol, options, err := c.convertToPylintRule(ctx, rule, provider) + if err != nil { + return nil, err } - results := make(chan ruleResult, len(rules)) - var wg sync.WaitGroup - - // Process each rule in parallel - for i, rule := range rules { - wg.Add(1) - go func(idx int, r schema.UserRule) { - defer wg.Done() - - symbol, options, err := c.convertSingleRule(ctx, r, provider) - results <- ruleResult{ - index: idx, - ruleID: r.ID, - symbol: symbol, - options: options, - err: err, - } - }(i, rule) + if symbol == "" { + return nil, nil } - // Wait for all goroutines - go func() { - wg.Wait() - close(results) - }() + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: pylintRuleData{ + Symbol: symbol, + Options: options, + }, + }, nil +} + +// BuildConfig assembles Pylint configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } - // Collect results with per-rule tracking enabledRules := make([]string, 0) options := make(map[string]map[string]interface{}) - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) - for result := range results { - if result.err != nil { - failedRuleIDs = append(failedRuleIDs, result.ruleID) - fmt.Fprintf(os.Stderr, "⚠️ Pylint rule %s conversion error: %v\n", result.ruleID, result.err) + for _, r := range results { + data, ok := r.Data.(pylintRuleData) + if !ok { continue } - if result.symbol != "" { - enabledRules = append(enabledRules, result.symbol) - successRuleIDs = append(successRuleIDs, result.ruleID) - if len(result.options) > 0 { - // Group options by section - for key, value := range result.options { - section := getOptionSection(key) - if _, ok := options[section]; !ok { - options[section] = make(map[string]interface{}) - } - options[section][key] = value + enabledRules = append(enabledRules, data.Symbol) + + if len(data.Options) > 0 { + // Group options by section + for key, value := range data.Options { + section := getOptionSection(key) + if _, ok := options[section]; !ok { + options[section] = make(map[string]interface{}) } + options[section][key] = value } - fmt.Fprintf(os.Stderr, "✓ Pylint rule %s → %s\n", result.ruleID, result.symbol) - } else { - // Skipped = cannot be enforced by this linter, fallback to llm-validator - failedRuleIDs = append(failedRuleIDs, result.ruleID) - fmt.Fprintf(os.Stderr, "⊘ Pylint rule %s skipped (cannot be enforced by Pylint)\n", result.ruleID) } } - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, + if len(enabledRules) == 0 { + return nil, nil } - // Generate config only if at least one rule succeeded - if len(enabledRules) > 0 { - content := c.generatePylintRC(enabledRules, options) - convResult.Config = &adapter.LinterConfig{ - Filename: ".pylintrc", - Content: []byte(content), - Format: "ini", - } - } + content := c.generatePylintRC(enabledRules, options) - return convResult, nil + return &linter.LinterConfig{ + Filename: ".pylintrc", + Content: []byte(content), + Format: "ini", + }, nil } -// convertSingleRule converts a single user rule to Pylint rule using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (string, map[string]interface{}, error) { +// convertToPylintRule converts a single user rule to Pylint rule using LLM +func (c *Converter) convertToPylintRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (string, map[string]interface{}, error) { systemPrompt := `You are a Pylint configuration expert. Convert natural language Python coding rules to Pylint rule configurations. Return ONLY a JSON object (no markdown fences) with this structure: @@ -223,11 +207,7 @@ Output: } // Parse response - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) if response == "" { return "", nil, fmt.Errorf("LLM returned empty response") diff --git a/internal/adapter/pylint/executor.go b/internal/linter/pylint/executor.go similarity index 64% rename from internal/adapter/pylint/executor.go rename to internal/linter/pylint/executor.go index 1d9c368..37b9f61 100644 --- a/internal/adapter/pylint/executor.go +++ b/internal/linter/pylint/executor.go @@ -6,35 +6,35 @@ import ( "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // execute runs Pylint with the given config and files. -func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { if len(files) == 0 { - return &adapter.ToolOutput{ + return &linter.ToolOutput{ Stdout: "[]", ExitCode: 0, }, nil } // Write config to temp file - configPath, err := a.writeConfigFile(config) + configPath, err := l.writeConfigFile(config) if err != nil { return nil, fmt.Errorf("failed to write config: %w", err) } defer func() { _ = os.Remove(configPath) }() // Get command and arguments - pylintCmd := a.getPylintCommand() - args := a.getExecutionArgs(configPath, files) + pylintCmd := l.getPylintCommand() + args := l.getExecutionArgs(configPath, files) // Execute - uses CWD by default - return a.executor.Execute(ctx, pylintCmd, args...) + return l.executor.Execute(ctx, pylintCmd, args...) } // getExecutionArgs returns the arguments for Pylint execution. -func (a *Adapter) getExecutionArgs(configPath string, files []string) []string { +func (l *Linter) getExecutionArgs(configPath string, files []string) []string { args := []string{ "--output-format=json", "--rcfile=" + configPath, // Use .pylintrc settings as-is @@ -45,8 +45,8 @@ func (a *Adapter) getExecutionArgs(configPath string, files []string) []string { } // writeConfigFile writes Pylint config to a temp file. -func (a *Adapter) writeConfigFile(config []byte) (string, error) { - tmpDir := filepath.Join(a.ToolsDir, ".tmp") +func (l *Linter) writeConfigFile(config []byte) (string, error) { + tmpDir := filepath.Join(l.ToolsDir, ".tmp") if err := os.MkdirAll(tmpDir, 0755); err != nil { return "", err } diff --git a/internal/adapter/pylint/executor_test.go b/internal/linter/pylint/executor_test.go similarity index 96% rename from internal/adapter/pylint/executor_test.go rename to internal/linter/pylint/executor_test.go index b3a99b3..83572dd 100644 --- a/internal/adapter/pylint/executor_test.go +++ b/internal/linter/pylint/executor_test.go @@ -14,7 +14,7 @@ func TestWriteConfigFile(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) config := []byte(`[MASTER] # Generated by Symphony CLI @@ -58,7 +58,7 @@ func TestWriteConfigFile_CreatesDirectory(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) config := []byte(`[MASTER]`) configPath, err := a.writeConfigFile(config) @@ -75,7 +75,7 @@ func TestWriteConfigFile_CreatesDirectory(t *testing.T) { } func TestGetExecutionArgs(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") configPath := "/tmp/pylintrc-123" files := []string{"src/main.py", "src/utils.py"} @@ -110,7 +110,7 @@ func TestExecute_TempFileCleanup(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) config := []byte(`[MASTER]`) // Write config file diff --git a/internal/adapter/pylint/adapter.go b/internal/linter/pylint/linter.go similarity index 72% rename from internal/adapter/pylint/adapter.go rename to internal/linter/pylint/linter.go index 10e2167..0577da3 100644 --- a/internal/adapter/pylint/adapter.go +++ b/internal/linter/pylint/linter.go @@ -9,10 +9,13 @@ import ( "runtime" "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -// Adapter wraps Pylint for Python static analysis. +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + +// Linter wraps Pylint for Python static analysis. // // Pylint is the comprehensive Python linter: // - Naming rules: invalid-name, disallowed-name @@ -21,9 +24,9 @@ import ( // - Error handling rules: bare-except, broad-except // - Complexity rules: too-many-branches, too-many-arguments // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { // ToolsDir is where Pylint virtualenv is installed // Default: ~/.sym/tools ToolsDir string @@ -32,30 +35,30 @@ type Adapter struct { PylintPath string // executor runs Pylint subprocess - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new Pylint adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new Pylint linter. +func New(toolsDir string) *Linter { if toolsDir == "" { home, _ := os.UserHomeDir() toolsDir = filepath.Join(home, ".sym", "tools") } - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "pylint" } -// GetCapabilities returns the Pylint adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the Pylint linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "pylint", SupportedLanguages: []string{"python", "py"}, SupportedCategories: []string{ @@ -73,9 +76,9 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if Pylint is installed. -func (a *Adapter) CheckAvailability(ctx context.Context) error { +func (l *Linter) CheckAvailability(ctx context.Context) error { // Try local installation first (virtualenv) - pylintPath := a.getPylintPath() + pylintPath := l.getPylintPath() if _, err := os.Stat(pylintPath); err == nil { return nil // Found in tools dir } @@ -90,21 +93,21 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // Install installs Pylint via pip in a virtualenv. -func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { // Ensure tools directory exists - if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } // Check if Python is available - pythonCmd := a.getPythonCommand() + pythonCmd := l.getPythonCommand() if _, err := exec.LookPath(pythonCmd); err != nil { return fmt.Errorf("python not found: please install Python 3.8+ first") } - venvPath := a.getVenvPath() - pipPath := a.getPipPath() - pylintPath := a.getPylintPath() + venvPath := l.getVenvPath() + pipPath := l.getPipPath() + pylintPath := l.getPylintPath() // Check if venv exists but is incomplete (no pip or no pylint) if _, err := os.Stat(venvPath); err == nil { @@ -126,7 +129,7 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err // Create virtualenv if it doesn't exist if _, err := os.Stat(venvPath); os.IsNotExist(err) { - output, err := a.executor.Execute(ctx, pythonCmd, "-m", "venv", venvPath) + output, err := l.executor.Execute(ctx, pythonCmd, "-m", "venv", venvPath) if err != nil { return fmt.Errorf("failed to create virtualenv: %w", err) } @@ -149,8 +152,8 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err // Ensure pip is available (some Linux distros don't include pip in venv by default) if _, err := os.Stat(pipPath); os.IsNotExist(err) { - pythonInVenv := a.getPythonInVenv() - output, err := a.executor.Execute(ctx, pythonInVenv, "-m", "ensurepip", "--upgrade") + pythonInVenv := l.getPythonInVenv() + output, err := l.executor.Execute(ctx, pythonInVenv, "-m", "ensurepip", "--upgrade") if err != nil { return fmt.Errorf("failed to install pip via ensurepip: %w", err) } @@ -166,7 +169,7 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Install Pylint in virtualenv - output, err := a.executor.Execute(ctx, pipPath, "install", fmt.Sprintf("pylint%s", version)) + output, err := l.executor.Execute(ctx, pipPath, "install", fmt.Sprintf("pylint%s", version)) if err != nil { return fmt.Errorf("pip install failed: %w", err) } @@ -178,25 +181,25 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Execute runs Pylint with the given config and files. -func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { // Implementation in executor.go - return a.execute(ctx, config, files) + return l.execute(ctx, config, files) } // ParseOutput converts Pylint JSON output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { // Implementation in parser.go return parseOutput(output) } // getVenvPath returns the path to Pylint virtualenv. -func (a *Adapter) getVenvPath() string { - return filepath.Join(a.ToolsDir, "pylint-venv") +func (l *Linter) getVenvPath() string { + return filepath.Join(l.ToolsDir, "pylint-venv") } // getPylintPath returns the path to local Pylint binary. -func (a *Adapter) getPylintPath() string { - venvPath := a.getVenvPath() +func (l *Linter) getPylintPath() string { + venvPath := l.getVenvPath() if runtime.GOOS == "windows" { return filepath.Join(venvPath, "Scripts", "pylint.exe") } @@ -204,8 +207,8 @@ func (a *Adapter) getPylintPath() string { } // getPipPath returns the path to pip in virtualenv. -func (a *Adapter) getPipPath() string { - venvPath := a.getVenvPath() +func (l *Linter) getPipPath() string { + venvPath := l.getVenvPath() if runtime.GOOS == "windows" { return filepath.Join(venvPath, "Scripts", "pip.exe") } @@ -213,7 +216,7 @@ func (a *Adapter) getPipPath() string { } // getPythonCommand returns the Python command to use. -func (a *Adapter) getPythonCommand() string { +func (l *Linter) getPythonCommand() string { // Try python3 first, then python if _, err := exec.LookPath("python3"); err == nil { return "python3" @@ -222,8 +225,8 @@ func (a *Adapter) getPythonCommand() string { } // getPythonInVenv returns the path to Python in virtualenv. -func (a *Adapter) getPythonInVenv() string { - venvPath := a.getVenvPath() +func (l *Linter) getPythonInVenv() string { + venvPath := l.getVenvPath() if runtime.GOOS == "windows" { return filepath.Join(venvPath, "Scripts", "python.exe") } @@ -231,14 +234,14 @@ func (a *Adapter) getPythonInVenv() string { } // getPylintCommand returns the Pylint command to use. -func (a *Adapter) getPylintCommand() string { +func (l *Linter) getPylintCommand() string { // If explicit path is set, use it - if a.PylintPath != "" { - return a.PylintPath + if l.PylintPath != "" { + return l.PylintPath } // Try local installation first (virtualenv) - localPath := a.getPylintPath() + localPath := l.getPylintPath() if _, err := os.Stat(localPath); err == nil { return localPath } diff --git a/internal/adapter/pylint/adapter_test.go b/internal/linter/pylint/linter_test.go similarity index 86% rename from internal/adapter/pylint/adapter_test.go rename to internal/linter/pylint/linter_test.go index bbc2a82..783072a 100644 --- a/internal/adapter/pylint/adapter_test.go +++ b/internal/linter/pylint/linter_test.go @@ -7,24 +7,24 @@ import ( "runtime" "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -func TestNewAdapter(t *testing.T) { - adapter := NewAdapter("") - if adapter == nil { - t.Fatal("NewAdapter() returned nil") +func TestNew(t *testing.T) { + l := New("") + if l == nil { + t.Fatal("New() returned nil") } - if adapter.ToolsDir == "" { + if l.ToolsDir == "" { t.Error("ToolsDir should not be empty") } } -func TestNewAdapter_CustomToolsDir(t *testing.T) { +func TestNew_CustomToolsDir(t *testing.T) { toolsDir := "/custom/tools" - a := NewAdapter(toolsDir) + a := New(toolsDir) if a.ToolsDir != toolsDir { t.Errorf("ToolsDir = %q, want %q", a.ToolsDir, toolsDir) @@ -32,14 +32,14 @@ func TestNewAdapter_CustomToolsDir(t *testing.T) { } func TestName(t *testing.T) { - a := NewAdapter("") + a := New("") if a.Name() != "pylint" { t.Errorf("Name() = %q, want %q", a.Name(), "pylint") } } func TestGetCapabilities(t *testing.T) { - a := NewAdapter("") + a := New("") caps := a.GetCapabilities() if caps.Name != "pylint" { @@ -76,7 +76,7 @@ func TestGetCapabilities(t *testing.T) { } func TestGetVenvPath(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") expected := filepath.Join("/test/tools", "pylint-venv") got := a.getVenvPath() @@ -86,7 +86,7 @@ func TestGetVenvPath(t *testing.T) { } func TestGetPylintPath(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") var expected string if runtime.GOOS == "windows" { @@ -102,7 +102,7 @@ func TestGetPylintPath(t *testing.T) { } func TestGetPipPath(t *testing.T) { - a := NewAdapter("/test/tools") + a := New("/test/tools") var expected string if runtime.GOOS == "windows" { @@ -118,7 +118,7 @@ func TestGetPipPath(t *testing.T) { } func TestCheckAvailability_NotFound(t *testing.T) { - a := NewAdapter("/nonexistent/path") + a := New("/nonexistent/path") ctx := context.Background() err := a.CheckAvailability(ctx) @@ -135,10 +135,10 @@ func TestInstall(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() - config := adapter.InstallConfig{ + config := linter.InstallConfig{ ToolsDir: tmpDir, } @@ -149,7 +149,7 @@ func TestInstall(t *testing.T) { } func TestExecute_EmptyFiles(t *testing.T) { - a := NewAdapter(t.TempDir()) + a := New(t.TempDir()) ctx := context.Background() config := []byte(`[MASTER]`) @@ -165,9 +165,9 @@ func TestExecute_EmptyFiles(t *testing.T) { } func TestParseOutput(t *testing.T) { - a := NewAdapter("") + a := New("") - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `[ { "type": "convention", diff --git a/internal/adapter/pylint/parser.go b/internal/linter/pylint/parser.go similarity index 92% rename from internal/adapter/pylint/parser.go rename to internal/linter/pylint/parser.go index cb8d977..1ca0614 100644 --- a/internal/adapter/pylint/parser.go +++ b/internal/linter/pylint/parser.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // PylintMessage represents a single Pylint JSON message. @@ -28,7 +28,7 @@ type PylintMessage struct { type PylintOutput []PylintMessage // parseOutput converts Pylint JSON output to violations. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { if output.Stdout == "" || output.Stdout == "[]" { return nil, nil // No violations } @@ -42,10 +42,10 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { return nil, fmt.Errorf("failed to parse Pylint output: %w", err) } - var violations []adapter.Violation + var violations []linter.Violation for _, msg := range pylintOutput { - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: msg.Path, Line: msg.Line, Column: msg.Column, diff --git a/internal/adapter/pylint/parser_test.go b/internal/linter/pylint/parser_test.go similarity index 95% rename from internal/adapter/pylint/parser_test.go rename to internal/linter/pylint/parser_test.go index d45e370..225e859 100644 --- a/internal/adapter/pylint/parser_test.go +++ b/internal/linter/pylint/parser_test.go @@ -3,7 +3,7 @@ package pylint import ( "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) func TestParseOutput_Empty(t *testing.T) { @@ -17,7 +17,7 @@ func TestParseOutput_Empty(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: tt.stdout, ExitCode: 0, } @@ -34,7 +34,7 @@ func TestParseOutput_Empty(t *testing.T) { } func TestParseOutput_SingleViolation(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `[{ "type": "error", "module": "mymodule", @@ -82,7 +82,7 @@ func TestParseOutput_SingleViolation(t *testing.T) { } func TestParseOutput_MultipleViolations(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `[ { "type": "convention", @@ -182,7 +182,7 @@ func TestFormatRuleID(t *testing.T) { } func TestParseOutput_InvalidJSON(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "not valid json", ExitCode: 1, } @@ -194,7 +194,7 @@ func TestParseOutput_InvalidJSON(t *testing.T) { } func TestParseOutput_WithStderr(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "invalid", Stderr: "pylint: error: no such module", ExitCode: 1, diff --git a/internal/linter/pylint/register.go b/internal/linter/pylint/register.go new file mode 100644 index 0000000..575b12f --- /dev/null +++ b/internal/linter/pylint/register.go @@ -0,0 +1,13 @@ +package pylint + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + ".pylintrc", + ) +} diff --git a/internal/linter/registry.go b/internal/linter/registry.go new file mode 100644 index 0000000..ebdde02 --- /dev/null +++ b/internal/linter/registry.go @@ -0,0 +1,170 @@ +package linter + +import ( + "fmt" + "log" + "sync" +) + +// ===== Errors ===== + +// errLinterNotFound is returned when no linter is found for the given tool name. +type errLinterNotFound struct { + ToolName string +} + +func (e *errLinterNotFound) Error() string { + return fmt.Sprintf("linter not found: %s", e.ToolName) +} + +// errNilLinter is returned when trying to register a nil linter. +var errNilLinter = fmt.Errorf("cannot register nil linter") + +// ===== Registry ===== + +// ToolRegistration contains all metadata for a linter tool. +type ToolRegistration struct { + Linter Linter // Linter instance + Converter Converter // Converter instance (optional) + ConfigFile string // Config filename (e.g., ".eslintrc.json") +} + +// Registry manages linter registrations. +type Registry struct { + mu sync.RWMutex + tools map[string]*ToolRegistration +} + +var ( + globalRegistry *Registry + once sync.Once +) + +// Global returns the singleton registry instance. +func Global() *Registry { + once.Do(func() { + globalRegistry = &Registry{ + tools: make(map[string]*ToolRegistration), + } + }) + return globalRegistry +} + +// RegisterTool registers a tool with linter, converter, and config file. +func (r *Registry) RegisterTool( + l Linter, + converter Converter, + configFile string, +) error { + if l == nil { + return errNilLinter + } + + name := l.Name() + + r.mu.Lock() + defer r.mu.Unlock() + + // Warn on duplicate registration (init order issues) + if _, exists := r.tools[name]; exists { + log.Printf("warning: linter already registered: %s (ignoring duplicate)", name) + return nil + } + + r.tools[name] = &ToolRegistration{ + Linter: l, + Converter: converter, + ConfigFile: configFile, + } + + return nil +} + +// GetLinter finds a linter by tool name (e.g., "eslint", "prettier", "tsc"). +func (r *Registry) GetLinter(toolName string) (Linter, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + if reg, ok := r.tools[toolName]; ok { + return reg.Linter, nil + } + + return nil, &errLinterNotFound{ToolName: toolName} +} + +// GetConverter returns Converter by tool name. +func (r *Registry) GetConverter(name string) (Converter, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + if reg, ok := r.tools[name]; ok && reg.Converter != nil { + return reg.Converter, true + } + return nil, false +} + +// GetConfigFile returns config filename by tool name. +func (r *Registry) GetConfigFile(name string) string { + r.mu.RLock() + defer r.mu.RUnlock() + + if reg, ok := r.tools[name]; ok { + return reg.ConfigFile + } + return "" +} + +// BuildLanguageMapping dynamically builds language->tools mapping from linter capabilities. +func (r *Registry) BuildLanguageMapping() map[string][]string { + r.mu.RLock() + defer r.mu.RUnlock() + + mapping := make(map[string][]string) + for name, reg := range r.tools { + caps := reg.Linter.GetCapabilities() + for _, lang := range caps.SupportedLanguages { + mapping[lang] = append(mapping[lang], name) + } + } + return mapping +} + +// GetAllToolNames returns all registered tool names. +func (r *Registry) GetAllToolNames() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.tools)) + for name := range r.tools { + names = append(names, name) + } + return names +} + +// GetAllConfigFiles returns all registered config file names. +func (r *Registry) GetAllConfigFiles() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + files := make([]string, 0, len(r.tools)) + for _, reg := range r.tools { + if reg.ConfigFile != "" { + files = append(files, reg.ConfigFile) + } + } + return files +} + +// GetAllConverters returns all registered converters. +func (r *Registry) GetAllConverters() []Converter { + r.mu.RLock() + defer r.mu.RUnlock() + + converters := make([]Converter, 0, len(r.tools)) + for _, reg := range r.tools { + if reg.Converter != nil { + converters = append(converters, reg.Converter) + } + } + return converters +} diff --git a/internal/adapter/subprocess.go b/internal/linter/subprocess.go similarity index 99% rename from internal/adapter/subprocess.go rename to internal/linter/subprocess.go index c9e6683..d32352f 100644 --- a/internal/adapter/subprocess.go +++ b/internal/linter/subprocess.go @@ -1,4 +1,4 @@ -package adapter +package linter import ( "context" diff --git a/internal/adapter/subprocess_test.go b/internal/linter/subprocess_test.go similarity index 99% rename from internal/adapter/subprocess_test.go rename to internal/linter/subprocess_test.go index ea3814c..f8c08e0 100644 --- a/internal/adapter/subprocess_test.go +++ b/internal/linter/subprocess_test.go @@ -1,4 +1,4 @@ -package adapter +package linter import ( "context" diff --git a/internal/adapter/tsc/converter.go b/internal/linter/tsc/converter.go similarity index 56% rename from internal/adapter/tsc/converter.go rename to internal/linter/tsc/converter.go index f4f2f4c..a890af6 100644 --- a/internal/adapter/tsc/converter.go +++ b/internal/linter/tsc/converter.go @@ -4,13 +4,15 @@ import ( "context" "encoding/json" "fmt" - "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) +// Compile-time interface check +var _ linter.Converter = (*Converter)(nil) + // Converter converts rules to TypeScript compiler configuration using LLM type Converter struct{} @@ -43,85 +45,94 @@ func (c *Converter) GetRoutingHints() []string { } } -// ConvertRules converts type-checking rules to tsconfig.json using LLM. -// Returns ConversionResult with per-rule success/failure tracking for fallback support. -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*adapter.ConversionResult, error) { +// tscRuleData holds TSC-specific conversion data (compiler options) +type tscRuleData struct { + Options map[string]interface{} +} + +// ConvertSingleRule converts ONE user rule to TSC compiler option. +// Returns (result, nil) on success, +// +// (nil, nil) if rule cannot be converted by TSC (skip), +// (nil, error) on actual conversion error. +// +// Note: Concurrency is handled by the main converter. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { if provider == nil { return nil, fmt.Errorf("LLM provider is required") } - // Start with strict TypeScript configuration - tsConfig := map[string]interface{}{ - "compilerOptions": map[string]interface{}{ - "target": "ES2020", - "module": "commonjs", - "lib": []string{"ES2020"}, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - }, + config, err := c.convertToTSCOption(ctx, rule, provider) + if err != nil { + return nil, err } - compilerOpts := tsConfig["compilerOptions"].(map[string]interface{}) + // Check if LLM returned empty config (rule cannot be enforced by TSC) + if len(config) == 0 { + return nil, nil + } - // Track rule conversion results - successRuleIDs := make([]string, 0) - failedRuleIDs := make([]string, 0) + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: tscRuleData{ + Options: config, + }, + }, nil +} - // Use LLM to infer settings from rules - for _, rule := range rules { - config, err := c.convertSingleRule(ctx, rule, provider) - if err != nil { - failedRuleIDs = append(failedRuleIDs, rule.ID) - continue - } +// BuildConfig assembles TypeScript configuration from successful rule conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } - // Check if LLM returned empty config (rule cannot be enforced by TSC) - if len(config) == 0 { - failedRuleIDs = append(failedRuleIDs, rule.ID) + // Start with base TypeScript configuration + compilerOpts := map[string]interface{}{ + "target": "ES2020", + "module": "commonjs", + "lib": []string{"ES2020"}, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + } + + // Merge all rule options + for _, r := range results { + data, ok := r.Data.(tscRuleData) + if !ok { continue } - - // Merge LLM-generated compiler options - for key, value := range config { + for key, value := range data.Options { compilerOpts[key] = value } - successRuleIDs = append(successRuleIDs, rule.ID) } - // Build result with tracking info - convResult := &adapter.ConversionResult{ - SuccessRules: successRuleIDs, - FailedRules: failedRuleIDs, + tsConfig := map[string]interface{}{ + "compilerOptions": compilerOpts, } - // Generate config only if at least one rule succeeded - if len(successRuleIDs) > 0 { - content, err := json.MarshalIndent(tsConfig, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } - - convResult.Config = &adapter.LinterConfig{ - Filename: "tsconfig.json", - Content: content, - Format: "json", - } + content, err := json.MarshalIndent(tsConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) } - return convResult, nil + return &linter.LinterConfig{ + Filename: "tsconfig.json", + Content: content, + Format: "json", + }, nil } -// convertSingleRule converts a single user rule to TypeScript compiler option using LLM -func (c *Converter) convertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (map[string]interface{}, error) { +// convertToTSCOption converts a single user rule to TypeScript compiler option using LLM +func (c *Converter) convertToTSCOption(ctx context.Context, rule schema.UserRule, provider llm.Provider) (map[string]interface{}, error) { systemPrompt := `You are a TypeScript compiler configuration expert. Convert natural language type-checking rules to tsconfig.json compiler options. Return ONLY a JSON object (no markdown fences) with TypeScript compiler options. @@ -179,11 +190,7 @@ Output: } // Parse response - response = strings.TrimSpace(response) - response = strings.TrimPrefix(response, "```json") - response = strings.TrimPrefix(response, "```") - response = strings.TrimSuffix(response, "```") - response = strings.TrimSpace(response) + response = linter.CleanJSONResponse(response) if response == "" { return nil, fmt.Errorf("LLM returned empty response") diff --git a/internal/adapter/tsc/executor.go b/internal/linter/tsc/executor.go similarity index 88% rename from internal/adapter/tsc/executor.go rename to internal/linter/tsc/executor.go index 63dfabc..b0404e3 100644 --- a/internal/adapter/tsc/executor.go +++ b/internal/linter/tsc/executor.go @@ -7,11 +7,11 @@ import ( "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // execute runs tsc with the given configuration. -func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { +func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { // Parse config and add files to check var tsconfig map[string]interface{} if err := json.Unmarshal(config, &tsconfig); err != nil { @@ -41,7 +41,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* // Write tsconfig.json to a temporary location with unique filename // Use ToolsDir/.tmp to avoid conflicts with project files - tmpDir := filepath.Join(a.ToolsDir, ".tmp") + tmpDir := filepath.Join(l.ToolsDir, ".tmp") if err := os.MkdirAll(tmpDir, 0755); err != nil { return nil, fmt.Errorf("failed to create temp dir: %w", err) } @@ -60,7 +60,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* defer func() { _ = os.Remove(configPath) }() // Determine tsc binary path - tscPath := a.getTSCPath() + tscPath := l.getTSCPath() if _, err := os.Stat(tscPath); os.IsNotExist(err) { // Try global tsc tscPath = "tsc" @@ -75,7 +75,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* } // Execute tsc (uses CWD by default) - output, err := a.executor.Execute(ctx, tscPath, args...) + output, err := l.executor.Execute(ctx, tscPath, args...) // TSC returns non-zero exit code when there are type errors // This is expected, so we don't treat it as an error diff --git a/internal/adapter/tsc/adapter.go b/internal/linter/tsc/linter.go similarity index 60% rename from internal/adapter/tsc/adapter.go rename to internal/linter/tsc/linter.go index f4c31f2..274bd48 100644 --- a/internal/adapter/tsc/adapter.go +++ b/internal/linter/tsc/linter.go @@ -8,48 +8,51 @@ import ( "os/exec" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -// Adapter wraps TypeScript Compiler (tsc) for type checking. +// Compile-time interface check +var _ linter.Linter = (*Linter)(nil) + +// Linter wraps TypeScript Compiler (tsc) for type checking. // // TSC provides: // - Type checking for TypeScript and JavaScript files // - Compilation errors and warnings // - Interface and type validation // -// Note: Adapter is goroutine-safe and stateless. WorkDir is determined -// by CWD at execution time, not stored in the adapter. -type Adapter struct { +// Note: Linter is goroutine-safe and stateless. WorkDir is determined +// by CWD at execution time, not stored in the linter. +type Linter struct { // ToolsDir is where TypeScript is installed // Default: ~/.sym/tools ToolsDir string // executor runs tsc subprocess - executor *adapter.SubprocessExecutor + executor *linter.SubprocessExecutor } -// NewAdapter creates a new TSC adapter. -func NewAdapter(toolsDir string) *Adapter { +// New creates a new TSC linter. +func New(toolsDir string) *Linter { if toolsDir == "" { home, _ := os.UserHomeDir() toolsDir = filepath.Join(home, ".sym", "tools") } - return &Adapter{ + return &Linter{ ToolsDir: toolsDir, - executor: adapter.NewSubprocessExecutor(), + executor: linter.NewSubprocessExecutor(), } } -// Name returns the adapter name. -func (a *Adapter) Name() string { +// Name returns the linter name. +func (l *Linter) Name() string { return "tsc" } -// GetCapabilities returns the TSC adapter capabilities. -func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { - return adapter.AdapterCapabilities{ +// GetCapabilities returns the TSC linter capabilities. +func (l *Linter) GetCapabilities() linter.Capabilities { + return linter.Capabilities{ Name: "tsc", SupportedLanguages: []string{"typescript"}, SupportedCategories: []string{"typechecker"}, @@ -58,9 +61,9 @@ func (a *Adapter) GetCapabilities() adapter.AdapterCapabilities { } // CheckAvailability checks if tsc is installed. -func (a *Adapter) CheckAvailability(ctx context.Context) error { +func (l *Linter) CheckAvailability(ctx context.Context) error { // Try local installation first - tscPath := a.getTSCPath() + tscPath := l.getTSCPath() if _, err := os.Stat(tscPath); err == nil { return nil // Found in tools dir } @@ -75,9 +78,9 @@ func (a *Adapter) CheckAvailability(ctx context.Context) error { } // Install installs TypeScript via npm. -func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) error { +func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error { // Ensure tools directory exists - if err := os.MkdirAll(a.ToolsDir, 0755); err != nil { + if err := os.MkdirAll(l.ToolsDir, 0755); err != nil { return fmt.Errorf("failed to create tools dir: %w", err) } @@ -93,16 +96,16 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err } // Initialize package.json if needed - packageJSON := filepath.Join(a.ToolsDir, "package.json") + packageJSON := filepath.Join(l.ToolsDir, "package.json") if _, err := os.Stat(packageJSON); os.IsNotExist(err) { - if err := a.initPackageJSON(); err != nil { + if err := l.initPackageJSON(); err != nil { return fmt.Errorf("failed to init package.json: %w", err) } } // Install TypeScript - a.executor.WorkDir = a.ToolsDir - _, err := a.executor.Execute(ctx, "npm", "install", fmt.Sprintf("typescript@%s", version)) + l.executor.WorkDir = l.ToolsDir + _, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("typescript@%s", version)) if err != nil { return fmt.Errorf("npm install failed: %w", err) } @@ -113,22 +116,22 @@ func (a *Adapter) Install(ctx context.Context, config adapter.InstallConfig) err // Execute runs tsc with the given config and files. // Returns type checking results. -func (a *Adapter) Execute(ctx context.Context, config []byte, files []string) (*adapter.ToolOutput, error) { - return a.execute(ctx, config, files) +func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { + return l.execute(ctx, config, files) } // ParseOutput converts tsc output to violations. -func (a *Adapter) ParseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { return parseOutput(output) } // getTSCPath returns the path to local tsc binary. -func (a *Adapter) getTSCPath() string { - return filepath.Join(a.ToolsDir, "node_modules", ".bin", "tsc") +func (l *Linter) getTSCPath() string { + return filepath.Join(l.ToolsDir, "node_modules", ".bin", "tsc") } // initPackageJSON creates a minimal package.json. -func (a *Adapter) initPackageJSON() error { +func (l *Linter) initPackageJSON() error { pkg := map[string]interface{}{ "name": "symphony-tools", "version": "1.0.0", @@ -141,6 +144,6 @@ func (a *Adapter) initPackageJSON() error { return err } - path := filepath.Join(a.ToolsDir, "package.json") + path := filepath.Join(l.ToolsDir, "package.json") return os.WriteFile(path, data, 0644) } diff --git a/internal/adapter/tsc/adapter_test.go b/internal/linter/tsc/linter_test.go similarity index 80% rename from internal/adapter/tsc/adapter_test.go rename to internal/linter/tsc/linter_test.go index 6de991f..aeb4082 100644 --- a/internal/adapter/tsc/adapter_test.go +++ b/internal/linter/tsc/linter_test.go @@ -7,43 +7,43 @@ import ( "strings" "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) -func TestNewAdapter(t *testing.T) { - adapter := NewAdapter("") - if adapter == nil { - t.Fatal("NewAdapter() returned nil") +func TestNew(t *testing.T) { + l := New("") + if l == nil { + t.Fatal("New() returned nil") } // Should have default ToolsDir - if adapter.ToolsDir == "" { + if l.ToolsDir == "" { t.Error("ToolsDir should not be empty") } } -func TestNewAdapter_CustomToolsDir(t *testing.T) { +func TestNew_CustomToolsDir(t *testing.T) { toolsDir := "/custom/tools" - adapter := NewAdapter(toolsDir) + l := New(toolsDir) - if adapter.ToolsDir != toolsDir { - t.Errorf("ToolsDir = %q, want %q", adapter.ToolsDir, toolsDir) + if l.ToolsDir != toolsDir { + t.Errorf("ToolsDir = %q, want %q", l.ToolsDir, toolsDir) } } func TestName(t *testing.T) { - adapter := NewAdapter("") - if adapter.Name() != "tsc" { - t.Errorf("Name() = %q, want %q", adapter.Name(), "tsc") + l := New("") + if l.Name() != "tsc" { + t.Errorf("Name() = %q, want %q", l.Name(), "tsc") } } func TestGetTSCPath(t *testing.T) { - adapter := NewAdapter("/test/tools") + l := New("/test/tools") expected := filepath.Join("/test/tools", "node_modules", ".bin", "tsc") - got := adapter.getTSCPath() + got := l.getTSCPath() if got != expected { t.Errorf("getTSCPath() = %q, want %q", got, expected) } @@ -57,9 +57,9 @@ func TestInitPackageJSON(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - adapter := NewAdapter(tmpDir) + l := New(tmpDir) - if err := adapter.initPackageJSON(); err != nil { + if err := l.initPackageJSON(); err != nil { t.Fatalf("initPackageJSON() error = %v", err) } @@ -91,10 +91,10 @@ func TestInitPackageJSON(t *testing.T) { func TestCheckAvailability_NotFound(t *testing.T) { // Use a non-existent directory - adapter := NewAdapter("/nonexistent/path") + l := New("/nonexistent/path") ctx := context.Background() - err := adapter.CheckAvailability(ctx) + err := l.CheckAvailability(ctx) // Should return error when tsc is not found if err == nil { @@ -111,10 +111,10 @@ func TestInstall_MissingNPM(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - a := NewAdapter(tmpDir) + a := New(tmpDir) ctx := context.Background() - config := adapter.InstallConfig{ + config := linter.InstallConfig{ ToolsDir: tmpDir, } @@ -137,14 +137,14 @@ func TestExecute_TempFileCleanup(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - adapter := NewAdapter(tmpDir) + l := New(tmpDir) ctx := context.Background() config := []byte(`{"compilerOptions": {"strict": true}}`) files := []string{"test.ts"} // Execute (will fail because tsc not installed, but we can test temp file cleanup) - _, _ = adapter.Execute(ctx, config, files) + _, _ = l.Execute(ctx, config, files) // Temp directory should exist (created by executor) tmpConfigDir := filepath.Join(tmpDir, ".tmp") @@ -162,9 +162,9 @@ func TestExecute_TempFileCleanup(t *testing.T) { } func TestParseOutput_Integration(t *testing.T) { - a := NewAdapter("") + a := New("") - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `src/main.ts(10,5): error TS2304: Cannot find name 'foo'. src/app.ts(20,10): error TS2339: Property 'bar' does not exist on type 'Object'.`, Stderr: "", diff --git a/internal/adapter/tsc/parser.go b/internal/linter/tsc/parser.go similarity index 85% rename from internal/adapter/tsc/parser.go rename to internal/linter/tsc/parser.go index 570f50f..fb6440b 100644 --- a/internal/adapter/tsc/parser.go +++ b/internal/linter/tsc/parser.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) // TSCDiagnostic represents a TypeScript diagnostic in JSON format. @@ -27,9 +27,9 @@ type TSCDiagnostic struct { // parseOutput parses tsc output and converts it to violations. // TSC output format (without --pretty): // file.ts(line,col): error TS2304: Message here. -func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { +func parseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { if output == nil { - return []adapter.Violation{}, nil + return []linter.Violation{}, nil } // Try JSON format first (if we use --diagnostics or custom formatter) @@ -43,9 +43,9 @@ func parseOutput(output *adapter.ToolOutput) ([]adapter.Violation, error) { // parseTextOutput parses tsc text output. // Format: src/main.ts(10,5): error TS2304: Cannot find name 'foo'. -func parseTextOutput(text string) ([]adapter.Violation, error) { +func parseTextOutput(text string) ([]linter.Violation, error) { lines := strings.Split(text, "\n") - violations := make([]adapter.Violation, 0) + violations := make([]linter.Violation, 0) // Regex to match tsc output: // file.ts(line,col): severity TScode: message @@ -69,7 +69,7 @@ func parseTextOutput(text string) ([]adapter.Violation, error) { code := matches[5] message := matches[6] - violations = append(violations, adapter.Violation{ + violations = append(violations, linter.Violation{ File: file, Line: lineNum, Column: col, @@ -83,15 +83,15 @@ func parseTextOutput(text string) ([]adapter.Violation, error) { } // parseJSONOutput parses tsc JSON output (if we implement custom formatter). -func parseJSONOutput(jsonStr string) ([]adapter.Violation, error) { +func parseJSONOutput(jsonStr string) ([]linter.Violation, error) { var diagnostics []TSCDiagnostic if err := json.Unmarshal([]byte(jsonStr), &diagnostics); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } - violations := make([]adapter.Violation, len(diagnostics)) + violations := make([]linter.Violation, len(diagnostics)) for i, diag := range diagnostics { - violations[i] = adapter.Violation{ + violations[i] = linter.Violation{ File: diag.File.FileName, Line: diag.Line, Column: diag.Column, diff --git a/internal/adapter/tsc/parser_test.go b/internal/linter/tsc/parser_test.go similarity index 97% rename from internal/adapter/tsc/parser_test.go rename to internal/linter/tsc/parser_test.go index 6c06cf4..8ded939 100644 --- a/internal/adapter/tsc/parser_test.go +++ b/internal/linter/tsc/parser_test.go @@ -3,7 +3,7 @@ package tsc import ( "testing" - "github.com/DevSymphony/sym-cli/internal/adapter" + "github.com/DevSymphony/sym-cli/internal/linter" ) func TestParseTextOutput(t *testing.T) { @@ -164,7 +164,7 @@ func TestMapCategory(t *testing.T) { } func TestParseOutput_EmptyOutput(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: "", Stderr: "", ExitCode: 0, @@ -192,7 +192,7 @@ func TestParseOutput_NilOutput(t *testing.T) { } func TestParseOutput_RealWorldExample(t *testing.T) { - output := &adapter.ToolOutput{ + output := &linter.ToolOutput{ Stdout: `src/index.ts(15,7): error TS2322: Type 'string' is not assignable to type 'number'. src/utils/helper.ts(42,15): error TS2339: Property 'nonExistent' does not exist on type 'MyType'. src/components/Button.tsx(8,3): warning TS6133: 'props' is declared but its value is never read.`, diff --git a/internal/linter/tsc/register.go b/internal/linter/tsc/register.go new file mode 100644 index 0000000..725fe77 --- /dev/null +++ b/internal/linter/tsc/register.go @@ -0,0 +1,13 @@ +package tsc + +import ( + "github.com/DevSymphony/sym-cli/internal/linter" +) + +func init() { + _ = linter.Global().RegisterTool( + New(linter.DefaultToolsDir()), + NewConverter(), + "tsconfig.json", + ) +} From 92708b66b4362f6bc796e1aaaad33437a1832135 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Mon, 8 Dec 2025 08:48:32 +0000 Subject: [PATCH 2/4] refactor: update imports from adapter to linter - internal/cmd/convert.go - internal/cmd/init.go - internal/validator/validator.go - tests/integration/*_test.go - cmd/test-linter/main.go --- cmd/test-linter/main.go | 8 ++++---- internal/cmd/convert.go | 4 ++-- internal/cmd/init.go | 4 ++-- internal/validator/validator.go | 17 ++++++++--------- tests/integration/checkstyle_test.go | 8 ++++---- tests/integration/eslint_test.go | 10 +++++----- tests/integration/init_test.go | 12 ++++++------ tests/integration/pmd_test.go | 10 +++++----- tests/integration/prettier_test.go | 10 +++++----- tests/integration/pylint_test.go | 8 ++++---- tests/integration/tsc_test.go | 10 +++++----- 11 files changed, 50 insertions(+), 51 deletions(-) diff --git a/cmd/test-linter/main.go b/cmd/test-linter/main.go index a344d89..28d5cd6 100644 --- a/cmd/test-linter/main.go +++ b/cmd/test-linter/main.go @@ -6,8 +6,8 @@ import ( "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/eslint" + "github.com/DevSymphony/sym-cli/internal/linter" + "github.com/DevSymphony/sym-cli/internal/linter/eslint" ) func main() { @@ -19,7 +19,7 @@ func main() { toolsDir := filepath.Join(homeDir, ".sym", "tools") workDir, _ := os.Getwd() - adp := eslint.NewAdapter(toolsDir) + adp := eslint.New(toolsDir) fmt.Printf("✓ Created ESLint adapter\n") fmt.Printf(" Tools directory: %s\n", adp.ToolsDir) @@ -31,7 +31,7 @@ func main() { fmt.Printf("⚠️ ESLint not available: %v\n", err) fmt.Println("\nInstalling ESLint...") // Try to install - installConfig := adapter.InstallConfig{ + installConfig := linter.InstallConfig{ ToolsDir: toolsDir, } installErr := adp.Install(ctx, installConfig) diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index d3f2f59..270b46c 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" "github.com/DevSymphony/sym-cli/internal/config" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/ui" @@ -54,7 +54,7 @@ func init() { // buildTargetsDescription dynamically builds the --targets flag description func buildTargetsDescription() string { - tools := registry.Global().GetAllToolNames() + tools := linter.Global().GetAllToolNames() if len(tools) == 0 { return "target linters (or 'all')" } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 3cc66d1..2e10610 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" "github.com/DevSymphony/sym-cli/internal/config" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/internal/ui" @@ -206,7 +206,7 @@ func initializeConfigFile() error { func removeExistingCodePolicy() error { // Get list of generated files from registry convertGeneratedFiles := []string{"code-policy.json"} - convertGeneratedFiles = append(convertGeneratedFiles, registry.Global().GetAllConfigFiles()...) + convertGeneratedFiles = append(convertGeneratedFiles, linter.Global().GetAllConfigFiles()...) // Check and remove from .sym directory symDir, err := getSymDir() diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 86cdbda..2cb47da 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -10,8 +10,7 @@ import ( "sync" "time" - "github.com/DevSymphony/sym-cli/internal/adapter" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/pkg/schema" @@ -37,7 +36,7 @@ type Violation struct { type Validator struct { policy *schema.CodePolicy verbose bool - adapterRegistry *adapterRegistry.Registry + linterRegistry *linter.Registry workDir string symDir string // .sym directory for config files selector *FileSelector @@ -59,7 +58,7 @@ func NewValidator(policy *schema.CodePolicy, verbose bool) *Validator { return &Validator{ policy: policy, verbose: verbose, - adapterRegistry: adapterRegistry.Global(), + linterRegistry: linter.Global(), workDir: workDir, symDir: symDir, selector: NewFileSelector(workDir), @@ -78,7 +77,7 @@ func NewValidatorWithWorkDir(policy *schema.CodePolicy, verbose bool, workDir st return &Validator{ policy: policy, verbose: verbose, - adapterRegistry: adapterRegistry.Global(), + linterRegistry: linter.Global(), workDir: workDir, symDir: symDir, selector: NewFileSelector(workDir), @@ -100,8 +99,8 @@ func (v *Validator) executeRule(engineName string, rule schema.PolicyRule, files return v.executeLLMRule(rule, files) } - // Get adapter directly by tool name (e.g., "eslint", "prettier", "tsc") - adp, err := v.adapterRegistry.GetAdapter(engineName) + // Get linter directly by tool name (e.g., "eslint", "prettier", "tsc") + adp, err := v.linterRegistry.GetLinter(engineName) if err != nil { return nil, fmt.Errorf("adapter not found: %s: %w", engineName, err) } @@ -111,7 +110,7 @@ func (v *Validator) executeRule(engineName string, rule schema.PolicyRule, files if v.verbose { fmt.Printf(" 📦 Installing %s...\n", adp.Name()) } - if err := adp.Install(v.ctx, adapter.InstallConfig{ + if err := adp.Install(v.ctx, linter.InstallConfig{ ToolsDir: filepath.Join(os.Getenv("HOME"), ".sym", "tools"), }); err != nil { return nil, fmt.Errorf("failed to install %s: %w", adp.Name(), err) @@ -308,7 +307,7 @@ Does this code violate the convention?`, file, rule.Desc, string(content)) // First checks .sym directory for existing config files, then generates from rule func (v *Validator) getAdapterConfig(adapterName string, rule schema.PolicyRule) ([]byte, error) { // Check for existing config in .sym directory (using registry) - configFile := v.adapterRegistry.GetConfigFile(adapterName) + configFile := v.linterRegistry.GetConfigFile(adapterName) if configFile != "" { configPath := filepath.Join(v.symDir, configFile) if data, err := os.ReadFile(configPath); err == nil { diff --git a/tests/integration/checkstyle_test.go b/tests/integration/checkstyle_test.go index 5c65ece..7b108e7 100644 --- a/tests/integration/checkstyle_test.go +++ b/tests/integration/checkstyle_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestCheckstyle_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("checkstyle") + adp, err := linter.Global().GetLinter("checkstyle") if err != nil { t.Skipf("Checkstyle adapter not found: %v", err) } @@ -114,7 +114,7 @@ func TestCheckstyle_NamingRules(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("checkstyle") + adp, err := linter.Global().GetLinter("checkstyle") if err != nil { t.Skipf("Checkstyle adapter not found: %v", err) } @@ -201,7 +201,7 @@ func TestCheckstyle_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("checkstyle") + adp, err := linter.Global().GetLinter("checkstyle") if err != nil { t.Skipf("Checkstyle adapter not found: %v", err) } diff --git a/tests/integration/eslint_test.go b/tests/integration/eslint_test.go index 99da796..46ef381 100644 --- a/tests/integration/eslint_test.go +++ b/tests/integration/eslint_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestESLint_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("eslint") + adp, err := linter.Global().GetLinter("eslint") if err != nil { t.Skipf("ESLint adapter not found: %v", err) } @@ -113,7 +113,7 @@ func TestESLint_NamingConventions(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("eslint") + adp, err := linter.Global().GetLinter("eslint") if err != nil { t.Skipf("ESLint adapter not found: %v", err) } @@ -170,7 +170,7 @@ func TestESLint_MaxLineLength(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("eslint") + adp, err := linter.Global().GetLinter("eslint") if err != nil { t.Skipf("ESLint adapter not found: %v", err) } @@ -225,7 +225,7 @@ func TestESLint_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("eslint") + adp, err := linter.Global().GetLinter("eslint") if err != nil { t.Skipf("ESLint adapter not found: %v", err) } diff --git a/tests/integration/init_test.go b/tests/integration/init_test.go index b13429e..90c1766 100644 --- a/tests/integration/init_test.go +++ b/tests/integration/init_test.go @@ -2,10 +2,10 @@ package integration // Import adapters to trigger init() registration import ( - _ "github.com/DevSymphony/sym-cli/internal/adapter/checkstyle" - _ "github.com/DevSymphony/sym-cli/internal/adapter/eslint" - _ "github.com/DevSymphony/sym-cli/internal/adapter/pmd" - _ "github.com/DevSymphony/sym-cli/internal/adapter/prettier" - _ "github.com/DevSymphony/sym-cli/internal/adapter/pylint" - _ "github.com/DevSymphony/sym-cli/internal/adapter/tsc" + _ "github.com/DevSymphony/sym-cli/internal/linter/checkstyle" + _ "github.com/DevSymphony/sym-cli/internal/linter/eslint" + _ "github.com/DevSymphony/sym-cli/internal/linter/pmd" + _ "github.com/DevSymphony/sym-cli/internal/linter/prettier" + _ "github.com/DevSymphony/sym-cli/internal/linter/pylint" + _ "github.com/DevSymphony/sym-cli/internal/linter/tsc" ) diff --git a/tests/integration/pmd_test.go b/tests/integration/pmd_test.go index 1455c57..34b4c21 100644 --- a/tests/integration/pmd_test.go +++ b/tests/integration/pmd_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestPMD_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("pmd") + adp, err := linter.Global().GetLinter("pmd") if err != nil { t.Skipf("PMD adapter not found: %v", err) } @@ -114,7 +114,7 @@ func TestPMD_EmptyCatchBlock(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("pmd") + adp, err := linter.Global().GetLinter("pmd") if err != nil { t.Skipf("PMD adapter not found: %v", err) } @@ -169,7 +169,7 @@ func TestPMD_UnusedPrivateMethod(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("pmd") + adp, err := linter.Global().GetLinter("pmd") if err != nil { t.Skipf("PMD adapter not found: %v", err) } @@ -224,7 +224,7 @@ func TestPMD_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("pmd") + adp, err := linter.Global().GetLinter("pmd") if err != nil { t.Skipf("PMD adapter not found: %v", err) } diff --git a/tests/integration/prettier_test.go b/tests/integration/prettier_test.go index 1be8e66..c657837 100644 --- a/tests/integration/prettier_test.go +++ b/tests/integration/prettier_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestPrettier_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("prettier") + adp, err := linter.Global().GetLinter("prettier") if err != nil { t.Skipf("Prettier adapter not found: %v", err) } @@ -114,7 +114,7 @@ func TestPrettier_FormattingCheck(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("prettier") + adp, err := linter.Global().GetLinter("prettier") if err != nil { t.Skipf("Prettier adapter not found: %v", err) } @@ -174,7 +174,7 @@ func TestPrettier_QuotesAndIndent(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("prettier") + adp, err := linter.Global().GetLinter("prettier") if err != nil { t.Skipf("Prettier adapter not found: %v", err) } @@ -227,7 +227,7 @@ func TestPrettier_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("prettier") + adp, err := linter.Global().GetLinter("prettier") if err != nil { t.Skipf("Prettier adapter not found: %v", err) } diff --git a/tests/integration/pylint_test.go b/tests/integration/pylint_test.go index 635e31e..d9ac580 100644 --- a/tests/integration/pylint_test.go +++ b/tests/integration/pylint_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestPylint_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("pylint") + adp, err := linter.Global().GetLinter("pylint") if err != nil { t.Skipf("Pylint adapter not found: %v", err) } @@ -115,7 +115,7 @@ func TestPylint_NamingConventions(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("pylint") + adp, err := linter.Global().GetLinter("pylint") if err != nil { t.Skipf("Pylint adapter not found: %v", err) } @@ -201,7 +201,7 @@ func TestPylint_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("pylint") + adp, err := linter.Global().GetLinter("pylint") if err != nil { t.Skipf("Pylint adapter not found: %v", err) } diff --git a/tests/integration/tsc_test.go b/tests/integration/tsc_test.go index d29208f..a2b95dc 100644 --- a/tests/integration/tsc_test.go +++ b/tests/integration/tsc_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" @@ -42,7 +42,7 @@ func TestTSC_ValidateChanges(t *testing.T) { defer v.Close() // 4. Check tool availability - adp, err := adapterRegistry.Global().GetAdapter("tsc") + adp, err := linter.Global().GetLinter("tsc") if err != nil { t.Skipf("TSC adapter not found: %v", err) } @@ -109,7 +109,7 @@ func TestTSC_StrictNullChecks(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("tsc") + adp, err := linter.Global().GetLinter("tsc") if err != nil { t.Skipf("TSC adapter not found: %v", err) } @@ -167,7 +167,7 @@ func TestTSC_TypeErrors(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("tsc") + adp, err := linter.Global().GetLinter("tsc") if err != nil { t.Skipf("TSC adapter not found: %v", err) } @@ -220,7 +220,7 @@ func TestTSC_ToolNameAndRuleID(t *testing.T) { v := validator.NewValidatorWithWorkDir(&policy, true, testdataDir) defer v.Close() - adp, err := adapterRegistry.Global().GetAdapter("tsc") + adp, err := linter.Global().GetLinter("tsc") if err != nil { t.Skipf("TSC adapter not found: %v", err) } From 607da5c7b9ab92fcaf9460ac4677fbd625417bc6 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Mon, 8 Dec 2025 08:48:51 +0000 Subject: [PATCH 3/4] refactor: simplify converter concurrency to 2-level structure - Remove nested linter-level parallelization - Add convertAllTasks() for flat (linter, rule) pair processing - Unify concurrency to CPU/2 for both routing and conversion --- internal/converter/converter.go | 217 +++++++++++++++++++------------- 1 file changed, 131 insertions(+), 86 deletions(-) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 1de8e55..9b213b0 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -10,8 +10,7 @@ import ( "strings" "sync" - "github.com/DevSymphony/sym-cli/internal/adapter" - "github.com/DevSymphony/sym-cli/internal/adapter/registry" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/pkg/schema" ) @@ -89,8 +88,8 @@ func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy) } } - // Step 4: Convert rules for each linter in parallel using goroutines - // Limit concurrency to CPU count to prevent CPU spike + // Step 4: Convert all (linter, rule) pairs in parallel with single semaphore + // This is a flat parallelization - no nested goroutines result := &ConvertResult{ GeneratedFiles: []string{}, CodePolicy: codePolicy, @@ -98,89 +97,56 @@ func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy) Warnings: []string{}, } - var wg sync.WaitGroup - var mu sync.Mutex - // Track failed rules per linter for fallback to llm-validator failedRulesPerLinter := make(map[string][]string) - // Limit concurrent LLM calls to CPU count - maxConcurrent := runtime.NumCPU() - sem := make(chan struct{}, maxConcurrent) - + // Collect all (linter, rule) pairs as tasks + var tasks []conversionTask for linterName, rules := range linterRules { - if len(rules) == 0 { - continue - } - - // Skip llm-validator - it will be handled in CodePolicy only if linterName == llmValidatorEngine { - continue + continue // Skip llm-validator - handled in CodePolicy only } + for _, rule := range rules { + tasks = append(tasks, conversionTask{linterName: linterName, rule: rule}) + } + } - wg.Add(1) - go func(linter string, ruleSet []schema.UserRule) { - defer wg.Done() - - // Acquire semaphore - sem <- struct{}{} - defer func() { <-sem }() + // Convert all tasks in parallel with single semaphore + successResults, failedResults := c.convertAllTasks(ctx, tasks) - // Get linter converter - converter := c.getLinterConverter(linter) - if converter == nil { - mu.Lock() - result.Errors[linter] = fmt.Errorf("unsupported linter: %s", linter) - // Mark all rules as failed for this linter - for _, rule := range ruleSet { - failedRulesPerLinter[linter] = append(failedRulesPerLinter[linter], rule.ID) - } - mu.Unlock() - return - } + // Update failedRulesPerLinter from conversion results + for linterName, ruleIDs := range failedResults { + failedRulesPerLinter[linterName] = append(failedRulesPerLinter[linterName], ruleIDs...) + } - // Convert rules using LLM - now returns ConversionResult with per-rule tracking - convResult, err := converter.ConvertRules(ctx, ruleSet, c.llmProvider) - if err != nil { - mu.Lock() - result.Errors[linter] = fmt.Errorf("conversion failed: %w", err) - // Total failure - all rules should fallback - for _, rule := range ruleSet { - failedRulesPerLinter[linter] = append(failedRulesPerLinter[linter], rule.ID) - } - mu.Unlock() - return - } + // Build configs and write files for each linter (sequential - no LLM calls) + for linterName, linterResults := range successResults { + converter := c.getLinterConverter(linterName) + if converter == nil { + result.Errors[linterName] = fmt.Errorf("unsupported linter: %s", linterName) + continue + } - // Track failed rules from partial conversion - if len(convResult.FailedRules) > 0 { - mu.Lock() - failedRulesPerLinter[linter] = append(failedRulesPerLinter[linter], convResult.FailedRules...) - mu.Unlock() - } + config, err := converter.BuildConfig(linterResults) + if err != nil { + result.Errors[linterName] = fmt.Errorf("failed to build config: %w", err) + continue + } - // Only write config if at least one rule succeeded - if convResult.Config != nil && len(convResult.SuccessRules) > 0 { - outputPath := filepath.Join(c.outputDir, convResult.Config.Filename) - if err := os.WriteFile(outputPath, convResult.Config.Content, 0644); err != nil { - mu.Lock() - result.Errors[linter] = fmt.Errorf("failed to write file: %w", err) - mu.Unlock() - return - } + if config == nil { + continue + } - mu.Lock() - result.GeneratedFiles = append(result.GeneratedFiles, outputPath) - mu.Unlock() + outputPath := filepath.Join(c.outputDir, config.Filename) + if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { + result.Errors[linterName] = fmt.Errorf("failed to write file: %w", err) + continue + } - fmt.Fprintf(os.Stderr, "✓ Generated %s configuration: %s\n", linter, outputPath) - } - }(linterName, rules) + result.GeneratedFiles = append(result.GeneratedFiles, outputPath) + fmt.Fprintf(os.Stderr, "✓ Generated %s configuration: %s\n", linterName, outputPath) } - // Wait for all goroutines to complete - wg.Wait() - // Step 4.1: Update ruleToLinters mapping - remove failed linters and add llm-validator fallback for linter, failedRuleIDs := range failedRulesPerLinter { for _, ruleID := range failedRuleIDs { @@ -328,14 +294,10 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us results := make(chan routeResult, len(userPolicy.Rules)) var wg sync.WaitGroup - // Limit concurrent LLM calls to prevent resource exhaustion - // Use CPU/4, minimum 2, maximum 4 to balance performance and stability - maxConcurrent := runtime.NumCPU() / 4 - if maxConcurrent < 2 { - maxConcurrent = 2 - } - if maxConcurrent > 4 { - maxConcurrent = 4 + // Limit concurrent LLM calls: CPU/2 (minimum 1) + maxConcurrent := runtime.NumCPU() / 2 + if maxConcurrent < 1 { + maxConcurrent = 1 } sem := make(chan struct{}, maxConcurrent) @@ -412,11 +374,11 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us // getAvailableLinters returns available linters for given languages func (c *Converter) getAvailableLinters(languages []string) []string { // Build language mapping dynamically from registry - languageLinterMapping := registry.Global().BuildLanguageMapping() + languageLinterMapping := linter.Global().BuildLanguageMapping() if len(languages) == 0 { // If no languages specified, return all registered tools - return registry.Global().GetAllToolNames() + return linter.Global().GetAllToolNames() } linterSet := make(map[string]bool) @@ -530,9 +492,9 @@ Reason: Requires knowing which packages are "large"`, linterDescriptions, routin } // getLinterConverter returns the appropriate converter for a linter -func (c *Converter) getLinterConverter(linterName string) adapter.LinterConverter { +func (c *Converter) getLinterConverter(linterName string) linter.Converter { // Use registry to get converter (no hardcoding) - converter, ok := registry.Global().GetConverter(linterName) + converter, ok := linter.Global().GetConverter(linterName) if !ok { return nil } @@ -544,7 +506,7 @@ func (c *Converter) buildLinterDescriptions(availableLinters []string) string { var descriptions []string for _, linterName := range availableLinters { - converter, ok := registry.Global().GetConverter(linterName) + converter, ok := linter.Global().GetConverter(linterName) if !ok || converter == nil { continue } @@ -568,7 +530,7 @@ func (c *Converter) buildRoutingHints(availableLinters []string) string { hintNumber := 7 // Start after the base rules (1-6) for _, linterName := range availableLinters { - converter, ok := registry.Global().GetConverter(linterName) + converter, ok := linter.Global().GetConverter(linterName) if !ok || converter == nil { continue } @@ -587,6 +549,89 @@ func (c *Converter) buildRoutingHints(availableLinters []string) string { return strings.Join(hints, "\n") } +// conversionTask represents a single (linter, rule) pair to be converted +type conversionTask struct { + linterName string + rule schema.UserRule +} + +// convertAllTasks converts all (linter, rule) pairs in parallel with a single semaphore. +// Returns results grouped by linter name, and failed rule IDs grouped by linter name. +func (c *Converter) convertAllTasks(ctx context.Context, tasks []conversionTask) (map[string][]*linter.SingleRuleResult, map[string][]string) { + if len(tasks) == 0 { + return make(map[string][]*linter.SingleRuleResult), make(map[string][]string) + } + + type taskResult struct { + linterName string + result *linter.SingleRuleResult + ruleID string + err error + } + + results := make(chan taskResult, len(tasks)) + var wg sync.WaitGroup + + // Single semaphore for all conversions: CPU/2 (minimum 1) + maxConcurrent := runtime.NumCPU() / 2 + if maxConcurrent < 1 { + maxConcurrent = 1 + } + sem := make(chan struct{}, maxConcurrent) + + // Process all tasks in parallel + for _, task := range tasks { + wg.Add(1) + go func(t conversionTask) { + defer wg.Done() + + // Acquire semaphore with context check + select { + case sem <- struct{}{}: + case <-ctx.Done(): + results <- taskResult{linterName: t.linterName, ruleID: t.rule.ID, err: ctx.Err()} + return + } + defer func() { <-sem }() + + // Get converter and convert single rule + converter := c.getLinterConverter(t.linterName) + if converter == nil { + results <- taskResult{linterName: t.linterName, ruleID: t.rule.ID, err: fmt.Errorf("unsupported linter: %s", t.linterName)} + return + } + + res, err := converter.ConvertSingleRule(ctx, t.rule, c.llmProvider) + results <- taskResult{linterName: t.linterName, result: res, ruleID: t.rule.ID, err: err} + }(task) + } + + // Close results channel after all goroutines complete + go func() { + wg.Wait() + close(results) + }() + + // Collect and group results by linter + successByLinter := make(map[string][]*linter.SingleRuleResult) + failedByLinter := make(map[string][]string) + + for res := range results { + if res.err != nil { + failedByLinter[res.linterName] = append(failedByLinter[res.linterName], res.ruleID) + continue + } + if res.result == nil { + // Skip = cannot be enforced by this linter, fallback to llm-validator + failedByLinter[res.linterName] = append(failedByLinter[res.linterName], res.ruleID) + continue + } + successByLinter[res.linterName] = append(successByLinter[res.linterName], res.result) + } + + return successByLinter, failedByLinter +} + // convertRBAC converts UserRBAC to PolicyRBAC func (c *Converter) convertRBAC(userRBAC *schema.UserRBAC) *schema.PolicyRBAC { if userRBAC == nil || len(userRBAC.Roles) == 0 { From b7a0a80835849ee5307122f5db1253abaad8678e Mon Sep 17 00:00:00 2001 From: ikjeong Date: Mon, 8 Dec 2025 08:51:34 +0000 Subject: [PATCH 4/4] docs: update linter README with current interface - Remove legacy ConvertRules/ConvertRulesParallel references - Document ConvertSingleRule + BuildConfig pattern - Add clear contribution guidelines - Follow llm/README.md style --- internal/linter/README.md | 205 ++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 98 deletions(-) diff --git a/internal/linter/README.md b/internal/linter/README.md index 5873f80..b6fc343 100644 --- a/internal/linter/README.md +++ b/internal/linter/README.md @@ -6,11 +6,11 @@ Unified interface for static linting tools. ``` internal/linter/ -├── linter.go # Linter interface + Capabilities, InstallConfig, ToolOutput, Violation -├── converter.go # Converter interface + LinterConfig, ConversionResult -├── registry.go # Registry, Global(), RegisterTool(), GetLinter() -├── helpers.go # DefaultToolsDir, WriteTempConfig, MapSeverity, FindTool -├── executor.go # SubprocessExecutor +├── linter.go # Linter interface (execution) +├── converter.go # Converter interface (rule conversion) +├── registry.go # Global registry, RegisterTool(), GetLinter() +├── helpers.go # CleanJSONResponse, DefaultToolsDir, WriteTempConfig +├── subprocess.go # SubprocessExecutor ├── eslint/ # JavaScript/TypeScript ├── prettier/ # Code formatting ├── pylint/ # Python @@ -22,43 +22,56 @@ internal/linter/ ## Usage ```go -import ( - "github.com/DevSymphony/sym-cli/internal/linter" -) +import "github.com/DevSymphony/sym-cli/internal/linter" -// Get linter by name +// 1. Get linter by name l, err := linter.Global().GetLinter("eslint") if err != nil { return err } -// Check availability and install if needed +// 2. Check availability and install if needed if err := l.CheckAvailability(ctx); err != nil { if err := l.Install(ctx, linter.InstallConfig{}); err != nil { return err } } -// Execute linter +// 3. Execute linter output, err := l.Execute(ctx, config, files) if err != nil { return err } -// Parse output +// 4. Parse output to violations violations, err := l.ParseOutput(output) ``` +### Getting Converter + +```go +converter, ok := linter.Global().GetConverter("eslint") +if !ok { + return fmt.Errorf("converter not found") +} + +// Convert single rule (called by main converter in parallel) +result, err := converter.ConvertSingleRule(ctx, rule, llmProvider) + +// Build config from results +config, err := converter.BuildConfig(results) +``` + ## Linter List -| Name | Languages | Categories | Config File | -|------|-----------|------------|-------------| -| `eslint` | JavaScript, TypeScript, JSX, TSX | pattern, length, style, ast | `.eslintrc.json` | -| `prettier` | JS, TS, JSON, CSS, HTML, Markdown | style | `.prettierrc.json` | -| `pylint` | Python | naming, style, docs, error_handling | `pylintrc` | -| `tsc` | TypeScript | typechecker | `tsconfig.json` | -| `checkstyle` | Java | naming, pattern, length, style | `checkstyle.xml` | -| `pmd` | Java | pattern, complexity, security, performance | `pmd-ruleset.xml` | +| Name | Languages | Config File | +|------|-----------|-------------| +| `eslint` | JavaScript, TypeScript, JSX, TSX | `.eslintrc.json` | +| `prettier` | JS, TS, JSON, CSS, HTML, Markdown | `.prettierrc` | +| `pylint` | Python | `.pylintrc` | +| `tsc` | TypeScript | `tsconfig.json` | +| `checkstyle` | Java | `checkstyle.xml` | +| `pmd` | Java | `pmd.xml` | ## Adding New Linter @@ -66,11 +79,11 @@ violations, err := l.ParseOutput(output) ``` internal/linter// -├── linter.go # Main struct + interface implementation -├── register.go # init() registration -├── converter.go # LLM rule conversion -├── executor.go # Tool execution -└── parser.go # Output parsing +├── linter.go # Linter implementation +├── converter.go # Converter implementation +├── executor.go # Tool execution logic +├── parser.go # Output parsing +└── register.go # init() registration ``` ### Step 2: Implement Linter Interface @@ -83,7 +96,7 @@ import ( "github.com/DevSymphony/sym-cli/internal/linter" ) -// Compile-time interface check +// Compile-time check var _ linter.Linter = (*Linter)(nil) type Linter struct { @@ -101,32 +114,27 @@ func New(toolsDir string) *Linter { } } -func (l *Linter) Name() string { - return "mylinter" -} +func (l *Linter) Name() string { return "mylinter" } func (l *Linter) GetCapabilities() linter.Capabilities { return linter.Capabilities{ Name: "mylinter", SupportedLanguages: []string{"ruby"}, SupportedCategories: []string{"pattern", "style"}, - Version: "^1.0.0", + Version: "1.0.0", } } func (l *Linter) CheckAvailability(ctx context.Context) error { - if path := linter.FindTool(l.localPath(), "mylinter"); path != "" { - return nil - } - return fmt.Errorf("mylinter not found") + // Check if tool binary exists } func (l *Linter) Install(ctx context.Context, cfg linter.InstallConfig) error { - // Install logic (gem install, pip install, etc.) + // Install tool (gem install, pip install, npm install, etc.) } func (l *Linter) Execute(ctx context.Context, config []byte, files []string) (*linter.ToolOutput, error) { - // Execution logic + // Run tool and return output } func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, error) { @@ -137,7 +145,16 @@ func (l *Linter) ParseOutput(output *linter.ToolOutput) ([]linter.Violation, err ### Step 3: Implement Converter Interface ```go -// Compile-time interface check +package mylinter + +import ( + "context" + "github.com/DevSymphony/sym-cli/internal/linter" + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Compile-time check var _ linter.Converter = (*Converter)(nil) type Converter struct{} @@ -151,24 +168,56 @@ func (c *Converter) SupportedLanguages() []string { } func (c *Converter) GetLLMDescription() string { - return "Ruby linter for style and quality" + return `Ruby code quality (style, naming, complexity) + - CAN: Naming conventions, line length, cyclomatic complexity + - CANNOT: Business logic, runtime behavior` } func (c *Converter) GetRoutingHints() []string { - return []string{"For Ruby code style → use mylinter"} + return []string{ + "For Ruby code style → use mylinter", + "For Ruby naming conventions → use mylinter", + } +} + +// ConvertSingleRule converts ONE user rule to linter-specific data. +// Concurrency is handled by main converter, not here. +func (c *Converter) ConvertSingleRule(ctx context.Context, rule schema.UserRule, provider llm.Provider) (*linter.SingleRuleResult, error) { + // Call LLM to convert rule + config, err := c.callLLM(ctx, rule, provider) + if err != nil { + return nil, err + } + + // Return nil, nil if rule cannot be enforced by this linter + if config == nil { + return nil, nil + } + + return &linter.SingleRuleResult{ + RuleID: rule.ID, + Data: config, // Linter-specific data + }, nil } -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*linter.ConversionResult, error) { - // Use helper for parallel conversion - results, successIDs, failedIDs := linter.ConvertRulesParallel(ctx, rules, c.convertSingle) +// BuildConfig assembles final config from all successful conversions. +func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.LinterConfig, error) { + if len(results) == 0 { + return nil, nil + } // Build config from results - config := buildConfig(results) + config := buildMyLinterConfig(results) - return &linter.ConversionResult{ - Config: config, - SuccessRules: successIDs, - FailedRules: failedIDs, + content, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, err + } + + return &linter.LinterConfig{ + Filename: ".mylinter.yml", + Content: content, + Format: "yaml", }, nil } ``` @@ -178,9 +227,7 @@ func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, p ```go package mylinter -import ( - "github.com/DevSymphony/sym-cli/internal/linter" -) +import "github.com/DevSymphony/sym-cli/internal/linter" func init() { _ = linter.Global().RegisterTool( @@ -200,53 +247,15 @@ import ( ) ``` -## Key Patterns - -### Compile-Time Interface Checks - -Every linter should include: -```go -var _ linter.Linter = (*Linter)(nil) -var _ linter.Converter = (*Converter)(nil) -``` - -### Helper Functions - -Use the provided helpers from `base.go`: - -```go -// Default tools directory (~/.sym/tools) -toolsDir := linter.DefaultToolsDir() - -// Write temp config file -configPath, err := linter.WriteTempConfig(toolsDir, "mylinter", configBytes) - -// Normalize severity -severity := linter.MapSeverity("warn") // returns "warning" - -// Find tool binary (local first, then PATH) -path := linter.FindTool(localPath, "mylinter") -``` - -### Parallel Rule Conversion - -Use `ConvertRulesParallel` for efficient parallel processing: - -```go -func (c *Converter) ConvertRules(ctx context.Context, rules []schema.UserRule, provider llm.Provider) (*linter.ConversionResult, error) { - results, successIDs, failedIDs := linter.ConvertRulesParallel(ctx, rules, - func(ctx context.Context, rule schema.UserRule) (*MyConfig, error) { - return c.convertSingleRule(ctx, rule, provider) - }, - ) - - // Build final config from successful conversions - // ... -} -``` - -## Error Handling +## Key Rules +- Add compile-time checks for both interfaces: + ```go + var _ linter.Linter = (*Linter)(nil) + var _ linter.Converter = (*Converter)(nil) + ``` +- `ConvertSingleRule()` handles ONE rule only - concurrency is managed by main converter +- Return `(nil, nil)` from `ConvertSingleRule()` if rule cannot be enforced (falls back to llm-validator) +- Use `linter.CleanJSONResponse()` to strip markdown fences from LLM responses - Return clear error messages if tool not installed -- Track per-rule success/failure in ConversionResult -- Failed rules automatically fall back to llm-validator +- Refer to existing linters (eslint, pylint) for patterns