Add script explainer module
Some checks failed
CI / test (push) Failing after 13s
CI / lint (push) Failing after 6s
CI / type-check (push) Failing after 10s

This commit is contained in:
2026-02-04 11:00:13 +00:00
parent 9c2fb72bb9
commit 468d24e658

300
shellgenius/explainer.py Normal file
View File

@@ -0,0 +1,300 @@
"""Script explainer module for ShellGenius."""
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from shellgenius.config import get_config
from shellgenius.generation import ShellParser, get_ollama_client
@dataclass
class LineExplanation:
"""Explanation for a single line."""
line_number: int
content: str
explanation: str
is_command: bool
@dataclass
class ScriptExplanation:
"""Complete script explanation."""
shell_type: str
line_explanations: List[LineExplanation]
summary: str
overall_purpose: str
class ShellExplainer:
"""Shell script explainer."""
KEYWORDS = {
"if": "Conditional statement",
"then": "Then clause of conditional",
"else": "Else clause of conditional",
"fi": "End of conditional",
"for": "Loop iteration",
"while": "While loop",
"until": "Until loop",
"do": "Loop body start",
"done": "Loop body end",
"case": "Case statement",
"esac": "End of case statement",
"function": "Function definition",
"return": "Return from function",
"export": "Export variable to environment",
"unset": "Remove variable",
"local": "Local variable declaration",
"readonly": "Mark variable as read-only",
"source": "Execute script in current shell",
"alias": "Create command alias",
"unalias": "Remove command alias",
}
COMMON_PATTERNS = [
(r"^#!", "Shebang - specifies interpreter"),
(r"^\s*#", "Comment"),
(r"\$[a-zA-Z_][a-zA-Z0-9_]*", "Variable reference"),
(r"\$\{[^}]+\}", "Brace variable reference"),
(r"\$\(([^)]+)\)", "Command substitution"),
(r"`([^`]+)`", "Backtick command substitution"),
(r"-eq|-ne|-lt|-le|-gt|-ge", "Numeric comparison operators"),
(r"=~|!~", "Pattern matching operators"),
(r"-z|-n", "String length checks"),
(r"-f|-d|-L|-e", "File tests"),
(r"2>&1|&>", "Redirect stderr to stdout"),
(r"\|", "Pipe output to next command"),
(r">>|>>", "Append redirection"),
(r">\s*\S+", "Output redirection"),
(r"<\s*\S+", "Input redirection"),
(r"set\s+-", "Set shell options"),
(r"trap\s+", "Signal handler setup"),
(r"exec\s+", "Replace current process"),
(r"cd\s+|pushd|popd", "Directory navigation"),
]
def __init__(self):
"""Initialize explainer."""
self.parser = ShellParser()
self.client = get_ollama_client()
def explain(
self, script: str, detailed: bool = True
) -> ScriptExplanation:
"""Explain a shell script.
Args:
script: Shell script content
detailed: Use detailed LLM-based explanation
Returns:
ScriptExplanation with line-by-line explanations
"""
shell_type = self.parser.detect_shell(script)
if detailed:
return self._explain_with_ai(script, shell_type)
else:
return self._explain_basic(script, shell_type)
def _explain_basic(self, script: str, shell_type: str) -> ScriptExplanation:
"""Provide basic script explanation.
Args:
script: Shell script content
shell_type: Detected shell type
Returns:
ScriptExplanation
"""
lines = script.split("\n")
line_explanations = []
for i, line in enumerate(lines, 1):
stripped = line.strip()
is_command = bool(stripped) and not stripped.startswith("#")
if is_command:
explanation = self._explain_line_basic(line)
else:
explanation = "Comment" if stripped.startswith("#") else "Empty line"
line_explanations.append(
LineExplanation(
line_number=i,
content=stripped,
explanation=explanation,
is_command=is_command,
)
)
summary = self._generate_summary(line_explanations, shell_type)
return ScriptExplanation(
shell_type=shell_type,
line_explanations=line_explanations,
summary=summary,
overall_purpose=self._detect_purpose(line_explanations),
)
def _explain_line_basic(self, line: str) -> str:
"""Provide basic explanation for a single line.
Args:
line: Shell command line
Returns:
Explanation string
"""
for keyword, description in self.KEYWORDS.items():
if re.search(r"\b" + keyword + r"\b", line):
return description
for pattern, description in self.COMMON_PATTERNS:
if re.search(pattern, line):
return description
return "Shell command"
def _explain_with_ai(
self, script: str, shell_type: str
) -> ScriptExplanation:
"""Use AI to explain script in detail.
Args:
script: Shell script content
shell_type: Shell type
Returns:
ScriptExplanation with AI explanations
"""
from shellgenius.generation import PromptTemplates
prompt = PromptTemplates.get_explain_prompt(script, shell_type)
result = self.client.generate(prompt)
if result["success"]:
raw_explanation = result["response"].get("response", "")
return self._parse_ai_explanation(script, raw_explanation, shell_type)
else:
return self._explain_basic(script, shell_type)
def _parse_ai_explanation(
self, script: str, raw: str, shell_type: str
) -> ScriptExplanation:
"""Parse AI explanation response.
Args:
script: Original script
raw: Raw AI response
shell_type: Shell type
Returns:
ScriptExplanation
"""
lines = script.split("\n")
line_explanations = []
explanations_by_line = {}
for line in raw.split("\n"):
match = re.match(r"Line\s+(\d+):\s*(.+?)\s*-\s*(.+)", line)
if match:
line_num = int(match.group(1))
command = match.group(2)
explanation = match.group(3)
explanations_by_line[line_num] = (command, explanation)
for i, line in enumerate(lines, 1):
stripped = line.strip()
is_command = bool(stripped) and not stripped.startswith("#")
if i in explanations_by_line:
cmd, exp = explanations_by_line[i]
explanation = exp
else:
explanation = self._explain_line_basic(stripped) if is_command else "Empty/Comment"
line_explanations.append(
LineExplanation(
line_number=i,
content=stripped,
explanation=explanation,
is_command=is_command,
)
)
summary = self._generate_summary(line_explanations, shell_type)
return ScriptExplanation(
shell_type=shell_type,
line_explanations=line_explanations,
summary=summary,
overall_purpose=self._detect_purpose(line_explanations),
)
def _generate_summary(
self, explanations: List[LineExplanation], shell_type: str
) -> str:
"""Generate overall summary.
Args:
explanations: List of line explanations
shell_type: Shell type
Returns:
Summary string
"""
command_count = sum(1 for e in explanations if e.is_command)
function_count = sum(
1 for e in explanations if "function" in e.explanation.lower()
)
loop_count = sum(
1 for e in explanations if "loop" in e.explanation.lower()
)
return f"{shell_type} script with {command_count} commands, {function_count} function(s), {loop_count} loop(s)."
def _detect_purpose(self, explanations: List[LineExplanation]) -> str:
"""Detect overall script purpose.
Args:
explanations: List of line explanations
Returns:
Detected purpose string
"""
keywords = []
for exp in explanations:
if exp.is_command:
cmd = exp.content.lower()
if "git" in cmd:
keywords.append("Git operations")
if "docker" in cmd or "kubectl" in cmd:
keywords.append("Container operations")
if "npm" in cmd or "pip" in cmd:
keywords.append("Package management")
if "curl" in cmd or "wget" in cmd:
keywords.append("Network operations")
if any(x in cmd for x in ["cd", "ls", "mkdir", "rm"]):
keywords.append("File system operations")
if keywords:
return " | ".join(set(keywords))
return "General shell script"
def explain_script(script: str, detailed: bool = True) -> ScriptExplanation:
"""Convenience function to explain a shell script.
Args:
script: Shell script content
detailed: Use detailed AI-based explanation
Returns:
ScriptExplanation
"""
explainer = ShellExplainer()
return explainer.explain(script, detailed)