blinter 1.1.43__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. blinter/__init__.py +36 -0
  2. blinter/__main__.py +6 -0
  3. blinter/_version.py +101 -0
  4. blinter/checkers/__init__.py +5 -0
  5. blinter/checkers/advanced/__init__.py +81 -0
  6. blinter/checkers/advanced/enhanced.py +200 -0
  7. blinter/checkers/advanced/escaping.py +145 -0
  8. blinter/checkers/advanced/style_security_perf.py +980 -0
  9. blinter/checkers/advanced/vars_syntax.py +882 -0
  10. blinter/checkers/cmd_extended.py +922 -0
  11. blinter/checkers/globals/__init__.py +77 -0
  12. blinter/checkers/globals/analysis.py +821 -0
  13. blinter/checkers/globals/exit_flow.py +476 -0
  14. blinter/checkers/globals/for_scope.py +155 -0
  15. blinter/checkers/globals/style_globals.py +443 -0
  16. blinter/checkers/line_endings.py +156 -0
  17. blinter/checkers/orchestration.py +325 -0
  18. blinter/checkers/performance.py +254 -0
  19. blinter/checkers/security.py +450 -0
  20. blinter/checkers/style.py +134 -0
  21. blinter/checkers/syntax.py +1183 -0
  22. blinter/checkers/vars.py +137 -0
  23. blinter/checkers/warnings.py +918 -0
  24. blinter/checkers/warnings_compat.py +173 -0
  25. blinter/cli/__init__.py +1 -0
  26. blinter/cli/args.py +400 -0
  27. blinter/cli/main.py +768 -0
  28. blinter/config/__init__.py +1 -0
  29. blinter/config/loader.py +310 -0
  30. blinter/constants.py +207 -0
  31. blinter/engine/__init__.py +5 -0
  32. blinter/engine/dependencies.py +604 -0
  33. blinter/engine/lines_cache.py +33 -0
  34. blinter/engine/linter.py +204 -0
  35. blinter/io/__init__.py +1 -0
  36. blinter/io/discovery.py +125 -0
  37. blinter/io/encoding.py +429 -0
  38. blinter/logging_config.py +7 -0
  39. blinter/models.py +224 -0
  40. blinter/output/__init__.py +1 -0
  41. blinter/output/formatters.py +398 -0
  42. blinter/output/json_formatter.py +146 -0
  43. blinter/output/sarif_formatter.py +379 -0
  44. blinter/parsing/__init__.py +1 -0
  45. blinter/parsing/context.py +475 -0
  46. blinter/parsing/embedded.py +368 -0
  47. blinter/parsing/structure.py +635 -0
  48. blinter/paths.py +44 -0
  49. blinter/patterns.py +490 -0
  50. blinter/py.typed +0 -0
  51. blinter/rules/__init__.py +1 -0
  52. blinter/rules/expansion_data.py +52 -0
  53. blinter/rules/helpers.py +98 -0
  54. blinter/rules/registry.py +1371 -0
  55. blinter-1.1.43.dist-info/METADATA +485 -0
  56. blinter-1.1.43.dist-info/RECORD +59 -0
  57. blinter-1.1.43.dist-info/WHEEL +4 -0
  58. blinter-1.1.43.dist-info/entry_points.txt +3 -0
  59. blinter-1.1.43.dist-info/licenses/COPYING +661 -0
blinter/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Blinter - professional batch file linter for Windows.
2
+
3
+ Public API
4
+ ----------
5
+ Import the symbols below from ``blinter`` for normal library and CLI use.
6
+
7
+ Advanced / internal APIs live in subpackages, for example::
8
+
9
+ from blinter.rules.registry import RULES
10
+ from blinter.patterns import DANGEROUS_COMMAND_PATTERNS
11
+ from blinter.checkers.syntax import _check_syntax_errors
12
+ """
13
+
14
+ from blinter._version import __author__, __license__, __version__
15
+ from blinter.cli.main import main
16
+ from blinter.config.loader import create_default_config_file, load_config
17
+ from blinter.engine.linter import lint_batch_file
18
+ from blinter.io.discovery import find_batch_files
19
+ from blinter.io.encoding import read_file_with_encoding
20
+ from blinter.models import BlinterConfig, LintIssue, Rule, RuleSeverity
21
+
22
+ __all__ = [
23
+ "__author__",
24
+ "__license__",
25
+ "__version__",
26
+ "BlinterConfig",
27
+ "LintIssue",
28
+ "Rule",
29
+ "RuleSeverity",
30
+ "create_default_config_file",
31
+ "find_batch_files",
32
+ "lint_batch_file",
33
+ "load_config",
34
+ "main",
35
+ "read_file_with_encoding",
36
+ ]
blinter/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m blinter."""
2
+
3
+ from blinter.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
blinter/_version.py ADDED
@@ -0,0 +1,101 @@
1
+ """Package version resolution."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ import logging
5
+ from pathlib import Path
6
+ import re
7
+
8
+ __author__ = "tboy1337"
9
+
10
+ __license__ = "AGPL-3.0-or-later"
11
+
12
+ _PACKAGE_NAME = "Blinter"
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Static import so Nuitka compiles VERSION into the exe as a constant.
17
+ # scripts/build_exe.py generates the module; source checkouts do not have it.
18
+ # Pylint then reports import-error and a false cycle through blinter/__init__.py.
19
+ _BAKED_VERSION: str | None
20
+ try:
21
+ from ._build_version import ( # pylint: disable=import-error,cyclic-import
22
+ VERSION as _BAKED_VERSION,
23
+ )
24
+ except ImportError:
25
+ logger.debug("Build version module is not present")
26
+ _BAKED_VERSION = None
27
+
28
+
29
+ _PROJECT_NAME_RE = re.compile(
30
+ r"""^\s*name\s*=\s*['"]Blinter['"]\s*$""",
31
+ re.MULTILINE | re.IGNORECASE,
32
+ )
33
+
34
+
35
+ def _pyproject_path() -> Path:
36
+ """Return pyproject.toml two directories above this module."""
37
+ module_file = Path(__file__).resolve()
38
+ package_dir = module_file.parent
39
+ source_path = package_dir.parent.parent / "pyproject.toml"
40
+ logger.debug("Resolved source pyproject.toml at %s", source_path)
41
+ return source_path
42
+
43
+
44
+ def _source_checkout_pyproject() -> Path | None:
45
+ """Return pyproject.toml only for a src/ checkout of this project."""
46
+ package_dir = Path(__file__).resolve().parent
47
+ if package_dir.parent.name != "src":
48
+ logger.debug("Package directory %s is not a src/ checkout", package_dir)
49
+ return None
50
+ pyproject = package_dir.parent.parent / "pyproject.toml"
51
+ if not pyproject.is_file():
52
+ logger.debug("pyproject.toml is missing at %s", pyproject)
53
+ return None
54
+ text = pyproject.read_text(encoding="utf-8")
55
+ if _PROJECT_NAME_RE.search(text) is None:
56
+ logger.debug("pyproject.toml at %s does not declare name = Blinter", pyproject)
57
+ return None
58
+ return pyproject
59
+
60
+
61
+ def _version_from_pyproject(pyproject: Path) -> str:
62
+ """Read the version assignment from a pyproject.toml file."""
63
+ for line in pyproject.read_text(encoding="utf-8").splitlines():
64
+ stripped = line.strip()
65
+ if stripped.startswith("version = "):
66
+ parsed = stripped.split("=", 1)[1].strip().strip('"').strip("'")
67
+ logger.debug("Parsed pyproject.toml version %s", parsed)
68
+ return parsed
69
+ logger.debug("pyproject.toml at %s has no version assignment", pyproject)
70
+ return "unknown"
71
+
72
+
73
+ def _fallback_version() -> str:
74
+ """Read version from pyproject.toml when the package is not installed."""
75
+ pyproject = _pyproject_path()
76
+ if not pyproject.is_file():
77
+ logger.debug("pyproject.toml is missing at %s", pyproject)
78
+ return "unknown"
79
+ return _version_from_pyproject(pyproject)
80
+
81
+
82
+ def get_version() -> str:
83
+ """Return the package version, preferring the Nuitka build constant."""
84
+ if isinstance(_BAKED_VERSION, str) and _BAKED_VERSION:
85
+ logger.debug("Using build-time version %s", _BAKED_VERSION)
86
+ return _BAKED_VERSION
87
+ source_pyproject = _source_checkout_pyproject()
88
+ if source_pyproject is not None:
89
+ pyproject_version = _version_from_pyproject(source_pyproject)
90
+ if pyproject_version != "unknown":
91
+ return pyproject_version
92
+ try:
93
+ installed = version(_PACKAGE_NAME)
94
+ except PackageNotFoundError:
95
+ logger.debug("Package metadata for %s was not found", _PACKAGE_NAME)
96
+ return "unknown"
97
+ logger.debug("Using installed metadata version %s", installed)
98
+ return installed
99
+
100
+
101
+ __version__ = get_version()
@@ -0,0 +1,5 @@
1
+ """Lint rule implementations organized by category."""
2
+
3
+ from blinter.checkers.orchestration import _process_file_checks
4
+
5
+ __all__ = ["_process_file_checks"]
@@ -0,0 +1,81 @@
1
+ """Advanced checker subpackage."""
2
+
3
+ from blinter.checkers.advanced.enhanced import (
4
+ _check_cmd_error_handling,
5
+ _check_deprecated_commands,
6
+ _check_enhanced_commands,
7
+ _check_for_f_options,
8
+ _check_if_comparison_quotes,
9
+ )
10
+ from blinter.checkers.advanced.escaping import (
11
+ _check_advanced_escaping_rules,
12
+ _check_continuation_spaces,
13
+ _check_double_percent_escaping,
14
+ _check_improper_caret_escape,
15
+ _check_multilevel_escaping,
16
+ _should_flag_caret_escape,
17
+ )
18
+ from blinter.checkers.advanced.style_security_perf import (
19
+ _check_advanced_performance,
20
+ _check_advanced_security,
21
+ _check_advanced_style_patterns,
22
+ _check_advanced_style_rules,
23
+ _check_enhanced_performance,
24
+ _check_enhanced_security_rules,
25
+ _check_function_docs,
26
+ _check_line_length,
27
+ _check_magic_numbers,
28
+ _check_unnecessary_output_p014,
29
+ _check_variable_naming,
30
+ _find_set_exclusion_ranges,
31
+ _get_safe_system_variables,
32
+ _has_nearby_interactive_cmds,
33
+ _is_number_in_special_context,
34
+ _is_safe_command_injection,
35
+ )
36
+ from blinter.checkers.advanced.vars_syntax import (
37
+ _check_advanced_for_rules,
38
+ _check_advanced_process_mgmt,
39
+ _check_advanced_vars,
40
+ _check_for_loop_var_syntax,
41
+ _check_percent_tilde_syntax,
42
+ _check_set_a_quoting,
43
+ _check_string_operation_syntax,
44
+ )
45
+
46
+ __all__ = [
47
+ "_should_flag_caret_escape",
48
+ "_check_improper_caret_escape",
49
+ "_check_multilevel_escaping",
50
+ "_check_continuation_spaces",
51
+ "_check_double_percent_escaping",
52
+ "_check_advanced_escaping_rules",
53
+ "_check_advanced_for_rules",
54
+ "_check_advanced_process_mgmt",
55
+ "_check_percent_tilde_syntax",
56
+ "_check_for_loop_var_syntax",
57
+ "_check_string_operation_syntax",
58
+ "_check_set_a_quoting",
59
+ "_check_advanced_vars",
60
+ "_check_for_f_options",
61
+ "_check_if_comparison_quotes",
62
+ "_check_deprecated_commands",
63
+ "_check_cmd_error_handling",
64
+ "_check_enhanced_commands",
65
+ "_check_advanced_security",
66
+ "_check_advanced_performance",
67
+ "_check_advanced_style_patterns",
68
+ "_check_variable_naming",
69
+ "_check_function_docs",
70
+ "_find_set_exclusion_ranges",
71
+ "_is_number_in_special_context",
72
+ "_check_magic_numbers",
73
+ "_check_line_length",
74
+ "_check_advanced_style_rules",
75
+ "_get_safe_system_variables",
76
+ "_is_safe_command_injection",
77
+ "_check_enhanced_security_rules",
78
+ "_check_unnecessary_output_p014",
79
+ "_has_nearby_interactive_cmds",
80
+ "_check_enhanced_performance",
81
+ ]
@@ -0,0 +1,200 @@
1
+ """Enhanced command and deprecation checks."""
2
+
3
+ import re
4
+ from typing import (
5
+ List,
6
+ Optional,
7
+ )
8
+
9
+ from blinter.models import LintIssue
10
+ from blinter.patterns import DEPRECATED_COMMANDS, REMOVED_COMMANDS
11
+ from blinter.rules.registry import RULES
12
+
13
+
14
+ def _check_for_f_options(stripped: str, line_number: int) -> Optional[LintIssue]:
15
+ """Check FOR /F without proper options (W020)."""
16
+ if re.match(
17
+ r'\s*for\s+/f\s+(?!.*"[^"]*(?:tokens|delims)[^"]*")[^(]*\(',
18
+ stripped,
19
+ re.IGNORECASE,
20
+ ):
21
+ return LintIssue(
22
+ line_number=line_number,
23
+ rule=RULES["W020"],
24
+ context="FOR /F without explicit tokens/delims options",
25
+ )
26
+ return None
27
+
28
+
29
+ def _check_if_comparison_quotes(stripped: str, line_number: int) -> Optional[LintIssue]:
30
+ """Check IF comparisons without quotes (W021)."""
31
+ if_pattern = r'\s*if\s+(?:not\s+)?%\w+%\s*==\s*[^"\']\w+'
32
+ if re.search(if_pattern, stripped, re.IGNORECASE):
33
+ return LintIssue(
34
+ line_number=line_number,
35
+ rule=RULES["W021"],
36
+ context="IF comparison should be quoted",
37
+ )
38
+ return None
39
+
40
+
41
+ def _check_deprecated_commands(stripped: str, line_number: int) -> List[LintIssue]:
42
+ """Check for deprecated commands (W024) and removed commands (E034)."""
43
+ issues: List[LintIssue] = []
44
+
45
+ # Skip comment lines (REM or ::)
46
+ if stripped.lower().startswith("rem ") or stripped.startswith("::"):
47
+ return issues
48
+
49
+ # First check for removed commands (more severe - Error level)
50
+ # Special handling for "NET PRINT" (just NET PRINT is removed, not NET itself)
51
+ if re.search(r"\bnet\s+print\b", stripped, re.IGNORECASE):
52
+ issues.append(
53
+ LintIssue(
54
+ line_number=line_number,
55
+ rule=RULES["E034"],
56
+ context="NET PRINT has been removed - use PowerShell Print cmdlets instead",
57
+ )
58
+ )
59
+
60
+ # Check other removed commands
61
+ first_word = stripped.split()[0].lower() if stripped.split() else ""
62
+ if first_word in REMOVED_COMMANDS:
63
+ replacement_map = {
64
+ "assign": "drive mounting with modern tools",
65
+ "backup": "modern backup tools (wbadmin)",
66
+ "diskcomp": "FC (file comparison)",
67
+ "append": "modify PATH or use full paths",
68
+ "browstat": "NET VIEW or PowerShell",
69
+ "edlin": "modern text editors",
70
+ "graftabl": "modern code-page handling",
71
+ "inuse": "HANDLE.EXE from Sysinternals",
72
+ "join": "drive mounting with modern tools",
73
+ "diskcopy": "ROBOCOPY or XCOPY",
74
+ "streams": "PowerShell Get-Item -Stream",
75
+ }
76
+ replacement = replacement_map.get(first_word, "a modern alternative")
77
+ issues.append(
78
+ LintIssue(
79
+ line_number=line_number,
80
+ rule=RULES["E034"],
81
+ context=(
82
+ f"Command '{first_word.upper()}' has been removed "
83
+ f"from Windows - use {replacement}"
84
+ ),
85
+ )
86
+ )
87
+
88
+ # Check for deprecated commands (Warning level)
89
+ # Special case for NET SEND
90
+ if re.search(r"\bnet\s+send\b", stripped, re.IGNORECASE):
91
+ issues.append(
92
+ LintIssue(
93
+ line_number=line_number,
94
+ rule=RULES["W024"],
95
+ context="Use MSG command instead of deprecated 'NET SEND'",
96
+ )
97
+ )
98
+
99
+ # Special case for AT command (needs special handling because AT is a common word)
100
+ # Only flag if it looks like the scheduling command (e.g., "at 14:00" or "at \\computer")
101
+ if re.search(r"\bat\s+(\d|\\\\)", stripped, re.IGNORECASE):
102
+ issues.append(
103
+ LintIssue(
104
+ line_number=line_number,
105
+ rule=RULES["W024"],
106
+ context="Use SCHTASKS command instead of deprecated 'AT'",
107
+ )
108
+ )
109
+
110
+ # Check single-word deprecated commands (skip AT; regex branch handles scheduling)
111
+ if first_word in DEPRECATED_COMMANDS and first_word != "at":
112
+ replacement_map = {
113
+ "wmic": "PowerShell WMI cmdlets (Get-WmiObject/Get-CimInstance)",
114
+ "cacls": "ICACLS command",
115
+ "caspol": "Code Access Security Policy Tool from SDK",
116
+ "winrm": "PowerShell Remoting (Enter-PSSession/Invoke-Command)",
117
+ "bitsadmin": "PowerShell BitsTransfer module",
118
+ "dpath": "PATH environment variable modification",
119
+ "keys": "CHOICE or SET /P commands",
120
+ }
121
+ replacement = replacement_map.get(first_word, "a modern alternative")
122
+ issues.append(
123
+ LintIssue(
124
+ line_number=line_number,
125
+ rule=RULES["W024"],
126
+ context=f"Command '{first_word.upper()}' is deprecated - use {replacement}",
127
+ )
128
+ )
129
+
130
+ return issues
131
+
132
+
133
+ def _check_cmd_error_handling(
134
+ stripped: str, line_number: int, lines: List[str]
135
+ ) -> Optional[LintIssue]:
136
+ """Check for missing error handling (W025)."""
137
+ commands_needing_handling = ["del", "copy", "move", "mkdir", "rmdir"]
138
+
139
+ for cmd in commands_needing_handling:
140
+ if re.match(rf"\s*{cmd}\s+", stripped, re.IGNORECASE):
141
+ # Check if next 3 lines have error handling
142
+ for j in range(line_number, min(line_number + 3, len(lines) + 1)):
143
+ if j <= len(lines) and (
144
+ "errorlevel" in lines[j - 1].lower()
145
+ or "if " in lines[j - 1].lower()
146
+ ):
147
+ return None
148
+
149
+ return LintIssue(
150
+ line_number=line_number,
151
+ rule=RULES["W025"],
152
+ context=f"{cmd.upper()} command without error checking",
153
+ )
154
+
155
+ return None
156
+
157
+
158
+ def _check_enhanced_commands(lines: List[str]) -> List[LintIssue]:
159
+ """Check for enhanced command validation issues (W020-W025)."""
160
+ issues: List[LintIssue] = []
161
+ uses_delayed_expansion = False
162
+
163
+ for i, line in enumerate(lines, start=1):
164
+ stripped = line.strip()
165
+
166
+ # Check for delayed expansion usage
167
+ if re.search(r"!\w+!", stripped):
168
+ uses_delayed_expansion = True
169
+
170
+ # Run all line-level checks
171
+ issue = _check_for_f_options(stripped, i)
172
+ if issue:
173
+ issues.append(issue)
174
+
175
+ issue = _check_if_comparison_quotes(stripped, i)
176
+ if issue:
177
+ issues.append(issue)
178
+
179
+ issues.extend(_check_deprecated_commands(stripped, i))
180
+
181
+ issue = _check_cmd_error_handling(stripped, i, lines)
182
+ if issue:
183
+ issues.append(issue)
184
+
185
+ # Check for missing SETLOCAL EnableDelayedExpansion (W022)
186
+ if uses_delayed_expansion:
187
+ has_setlocal = any(
188
+ re.search(r"setlocal.*enabledelayedexpansion", line, re.IGNORECASE)
189
+ for line in lines
190
+ )
191
+ if not has_setlocal:
192
+ issues.append(
193
+ LintIssue(
194
+ line_number=1,
195
+ rule=RULES["W022"],
196
+ context="Script uses !var! but missing SETLOCAL EnableDelayedExpansion",
197
+ )
198
+ )
199
+
200
+ return issues
@@ -0,0 +1,145 @@
1
+ """Advanced caret and percent escaping rules (E030-E033)."""
2
+
3
+ import re
4
+ from typing import List
5
+
6
+ from blinter.models import LintIssue
7
+ from blinter.rules.helpers import strip_percent_expansions
8
+ from blinter.rules.registry import RULES
9
+
10
+
11
+ def _should_flag_caret_escape(stripped: str, caret_pos: int, line: str = "") -> bool:
12
+ """Check if a caret escape sequence should be flagged as improper."""
13
+ # Check if this is within a FOR loop command string (within single quotes)
14
+ # In FOR loops, command strings like 'command 2^>nul ^| filter' use single caret correctly
15
+ if re.search(r"\bfor\s+", stripped, re.IGNORECASE):
16
+ # Find all single-quoted strings in FOR commands
17
+ # Look for patterns like FOR ... IN ('...') where carets inside quotes are valid
18
+ for_match = re.search(r"\bin\s*\('([^']*)'\)", stripped, re.IGNORECASE)
19
+ if for_match:
20
+ # Check if the caret is within the quoted string
21
+ quote_start = for_match.start(1)
22
+ quote_end = for_match.end(1)
23
+ if quote_start <= caret_pos < quote_end:
24
+ return False
25
+
26
+ # Check if this is an ECHO statement (likely ASCII art)
27
+ if re.match(r"echo\s+", stripped, re.IGNORECASE):
28
+ # ECHO statements often contain ASCII art with carets - don't flag these
29
+ return False
30
+
31
+ # Check if this is a SET statement (storing escaped commands)
32
+ # SET commands often store command strings with escaped special characters
33
+ # Example: SET @PRINT_IF_DEBUG=ECHO:^& SET @^& ECHO:^& TIMEOUT 5
34
+ # Also check for SET inside IF statements: IF ... (SET VAR=value^&...)
35
+ if re.search(r"\bset\s+", stripped, re.IGNORECASE):
36
+ # SET statements commonly use single carets to store command strings - don't flag these
37
+ return False
38
+
39
+ # Check if this line is within a parenthesized command block (FOR DO block, IF block, etc.)
40
+ # Lines inside blocks are typically indented and need carets for proper redirection
41
+ # Pattern: line starts with whitespace/tabs (indented) and contains command with redirection
42
+ if line and re.match(r"^\s+", line):
43
+ # This is an indented line, likely inside a block
44
+ # Carets for redirection (2^>NUL, ^|, etc.) are necessary in blocks
45
+ # to prevent premature evaluation
46
+ return False
47
+
48
+ # Check if this line is a DO block on the same line as FOR
49
+ # Pattern: FOR ... DO ( command with carets )
50
+ if re.search(r"\bdo\s*\(", stripped, re.IGNORECASE):
51
+ # This is a FOR DO block, carets are necessary
52
+ return False
53
+
54
+ return True
55
+
56
+
57
+ def _check_improper_caret_escape(
58
+ stripped: str, line_number: int, line: str = ""
59
+ ) -> List[LintIssue]:
60
+ """Check for E030: Improper caret escape sequence."""
61
+ issues: List[LintIssue] = []
62
+ # Look for single caret attempting to escape special chars
63
+ # But exclude FOR loop command strings, ECHO statements (ASCII art), and SET commands
64
+ caret_matches = re.finditer(r"\^[&|><](?!\^)", stripped)
65
+ for match in caret_matches:
66
+ caret_pos = match.start()
67
+ if _should_flag_caret_escape(stripped, caret_pos, line):
68
+ issues.append(LintIssue(line_number, RULES["E030"], context=stripped))
69
+ return issues
70
+
71
+
72
+ def _check_multilevel_escaping(stripped: str, line_number: int) -> List[LintIssue]:
73
+ """Check for E031: Invalid multilevel escaping."""
74
+ issues: List[LintIssue] = []
75
+ # Check for incorrect caret counts in multilevel escaping
76
+ caret_sequences: List[str] = re.findall(r"\^+[&|><]", stripped)
77
+ for seq in caret_sequences:
78
+ caret_count = len(seq) - 1 # -1 for the target character
79
+ # Valid counts follow 2^n-1 pattern: 1, 3, 7, 15...
80
+ valid_counts: List[int] = [1, 3, 7, 15, 31] # 2^n-1 pattern for n=1 to 5
81
+ if caret_count > 0 and caret_count not in valid_counts:
82
+ issues.append(LintIssue(line_number, RULES["E031"], context=seq))
83
+ return issues
84
+
85
+
86
+ def _check_continuation_spaces(
87
+ line: str, stripped: str, line_number: int
88
+ ) -> List[LintIssue]:
89
+ """Check for E032: Continuation character with trailing spaces."""
90
+ issues: List[LintIssue] = []
91
+ # Check if line ends with ^ followed by spaces/tabs (before the line ending)
92
+ if stripped.endswith("^"):
93
+ # Get the line without line endings
94
+ line_no_newline = line.rstrip("\r\n")
95
+ # If the line doesn't end with ^ after removing spaces,
96
+ # then there are trailing spaces after ^
97
+ if not line_no_newline.endswith("^"):
98
+ issues.append(
99
+ LintIssue(
100
+ line_number, RULES["E032"], context="Caret with trailing spaces"
101
+ )
102
+ )
103
+ return issues
104
+
105
+
106
+ def _check_double_percent_escaping(stripped: str, line_number: int) -> List[LintIssue]:
107
+ """Check for E033: Double percent escaping error."""
108
+ issues: List[LintIssue] = []
109
+ # Look for single % in echo statements that should be %%
110
+ if stripped.lower().startswith("echo") and "%" in stripped:
111
+ # Only flag if there's a literal percentage (like "50%") not a variable reference
112
+ # Variable references like %var% are fine
113
+ # Check for percentage signs that might need escaping (number followed by %)
114
+ # But exclude variable references %VAR%
115
+ line_without_vars = strip_percent_expansions(stripped)
116
+ if re.search(r"\b\d+%(?!%)", line_without_vars):
117
+ issues.append(
118
+ LintIssue(
119
+ line_number,
120
+ RULES["E033"],
121
+ context="Percentage needs double escaping",
122
+ )
123
+ )
124
+ return issues
125
+
126
+
127
+ def _check_advanced_escaping_rules(line: str, line_number: int) -> List[LintIssue]:
128
+ """Check for advanced escaping technique issues."""
129
+ # Multiple escaping rules (E030-E033) require checking various patterns
130
+ issues: List[LintIssue] = []
131
+ stripped = line.strip()
132
+
133
+ # E030: Improper caret escape sequence
134
+ issues.extend(_check_improper_caret_escape(stripped, line_number, line))
135
+
136
+ # E031: Invalid multilevel escaping
137
+ issues.extend(_check_multilevel_escaping(stripped, line_number))
138
+
139
+ # E032: Continuation character with trailing spaces
140
+ issues.extend(_check_continuation_spaces(line, stripped, line_number))
141
+
142
+ # E033: Double percent escaping error
143
+ issues.extend(_check_double_percent_escaping(stripped, line_number))
144
+
145
+ return issues