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.
- blinter/__init__.py +36 -0
- blinter/__main__.py +6 -0
- blinter/_version.py +101 -0
- blinter/checkers/__init__.py +5 -0
- blinter/checkers/advanced/__init__.py +81 -0
- blinter/checkers/advanced/enhanced.py +200 -0
- blinter/checkers/advanced/escaping.py +145 -0
- blinter/checkers/advanced/style_security_perf.py +980 -0
- blinter/checkers/advanced/vars_syntax.py +882 -0
- blinter/checkers/cmd_extended.py +922 -0
- blinter/checkers/globals/__init__.py +77 -0
- blinter/checkers/globals/analysis.py +821 -0
- blinter/checkers/globals/exit_flow.py +476 -0
- blinter/checkers/globals/for_scope.py +155 -0
- blinter/checkers/globals/style_globals.py +443 -0
- blinter/checkers/line_endings.py +156 -0
- blinter/checkers/orchestration.py +325 -0
- blinter/checkers/performance.py +254 -0
- blinter/checkers/security.py +450 -0
- blinter/checkers/style.py +134 -0
- blinter/checkers/syntax.py +1183 -0
- blinter/checkers/vars.py +137 -0
- blinter/checkers/warnings.py +918 -0
- blinter/checkers/warnings_compat.py +173 -0
- blinter/cli/__init__.py +1 -0
- blinter/cli/args.py +400 -0
- blinter/cli/main.py +768 -0
- blinter/config/__init__.py +1 -0
- blinter/config/loader.py +310 -0
- blinter/constants.py +207 -0
- blinter/engine/__init__.py +5 -0
- blinter/engine/dependencies.py +604 -0
- blinter/engine/lines_cache.py +33 -0
- blinter/engine/linter.py +204 -0
- blinter/io/__init__.py +1 -0
- blinter/io/discovery.py +125 -0
- blinter/io/encoding.py +429 -0
- blinter/logging_config.py +7 -0
- blinter/models.py +224 -0
- blinter/output/__init__.py +1 -0
- blinter/output/formatters.py +398 -0
- blinter/output/json_formatter.py +146 -0
- blinter/output/sarif_formatter.py +379 -0
- blinter/parsing/__init__.py +1 -0
- blinter/parsing/context.py +475 -0
- blinter/parsing/embedded.py +368 -0
- blinter/parsing/structure.py +635 -0
- blinter/paths.py +44 -0
- blinter/patterns.py +490 -0
- blinter/py.typed +0 -0
- blinter/rules/__init__.py +1 -0
- blinter/rules/expansion_data.py +52 -0
- blinter/rules/helpers.py +98 -0
- blinter/rules/registry.py +1371 -0
- blinter-1.1.43.dist-info/METADATA +485 -0
- blinter-1.1.43.dist-info/RECORD +59 -0
- blinter-1.1.43.dist-info/WHEEL +4 -0
- blinter-1.1.43.dist-info/entry_points.txt +3 -0
- 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
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,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
|