Skip to content
Closed
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
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import (
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/adjacent_overload_signatures"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/array_type"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/await_thenable"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/ban_ts_comment"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/class_literal_property_style"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/consistent_generic_constructors"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/consistent_indexed_object_style"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/consistent_type_assertions"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/consistent_type_definitions"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/no_array_delete"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/no_base_to_string"
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/no_confusing_void_expression"
Expand Down Expand Up @@ -333,7 +338,12 @@ func registerAllTypeScriptEslintPluginRules() {
GlobalRuleRegistry.Register("@typescript-eslint/adjacent-overload-signatures", adjacent_overload_signatures.AdjacentOverloadSignaturesRule)
GlobalRuleRegistry.Register("@typescript-eslint/array-type", array_type.ArrayTypeRule)
GlobalRuleRegistry.Register("@typescript-eslint/await-thenable", await_thenable.AwaitThenableRule)
GlobalRuleRegistry.Register("@typescript-eslint/ban-ts-comment", ban_ts_comment.BanTsCommentRule)
GlobalRuleRegistry.Register("@typescript-eslint/class-literal-property-style", class_literal_property_style.ClassLiteralPropertyStyleRule)
GlobalRuleRegistry.Register("@typescript-eslint/consistent-generic-constructors", consistent_generic_constructors.ConsistentGenericConstructorsRule)
GlobalRuleRegistry.Register("@typescript-eslint/consistent-indexed-object-style", consistent_indexed_object_style.ConsistentIndexedObjectStyleRule)
GlobalRuleRegistry.Register("@typescript-eslint/consistent-type-assertions", consistent_type_assertions.ConsistentTypeAssertionsRule)
GlobalRuleRegistry.Register("@typescript-eslint/consistent-type-definitions", consistent_type_definitions.ConsistentTypeDefinitionsRule)
GlobalRuleRegistry.Register("@typescript-eslint/dot-notation", dot_notation.DotNotationRule)
GlobalRuleRegistry.Register("@typescript-eslint/no-array-delete", no_array_delete.NoArrayDeleteRule)
GlobalRuleRegistry.Register("@typescript-eslint/no-base-to-string", no_base_to_string.NoBaseToStringRule)
Expand Down
3 changes: 3 additions & 0 deletions internal/linter/linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,10 @@ func RunLinterInProgram(program *compiler.Program, allowFiles []string, skipFile

return false
}
// Run listeners for the SourceFile node itself before visiting children
runListeners(ast.KindSourceFile, &file.Node)
file.Node.ForEachChild(childVisitor)
runListeners(rule.ListenerOnExit(ast.KindSourceFile), &file.Node)
clear(registeredListeners)
}

Expand Down
233 changes: 233 additions & 0 deletions internal/plugins/typescript/rules/ban_ts_comment/ban_ts_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package ban_ts_comment

import (
"regexp"
"strings"

"github.com/microsoft/typescript-go/shim/ast"
"github.com/microsoft/typescript-go/shim/core"
"github.com/web-infra-dev/rslint/internal/rule"
"github.com/web-infra-dev/rslint/internal/utils"
)

// BanTsCommentOptions defines configuration for the ban-ts-comment rule
type BanTsCommentOptions struct {
TsExpectError interface{} `json:"ts-expect-error"`
TsIgnore interface{} `json:"ts-ignore"`
TsNocheck interface{} `json:"ts-nocheck"`
TsCheck interface{} `json:"ts-check"`
MinimumDescriptionLength int `json:"minimumDescriptionLength"`
}

type directiveConfig struct {
banned bool
requireDescription bool
descriptionFormat *regexp.Regexp
minimumDescriptionLength int
}

func parseDirectiveOption(option interface{}, globalMinLength int) directiveConfig {
if option == nil {
return directiveConfig{banned: false}
}

switch v := option.(type) {
case bool:
if v {
// true means completely banned
return directiveConfig{banned: true}
}
// false means allowed without restriction
return directiveConfig{banned: false}
case string:
if v == "allow-with-description" {
return directiveConfig{
banned: false,
requireDescription: true,
minimumDescriptionLength: globalMinLength,
}
}
// Default to banned if unrecognized string
return directiveConfig{banned: true}
case map[string]interface{}:
config := directiveConfig{banned: false}

if descRequired, ok := v["descriptionFormat"]; ok {
if formatStr, ok := descRequired.(string); ok {
if re, err := regexp.Compile(formatStr); err == nil {
config.descriptionFormat = re
config.requireDescription = true
}
}
}

return config
default:
return directiveConfig{banned: false}
}
}

func parseOptions(options interface{}) BanTsCommentOptions {
opts := BanTsCommentOptions{
TsExpectError: true, // Default: banned
TsIgnore: true, // Default: banned
TsNocheck: true, // Default: banned
TsCheck: false, // Default: allowed
MinimumDescriptionLength: 3,
}

if options == nil {
return opts
}

switch v := options.(type) {
case map[string]interface{}:
if val, ok := v["ts-expect-error"]; ok {
opts.TsExpectError = val
}
if val, ok := v["ts-ignore"]; ok {
opts.TsIgnore = val
}
if val, ok := v["ts-nocheck"]; ok {
opts.TsNocheck = val
}
if val, ok := v["ts-check"]; ok {
opts.TsCheck = val
}
if val, ok := v["minimumDescriptionLength"]; ok {
if length, ok := val.(float64); ok {
opts.MinimumDescriptionLength = int(length)
}
}
}

return opts
}

func buildBannedMessage(directive string) rule.RuleMessage {
return rule.RuleMessage{
Id: "tsDirectiveComment",
Description: "Do not use \"@" + directive + "\" because it alters compilation errors.",
}
}

func buildDescriptionNotMatchFormatMessage(directive string) rule.RuleMessage {
return rule.RuleMessage{
Id: "tsDirectiveCommentDescriptionNotMatchPattern",
Description: "The description for the \"@" + directive + "\" directive must match the format.",
}
}

func buildDescriptionTooShortMessage(directive string, minLength int) rule.RuleMessage {
return rule.RuleMessage{
Id: "tsDirectiveCommentRequiresDescription",
Description: "Include a description after the \"@" + directive + "\" directive to explain why the suppression is necessary.",
}
}

var BanTsCommentRule = rule.CreateRule(rule.Rule{
Name: "ban-ts-comment",
Run: func(ctx rule.RuleContext, options interface{}) rule.RuleListeners {
opts := parseOptions(options)

directives := map[string]directiveConfig{
"ts-expect-error": parseDirectiveOption(opts.TsExpectError, opts.MinimumDescriptionLength),
"ts-ignore": parseDirectiveOption(opts.TsIgnore, opts.MinimumDescriptionLength),
"ts-nocheck": parseDirectiveOption(opts.TsNocheck, opts.MinimumDescriptionLength),
"ts-check": parseDirectiveOption(opts.TsCheck, opts.MinimumDescriptionLength),
}

checkComment := func(commentText string, pos int, end int) {
// Normalize comment text: remove leading // or /* and trailing */
text := strings.TrimSpace(commentText)
text = strings.TrimPrefix(text, "//")
text = strings.TrimPrefix(text, "/*")
text = strings.TrimSuffix(text, "*/")
text = strings.TrimSpace(text)

// Check if it starts with @ (TypeScript directive)
if !strings.HasPrefix(text, "@") {
return
}

// Extract directive name and description
// Handle both space and colon as separators (e.g., "@ts-expect-error" or "@ts-expect-error: description")
afterAt := text[1:] // Skip @ symbol

// First try to split by colon, then by space
var directiveName, description string
if colonIdx := strings.Index(afterAt, ":"); colonIdx != -1 {
directiveName = strings.TrimSpace(afterAt[:colonIdx])
description = strings.TrimSpace(afterAt[colonIdx+1:])
} else if spaceIdx := strings.Index(afterAt, " "); spaceIdx != -1 {
directiveName = strings.TrimSpace(afterAt[:spaceIdx])
description = strings.TrimSpace(afterAt[spaceIdx+1:])
} else {
directiveName = strings.TrimSpace(afterAt)
description = ""
}

// Check if this is a directive we care about
config, exists := directives[directiveName]
if !exists {
return
}

// If completely banned, report immediately
if config.banned {
ctx.ReportRange(core.NewTextRange(pos, end), buildBannedMessage(directiveName))
return
}

// If description is required
if config.requireDescription {
// Check minimum length
if len(description) < config.minimumDescriptionLength {
ctx.ReportRange(core.NewTextRange(pos, end), buildDescriptionTooShortMessage(directiveName, config.minimumDescriptionLength))
return
}

// Check description format if pattern is specified
if config.descriptionFormat != nil && !config.descriptionFormat.MatchString(description) {
ctx.ReportRange(core.NewTextRange(pos, end), buildDescriptionNotMatchFormatMessage(directiveName))
return
}
}
}

return rule.RuleListeners{
ast.KindSourceFile: func(node *ast.Node) {
sourceFile := ctx.SourceFile
text := sourceFile.Text()

// Use ForEachComment to iterate over all comments in the file
utils.ForEachComment(node, func(comment *ast.CommentRange) {
// The scanner seems to return incorrect end positions, so calculate the correct end manually
start := comment.Pos()
end := start

// Check if it's a // comment
if end+1 < len(text) && text[end] == '/' && text[end+1] == '/' {
// Find the end of the line
end += 2
for end < len(text) && text[end] != '\n' && text[end] != '\r' {
end++
}
} else if end+1 < len(text) && text[end] == '/' && text[end+1] == '*' {
// Find the closing */
end += 2
for end+1 < len(text) && !(text[end] == '*' && text[end+1] == '/') {
end++
}
if end+1 < len(text) {
end += 2
}
}

commentText := text[start:end]
checkComment(commentText, start, end)
}, sourceFile)
},
}
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package ban_ts_comment

import (
"testing"

"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/fixtures"
"github.com/web-infra-dev/rslint/internal/rule_tester"
)

func TestBanTsComment(t *testing.T) {
rule_tester.RunRuleTester(
fixtures.GetRootDir(),
"tsconfig.json",
t,
&BanTsCommentRule,
[]rule_tester.ValidTestCase{
// No directive comments
{Code: `const x = 1;`},
{Code: `// Regular comment
const x = 1;`},

// ts-expect-error with allow-with-description
{
Code: `// @ts-expect-error: TS2345 Argument type is incorrect
const x: number = "test";`,
Options: map[string]interface{}{
"ts-expect-error": "allow-with-description",
},
},

// ts-check is allowed by default
{Code: `// @ts-check
const x = 1;`},

// Allow ts-ignore when configured
{
Code: `// @ts-ignore
const x = 1;`,
Options: map[string]interface{}{
"ts-ignore": false,
},
},
},
[]rule_tester.InvalidTestCase{
// Banned by default: ts-expect-error
{
Code: `// @ts-expect-error
const x: number = "test";`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "tsDirectiveComment"},
},
},

// Banned by default: ts-ignore
{
Code: `// @ts-ignore
const x = 1;`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "tsDirectiveComment"},
},
},

// Banned by default: ts-nocheck
{
Code: `// @ts-nocheck
const x = 1;`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "tsDirectiveComment"},
},
},

// Description too short with allow-with-description
{
Code: `// @ts-expect-error: hi
const x: number = "test";`,
Options: map[string]interface{}{
"ts-expect-error": "allow-with-description",
"minimumDescriptionLength": 10,
},
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "tsDirectiveCommentRequiresDescription"},
},
},

// Block comment with ts-ignore
{
Code: `/* @ts-ignore */
const x = 1;`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "tsDirectiveComment"},
},
},
},
)
}
Loading
Loading