Repository: VKCOM/noverify
Pull request #1244 (2025-01-31 13:26:13)

linter: mask filtering rules path


Patch:
diff --git a/src/linter/root.go b/src/linter/root.go
index 8f36140..10d69a3 100644
--- a/src/linter/root.go
+++ b/src/linter/root.go
@@ -1526,21 +1526,79 @@ func IsRuleEnabledForPath(root *RuleNode, filePath string, checkRule string) boo
 	// Starting with global state. We have guarantee while parsing config that rule is `on` and exist
 	ruleState := true
 
-	for _, part := range parts {
+	i := 0
+	for i < len(parts) {
+		part := parts[i]
 		if part == "" {
+			i++
 			continue
 		}
-		if node, exists := currentNode.Children[part]; exists {
-			if node.Disabled[checkRule] {
-				ruleState = false // Disable on this path
+
+		// 1) Try to find precision node (part)
+		nextNode, ok := currentNode.Children[part]
+		if ok {
+			// If found precision node: apply Enabled/Disabled
+			if nextNode.Disabled[checkRule] {
+				ruleState = false
 			}
-			if node.Enabled[checkRule] {
-				ruleState = true // Enable on this path
+			if nextNode.Enabled[checkRule] {
+				ruleState = true
 			}
-			currentNode = node
-		} else {
+			currentNode = nextNode
+			i++
+			continue
+		}
+
+		// 2) If there is no exact match, lets find node "*" (wildcard)
+		starNode, ok := currentNode.Children["*"]
+		if !ok {
+			// not precision matching & not "*"
 			break
 		}
+
+		// Apply Enabled/Disabled for "*"
+		if starNode.Disabled[checkRule] {
+			ruleState = false
+		}
+		if starNode.Enabled[checkRule] {
+			ruleState = true
+		}
+
+		// move to node "*"
+		currentNode = starNode
+
+		// Now the logic is to "swallow" several directories:
+		//
+		// Until we meet an exact match in `Children` in the next step
+		// (except "*"), we can continue to "eat" directories while remaining in `starNode`.
+		//
+		// Or if we have reached the end of the path, we exit the loop.
+
+		for {
+			i++
+			if i >= len(parts) {
+				// the path ended - all remaining directories were swallowed
+				return ruleState
+			}
+			AfterStarPart := parts[i]
+
+			// if starNode have Children[AfterStarPart] (except "*"),
+			// so we found the next "literal" node, we exit the inner loop,
+			// to go through the usual logic at the top level.
+			if AfterStarPart == "" {
+				continue
+			}
+
+			_, hasLiteral := currentNode.Children[AfterStarPart]
+			if hasLiteral {
+				// Let's exit - let the outer loop handle it
+				break
+			}
+
+			// There are directories left - we eat them, continuing the while loop
+		}
+		// Exit to outer loop: i points to potential literal part
+		// (or "*"), but the outer iteration will re-check it
 	}
 
 	return ruleState


Test patch:
diff --git a/src/tests/infra/pathRulesSet_config_test.go b/src/tests/infra/pathRulesSet_config_test.go
index df027b2..3632db5 100644
--- a/src/tests/infra/pathRulesSet_config_test.go
+++ b/src/tests/infra/pathRulesSet_config_test.go
@@ -32,6 +32,20 @@ func pathRulesSetInit(t *testing.T) *linttest.Suite {
 				"undefinedFunction": true,
 			},
 		},
+		"star/*/tests": {
+			Enabled: map[string]bool{
+				"emptyStmt": true,
+			},
+			Disabled: map[string]bool{},
+		},
+		"mixed/*/tests": {
+			Enabled: map[string]bool{
+				"emptyStmt": true,
+			},
+			Disabled: map[string]bool{
+				"undefinedFunction": true,
+			},
+		},
 	})
 
 	var suite = linttest.NewSuite(t)
@@ -63,6 +77,50 @@ require_once 'foo.php';;
 	test.RunAndMatch()
 }
 
+func TestStarPath(t *testing.T) {
+	test := pathRulesSetInit(t)
+	code := `<?php
+require_once 'foo.php';;
+        `
+	test.AddNamedFile("star/something/another/tests/foo.php", code)
+
+	test.Expect = []string{
+		`Semicolon (;) is not needed here, it can be safely removed at`,
+	}
+	test.RunAndMatch()
+}
+
+func TestMixedStarPath(t *testing.T) {
+	test := pathRulesSetInit(t)
+	code := `<?php
+require_once 'foo.php';;
+        `
+
+	test.AddNamedFile("mixed/something/another/tests/foo.php", code)
+
+	code = `<?php
+function function_exists($name) { return 1 == 2; }
+
+function f($cond) {
+  if (!function_exists('\foo')) {
+    \foo();
+  }
+  if ($cond && !function_exists('bar')) {
+    bar("a", "b");
+  }
+  if ($cond || !function_exists('a\b\baz')) {
+    a\b\baz(1);
+  }
+}
+`
+	test.AddNamedFile("mixed/something/another/tests/foo2.php", code)
+
+	test.Expect = []string{
+		`Semicolon (;) is not needed here, it can be safely removed at`,
+	}
+	test.RunAndMatch()
+}
+
 func TestMixedRulesPath(t *testing.T) {
 	test := pathRulesSetInit(t)
 	mergedCode := `<?php