diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f72188e..52e476e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,12 @@ "ghcr.io/devcontainers/features/node:1": { "version": "22" }, - "ghcr.io/anthropics/devcontainer-features/claude-code:1": {} + "ghcr.io/anthropics/devcontainer-features/claude-code:1": {}, + "ghcr.io/devcontainers/features/java:1": { + "version": "21", + "installMaven": "false", + "installGradle": "false" + } }, "containerUser": "vscode", diff --git a/README.md b/README.md index 673eee7..4730efc 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,6 @@ sym init ## 요구사항 - Node.js >= 16.0.0 -- Policy file: `.sym/user-policy.json` --- diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 402c974..d2b5f46 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -239,8 +239,16 @@ func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy) // Add selector if languages are specified (for non-LLM linters) if linterName != llmValidatorEngine && (len(userRule.Languages) > 0 || len(userRule.Include) > 0 || len(userRule.Exclude) > 0) { + // Filter languages to only those supported by this linter + filteredLanguages := userRule.Languages + if conv, ok := linter.Global().GetConverter(linterName); ok { + supportedLangs := conv.SupportedLanguages() + if len(supportedLangs) > 0 && len(userRule.Languages) > 0 { + filteredLanguages = intersectLanguages(userRule.Languages, supportedLangs) + } + } policyRule.When = &schema.Selector{ - Languages: userRule.Languages, + Languages: filteredLanguages, Include: userRule.Include, Exclude: userRule.Exclude, } @@ -711,3 +719,40 @@ func (c *Converter) convertRBAC(userRBAC *schema.UserRBAC) *schema.PolicyRBAC { return policyRBAC } + +// intersectLanguages returns the intersection of two language slices. +// It normalizes language names (e.g., "ts" -> "typescript") for comparison. +func intersectLanguages(langs1, langs2 []string) []string { + // Normalization map for common language aliases + normalize := func(lang string) string { + switch strings.ToLower(lang) { + case "ts", "tsx": + return "typescript" + case "js", "jsx": + return "javascript" + case "py": + return "python" + default: + return strings.ToLower(lang) + } + } + + // Build a set of normalized languages from langs2 + supported := make(map[string]bool) + for _, lang := range langs2 { + supported[normalize(lang)] = true + } + + // Find intersection - keep original language names from langs1 + var result []string + seen := make(map[string]bool) + for _, lang := range langs1 { + normalized := normalize(lang) + if supported[normalized] && !seen[normalized] { + result = append(result, lang) + seen[normalized] = true + } + } + + return result +} diff --git a/internal/linter/eslint/converter.go b/internal/linter/eslint/converter.go index 86541d4..49e8548 100644 --- a/internal/linter/eslint/converter.go +++ b/internal/linter/eslint/converter.go @@ -108,6 +108,7 @@ func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.Lin "node": true, "browser": true, }, + "parser": "@typescript-eslint/parser", "parserOptions": map[string]interface{}{ "ecmaVersion": "latest", "sourceType": "module", diff --git a/internal/linter/eslint/executor.go b/internal/linter/eslint/executor.go index e393975..4f811a6 100644 --- a/internal/linter/eslint/executor.go +++ b/internal/linter/eslint/executor.go @@ -30,7 +30,8 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*l eslintCmd, args := l.getExecutionArgs(configPath, files) // Execute with environment variable to support both ESLint 8 and 9 - // Uses CWD by default + // Reset WorkDir to use CWD (Install() may have set it to ToolsDir) + l.executor.WorkDir = "" l.executor.Env = map[string]string{ "ESLINT_USE_FLAT_CONFIG": "false", } diff --git a/internal/linter/eslint/linter.go b/internal/linter/eslint/linter.go index 29465f3..68852b6 100644 --- a/internal/linter/eslint/linter.go +++ b/internal/linter/eslint/linter.go @@ -60,21 +60,45 @@ func (l *Linter) GetCapabilities() linter.Capabilities { } } -// CheckAvailability checks if ESLint is installed. +// CheckAvailability checks if ESLint and TypeScript parser are installed. func (l *Linter) CheckAvailability(ctx context.Context) error { - // Try local installation first eslintPath := l.getESLintPath() + parserPath := l.getTypeScriptParserPath() + + // Check local installation (both ESLint and TypeScript parser required) + eslintExists := false + parserExists := false + if _, err := os.Stat(eslintPath); err == nil { - return nil // Found in tools dir + eslintExists = true + } + if _, err := os.Stat(parserPath); err == nil { + parserExists = true } - // Try global installation - cmd := exec.CommandContext(ctx, "eslint", "--version") - if err := cmd.Run(); err == nil { - return nil // Found globally + // If both exist locally, we're good + if eslintExists && parserExists { + return nil } - return fmt.Errorf("eslint not found (checked: %s and global PATH)", eslintPath) + // Try global ESLint installation + if !eslintExists { + cmd := exec.CommandContext(ctx, "eslint", "--version") + if err := cmd.Run(); err == nil { + eslintExists = true + } + } + + // If ESLint exists but parser doesn't, need to install + if eslintExists && !parserExists { + return fmt.Errorf("@typescript-eslint/parser not found (required for TypeScript support)") + } + + if !eslintExists { + return fmt.Errorf("eslint not found (checked: %s and global PATH)", eslintPath) + } + + return nil } // Install installs ESLint via npm. @@ -103,9 +127,9 @@ func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error } } - // Install ESLint + // Install ESLint and TypeScript parser l.executor.WorkDir = l.ToolsDir - _, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version)) + _, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version), "@typescript-eslint/parser") if err != nil { return fmt.Errorf("npm install failed: %w", err) } @@ -130,6 +154,11 @@ func (l *Linter) getESLintPath() string { return filepath.Join(l.ToolsDir, "node_modules", ".bin", "eslint") } +// getTypeScriptParserPath returns the path to @typescript-eslint/parser. +func (l *Linter) getTypeScriptParserPath() string { + return filepath.Join(l.ToolsDir, "node_modules", "@typescript-eslint", "parser") +} + // initPackageJSON creates a minimal package.json. func (l *Linter) initPackageJSON() error { pkg := map[string]interface{}{ diff --git a/internal/linter/prettier/executor.go b/internal/linter/prettier/executor.go index b94b7fa..38b44ec 100644 --- a/internal/linter/prettier/executor.go +++ b/internal/linter/prettier/executor.go @@ -40,7 +40,9 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string, mod args = append(args, files...) - // Execute (uses CWD by default) + // Execute + // Reset WorkDir to use CWD (Install() may have set it to ToolsDir) + l.executor.WorkDir = "" output, err := l.executor.Execute(ctx, prettierCmd, args...) // Prettier returns non-zero exit code if files need formatting (in --check mode) diff --git a/internal/linter/tsc/executor.go b/internal/linter/tsc/executor.go index 81e8409..74297ed 100644 --- a/internal/linter/tsc/executor.go +++ b/internal/linter/tsc/executor.go @@ -74,7 +74,9 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*l "--pretty", "false", } - // Execute tsc (uses CWD by default) + // Execute tsc + // Reset WorkDir to use CWD (Install() may have set it to ToolsDir) + l.executor.WorkDir = "" output, err := l.executor.Execute(ctx, tscPath, args...) // TSC returns non-zero exit code when there are type errors diff --git a/internal/linter/tsc/parser.go b/internal/linter/tsc/parser.go index 0a2e951..f4b23c5 100644 --- a/internal/linter/tsc/parser.go +++ b/internal/linter/tsc/parser.go @@ -10,6 +10,31 @@ import ( "github.com/DevSymphony/sym-cli/internal/linter" ) +// errorCodeMapping maps TSC error codes to policy rule keywords. +var errorCodeMapping = map[string]string{ + // no-implicit-any: Parameter/variable implicitly has 'any' type + "7006": "no-implicit-any", + "7005": "no-implicit-any", + "7019": "no-implicit-any", + "7031": "no-implicit-any", + + // strict-null-checks: Object is possibly null/undefined + "2531": "strict-null-checks", + "2532": "strict-null-checks", + "2533": "strict-null-checks", + "18047": "strict-null-checks", + "18048": "strict-null-checks", +} + +// mapErrorCode converts TSC error code to RuleID. +// Returns mapped keyword if exists, otherwise returns "TS{code}" format. +func mapErrorCode(code string) string { + if keyword, ok := errorCodeMapping[code]; ok { + return keyword + } + return fmt.Sprintf("TS%s", code) +} + // TSCDiagnostic represents a TypeScript diagnostic in JSON format. type TSCDiagnostic struct { File struct { @@ -76,7 +101,7 @@ func parseTextOutput(text string) ([]linter.Violation, error) { Column: col, Message: message, Severity: mapSeverity(severity), - RuleID: fmt.Sprintf("TS%s", code), + RuleID: mapErrorCode(code), }) } @@ -98,7 +123,7 @@ func parseJSONOutput(jsonStr string) ([]linter.Violation, error) { Column: diag.Column, Message: diag.Message, Severity: mapCategory(diag.Category), - RuleID: fmt.Sprintf("TS%d", diag.Code), + RuleID: mapErrorCode(strconv.Itoa(diag.Code)), } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0104f3b..923ecb1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -11,6 +11,7 @@ import ( "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/importer" + "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -837,9 +838,15 @@ func (s *Server) needsConversion(codePolicyPath string) bool { // extractSourceRuleID extracts the original user-policy rule ID from a code-policy rule ID. // For example: "FMT-001-eslint" -> "FMT-001" func extractSourceRuleID(codePolicyRuleID string) string { - // Known linter suffixes that are appended during conversion (see converter.go:179) - linterSuffixes := []string{"-eslint", "-prettier", "-tsc", "-pylint", "-checkstyle", "-pmd", "-llm-validator"} - for _, suffix := range linterSuffixes { + // Build linter suffixes dynamically from registry + llm-validator + toolNames := linter.Global().GetAllToolNames() + suffixes := make([]string, 0, len(toolNames)+1) + for _, name := range toolNames { + suffixes = append(suffixes, "-"+name) + } + suffixes = append(suffixes, "-llm-validator") // llm-validator is not a linter but a validator + + for _, suffix := range suffixes { if strings.HasSuffix(codePolicyRuleID, suffix) { return strings.TrimSuffix(codePolicyRuleID, suffix) } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 758153c..82673af 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -129,17 +129,31 @@ func (v *Validator) groupRulesByEngine(rules []schema.PolicyRule, changes []git. for _, rule := range rules { if !rule.Enabled { + if v.verbose { + fmt.Printf(" [skip] %s: disabled\n", rule.ID) + } continue } engineName := getEngineName(rule) if engineName == "" { + if v.verbose { + fmt.Printf(" [skip] %s: no engine specified\n", rule.ID) + } continue } // Filter changes relevant to this rule relevantChanges := v.filterChangesForRule(changes, &rule) if len(relevantChanges) == 0 { + if v.verbose { + langs := []string{} + if rule.When != nil { + langs = rule.When.Languages + } + fmt.Printf(" [skip] %s (%s): no matching files (languages: %v)\n", + rule.ID, engineName, langs) + } continue } diff --git a/npm/README.md b/npm/README.md index 673eee7..4730efc 100644 --- a/npm/README.md +++ b/npm/README.md @@ -158,7 +158,6 @@ sym init ## 요구사항 - Node.js >= 16.0.0 -- Policy file: `.sym/user-policy.json` --- diff --git a/npm/package.json b/npm/package.json index 40a5e4e..54ce1a4 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@dev-symphony/sym", - "version": "0.1.12", + "version": "0.1.13", "description": "Symphony - LLM-friendly convention linter for AI coding assistants", "keywords": [ "mcp",