Skip to content

Commit 1847352

Browse files
authored
fix: improve linter execution with TypeScript support and language filtering (#57)
* chore: add Java 21 to devcontainer for java linter test * feat: dynamically build linter suffixes for code-policy rule ID extraction * refactor: enhance verbose logging in rule processing for better debugging * fix: filter supported languages for non-LLM linters * chore: remove reference to user-policy.json from README files * chore: reset WorkDir to use CWD in linter executors for ESLint, Prettier, and TSC * feat: add TypeScript support by integrating @typescript-eslint/parser * feat: add error code mapping for TypeScript diagnostics * chore: bump version to 0.1.13 in package.json
1 parent 6e0742f commit 1847352

13 files changed

Lines changed: 152 additions & 23 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
"ghcr.io/devcontainers/features/node:1": {
1212
"version": "22"
1313
},
14-
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {}
14+
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
15+
"ghcr.io/devcontainers/features/java:1": {
16+
"version": "21",
17+
"installMaven": "false",
18+
"installGradle": "false"
19+
}
1520
},
1621

1722
"containerUser": "vscode",

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,6 @@ sym init
158158
## 요구사항
159159

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

163162
---
164163

internal/converter/converter.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,16 @@ func (c *Converter) Convert(ctx context.Context, userPolicy *schema.UserPolicy)
239239

240240
// Add selector if languages are specified (for non-LLM linters)
241241
if linterName != llmValidatorEngine && (len(userRule.Languages) > 0 || len(userRule.Include) > 0 || len(userRule.Exclude) > 0) {
242+
// Filter languages to only those supported by this linter
243+
filteredLanguages := userRule.Languages
244+
if conv, ok := linter.Global().GetConverter(linterName); ok {
245+
supportedLangs := conv.SupportedLanguages()
246+
if len(supportedLangs) > 0 && len(userRule.Languages) > 0 {
247+
filteredLanguages = intersectLanguages(userRule.Languages, supportedLangs)
248+
}
249+
}
242250
policyRule.When = &schema.Selector{
243-
Languages: userRule.Languages,
251+
Languages: filteredLanguages,
244252
Include: userRule.Include,
245253
Exclude: userRule.Exclude,
246254
}
@@ -711,3 +719,40 @@ func (c *Converter) convertRBAC(userRBAC *schema.UserRBAC) *schema.PolicyRBAC {
711719

712720
return policyRBAC
713721
}
722+
723+
// intersectLanguages returns the intersection of two language slices.
724+
// It normalizes language names (e.g., "ts" -> "typescript") for comparison.
725+
func intersectLanguages(langs1, langs2 []string) []string {
726+
// Normalization map for common language aliases
727+
normalize := func(lang string) string {
728+
switch strings.ToLower(lang) {
729+
case "ts", "tsx":
730+
return "typescript"
731+
case "js", "jsx":
732+
return "javascript"
733+
case "py":
734+
return "python"
735+
default:
736+
return strings.ToLower(lang)
737+
}
738+
}
739+
740+
// Build a set of normalized languages from langs2
741+
supported := make(map[string]bool)
742+
for _, lang := range langs2 {
743+
supported[normalize(lang)] = true
744+
}
745+
746+
// Find intersection - keep original language names from langs1
747+
var result []string
748+
seen := make(map[string]bool)
749+
for _, lang := range langs1 {
750+
normalized := normalize(lang)
751+
if supported[normalized] && !seen[normalized] {
752+
result = append(result, lang)
753+
seen[normalized] = true
754+
}
755+
}
756+
757+
return result
758+
}

internal/linter/eslint/converter.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func (c *Converter) BuildConfig(results []*linter.SingleRuleResult) (*linter.Lin
108108
"node": true,
109109
"browser": true,
110110
},
111+
"parser": "@typescript-eslint/parser",
111112
"parserOptions": map[string]interface{}{
112113
"ecmaVersion": "latest",
113114
"sourceType": "module",

internal/linter/eslint/executor.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*l
3030
eslintCmd, args := l.getExecutionArgs(configPath, files)
3131

3232
// Execute with environment variable to support both ESLint 8 and 9
33-
// Uses CWD by default
33+
// Reset WorkDir to use CWD (Install() may have set it to ToolsDir)
34+
l.executor.WorkDir = ""
3435
l.executor.Env = map[string]string{
3536
"ESLINT_USE_FLAT_CONFIG": "false",
3637
}

internal/linter/eslint/linter.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,45 @@ func (l *Linter) GetCapabilities() linter.Capabilities {
6060
}
6161
}
6262

63-
// CheckAvailability checks if ESLint is installed.
63+
// CheckAvailability checks if ESLint and TypeScript parser are installed.
6464
func (l *Linter) CheckAvailability(ctx context.Context) error {
65-
// Try local installation first
6665
eslintPath := l.getESLintPath()
66+
parserPath := l.getTypeScriptParserPath()
67+
68+
// Check local installation (both ESLint and TypeScript parser required)
69+
eslintExists := false
70+
parserExists := false
71+
6772
if _, err := os.Stat(eslintPath); err == nil {
68-
return nil // Found in tools dir
73+
eslintExists = true
74+
}
75+
if _, err := os.Stat(parserPath); err == nil {
76+
parserExists = true
6977
}
7078

71-
// Try global installation
72-
cmd := exec.CommandContext(ctx, "eslint", "--version")
73-
if err := cmd.Run(); err == nil {
74-
return nil // Found globally
79+
// If both exist locally, we're good
80+
if eslintExists && parserExists {
81+
return nil
7582
}
7683

77-
return fmt.Errorf("eslint not found (checked: %s and global PATH)", eslintPath)
84+
// Try global ESLint installation
85+
if !eslintExists {
86+
cmd := exec.CommandContext(ctx, "eslint", "--version")
87+
if err := cmd.Run(); err == nil {
88+
eslintExists = true
89+
}
90+
}
91+
92+
// If ESLint exists but parser doesn't, need to install
93+
if eslintExists && !parserExists {
94+
return fmt.Errorf("@typescript-eslint/parser not found (required for TypeScript support)")
95+
}
96+
97+
if !eslintExists {
98+
return fmt.Errorf("eslint not found (checked: %s and global PATH)", eslintPath)
99+
}
100+
101+
return nil
78102
}
79103

80104
// Install installs ESLint via npm.
@@ -103,9 +127,9 @@ func (l *Linter) Install(ctx context.Context, config linter.InstallConfig) error
103127
}
104128
}
105129

106-
// Install ESLint
130+
// Install ESLint and TypeScript parser
107131
l.executor.WorkDir = l.ToolsDir
108-
_, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version))
132+
_, err := l.executor.Execute(ctx, "npm", "install", fmt.Sprintf("eslint@%s", version), "@typescript-eslint/parser")
109133
if err != nil {
110134
return fmt.Errorf("npm install failed: %w", err)
111135
}
@@ -130,6 +154,11 @@ func (l *Linter) getESLintPath() string {
130154
return filepath.Join(l.ToolsDir, "node_modules", ".bin", "eslint")
131155
}
132156

157+
// getTypeScriptParserPath returns the path to @typescript-eslint/parser.
158+
func (l *Linter) getTypeScriptParserPath() string {
159+
return filepath.Join(l.ToolsDir, "node_modules", "@typescript-eslint", "parser")
160+
}
161+
133162
// initPackageJSON creates a minimal package.json.
134163
func (l *Linter) initPackageJSON() error {
135164
pkg := map[string]interface{}{

internal/linter/prettier/executor.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string, mod
4040

4141
args = append(args, files...)
4242

43-
// Execute (uses CWD by default)
43+
// Execute
44+
// Reset WorkDir to use CWD (Install() may have set it to ToolsDir)
45+
l.executor.WorkDir = ""
4446
output, err := l.executor.Execute(ctx, prettierCmd, args...)
4547

4648
// Prettier returns non-zero exit code if files need formatting (in --check mode)

internal/linter/tsc/executor.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ func (l *Linter) execute(ctx context.Context, config []byte, files []string) (*l
7474
"--pretty", "false",
7575
}
7676

77-
// Execute tsc (uses CWD by default)
77+
// Execute tsc
78+
// Reset WorkDir to use CWD (Install() may have set it to ToolsDir)
79+
l.executor.WorkDir = ""
7880
output, err := l.executor.Execute(ctx, tscPath, args...)
7981

8082
// TSC returns non-zero exit code when there are type errors

internal/linter/tsc/parser.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ import (
1010
"github.com/DevSymphony/sym-cli/internal/linter"
1111
)
1212

13+
// errorCodeMapping maps TSC error codes to policy rule keywords.
14+
var errorCodeMapping = map[string]string{
15+
// no-implicit-any: Parameter/variable implicitly has 'any' type
16+
"7006": "no-implicit-any",
17+
"7005": "no-implicit-any",
18+
"7019": "no-implicit-any",
19+
"7031": "no-implicit-any",
20+
21+
// strict-null-checks: Object is possibly null/undefined
22+
"2531": "strict-null-checks",
23+
"2532": "strict-null-checks",
24+
"2533": "strict-null-checks",
25+
"18047": "strict-null-checks",
26+
"18048": "strict-null-checks",
27+
}
28+
29+
// mapErrorCode converts TSC error code to RuleID.
30+
// Returns mapped keyword if exists, otherwise returns "TS{code}" format.
31+
func mapErrorCode(code string) string {
32+
if keyword, ok := errorCodeMapping[code]; ok {
33+
return keyword
34+
}
35+
return fmt.Sprintf("TS%s", code)
36+
}
37+
1338
// TSCDiagnostic represents a TypeScript diagnostic in JSON format.
1439
type TSCDiagnostic struct {
1540
File struct {
@@ -76,7 +101,7 @@ func parseTextOutput(text string) ([]linter.Violation, error) {
76101
Column: col,
77102
Message: message,
78103
Severity: mapSeverity(severity),
79-
RuleID: fmt.Sprintf("TS%s", code),
104+
RuleID: mapErrorCode(code),
80105
})
81106
}
82107

@@ -98,7 +123,7 @@ func parseJSONOutput(jsonStr string) ([]linter.Violation, error) {
98123
Column: diag.Column,
99124
Message: diag.Message,
100125
Severity: mapCategory(diag.Category),
101-
RuleID: fmt.Sprintf("TS%d", diag.Code),
126+
RuleID: mapErrorCode(strconv.Itoa(diag.Code)),
102127
}
103128
}
104129

internal/mcp/server.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/DevSymphony/sym-cli/internal/converter"
1313
"github.com/DevSymphony/sym-cli/internal/importer"
14+
"github.com/DevSymphony/sym-cli/internal/linter"
1415
"github.com/DevSymphony/sym-cli/internal/llm"
1516
"github.com/DevSymphony/sym-cli/internal/policy"
1617
"github.com/DevSymphony/sym-cli/internal/roles"
@@ -837,9 +838,15 @@ func (s *Server) needsConversion(codePolicyPath string) bool {
837838
// extractSourceRuleID extracts the original user-policy rule ID from a code-policy rule ID.
838839
// For example: "FMT-001-eslint" -> "FMT-001"
839840
func extractSourceRuleID(codePolicyRuleID string) string {
840-
// Known linter suffixes that are appended during conversion (see converter.go:179)
841-
linterSuffixes := []string{"-eslint", "-prettier", "-tsc", "-pylint", "-checkstyle", "-pmd", "-llm-validator"}
842-
for _, suffix := range linterSuffixes {
841+
// Build linter suffixes dynamically from registry + llm-validator
842+
toolNames := linter.Global().GetAllToolNames()
843+
suffixes := make([]string, 0, len(toolNames)+1)
844+
for _, name := range toolNames {
845+
suffixes = append(suffixes, "-"+name)
846+
}
847+
suffixes = append(suffixes, "-llm-validator") // llm-validator is not a linter but a validator
848+
849+
for _, suffix := range suffixes {
843850
if strings.HasSuffix(codePolicyRuleID, suffix) {
844851
return strings.TrimSuffix(codePolicyRuleID, suffix)
845852
}

0 commit comments

Comments
 (0)