diff --git a/scripts/codelens.py b/scripts/codelens.py index ae515831..b1ac13c1 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -842,6 +842,8 @@ def main(): global_max_tokens = None global_lite = False global_deep = False + global_disable_suppression = False + global_ignore_pattern = None i = 1 while i < len(sys.argv): @@ -878,10 +880,22 @@ def main(): global_lite = True elif arg == '--deep': global_deep = True + elif arg == '--disable-suppression': + global_disable_suppression = True + elif arg == '--codelens-ignore-pattern' and i + 1 < len(sys.argv): + global_ignore_pattern = sys.argv[i + 1] + elif arg.startswith('--codelens-ignore-pattern='): + global_ignore_pattern = arg.split('=', 1)[1] i += 1 args = parser.parse_args() + # Apply global flags to args + if getattr(args, 'disable_suppression', None) is None: + args.disable_suppression = globals().get('global_disable_suppression', False) + if getattr(args, 'codelens_ignore_pattern', None) is None: + args.codelens_ignore_pattern = globals().get('global_ignore_pattern', None) + # Resolve format: subparser --format overrides global --format # If neither is set, use the parser's default (which may be "ai" if CODELENS_AI_MODE=1) subparser_format = getattr(args, 'format', None) @@ -1126,6 +1140,62 @@ def main(): if args.command == "ask" and isinstance(result, dict): format_command = result.get("query_interpretation", {}).get("interpreted_as", "ask") + # ─── Apply inline suppressions ── + if not getattr(args, 'disable_suppression', False) and isinstance(result, dict): + try: + from suppression import apply_suppressions, update_stats_with_suppressions, DEFAULT_KEYWORD_PATTERN + + keyword_pattern = getattr(args, 'codelens_ignore_pattern', None) or DEFAULT_KEYWORD_PATTERN + + # Collect source files from the result + source_files = {} + finding_keys = ("findings", "leaks", "hints", "issues", "violations", "matches", "chains") + for key in finding_keys: + val = result.get(key) + if isinstance(val, list): + for f in val: + if isinstance(f, dict): + fp = f.get("file") or f.get("defined_in") or "" + if fp and fp not in source_files: + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as fh: + source_files[fp] = fh.read() + except (IOError, OSError): + pass + elif isinstance(val, dict): + for sub_val in val.values(): + if isinstance(sub_val, list): + for f in sub_val: + if isinstance(f, dict): + fp = f.get("file") or f.get("defined_in") or "" + if fp and fp not in source_files: + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as fh: + source_files[fp] = fh.read() + except (IOError, OSError): + pass + + if source_files: + # Collect all findings from result + all_findings = [] + for key in finding_keys: + val = result.get(key) + if isinstance(val, list): + all_findings.extend(val) + elif isinstance(val, dict): + for sub_val in val.values(): + if isinstance(sub_val, list): + all_findings.extend(sub_val) + + if all_findings: + apply_suppressions(all_findings, source_files, keyword_pattern=keyword_pattern) + update_stats_with_suppressions(result) + + except ImportError: + pass # suppression module not available + except Exception as e: + logger.warning(f"Suppression processing failed: {e}", exc_info=True) + # ─── Format and print output ── print(format_output(result, args.format, format_command, workspace)) diff --git a/scripts/formatters/sarif.py b/scripts/formatters/sarif.py index 11ac41c3..3b3bb33e 100644 --- a/scripts/formatters/sarif.py +++ b/scripts/formatters/sarif.py @@ -313,6 +313,30 @@ def _build_results(command: str, findings: List[Dict], workspace: str) -> List[D if finding.get("category"): result["properties"]["category"] = finding["category"] + # ─── Add suppressions field per SARIF spec ────────────────────── + # https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html#_Toc34317632 + if finding.get("suppressed"): + suppressions = [] + reason = finding.get("suppressed_reason", "") + suppressed_rules = finding.get("suppressed_rules", []) + + if suppressed_rules: + # Specific rules suppressed + for rid in suppressed_rules: + suppressions.append({ + "kind": "inSource", + "justification": reason or f"Suppressed via inline annotation for rule: {rid}", + }) + else: + # All rules suppressed on this line + suppressions.append({ + "kind": "inSource", + "justification": reason or "Suppressed via inline annotation (all rules)", + }) + + result["suppressions"] = suppressions + result["kind"] = "informational" + # Add related locations for taint analysis if finding.get("taint_path") and finding.get("source"): source_loc = { diff --git a/scripts/suppression.py b/scripts/suppression.py new file mode 100644 index 00000000..32fddc31 --- /dev/null +++ b/scripts/suppression.py @@ -0,0 +1,476 @@ +""" +Inline Suppression Detection and Application for CodeLens. + +Supports cross-language inline suppression annotations: + - ``# codelens-ignore`` (Python, Ruby, PHP, shell) + - ``// codelens-ignore`` (JS, TS, Rust, Go, Java, C, C++) + - ``/* codelens-ignore */`` (CSS, multi-line C-family) + - ```` (HTML) + +Three keyword aliases: + - ``codelens-ignore`` (default, brandable) + - ``nolens`` (short alias) + - ``nosemgrep`` (Semgrep ecosystem compat) + +Syntax variants: + - `` // codelens-ignore: rule-id-1, rule-id-2 -- reason`` + - `` // codelens-ignore`` (suppress all rules on line) + - ``/* codelens-ignore-next: rule-id */`` (suppress next line) + +Suppressed findings remain in output with ``status: "suppressed"`` for auditability. +SARIF ``suppressions`` field is populated per spec. +""" + +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set + + +# ─── Default keyword pattern ──────────────────────────────────────────────── + +DEFAULT_KEYWORD_PATTERN = r"codelens-ignore|nolens|nosemgrep" + +# All supported keywords (for documentation / validation) +SUPPORTED_KEYWORDS = ("codelens-ignore", "nolens", "nosemgrep") + + +# ─── SuppressionInfo Dataclass ────────────────────────────────────────────── + +@dataclass +class SuppressionInfo: + """Parsed suppression annotation information. + + Attributes: + rule_ids: List of specific rule IDs to suppress. Empty list = suppress all. + reason: Human-readable reason for suppression (empty string if not provided). + is_next_line: True if this is a ``-next`` annotation that applies to the + following line rather than the current line. + keyword: The actual keyword used (e.g., "codelens-ignore", "nolens"). + """ + + rule_ids: List[str] = field(default_factory=list) + reason: str = "" + is_next_line: bool = False + keyword: str = "codelens-ignore" + + +# ─── Detection ────────────────────────────────────────────────────────────── + +# Pre-compiled regex for performance. +# Matches: keyword[: rule1, rule2 -- reason] or keyword-next[: rule1, rule2 -- reason] +# Examples: +# codelens-ignore +# codelens-ignore: rule-1, rule-2 -- some reason +# codelens-ignore-next: rule-1 +# nolens +# nosemgrep: rule-1 -- false positive +# Step 1: Match the keyword (with optional -next suffix) +_KEYWORD_RE = re.compile( + r"(?Pcodelens-ignore|nolens|nosemgrep)(?P-next)?", + re.IGNORECASE, +) + + +def detect_suppression( + comment_text: str, + default_keyword: str = "codelens-ignore", + keyword_pattern: str = DEFAULT_KEYWORD_PATTERN, +) -> Optional[SuppressionInfo]: + """Detect a suppression annotation in a comment string. + + Args: + comment_text: The raw comment text (may include comment delimiters like ``#``, ``//``). + default_keyword: The default keyword to use in SuppressionInfo. + keyword_pattern: Regex pattern string for custom keywords. + + Returns: + SuppressionInfo if a suppression annotation is found, None otherwise. + """ + if not comment_text: + return None + + # Strip comment delimiters to get the inner text + text = comment_text.strip() + # Remove common comment prefixes + text = re.sub(r"^(#+|//+|/\*+|)\s*$", "", text).strip() + + # Check if any keyword is present using the keyword pattern + keyword_re = re.compile(keyword_pattern, re.IGNORECASE) + kw_match = keyword_re.search(text) + if not kw_match: + return None + + # Get the matched keyword + keyword = kw_match.group(0).lower() + + # Check for -next suffix right after the keyword + after_kw_match = text[kw_match.end():] + is_next = after_kw_match.startswith("-next") + if is_next: + after_keyword = after_kw_match[5:].strip() # Skip "-next" + else: + after_keyword = after_kw_match.strip() + + rule_ids: List[str] = [] + reason: str = "" + + if after_keyword: + # Check for ":" to separate rules + if after_keyword.startswith(":"): + after_colon = after_keyword[1:].strip() + # Check for "--" to separate reason + if " -- " in after_colon: + parts = after_colon.split(" -- ", 1) + rules_str = parts[0].strip() + reason = parts[1].strip() + else: + rules_str = after_colon + reason = "" + + # Parse comma-separated rule IDs + rule_ids = [r.strip() for r in rules_str.split(",") if r.strip()] + elif after_keyword.startswith("--"): + # Only reason, no rules + reason = after_keyword[2:].strip() + + return SuppressionInfo( + rule_ids=rule_ids, + reason=reason, + is_next_line=is_next, + keyword=keyword, + ) + + +# ─── Comment Detection Per Language ───────────────────────────────────────── + +# Language → comment prefixes (for fallback regex parsers) +COMMENT_PREFIXES: Dict[str, List[str]] = { + "python": ["#"], + "ruby": ["#"], + "php": ["#", "//", "/*"], + "shell": ["#"], + "yaml": ["#"], + "toml": ["#"], + "javascript": ["//", "/*"], + "typescript": ["//", "/*"], + "rust": ["//", "/*"], + "go": ["//", "/*"], + "java": ["//", "/*"], + "c": ["//", "/*"], + "cpp": ["//", "/*"], + "csharp": ["//", "/*"], + "kotlin": ["//", "/*"], + "swift": ["//", "/*"], + "css": ["/*"], + "html": ["") + assert result is not None + assert result.rule_ids == [] + + def test_custom_keyword_pattern(self): + """Custom keyword pattern.""" + result = detect_suppression( + "# my-ignore: rule-1", + keyword_pattern=r"my-ignore", + ) + assert result is not None + assert result.rule_ids == ["rule-1"] + + def test_case_insensitive(self): + """Keyword matching is case-insensitive.""" + result = detect_suppression("# CODELENS-IGNORE: rule-1") + assert result is not None + assert result.rule_ids == ["rule-1"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Part 2: Per-language suppression tests (12 languages × 3 cases = 36) +# ═══════════════════════════════════════════════════════════════════════════ + +def _make_finding(file_path: str, line: int, rule_id: str = "", category: str = "") -> dict: + """Create a minimal finding dict for testing.""" + f = {"file": file_path, "line": line, "severity": "high", "message": "test finding"} + if rule_id: + f["rule_id"] = rule_id + if category: + f["category"] = category + return f + + +class TestPythonSuppression: + """Python: # codelens-ignore""" + + def test_suppress_all(self): + code = "x = eval(input()) # codelens-ignore\n" + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific_rule(self): + code = "x = eval(input()) # codelens-ignore: eval-injection -- safe in tests\n" + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + assert result[0]["suppressed_reason"] == "safe in tests" + + def test_next_line_suppression(self): + code = "# codelens-ignore-next: eval-injection\nx = eval(input())\n" + fp = "/tmp/test.py" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestJavaScriptSuppression: + """JavaScript: // codelens-ignore""" + + def test_suppress_all(self): + code = "eval(userInput); // codelens-ignore\n" + fp = "/tmp/test.js" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific_rule(self): + code = "eval(userInput); // codelens-ignore: eval-injection\n" + fp = "/tmp/test.js" + findings = [_make_finding(fp, 1, "other-rule")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + def test_next_line_suppression(self): + code = "// codelens-ignore-next\neval(userInput);\n" + fp = "/tmp/test.js" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestTypeScriptSuppression: + """TypeScript: // codelens-ignore""" + + def test_suppress_all(self): + code = "const x: any = eval(input); // codelens-ignore\n" + fp = "/tmp/test.ts" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_with_reason(self): + code = "const x: any = eval(input); // codelens-ignore: eval-injection -- test only\n" + fp = "/tmp/test.ts" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + assert result[0]["suppressed_reason"] == "test only" + + def test_next_line_suppression(self): + code = "// codelens-ignore-next: eval-injection\nconst x: any = eval(input);\n" + fp = "/tmp/test.ts" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestRustSuppression: + """Rust: // codelens-ignore""" + + def test_suppress_all(self): + code = "unsafe { std::ptr::null_mut(); } // codelens-ignore\n" + fp = "/tmp/test.rs" + findings = [_make_finding(fp, 1, "unsafe-block")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific_rule(self): + code = "unsafe { std::ptr::null_mut(); } // codelens-ignore: unsafe-block\n" + fp = "/tmp/test.rs" + findings = [_make_finding(fp, 1, "unsafe-block")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_no_suppression(self): + code = "unsafe { std::ptr::null_mut(); }\n" + fp = "/tmp/test.rs" + findings = [_make_finding(fp, 1, "unsafe-block")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + +class TestGoSuppression: + """Go: // codelens-ignore""" + + def test_suppress_all(self): + code = 'fmt.Sprintf(userInput) // codelens-ignore\n' + fp = "/tmp/test.go" + findings = [_make_finding(fp, 1, "fmt-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_next_line(self): + code = '// codelens-ignore-next: fmt-injection\nfmt.Sprintf(userInput)\n' + fp = "/tmp/test.go" + findings = [_make_finding(fp, 2, "fmt-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_non_matching_rule(self): + code = '// codelens-ignore: other-rule\nfmt.Sprintf(userInput)\n' + fp = "/tmp/test.go" + findings = [_make_finding(fp, 2, "fmt-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + +class TestJavaSuppression: + """Java: // codelens-ignore""" + + def test_suppress_all(self): + code = 'Runtime.exec(userInput); // codelens-ignore\n' + fp = "/tmp/test.java" + findings = [_make_finding(fp, 1, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific(self): + code = 'Runtime.exec(userInput); // codelens-ignore: command-injection -- safe\n' + fp = "/tmp/test.java" + findings = [_make_finding(fp, 1, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + assert result[0]["suppressed_reason"] == "safe" + + def test_next_line(self): + code = '// codelens-ignore-next\nRuntime.exec(userInput);\n' + fp = "/tmp/test.java" + findings = [_make_finding(fp, 2, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestCSuppression: + """C: // codelens-ignore""" + + def test_suppress_all(self): + code = 'strcpy(dst, userInput); // codelens-ignore\n' + fp = "/tmp/test.c" + findings = [_make_finding(fp, 1, "buffer-overflow")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_block_comment(self): + code = 'strcpy(dst, userInput); /* codelens-ignore: buffer-overflow */\n' + fp = "/tmp/test.c" + findings = [_make_finding(fp, 1, "buffer-overflow")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_next_line(self): + code = '// codelens-ignore-next: buffer-overflow\nstrcpy(dst, userInput);\n' + fp = "/tmp/test.c" + findings = [_make_finding(fp, 2, "buffer-overflow")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestCppSuppression: + """C++: // codelens-ignore""" + + def test_suppress_all(self): + code = 'system(userInput); // codelens-ignore\n' + fp = "/tmp/test.cpp" + findings = [_make_finding(fp, 1, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_specific_rule(self): + code = 'system(userInput); // codelens-ignore: command-injection\n' + fp = "/tmp/test.cpp" + findings = [_make_finding(fp, 1, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_non_matching(self): + code = 'system(userInput); // codelens-ignore: other-rule\n' + fp = "/tmp/test.cpp" + findings = [_make_finding(fp, 1, "command-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + +class TestRubySuppression: + """Ruby: # codelens-ignore""" + + def test_suppress_all(self): + code = 'eval(user_input) # codelens-ignore\n' + fp = "/tmp/test.rb" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_specific_rule(self): + code = 'eval(user_input) # codelens-ignore: eval-injection -- safe\n' + fp = "/tmp/test.rb" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + assert result[0]["suppressed_reason"] == "safe" + + def test_next_line(self): + code = '# codelens-ignore-next: eval-injection\neval(user_input)\n' + fp = "/tmp/test.rb" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestPHPSuppression: + """PHP: # codelens-ignore or // codelens-ignore""" + + def test_hash_comment(self): + code = '""" + + def test_suppress_all(self): + code = ' \n' + fp = "/tmp/test.html" + findings = [_make_finding(fp, 1, "xss")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific(self): + code = ' \n' + fp = "/tmp/test.html" + findings = [_make_finding(fp, 1, "xss")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_next_line(self): + code = '\n\n' + fp = "/tmp/test.html" + findings = [_make_finding(fp, 2, "xss")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestCSSSuppression: + """CSS: /* codelens-ignore */""" + + def test_suppress_all(self): + code = '.hidden { display: none; } /* codelens-ignore */\n' + fp = "/tmp/test.css" + findings = [_make_finding(fp, 1, "display-none")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_suppress_specific(self): + code = '.hidden { display: none; } /* codelens-ignore: display-none */\n' + fp = "/tmp/test.css" + findings = [_make_finding(fp, 1, "display-none")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_next_line(self): + code = '/* codelens-ignore-next: display-none */\n.hidden { display: none; }\n' + fp = "/tmp/test.css" + findings = [_make_finding(fp, 2, "display-none")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +# ═══════════════════════════════════════════════════════════════════════════ +# Part 3: Multi-rule, custom keyword, disable-suppression mode +# ═══════════════════════════════════════════════════════════════════════════ + +class TestMultiRuleSuppression: + """Tests for multi-rule suppression on a single line.""" + + def test_multi_rule_suppress_matching(self): + code = 'x = eval(input) # codelens-ignore: rule-a, rule-b, eval-injection\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_multi_rule_suppress_non_matching(self): + code = 'x = eval(input) # codelens-ignore: rule-a, rule-b\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + def test_suffix_match(self): + """Rule ID suffix matching: 'long-function' matches 'codelens/smell/long-function'.""" + code = 'def very_long_function(): # codelens-ignore: long-function\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "codelens/smell/long-function")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + +class TestCustomKeyword: + """Tests for custom keyword pattern.""" + + def test_custom_keyword_suppress(self): + code = 'eval(input) # mycustom-ignore: eval-injection\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions( + findings, {fp: code}, + keyword_pattern=r"mycustom-ignore", + ) + assert result[0]["suppressed"] is True + + def test_custom_keyword_not_default(self): + """Default keyword should not match when custom is set.""" + code = 'eval(input) # codelens-ignore\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + result = apply_suppressions( + findings, {fp: code}, + keyword_pattern=r"mycustom-ignore", + ) + assert result[0]["suppressed"] is False + + +class TestCountPipeline: + """Tests for count pipeline — UBS bug #51 pattern audit.""" + + def test_suppressed_count_in_stats(self): + """Suppressed findings are counted as suppressed, not active.""" + code = 'eval(input) # codelens-ignore\n' + fp = "/tmp/test.py" + findings = [ + _make_finding(fp, 1, "eval-injection"), + _make_finding(fp, 2, "other-rule"), # line 2 has no suppression + ] + apply_suppressions(findings, {fp: code}) + result = {"findings": findings, "stats": {"total_findings": 2}} + update_stats_with_suppressions(result) + assert result["stats"]["suppressed_count"] == 1 + assert result["stats"]["total_findings"] == 2 # Total includes suppressed + + def test_all_suppressed(self): + """All findings suppressed — suppressed_count equals total.""" + code = 'eval(input) # codelens-ignore\neval(input2) # codelens-ignore\n' + fp = "/tmp/test.py" + findings = [ + _make_finding(fp, 1, "eval-injection"), + _make_finding(fp, 2, "eval-injection"), + ] + apply_suppressions(findings, {fp: code}) + result = {"findings": findings, "stats": {"total_findings": 2}} + update_stats_with_suppressions(result) + assert result["stats"]["suppressed_count"] == 2 + + def test_none_suppressed(self): + """No findings suppressed — suppressed_count is 0.""" + code = 'eval(input)\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + apply_suppressions(findings, {fp: code}) + result = {"findings": findings, "stats": {"total_findings": 1}} + update_stats_with_suppressions(result) + assert result["stats"]["suppressed_count"] == 0 + + def test_by_category_findings(self): + """Findings in by_category dict are properly counted.""" + code = 'eval(input) # codelens-ignore\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "eval-injection")] + apply_suppressions(findings, {fp: code}) + result = {"by_category": {"security": findings}, "stats": {"total_findings": 1}} + update_stats_with_suppressions(result) + assert result["stats"]["suppressed_count"] == 1 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Part 4: Edge cases +# ═══════════════════════════════════════════════════════════════════════════ + +class TestEdgeCases: + """Edge case tests.""" + + def test_finding_on_wrong_line_not_suppressed(self): + """Suppression on line 1 does not affect finding on line 3.""" + code = 'eval(input) # codelens-ignore\nx = 1\ny = 2\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 3, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + def test_multiple_findings_same_line(self): + """Multiple findings on the same suppressed line are all suppressed.""" + code = 'eval(input) # codelens-ignore\n' + fp = "/tmp/test.py" + findings = [ + _make_finding(fp, 1, "eval-injection"), + _make_finding(fp, 1, "other-rule"), + ] + result = apply_suppressions(findings, {fp: code}) + assert all(f["suppressed"] for f in result) + + def test_suppression_in_string_not_detected(self): + """Comment-like text inside a string is not a suppression.""" + code = 'x = "# codelens-ignore"\neval(input)\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + + def test_nolens_next_line(self): + """nolens-next variant works.""" + code = '# nolens-next: eval-injection\neval(input)\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 2, "eval-injection")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is True + + def test_language_detection(self): + """Language detection from file extension.""" + assert _detect_language_from_extension("/tmp/test.py") == "python" + assert _detect_language_from_extension("/tmp/test.js") == "javascript" + assert _detect_language_from_extension("/tmp/test.rs") == "rust" + assert _detect_language_from_extension("/tmp/test.go") == "go" + assert _detect_language_from_extension("/tmp/test.java") == "java" + assert _detect_language_from_extension("/tmp/test.cpp") == "cpp" + assert _detect_language_from_extension("/tmp/test.css") == "css" + assert _detect_language_from_extension("/tmp/test.html") == "html" + assert _detect_language_from_extension("/tmp/test.rb") == "ruby" + assert _detect_language_from_extension("/tmp/test.php") == "php" + assert _detect_language_from_extension("/tmp/test.ts") == "typescript" + assert _detect_language_from_extension("/tmp/test.c") == "c" + + def test_comment_extraction_python(self): + """Python comment extraction.""" + comments = _extract_comments_from_line("x = 1 # codelens-ignore", "python") + assert len(comments) >= 1 + assert "codelens-ignore" in comments[0] + + def test_comment_extraction_javascript(self): + """JavaScript comment extraction.""" + comments = _extract_comments_from_line("x = 1; // codelens-ignore", "javascript") + assert len(comments) >= 1 + assert "codelens-ignore" in comments[0] + + def test_comment_extraction_css(self): + """CSS comment extraction.""" + comments = _extract_comments_from_line("x { } /* codelens-ignore */", "css") + assert len(comments) >= 1 + assert "codelens-ignore" in comments[0] + + def test_no_suppression_fields_initialized(self): + """Findings without suppression get default fields.""" + code = 'x = 1\n' + fp = "/tmp/test.py" + findings = [_make_finding(fp, 1, "some-rule")] + result = apply_suppressions(findings, {fp: code}) + assert result[0]["suppressed"] is False + assert result[0]["suppressed_rules"] == [] + assert result[0]["suppressed_reason"] == ""