Skip to content
Merged
7 changes: 6 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ sym init
## 요구사항

- Node.js >= 16.0.0
- Policy file: `.sym/user-policy.json`

---

Expand Down
47 changes: 46 additions & 1 deletion internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/linter/eslint/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion internal/linter/eslint/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
49 changes: 39 additions & 10 deletions internal/linter/eslint/linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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{}{
Expand Down
4 changes: 3 additions & 1 deletion internal/linter/prettier/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion internal/linter/tsc/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions internal/linter/tsc/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
})
}

Expand All @@ -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)),
}
}

Expand Down
13 changes: 10 additions & 3 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
14 changes: 14 additions & 0 deletions internal/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 0 additions & 1 deletion npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ sym init
## 요구사항

- Node.js >= 16.0.0
- Policy file: `.sym/user-policy.json`

---

Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down