Skip to content

Commit c4e70af

Browse files
authored
Merge pull request #84 from mxlint/feature/improve-noqa
Feature/improve noqa with --ignore-noqa and rule level skipping
2 parents b91260c + 2866767 commit c4e70af

10 files changed

Lines changed: 389 additions & 60 deletions

File tree

‎README.md‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,62 @@ PASS (0.00158s) modelsource/MyFirstModule/DomainModels$DomainModel.yaml
8989
![Mendix Lint report](./resources/lint-xunit-report.png)
9090
Lint Mendix Yaml files. This tool checks for common mistakes and enforces best practices. It uses OPA as policy engine. Therefore policies must be written in the powerful Rego language. Please refer to [Rego language reference](https://www.openpolicyagent.org/docs/latest/policy-reference/) for more information on the syntax and semantics.
9191

92-
### NOQA (Ignore document)
92+
### NOQA (Ignore document or specific rules)
9393

94-
A specific document can be marked as "Skipped" if you have a line in the `documentation` field that starts with either `#noqa` or `# noqa` followed by an optional message (Case in-sensitive). This message will be included as "Skipped" reason in linting results.
94+
Documents can be marked with noqa directives in the `documentation` field to skip linting. There are two supported formats:
95+
96+
#### Skip all rules (document-level noqa)
97+
98+
A specific document can be marked as "Skipped" for all rules if you have a line in the `documentation` field that starts with either `#noqa` or `# noqa` followed by an optional message (Case in-sensitive). This message will be included as "Skipped" reason in linting results.
99+
100+
Example:
101+
```yaml
102+
Documentation: |
103+
#noqa This document is excluded from all linting rules
104+
```
105+
106+
#### Skip specific rules (rule-level noqa)
107+
108+
You can skip specific rules by providing a comma-separated list of rule numbers after a colon. This allows fine-grained control over which rules to ignore.
109+
110+
Example:
111+
```yaml
112+
Documentation: |
113+
#noqa:001_0002,001_0003 Temporarily skipping these rules due to legacy code
114+
```
115+
116+
In this example, only rules `001_0002` and `001_0003` will be skipped for this document, while all other rules will still be evaluated.
117+
118+
**Syntax:**
119+
- `#noqa` or `# noqa` - Skip all rules
120+
- `#noqa:rule1,rule2,...` or `# noqa:rule1,rule2,...` - Skip specific rules
121+
- Optional reason can be added after the rule list, separated by a space
122+
123+
**Notes:**
124+
- The noqa directive is case-insensitive
125+
- Multiple rule numbers should be separated by commas (no spaces)
126+
- The entire line after the noqa directive will be recorded as the skip reason
127+
128+
#### Ignore NOQA directives (--ignore-noqa flag)
129+
130+
In some scenarios, you may want to run linting on all documents, including those marked with noqa directives. The `--ignore-noqa` flag allows you to temporarily disable the noqa functionality.
131+
132+
**Usage:**
133+
```bash
134+
./mxlint-cli lint --ignore-noqa
135+
```
136+
137+
When this flag is set:
138+
- All noqa directives (both document-level and rule-level) are ignored
139+
- Documents that would normally be skipped are evaluated against all rules
140+
- The default behavior is `false` (noqa directives are respected)
141+
142+
**Example scenarios:**
143+
- **Audit mode**: Run a complete check to see all violations, even in documents marked with noqa
144+
- **CI/CD validation**: Enforce that certain branches (e.g., production) don't rely on noqa suppressions
145+
- **Cleanup**: Identify which noqa markers can be removed after fixing underlying issues
146+
147+
**Note:** When using `--ignore-noqa`, results are not cached to ensure consistency with normal linting behavior.
95148

96149
## serve
97150

‎lint/lint.go‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func printTestsuite(ts Testsuite) {
3030

3131
// EvalAllWithResults evaluates all rules and returns the results
3232
// This is similar to EvalAll but returns the results instead of just printing them
33-
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string) (interface{}, error) {
33+
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) (interface{}, error) {
3434
rules, err := ReadRulesMetadata(rulesPath)
3535
if err != nil {
3636
return nil, err
@@ -54,7 +54,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
5454
go func(index int, r Rule) {
5555
defer wg.Done()
5656

57-
testsuite, err := evalTestsuite(r, modelSourcePath)
57+
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa)
5858
if err != nil {
5959
errChan <- err
6060
return
@@ -138,7 +138,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
138138
return testsuitesContainer, nil
139139
}
140140

141-
func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string) error {
141+
func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) error {
142142
rules, err := ReadRulesMetadata(rulesPath)
143143
if err != nil {
144144
return err
@@ -162,7 +162,7 @@ func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonF
162162
go func(index int, r Rule) {
163163
defer wg.Done()
164164

165-
testsuite, err := evalTestsuite(r, modelSourcePath)
165+
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa)
166166
if err != nil {
167167
errChan <- err
168168
return
@@ -259,7 +259,7 @@ func countTotalTestcases(testsuites []Testsuite) int {
259259
return count
260260
}
261261

262-
func evalTestsuite(rule Rule, modelSourcePath string) (*Testsuite, error) {
262+
func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsuite, error) {
263263

264264
log.Debugf("evaluating rule %s", rule.Path)
265265

@@ -276,30 +276,36 @@ func evalTestsuite(rule Rule, modelSourcePath string) (*Testsuite, error) {
276276

277277
for _, inputFile := range inputFiles {
278278

279-
// Try to load from cache first
279+
// Try to load from cache first (but skip cache if ignoreNoqa is true)
280280
cacheKey, err := createCacheKey(rule.Path, inputFile)
281281
if err != nil {
282282
log.Debugf("Error creating cache key: %v", err)
283-
} else {
283+
} else if !ignoreNoqa {
284284
cachedTestcase, found := loadCachedTestcase(*cacheKey)
285285
if found {
286286
testcase = cachedTestcase
287287
log.Debugf("Using cached result for %s", inputFile)
288288
} else {
289289
// Cache miss - evaluate and save to cache
290-
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey)
290+
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa)
291291
if err != nil {
292292
return nil, err
293293
}
294294
}
295+
} else {
296+
// ignoreNoqa is true, skip cache and evaluate directly
297+
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa)
298+
if err != nil {
299+
return nil, err
300+
}
295301
}
296302

297303
// Fallback if cache key creation failed
298304
if cacheKey == nil {
299305
if rule.Language == LanguageRego {
300-
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile)
306+
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
301307
} else if rule.Language == LanguageJavascript {
302-
testcase, err = evalTestcase_Javascript(rule.Path, inputFile)
308+
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
303309
}
304310
if err != nil {
305311
return nil, err
@@ -332,24 +338,27 @@ func evalTestsuite(rule Rule, modelSourcePath string) (*Testsuite, error) {
332338
}
333339

334340
// evalTestcaseWithCaching evaluates a testcase and saves the result to cache
335-
func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey) (*Testcase, error) {
341+
func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey, ignoreNoqa bool) (*Testcase, error) {
336342
var testcase *Testcase
337343
var err error
338344

339345
if rule.Language == LanguageRego {
340-
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile)
346+
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
341347
} else if rule.Language == LanguageJavascript {
342-
testcase, err = evalTestcase_Javascript(rule.Path, inputFile)
348+
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
343349
}
344350

345351
if err != nil {
346352
return nil, err
347353
}
348354

349-
// Save to cache
350-
if cacheErr := saveCachedTestcase(*cacheKey, testcase); cacheErr != nil {
351-
log.Debugf("Error saving to cache: %v", cacheErr)
352-
// Don't fail the evaluation if cache save fails
355+
// Only save to cache when ignoreNoqa is false
356+
// When ignoreNoqa is true, the result might differ from the normal behavior
357+
if !ignoreNoqa {
358+
if cacheErr := saveCachedTestcase(*cacheKey, testcase); cacheErr != nil {
359+
log.Debugf("Error saving to cache: %v", cacheErr)
360+
// Don't fail the evaluation if cache save fails
361+
}
353362
}
354363

355364
return testcase, nil

‎lint/lint_javascript.go‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"gopkg.in/yaml.v3"
1111
)
1212

13-
func evalTestcase_Javascript(rulePath string, inputFilePath string) (*Testcase, error) {
13+
func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) {
1414
ruleContent, _ := os.ReadFile(rulePath)
1515
log.Debugf("js file: \n%s", ruleContent)
1616

@@ -34,19 +34,15 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string) (*Testcase,
3434
return nil, err
3535
}
3636

37-
// if data["Documentation"] contains #noqa, skip the testcase; Documentation attribute might not exist
37+
// Check if this rule should be skipped based on noqa directives
3838
if doc, ok := data["Documentation"].(string); ok {
39-
lines := strings.Split(doc, "\n")
40-
for _, line := range lines {
41-
line = strings.TrimSpace(line)
42-
lineLower := strings.ToLower(line)
43-
if strings.HasPrefix(lineLower, NOQA) || strings.HasPrefix(lineLower, NOQA_ALIAS) {
44-
return &Testcase{
45-
Name: inputFilePath,
46-
Time: 0,
47-
Skipped: &Skipped{Message: line},
48-
}, nil
49-
}
39+
shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa)
40+
if shouldSkip {
41+
return &Testcase{
42+
Name: inputFilePath,
43+
Time: 0,
44+
Skipped: &Skipped{Message: reason},
45+
}, nil
5046
}
5147
}
5248

‎lint/lint_rego.go‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"gopkg.in/yaml.v3"
1212
)
1313

14-
func evalTestcase_Rego(rulePath string, queryString string, inputFilePath string) (*Testcase, error) {
14+
func evalTestcase_Rego(rulePath string, queryString string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) {
1515
regoFile, _ := os.ReadFile(rulePath)
1616
log.Debugf("rego file: \n%s", regoFile)
1717

@@ -34,19 +34,15 @@ func evalTestcase_Rego(rulePath string, queryString string, inputFilePath string
3434
return nil, err
3535
}
3636

37-
// if data["Documentation"] contains #noqa, skip the testcase; Documentation attribute might not exist
37+
// Check if this rule should be skipped based on noqa directives
3838
if doc, ok := data["Documentation"].(string); ok {
39-
lines := strings.Split(doc, "\n")
40-
for _, line := range lines {
41-
line = strings.TrimSpace(line)
42-
lineLower := strings.ToLower(line)
43-
if strings.HasPrefix(lineLower, NOQA) || strings.HasPrefix(lineLower, NOQA_ALIAS) {
44-
return &Testcase{
45-
Name: inputFilePath,
46-
Time: 0,
47-
Skipped: &Skipped{Message: line},
48-
}, nil
49-
}
39+
shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa)
40+
if shouldSkip {
41+
return &Testcase{
42+
Name: inputFilePath,
43+
Time: 0,
44+
Skipped: &Skipped{Message: reason},
45+
}, nil
5046
}
5147
}
5248

‎lint/lint_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestLintSingle(t *testing.T) {
1919
// })
2020
t.Run("single Rego rule passes", func(t *testing.T) {
2121
rule, _ := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego")
22-
result, err := evalTestsuite(*rule, "./../resources/modelsource-v1")
22+
result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false)
2323

2424
if err != nil {
2525
t.Errorf("Failed to evaluate")
@@ -31,7 +31,7 @@ func TestLintSingle(t *testing.T) {
3131
})
3232
t.Run("single JS rule passes", func(t *testing.T) {
3333
rule, _ := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js")
34-
result, err := evalTestsuite(*rule, "./../resources/modelsource-v1")
34+
result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false)
3535

3636
if err != nil {
3737
t.Errorf("Failed to evaluate")
@@ -45,7 +45,7 @@ func TestLintSingle(t *testing.T) {
4545

4646
func TestLintBundle(t *testing.T) {
4747
t.Run("all-rules", func(t *testing.T) {
48-
err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "")
48+
err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false)
4949

5050
if err != nil {
5151
t.Errorf("No failures expected: %v", err)

‎lint/utils.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,94 @@ func SetLogger(logger *logrus.Logger) {
1616
log = logger
1717
}
1818

19+
// parseNoqaDirective parses a noqa directive and returns the list of rules to skip
20+
// and the reason (if provided).
21+
// Supports two formats:
22+
// - "#noqa" or "# noqa" - skips all rules
23+
// - "#noqa:rule1,rule2" or "# noqa:rule1,rule2 reason" - skips specific rules
24+
// Returns: (skipAllRules bool, skipRules []string, reason string)
25+
func parseNoqaDirective(line string) (bool, []string, string) {
26+
line = strings.TrimSpace(line)
27+
lineLower := strings.ToLower(line)
28+
29+
// Check if line starts with #noqa or # noqa
30+
if !strings.HasPrefix(lineLower, NOQA) && !strings.HasPrefix(lineLower, NOQA_ALIAS) {
31+
return false, nil, ""
32+
}
33+
34+
// Remove the prefix to get the rest
35+
var rest string
36+
if strings.HasPrefix(lineLower, NOQA) {
37+
rest = strings.TrimSpace(line[len(NOQA):])
38+
} else {
39+
rest = strings.TrimSpace(line[len(NOQA_ALIAS):])
40+
}
41+
42+
// If nothing follows, skip all rules
43+
if rest == "" {
44+
return true, nil, line
45+
}
46+
47+
// Check if it starts with colon (rule-specific noqa)
48+
if strings.HasPrefix(rest, ":") {
49+
rest = strings.TrimPrefix(rest, ":")
50+
51+
// Split by space to separate rules from reason
52+
parts := strings.SplitN(rest, " ", 2)
53+
rulesStr := strings.TrimSpace(parts[0])
54+
reason := line // Use full line as reason
55+
56+
// Split rules by comma
57+
rules := strings.Split(rulesStr, ",")
58+
skipRules := make([]string, 0, len(rules))
59+
for _, rule := range rules {
60+
rule = strings.TrimSpace(rule)
61+
if rule != "" {
62+
skipRules = append(skipRules, rule)
63+
}
64+
}
65+
66+
if len(skipRules) > 0 {
67+
return false, skipRules, reason
68+
}
69+
}
70+
71+
// Default: skip all rules with the line as reason
72+
return true, nil, line
73+
}
74+
75+
// shouldSkipRule checks if a specific rule should be skipped based on noqa directives
76+
// in the documentation field
77+
func shouldSkipRule(documentation string, ruleNumber string, ignoreNoqa bool) (bool, string) {
78+
// If ignoreNoqa is true, never skip rules based on noqa directives
79+
if ignoreNoqa {
80+
return false, ""
81+
}
82+
83+
if documentation == "" {
84+
return false, ""
85+
}
86+
87+
lines := strings.Split(documentation, "\n")
88+
for _, line := range lines {
89+
skipAll, skipRules, reason := parseNoqaDirective(line)
90+
91+
// If skipAll is true, skip this rule
92+
if skipAll {
93+
return true, reason
94+
}
95+
96+
// Check if this specific rule is in the skip list
97+
for _, skipRule := range skipRules {
98+
if skipRule == ruleNumber {
99+
return true, reason
100+
}
101+
}
102+
}
103+
104+
return false, ""
105+
}
106+
19107
func expandPaths(pattern string, workingDirectory string) ([]string, error) {
20108
// backwards compatible with old filepath.glob(...)
21109
if !strings.HasPrefix(pattern, ".*") {

0 commit comments

Comments
 (0)