wizard-central-toolkit 0.2.0__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 (77) hide show
  1. wiz_central_toolkit/__init__.py +2 -0
  2. wiz_central_toolkit/bannerwiz/__main__.py +6 -0
  3. wiz_central_toolkit/bannerwiz/cli.py +79 -0
  4. wiz_central_toolkit/bannerwiz/lib/__init__.py +1 -0
  5. wiz_central_toolkit/bannerwiz/lib/banner_wizard.py +999 -0
  6. wiz_central_toolkit/bannerwiz/lib/banner_wrapper.py +199 -0
  7. wiz_central_toolkit/bannerwiz/lib/cli.py +79 -0
  8. wiz_central_toolkit/codeguardwiz/__init__.py +1 -0
  9. wiz_central_toolkit/codeguardwiz/__main__.py +19 -0
  10. wiz_central_toolkit/codeguardwiz/cli.py +971 -0
  11. wiz_central_toolkit/codeguardwiz/codeguard_wizard.py +269 -0
  12. wiz_central_toolkit/codeguardwiz/rust_engine.py +348 -0
  13. wiz_central_toolkit/codeguardwiz/static_engine.py +307 -0
  14. wiz_central_toolkit/common/__init__.py +1 -0
  15. wiz_central_toolkit/debugwiz/__init__.py +1 -0
  16. wiz_central_toolkit/debugwiz/__main__.py +360 -0
  17. wiz_central_toolkit/debugwiz/debug_wizard.py +364 -0
  18. wiz_central_toolkit/diffwiz/__init__.py +0 -0
  19. wiz_central_toolkit/diffwiz/__main__.py +21 -0
  20. wiz_central_toolkit/diffwiz/cli.py +134 -0
  21. wiz_central_toolkit/diffwiz/lib/__init__.py +1 -0
  22. wiz_central_toolkit/diffwiz/lib/cli.py +144 -0
  23. wiz_central_toolkit/diffwiz/lib/diff_wizard.py +89 -0
  24. wiz_central_toolkit/godfatherwiz/__init__.py +0 -0
  25. wiz_central_toolkit/godfatherwiz/__main__.py +18 -0
  26. wiz_central_toolkit/godfatherwiz/ai.py +136 -0
  27. wiz_central_toolkit/godfatherwiz/lib/__init__.py +1 -0
  28. wiz_central_toolkit/godfatherwiz/lib/cli.py +925 -0
  29. wiz_central_toolkit/godfatherwiz/lib/godfather_wizard.py +1552 -0
  30. wiz_central_toolkit/jsonwiz/__init__.py +0 -0
  31. wiz_central_toolkit/jsonwiz/__main__.py +21 -0
  32. wiz_central_toolkit/jsonwiz/cli.py +4 -0
  33. wiz_central_toolkit/jsonwiz/lib/__init__.py +1 -0
  34. wiz_central_toolkit/jsonwiz/lib/cli.py +138 -0
  35. wiz_central_toolkit/jsonwiz/lib/json_wizard.py +133 -0
  36. wiz_central_toolkit/pythonwiz/__init__.py +0 -0
  37. wiz_central_toolkit/pythonwiz/__main__.py +21 -0
  38. wiz_central_toolkit/pythonwiz/lib/__init__.py +1 -0
  39. wiz_central_toolkit/pythonwiz/lib/cli.py +247 -0
  40. wiz_central_toolkit/pythonwiz/lib/python_wizard.py +353 -0
  41. wiz_central_toolkit/readwiz/__init__.py +5 -0
  42. wiz_central_toolkit/readwiz/__main__.py +15 -0
  43. wiz_central_toolkit/readwiz/cli.py +46 -0
  44. wiz_central_toolkit/readwiz/lib/__init__.py +1 -0
  45. wiz_central_toolkit/readwiz/lib/cli.py +47 -0
  46. wiz_central_toolkit/readwiz/lib/read_wizard.py +121 -0
  47. wiz_central_toolkit/rustwiz/__init__.py +5 -0
  48. wiz_central_toolkit/rustwiz/__main__.py +7 -0
  49. wiz_central_toolkit/rustwiz/cli.py +4 -0
  50. wiz_central_toolkit/rustwiz/lib/__init__.py +1 -0
  51. wiz_central_toolkit/rustwiz/lib/cli.py +53 -0
  52. wiz_central_toolkit/rustwiz/lib/rust_wizard.py +128 -0
  53. wiz_central_toolkit/tmuxwiz/__init__.py +0 -0
  54. wiz_central_toolkit/tmuxwiz/__main__.py +15 -0
  55. wiz_central_toolkit/tmuxwiz/cli.py +7 -0
  56. wiz_central_toolkit/tmuxwiz/lib/__init__.py +1 -0
  57. wiz_central_toolkit/tmuxwiz/lib/cli.py +10 -0
  58. wiz_central_toolkit/tmuxwiz/lib/tmux_wizard.py +229 -0
  59. wiz_central_toolkit/todowiz/__init__.py +0 -0
  60. wiz_central_toolkit/todowiz/__main__.py +22 -0
  61. wiz_central_toolkit/todowiz/cli.py +4 -0
  62. wiz_central_toolkit/todowiz/lib/__init__.py +1 -0
  63. wiz_central_toolkit/todowiz/lib/cli.py +82 -0
  64. wiz_central_toolkit/todowiz/lib/todo_wizard.py +134 -0
  65. wiz_central_toolkit/tools_registry.py +62 -0
  66. wiz_central_toolkit/wiz/__init__.py +0 -0
  67. wiz_central_toolkit/wiz/__main__.py +141 -0
  68. wiz_central_toolkit/writewiz/__init__.py +1 -0
  69. wiz_central_toolkit/writewiz/__main__.py +9 -0
  70. wiz_central_toolkit/writewiz/cli.py +264 -0
  71. wiz_central_toolkit/writewiz/write_wizard.py +384 -0
  72. wizard_central_toolkit-0.2.0.dist-info/METADATA +203 -0
  73. wizard_central_toolkit-0.2.0.dist-info/RECORD +77 -0
  74. wizard_central_toolkit-0.2.0.dist-info/WHEEL +5 -0
  75. wizard_central_toolkit-0.2.0.dist-info/entry_points.txt +50 -0
  76. wizard_central_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
  77. wizard_central_toolkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,269 @@
1
+ """
2
+ CodeGuard Wizard — universal code auditor & auto-fixer.
3
+ Naturalized citizen: stdlib-only (no requests), live daemon roster (no
4
+ hardcoded models), localhost default. Verdicts are plain text.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import urllib.request
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ OLLAMA_BASE = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
15
+
16
+ # Seat preferences, honored ONLY if the live daemon actually serves them.
17
+ AUDIT_PREFS = ["qwen2.5-coder:3b-instruct", "stable-code:latest",
18
+ "qwen2.5-coder:1.5b-instruct"]
19
+ FIX_PREFS = ["debug-seat:latest", "ops-seat:latest", "scholar-seat:latest",
20
+ "qwen2.5-coder:3b-instruct"]
21
+
22
+ EXT_MAP = {
23
+ ".py": "python", ".js": "javascript", ".ts": "typescript", ".jsx": "javascript",
24
+ ".tsx": "typescript", ".java": "java", ".c": "c", ".cpp": "cpp", ".cc": "cpp",
25
+ ".h": "c", ".hpp": "cpp", ".cs": "csharp", ".go": "go", ".rs": "rust",
26
+ ".rb": "ruby", ".php": "php", ".swift": "swift", ".kt": "kotlin",
27
+ ".scala": "scala", ".lua": "lua", ".pl": "perl", ".r": "r",
28
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash", ".fish": "bash",
29
+ ".ps1": "powershell", ".bat": "batch", ".cmd": "batch",
30
+ ".sql": "sql", ".html": "html", ".css": "css", ".scss": "scss",
31
+ ".yml": "yaml", ".yaml": "yaml", ".json": "json", ".xml": "xml",
32
+ ".toml": "toml", ".ini": "ini", ".cfg": "ini",
33
+ }
34
+ SHEBANG_PATTERNS = {
35
+ "python": ["python", "python3"],
36
+ "bash": ["bash", "sh", "zsh", "fish"],
37
+ "ruby": ["ruby"], "perl": ["perl"], "node": ["node"],
38
+ "lua": ["lua"], "php": ["php"],
39
+ "powershell": ["pwsh", "powershell"],
40
+ }
41
+
42
+
43
+ def load_roster() -> list:
44
+ """Ground truth: models actually served by the live daemon."""
45
+ try:
46
+ with urllib.request.urlopen(f"{OLLAMA_BASE}/api/tags", timeout=5) as r:
47
+ return sorted(m.get("name", "?")
48
+ for m in json.loads(r.read()).get("models", []))
49
+ except Exception:
50
+ return []
51
+
52
+
53
+ def pick_seat(roster, prefs, env_var):
54
+ """Env override wins; then preference order; then nothing (honest FAIL)."""
55
+ override = os.environ.get(env_var, "").strip()
56
+ if override:
57
+ return override if override in roster or roster == [] else None
58
+ for pref in prefs:
59
+ for name in roster:
60
+ if name == pref or name.startswith(pref.split(":")[0]):
61
+ return name
62
+ return None
63
+
64
+
65
+ def detect_language(filepath: str) -> str:
66
+ """Extension, then shebang, then content sniffing."""
67
+ p = Path(filepath)
68
+ ext = p.suffix.lower()
69
+ if ext in EXT_MAP:
70
+ return EXT_MAP[ext]
71
+ name = p.name.lower()
72
+ if name == "dockerfile":
73
+ return "dockerfile"
74
+ if name == "makefile":
75
+ return "makefile"
76
+ try:
77
+ with open(filepath, "r", errors="ignore") as f:
78
+ first_line = f.readline().strip()
79
+ if first_line.startswith("#!"):
80
+ shebang = first_line.lower()
81
+ for lang, markers in SHEBANG_PATTERNS.items():
82
+ if any(m in shebang for m in markers):
83
+ return lang
84
+ except Exception:
85
+ pass
86
+ try:
87
+ with open(filepath, encoding="utf-8", errors="ignore") as fh:
88
+ content = fh.read(2048).lower()
89
+ sigs = {
90
+ "python": ["def ", "import ", "class ", "self.", "print(", "if __name__"],
91
+ "javascript": ["function ", "const ", "let ", "var ", "console.log", "=>"],
92
+ "go": ["func ", "package ", "import (", "fmt.Print"],
93
+ "rust": ["fn ", "let mut", "impl ", "pub fn", "println!"],
94
+ "ruby": ["def ", "puts ", "require ", "module "],
95
+ "php": ["<?php", "function ", "$this->", "echo "],
96
+ "c": ["#include", "int main", "printf(", "struct "],
97
+ "cpp": ["#include", "std::", "int main", "cout <<"],
98
+ }
99
+ scores = {lang: sum(content.count(kw) for kw in kws)
100
+ for lang, kws in sigs.items()}
101
+ best = max(scores, key=lambda k: scores[k]) if scores else None
102
+ if best and scores[best] >= 3:
103
+ return best
104
+ except Exception:
105
+ pass
106
+ return "unknown"
107
+
108
+
109
+ def _call_ollama(model: str, prompt: str, timeout: int = 120) -> str:
110
+ """Stdlib-only daemon call. Returns '' on any failure."""
111
+ try:
112
+ payload = json.dumps({"model": model, "prompt": prompt,
113
+ "stream": False}).encode()
114
+ req = urllib.request.Request(f"{OLLAMA_BASE}/api/generate", data=payload,
115
+ headers={"Content-Type": "application/json"})
116
+ with urllib.request.urlopen(req, timeout=timeout) as r:
117
+ return json.loads(r.read()).get("response", "").strip()
118
+ except Exception:
119
+ return ""
120
+
121
+
122
+ def _try_parse_json(text: str):
123
+ try:
124
+ return json.loads(text.strip())
125
+ except json.JSONDecodeError:
126
+ pass
127
+ match = re.search(r"\{[\s\S]*\}", text)
128
+ if match:
129
+ try:
130
+ return json.loads(match.group())
131
+ except json.JSONDecodeError:
132
+ pass
133
+ return None
134
+
135
+
136
+ def _hash(content: str) -> str:
137
+ return hashlib.sha256(content.encode()).hexdigest()
138
+
139
+
140
+ def build_audit_prompt(language, code, filepath):
141
+ return f"""You are a senior code auditor and security engineer. Analyze the following {language} code.
142
+
143
+ FILE: {filepath}
144
+ LANGUAGE: {language}
145
+
146
+ Audit for: security flaws, logic bugs, edge cases, quality issues, design concerns.
147
+
148
+ Format your response EXACTLY as this JSON structure:
149
+ {{
150
+ "language": "{language}",
151
+ "issues": [
152
+ {{
153
+ "severity": "CRITICAL|HIGH|MEDIUM|LOW",
154
+ "category": "security|logic|edge_case|quality|design",
155
+ "line_range": [start_line, end_line],
156
+ "title": "Short title",
157
+ "description": "Detailed explanation",
158
+ "fix": "Concrete fix suggestion"
159
+ }}
160
+ ],
161
+ "summary": "Overall assessment paragraph",
162
+ "score": 0-100
163
+ }}
164
+
165
+ CODE TO AUDIT:
166
+ ---
167
+ {code}
168
+ ---"""
169
+
170
+
171
+ def build_fix_prompt(language, code, filepath, issues_json):
172
+ return f"""You are an expert {language} developer. Fix ALL the issues identified in the audit.
173
+
174
+ Return ONLY the complete corrected code — no markdown fences, no explanations, no preamble.
175
+ Preserve comments and formatting not part of an issue. Add imports if a fix requires them.
176
+
177
+ ORIGINAL FILE: {filepath}
178
+
179
+ AUDIT REPORT:
180
+ {issues_json}
181
+
182
+ ORIGINAL CODE:
183
+ {code}
184
+
185
+ OUTPUT THE FULL CORRECTED CODE NOW:"""
186
+
187
+
188
+ def read_code(filepath):
189
+ try:
190
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
191
+ return f.read()
192
+ except OSError:
193
+ return None
194
+
195
+
196
+ def audit(filepath: str, model: str | None = None) -> dict:
197
+ """Audit one file. Returns a result dict; status OK/FAILED."""
198
+ code = read_code(filepath)
199
+ if code is None:
200
+ return {"status": "FAILED", "reason": "cannot read file"}
201
+ language = detect_language(filepath)
202
+ if language == "unknown":
203
+ return {"status": "FAILED", "reason": "cannot detect language"}
204
+ roster = load_roster()
205
+ audit_model = model or pick_seat(roster, AUDIT_PREFS, "CODEGUARD_AUDIT_MODEL")
206
+ if not audit_model:
207
+ return {"status": "FAILED",
208
+ "reason": "no audit seat: daemon unreachable or no coder seat served",
209
+ "roster_size": len(roster)}
210
+ raw = _call_ollama(audit_model,
211
+ build_audit_prompt(language, code, filepath))
212
+ if not raw:
213
+ return {"status": "FAILED", "reason": "ollama audit call failed",
214
+ "model": audit_model}
215
+ audit_data = _try_parse_json(raw)
216
+ if audit_data is None:
217
+ return {"status": "FAILED", "reason": "unparseable audit response",
218
+ "raw": raw[:400]}
219
+ counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
220
+ for issue in audit_data.get("issues", []):
221
+ sev = issue.get("severity", "LOW")
222
+ if sev in counts:
223
+ counts[sev] += 1
224
+ return {"status": "OK", "filepath": filepath, "language": language,
225
+ "model": audit_model, "code_hash": _hash(code),
226
+ "audit": audit_data, "issues_count": counts,
227
+ "timestamp": datetime.now().isoformat()}
228
+
229
+
230
+ def fix(
231
+ filepath: str, audit_result: dict | None = None, model: str | None = None
232
+ ) -> dict:
233
+ """Generate fixed code for one file. Does NOT write — caller consents."""
234
+ code = read_code(filepath)
235
+ if code is None:
236
+ return {"status": "FAILED", "reason": "cannot read file"}
237
+ original_hash = _hash(code)
238
+ if audit_result is None:
239
+ audit_result = audit(filepath)
240
+ if audit_result["status"] != "OK":
241
+ return {"status": "FAILED", "reason": "audit failed before fix"}
242
+ issues = audit_result.get("audit", {}).get("issues", [])
243
+ if not issues:
244
+ return {"status": "OK", "filepath": filepath,
245
+ "original_hash": original_hash, "fixed_hash": original_hash,
246
+ "fixed_code": code, "issues_fixed": 0,
247
+ "message": "no issues to fix"}
248
+ roster = load_roster()
249
+ fix_model = model or pick_seat(roster, FIX_PREFS, "CODEGUARD_FIX_MODEL")
250
+ if not fix_model:
251
+ return {"status": "FAILED",
252
+ "reason": "no fix seat: daemon unreachable or no seat served"}
253
+ language = audit_result.get("language", "unknown")
254
+ prompt = build_fix_prompt(language, code, filepath,
255
+ json.dumps(issues, indent=2))
256
+ fixed_code = _call_ollama(fix_model, prompt, timeout=180)
257
+ if not fixed_code:
258
+ return {"status": "FAILED", "reason": "ollama fix call failed"}
259
+ if fixed_code.startswith("```"):
260
+ lines = fixed_code.split("\n")
261
+ fixed_code = "\n".join(lines[1:-1] if lines[-1].startswith("```")
262
+ else lines[1:])
263
+ fixed_code = fixed_code.strip()
264
+ return {"status": "OK", "filepath": filepath,
265
+ "original_hash": original_hash, "fixed_hash": _hash(fixed_code),
266
+ "fixed_code": fixed_code, "fix_model": fix_model,
267
+ "issues_fixed": len(issues),
268
+ "hashes_match": original_hash == _hash(fixed_code),
269
+ "timestamp": datetime.now().isoformat()}
@@ -0,0 +1,348 @@
1
+ """codeguardwiz rust engine — the Rust half of the seatless audit.
2
+
3
+ Standard library only. The static engine's sibling: same finding
4
+ shape, same severity scale, same score inputs, so audit / explain /
5
+ watch / fix consume Rust findings with no downstream changes. It
6
+ never calls a model, never writes a file, and — by design — never
7
+ executes anything in the project it audits: no cargo, no rustc, no
8
+ subprocess against the target at all. An audit tool must not run
9
+ the code it is auditing (a `cargo check` would execute build
10
+ scripts), so there is deliberately no toolchain pass.
11
+
12
+ Findings are plain dicts, exactly the static engine's shape:
13
+ {"path", "line", "rule", "name", "severity", "detail"}
14
+ with severity one of "high" | "medium" | "low".
15
+
16
+ Severity mapping (the design's words onto this scale): the
17
+ critical-class rules (shell construction, hardcoded secrets) sit at
18
+ "high" beside their Python twins CG004/CG002; transmute/unchecked
19
+ is "high" too — undefined behaviour outranks a warning. The
20
+ warning-class rules (unsafe regions, unwrap/expect, unpinned
21
+ dependencies) sit at "medium", where the Python scale keeps its
22
+ conditional risks (pickle, weak hash, bare except). The marker
23
+ rule is "low", beside CG009's TODO markers.
24
+
25
+ Scanning honesty: Rust has no standard-library AST, so this engine
26
+ is a line scanner, not a parser. Full-line `//` comments are
27
+ skipped for the code rules (the Python side's code rules read the
28
+ AST, where comments never fire). Patterns inside string literals
29
+ or /* */ block comments can still fire — the same honesty level as
30
+ the Python side's text lane, and the reason every finding names
31
+ its line for a human to judge.
32
+ """
33
+ import re
34
+ from pathlib import Path
35
+
36
+ ENGINE_LABEL = "static (built-in rules)"
37
+
38
+ # ---------------------------------------------------------------- rules
39
+
40
+ _RULE_DEFS = [
41
+ ("RS001", "unsafe-block", "medium",
42
+ "An unsafe block (or unsafe fn) is where Rust's memory-safety "
43
+ "guarantees stop being checked by the compiler and become the "
44
+ "programmer's promise instead. Unsafe is sometimes necessary — "
45
+ "FFI, low-level data structures — but every unsafe region is a "
46
+ "place a reviewer should be able to find and question, so the "
47
+ "audit keeps them all visible."),
48
+ ("RS002", "unwrap-expect", "medium",
49
+ "unwrap() and expect() turn a missing value (None) or a "
50
+ "failure (Err) into a panic that unwinds the program. Where "
51
+ "the value genuinely cannot be absent they are merely terse; "
52
+ "anywhere input, files, or the network are involved they are "
53
+ "crash sites waiting for the one case nobody tested. Handle "
54
+ "the None/Err, or use `?` and let the caller decide."),
55
+ ("RS003", "panic-marker", "low",
56
+ "panic!, todo!, and unimplemented! announce in the source "
57
+ "that a path crashes by design or is not finished yet. Like "
58
+ "a TODO comment (CG009) they are promises the code has not "
59
+ "kept — listed so they stay visible instead of being "
60
+ "discovered at runtime."),
61
+ ("RS004", "shell-construction", "high",
62
+ "A command handed to a shell (sh, bash, cmd) is interpreted "
63
+ "as text: metacharacters in any part of it become commands "
64
+ "in their own right, and a command string assembled with "
65
+ "format! has the same shape — what runs is decided by data "
66
+ "at runtime. Run the program directly with an argument list "
67
+ "(Command::new(\"prog\").arg(...)) so arguments stay "
68
+ "arguments."),
69
+ ("RS005", "unchecked-conversion", "high",
70
+ "transmute and the *_unchecked family switch off the safety "
71
+ "checks on a conversion: transmute reinterprets bytes as "
72
+ "another type with no validity guarantee, and get_unchecked "
73
+ "/ from_utf8_unchecked / unwrap_unchecked assume conditions "
74
+ "the compiler no longer verifies. When the assumption is "
75
+ "wrong the result is undefined behaviour, not an error "
76
+ "message. Prefer the checked conversion."),
77
+ ("RS006", "hardcoded-secret", "high",
78
+ "A password, token, or key written as a string literal "
79
+ "directly in the source. Anyone who can read the code owns "
80
+ "the secret, and the secret travels with every copy of the "
81
+ "file. Move it to the environment or a config the code reads "
82
+ "at runtime. The audit names the variable and the pattern "
83
+ "kind only — never the value."),
84
+ ("RS007", "unpinned-dependency", "medium",
85
+ "A dependency that can silently change under the build: a "
86
+ "\"*\" version accepts whatever is newest on the day of the "
87
+ "build, and a git dependency with no rev tracks a moving "
88
+ "branch. Either way the same source can build against "
89
+ "different code tomorrow — including code nobody reviewed. "
90
+ "Pin a version, or pin a rev."),
91
+ ]
92
+
93
+
94
+ class Rule:
95
+ __slots__ = ("id", "name", "severity", "explanation")
96
+
97
+ def __init__(self, rid, name, severity, explanation):
98
+ self.id = rid
99
+ self.name = name
100
+ self.severity = severity
101
+ self.explanation = explanation
102
+
103
+
104
+ RULES = [Rule(*d) for d in _RULE_DEFS]
105
+ RULE_BY_ID = {r.id: r for r in RULES}
106
+
107
+
108
+ def find_rule(query):
109
+ """Look a rule up by id (RS002), exact name, or name fragment."""
110
+ q = str(query).strip()
111
+ if not q:
112
+ return None
113
+ if q.upper() in RULE_BY_ID:
114
+ return RULE_BY_ID[q.upper()]
115
+ ql = q.lower()
116
+ for r in RULES:
117
+ if r.name == ql:
118
+ return r
119
+ for r in RULES:
120
+ if ql in r.name:
121
+ return r
122
+ return None
123
+
124
+
125
+ # ---------------------------------------------------------------- scanning
126
+
127
+ _UNSAFE = re.compile(r"\bunsafe\s*(\{|fn\b)")
128
+ _UNWRAP = re.compile(r"\.(unwrap|expect)\s*\(")
129
+ _PANIC = re.compile(r"\b(panic|todo|unimplemented)!\s*[\(\[\{]")
130
+ _SHELL_NEW = re.compile(r"Command::new\s*\(\s*\"(sh|bash|cmd)\"")
131
+ _SHELL_FMT = re.compile(r"Command::new\s*\(\s*format!\s*\(")
132
+ _SHELL_ARGC = re.compile(r"\.arg\s*\(\s*\"(-c|/C)\"\s*\)")
133
+ _TRANSMUTE = re.compile(r"\btransmute(_copy)?\b")
134
+ _UNCHECKED = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*_unchecked)\s*\(")
135
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
136
+
137
+ # The same secret-name vocabulary the static engine uses for CG002.
138
+ _SECRET_NAME = re.compile(
139
+ r"(password|passwd|pwd|secret|api[_-]?key|access[_-]?token"
140
+ r"|auth[_-]?token|private[_-]?key)", re.IGNORECASE)
141
+
142
+
143
+ def _finding(path, line, rule, detail):
144
+ return {"path": str(path), "line": line, "rule": rule.id,
145
+ "name": rule.name, "severity": rule.severity,
146
+ "detail": detail}
147
+
148
+
149
+ def _is_secret_name(name):
150
+ return bool(_SECRET_NAME.search(name)) or name.lower() == "token" \
151
+ or name.lower().endswith("_token")
152
+
153
+
154
+ def _secret_literal_on_line(text):
155
+ """Name of a secret-shaped variable given a string literal here.
156
+
157
+ Mirrors CG002's two shapes in Rust dress: `let NAME = "..."`,
158
+ `const NAME: Type = "..."`, and the struct-field form
159
+ `name: "..."`. Returns the variable name only — the literal
160
+ itself never leaves this function, so no finding can carry it.
161
+ """
162
+ for m in _IDENT.finditer(text):
163
+ name = m.group(0)
164
+ if not _is_secret_name(name):
165
+ continue
166
+ tail = text[m.end():].lstrip()
167
+ if tail.startswith(":"):
168
+ tail = tail[1:].lstrip()
169
+ if not tail.startswith('"'):
170
+ eq = tail.find("=")
171
+ if eq == -1:
172
+ continue
173
+ tail = tail[eq + 1:].lstrip()
174
+ elif tail.startswith("="):
175
+ if tail.startswith("=="):
176
+ continue
177
+ tail = tail[1:].lstrip()
178
+ else:
179
+ continue
180
+ # a non-empty string literal: opening quote, then a character
181
+ # that is not the closing quote
182
+ if len(tail) >= 2 and tail[0] == '"' and tail[1] != '"':
183
+ return name
184
+ return None
185
+
186
+
187
+ def scan_source(source, path="<source>"):
188
+ """All findings in one Rust source string, sorted by line."""
189
+ findings = []
190
+ for i, text in enumerate(source.splitlines(), 1):
191
+ if text.strip().startswith("//"):
192
+ continue # full-line comment: the code rules do not fire
193
+ m = _UNSAFE.search(text)
194
+ if m:
195
+ what = "unsafe fn" if m.group(1) == "fn" else "unsafe block"
196
+ findings.append(_finding(
197
+ path, i, RULE_BY_ID["RS001"],
198
+ f"{what}: the compiler's memory-safety checks are "
199
+ "suspended here"))
200
+ for m in _UNWRAP.finditer(text):
201
+ findings.append(_finding(
202
+ path, i, RULE_BY_ID["RS002"],
203
+ f".{m.group(1)}() panics when the value is None "
204
+ "or Err"))
205
+ for m in _PANIC.finditer(text):
206
+ findings.append(_finding(
207
+ path, i, RULE_BY_ID["RS003"],
208
+ f"{m.group(1)}! left in the code — an explicit "
209
+ "panic/unfinished marker"))
210
+ m = _SHELL_NEW.search(text)
211
+ if m:
212
+ findings.append(_finding(
213
+ path, i, RULE_BY_ID["RS004"],
214
+ f"Command::new(\"{m.group(1)}\") spawns a shell: "
215
+ "the string it is handed will be interpreted, "
216
+ "not just run"))
217
+ elif _SHELL_FMT.search(text):
218
+ findings.append(_finding(
219
+ path, i, RULE_BY_ID["RS004"],
220
+ "the program to run is assembled with format!: a "
221
+ "string built at runtime decides what executes"))
222
+ m = _SHELL_ARGC.search(text)
223
+ if m:
224
+ findings.append(_finding(
225
+ path, i, RULE_BY_ID["RS004"],
226
+ f"shell flag \"{m.group(1)}\" passed as an argument: "
227
+ "a shell will interpret the command string "
228
+ "that follows"))
229
+ m = _TRANSMUTE.search(text)
230
+ if m:
231
+ findings.append(_finding(
232
+ path, i, RULE_BY_ID["RS005"],
233
+ f"'{m.group(0)}' reinterprets bytes as another "
234
+ "type with no validity check"))
235
+ else:
236
+ m = _UNCHECKED.search(text)
237
+ if m:
238
+ findings.append(_finding(
239
+ path, i, RULE_BY_ID["RS005"],
240
+ f"'{m.group(1)}' skips the checked conversion: "
241
+ "a wrong assumption is undefined behaviour"))
242
+ name = _secret_literal_on_line(text)
243
+ if name:
244
+ findings.append(_finding(
245
+ path, i, RULE_BY_ID["RS006"],
246
+ f"string literal assigned to '{name}'"))
247
+ findings.sort(key=lambda f: (f["line"], f["rule"]))
248
+ return findings
249
+
250
+
251
+ # ---------------------------------------------------------------- Cargo.toml
252
+
253
+ _SECTION = re.compile(r"^\s*\[([^\]]+)\]")
254
+ _DEP_WILD = re.compile(r"^([A-Za-z0-9_-]+)\s*=\s*\"\*\"")
255
+ _DEP_NAME = re.compile(r"^([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*$")
256
+ _VERSION_WILD = re.compile(r"version\s*=\s*\"\*\"")
257
+ _GIT_DEP = re.compile(r"\bgit\s*=")
258
+ _REV_PIN = re.compile(r"\brev\s*=")
259
+
260
+
261
+ def scan_toml_source(source, path="<source>"):
262
+ """RS007 findings in one Cargo.toml source string."""
263
+ findings = []
264
+ in_deps = False
265
+ for i, text in enumerate(source.splitlines(), 1):
266
+ stripped = text.strip()
267
+ if stripped.startswith("#"):
268
+ continue
269
+ m = _SECTION.match(text)
270
+ if m:
271
+ in_deps = "dependencies" in m.group(1)
272
+ continue
273
+ if not in_deps or not stripped:
274
+ continue
275
+ m = _DEP_WILD.match(stripped)
276
+ if m:
277
+ findings.append(_finding(
278
+ path, i, RULE_BY_ID["RS007"],
279
+ f"dependency '{m.group(1)}' has version \"*\": it "
280
+ "accepts whatever is newest on build day"))
281
+ continue
282
+ m = _DEP_NAME.match(stripped)
283
+ if m:
284
+ name, body = m.group(1), m.group(2)
285
+ if _VERSION_WILD.search(body):
286
+ findings.append(_finding(
287
+ path, i, RULE_BY_ID["RS007"],
288
+ f"dependency '{name}' has version \"*\": it "
289
+ "accepts whatever is newest on build day"))
290
+ elif _GIT_DEP.search(body) and not _REV_PIN.search(body):
291
+ findings.append(_finding(
292
+ path, i, RULE_BY_ID["RS007"],
293
+ f"git dependency '{name}' has no pinned rev: "
294
+ "it tracks whatever the branch points at"))
295
+ findings.sort(key=lambda f: (f["line"], f["rule"]))
296
+ return findings
297
+
298
+
299
+ def scan_file(path):
300
+ """Findings for one file on disk. Raises OSError if unreadable."""
301
+ source = Path(path).read_text(encoding="utf-8", errors="ignore")
302
+ if Path(path).name == "Cargo.toml":
303
+ return scan_toml_source(source, str(path))
304
+ return scan_source(source, str(path))
305
+
306
+
307
+ def scan_toml_file(path):
308
+ """RS007 findings for one Cargo.toml on disk."""
309
+ source = Path(path).read_text(encoding="utf-8", errors="ignore")
310
+ return scan_toml_source(source, str(path))
311
+
312
+
313
+ def collect_files(target):
314
+ """The .rs files and Cargo.toml manifests a file-or-dir target names."""
315
+ p = Path(target)
316
+ if p.is_file():
317
+ return [p] if p.suffix == ".rs" or p.name == "Cargo.toml" else []
318
+ if p.is_dir():
319
+ out = [f for f in p.rglob("*.rs")
320
+ if "__pycache__" not in str(f) and ".venv" not in str(f)]
321
+ out += [f for f in p.rglob("Cargo.toml")
322
+ if "__pycache__" not in str(f) and ".venv" not in str(f)]
323
+ return sorted(out)
324
+ return []
325
+
326
+
327
+ def counts(findings):
328
+ out = {"high": 0, "medium": 0, "low": 0}
329
+ for f in findings:
330
+ out[f["severity"]] += 1
331
+ return out
332
+
333
+
334
+ # ---------------------------------------------------------------- fixing
335
+
336
+ FIXABLE_RULES = ()
337
+
338
+
339
+ def apply_fixes(source, findings):
340
+ """No Rust finding has a defensible deterministic rewrite.
341
+
342
+ The static engine stages exactly one mechanical rewrite
343
+ (CG007, the bare `except:`); there is no Rust equivalent —
344
+ nobody's `unwrap()` gets rewritten by a pattern. Rust findings
345
+ are reported and explained; a rewrite proposal is the model
346
+ engine's job. Returns (source, 0), unchanged, always.
347
+ """
348
+ return source, 0