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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/mxlint/mxlint-cli
go 1.24.6

require (
github.com/evanw/esbuild v0.27.2
github.com/fsnotify/fsnotify v1.9.0
github.com/glebarez/go-sqlite v1.22.0
github.com/grafana/sobek v0.0.0-20251124090928-9a028a30ff58
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.27.2 h1:3xBEws9y/JosfewXMM2qIyHAi+xRo8hVx475hVkJfNg=
github.com/evanw/esbuild v0.27.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
Expand Down Expand Up @@ -179,6 +181,7 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
Expand Down
11 changes: 11 additions & 0 deletions lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageJavascript {
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageTypescript {
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
}
if err != nil {
return nil, err
Expand Down Expand Up @@ -346,6 +348,8 @@ func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, ca
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageJavascript {
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageTypescript {
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
}

if err != nil {
Expand Down Expand Up @@ -384,6 +388,13 @@ func ReadRulesMetadata(rulesPath string) ([]Rule, error) {
}
rules = append(rules, *rule)
}
if !info.IsDir() && !strings.HasSuffix(info.Name(), "_test.ts") && strings.HasSuffix(info.Name(), ".ts") {
rule, err := parseRuleMetadata_Typescript(path)
if err != nil {
return err
}
rules = append(rules, *rule)
}
return nil
})
return rules, nil
Expand Down
303 changes: 303 additions & 0 deletions lint/lint_typscript.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
package lint

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"

"github.com/evanw/esbuild/pkg/api"
"github.com/grafana/sobek"
"gopkg.in/yaml.v3"
)

type typescriptRuleCacheEntry struct {
hash string
code string
}

var typescriptRuleCache = struct {
mu sync.RWMutex
entries map[string]typescriptRuleCacheEntry
}{
entries: make(map[string]typescriptRuleCacheEntry),
}

func hashRuleContent(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

func formatEsbuildErrors(errors []api.Message) string {
messages := api.FormatMessages(errors, api.FormatMessagesOptions{
Kind: api.ErrorMessage,
Color: false,
})
return strings.Join(messages, "\n")
}

func transpileTypescriptRule(rulePath string) (string, error) {
content, err := os.ReadFile(rulePath)
if err != nil {
return "", err
}

ruleHash := hashRuleContent(content)

typescriptRuleCache.mu.RLock()
cached, found := typescriptRuleCache.entries[rulePath]
typescriptRuleCache.mu.RUnlock()
if found && cached.hash == ruleHash {
return cached.code, nil
}

result := api.Transform(string(content), api.TransformOptions{
Loader: api.LoaderTS,
Target: api.ES2019,
Sourcefile: rulePath,
})
if len(result.Errors) > 0 {
return "", fmt.Errorf("failed to transpile typescript rule %s: %s", rulePath, formatEsbuildErrors(result.Errors))
}

code := string(result.Code)

typescriptRuleCache.mu.Lock()
typescriptRuleCache.entries[rulePath] = typescriptRuleCacheEntry{
hash: ruleHash,
code: code,
}
typescriptRuleCache.mu.Unlock()

return code, nil
}

func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) {
ruleContent, err := transpileTypescriptRule(rulePath)
if err != nil {
return nil, err
}
log.Debugf("ts file transpiled: \n%s", ruleContent)

documentContent, err := os.ReadFile(inputFilePath)
if err != nil {
log.Errorf("Error reading YAML file: %s\n", err)
return nil, err
}

// parse the input file as YAML
var data map[string]interface{}
var node yaml.Node
err = yaml.Unmarshal(documentContent, &node)
if err != nil {
log.Errorf("Error parsing YAML file: %s\n", err)
return nil, err
}
err = node.Decode(&data)
if err != nil {
log.Errorf("Error decoding YAML file: %s\n", err)
return nil, err
}

// Check if this rule should be skipped based on noqa directives
if doc, ok := data["Documentation"].(string); ok {
shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa)
if shouldSkip {
return &Testcase{
Name: inputFilePath,
Time: 0,
Skipped: &Skipped{Message: reason},
}, nil
}
}

startTime := time.Now()

// Use the directory containing the input file as the working directory
workingDirectory := filepath.Dir(inputFilePath)
vm := setupJavascriptVM(workingDirectory)
_, err = vm.RunString(ruleContent)
if err != nil {
panic(err)
}

ruleFunction, ok := sobek.AssertFunction(vm.Get("rule"))
if !ok {
panic("rule(...) function not found in rule file: " + rulePath)
}

res, err := ruleFunction(sobek.Undefined(), vm.ToValue(data))
if err != nil {
panic(err)
}

rs := res.Export().(map[string]interface{})

duration := time.Since(startTime)

var failure *Failure = nil

log.Debugf("Result: %v", rs)
result := rs["allow"].(bool)
errors := rs["errors"].([]interface{})
if !result {
myErrors := make([]string, 0)
for _, err := range errors {
//log.Warnf("Rule failed: %s", err)
myErrors = append(myErrors, fmt.Sprintf("%s", err))
}
failure = &Failure{
Message: strings.Join(myErrors, "\n"),
Type: "AssertionError",
}
}
testcase := &Testcase{
Name: inputFilePath,
Time: float64(duration.Nanoseconds()) / 1e9, // convert to seconds
Failure: failure,
Skipped: nil,
}
return testcase, nil
}

func parseRuleMetadata_Typescript(rulePath string) (*Rule, error) {

log.Debugf("reading rule %s", rulePath)

ruleContent, err := transpileTypescriptRule(rulePath)
if err != nil {
return nil, err
}

vm := sobek.New()
_, err = vm.RunString(ruleContent)
if err != nil {
panic(err)
}
// FIXME: handle the case where metadata is not defined correctly
metadata := vm.Get("metadata")
metadataMap := metadata.ToObject(vm)

var packageName string = rulePath
var title string = metadataMap.Get("title").String()
var description string = metadataMap.Get("description").String()

// custom metadata
custom := metadataMap.Get("custom").ToObject(vm)
var category string = custom.Get("category").String()
var severity string = custom.Get("severity").String()
var ruleNumber string = custom.Get("rulenumber").String()
var remediation string = custom.Get("remediation").String()
var ruleName string = custom.Get("rulename").String()
var pattern string = custom.Get("input").String()

rule := &Rule{
Title: title,
Description: description,
Category: category,
Severity: severity,
RuleNumber: ruleNumber,
Remediation: remediation,
RuleName: ruleName,
Path: rulePath,
Pattern: pattern,
PackageName: packageName,
Language: LanguageTypescript,
}
return rule, nil
}

func runTypescriptTestCases(rule Rule) error {
ruleContent, err := transpileTypescriptRule(rule.Path)
if err != nil {
return err
}

testFilePath := strings.Replace(rule.Path, ".ts", "_test.yaml", 1)
testCases, err := readTestCases(testFilePath)
if err != nil {
return err
}

for _, testCase := range testCases {
var input map[string]interface{}
var allow bool

// Handle different map types based on YAML parser
switch tcMap := testCase.(type) {
case map[interface{}]interface{}:
// For yaml.v2
input = convertToStringKeyMap(tcMap["input"].(map[interface{}]interface{}))
allow = tcMap["allow"].(bool)
case map[string]interface{}:
// For yaml.v3
inputVal := tcMap["input"]
switch inputMap := inputVal.(type) {
case map[interface{}]interface{}:
input = convertToStringKeyMap(inputMap)
case map[string]interface{}:
input = inputMap
default:
return fmt.Errorf("unexpected input type: %T", inputVal)
}
allow = tcMap["allow"].(bool)
default:
return fmt.Errorf("unexpected testCase type: %T", testCase)
}

// Use the directory containing the rule file as the working directory
workingDirectory := filepath.Dir(rule.Path)
vm := setupJavascriptVM(workingDirectory)
_, err = vm.RunString(ruleContent)
if err != nil {
panic(err)
}

ruleFunction, ok := sobek.AssertFunction(vm.Get("rule"))
if !ok {
panic("rule(...) function not found")
}

res, err := ruleFunction(sobek.Undefined(), vm.ToValue(input))
if err != nil {
panic(err)
}

rs := res.Export().(map[string]interface{})

result := rs["allow"].(bool)
errors := rs["errors"].([]interface{})

// Get the test case name
var name string
switch tcMap := testCase.(type) {
case map[interface{}]interface{}:
if n, ok := tcMap["name"].(string); ok {
name = n
} else {
name = "unnamed test"
}
case map[string]interface{}:
if n, ok := tcMap["name"].(string); ok {
name = n
} else {
name = "unnamed test"
}
}

if result != allow {
for _, error := range errors {
log.Errorf("Error: %s", error)
}
return fmt.Errorf("FAIL %s: Expected %v, got: %v", name, allow, result)
} else {
log.Infof("PASS %s ", name)
}
}

return nil
}
Loading
Loading