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,307 @@
1
+ """codeguardwiz static engine — the built-in, seatless audit.
2
+
3
+ Standard library only. Real rules over Python source: `ast` where the
4
+ tree can see, text patterns where it cannot (comments and markers).
5
+ This is the always-available half of codeguardwiz (AVS's R1 ruling:
6
+ both engines, the user's choice). It never calls a model, never
7
+ writes a file, and everything it produces is labelled as its own.
8
+
9
+ Findings are plain dicts:
10
+ {"path", "line", "rule", "name", "severity", "detail"}
11
+ with severity one of "high" | "medium" | "low".
12
+ """
13
+ import ast
14
+ import re
15
+ from pathlib import Path
16
+
17
+ ENGINE_LABEL = "static (built-in rules)"
18
+
19
+ # ---------------------------------------------------------------- rules
20
+
21
+ _RULE_DEFS = [
22
+ ("CG001", "syntax-error", "high",
23
+ "The file does not parse as Python at all, so nothing in it can "
24
+ "run. Every other check is blind until this is fixed: a file "
25
+ "that cannot compile cannot be trusted to do what it says."),
26
+ ("CG002", "hardcoded-secret", "high",
27
+ "A password, token, or key written as a string literal directly "
28
+ "in the source. Anyone who can read the code owns the secret, "
29
+ "and the secret travels with every copy of the file. Move it to "
30
+ "the environment or a config the code reads at runtime."),
31
+ ("CG003", "eval-exec", "high",
32
+ "eval() or exec() runs a string as code. If any part of that "
33
+ "string can be influenced from outside, the caller can run "
34
+ "anything. Parse the data instead (ast.literal_eval for "
35
+ "literals), or dispatch to named functions."),
36
+ ("CG004", "shell-execution", "high",
37
+ "A shell interprets this command (os.system, os.popen, or a "
38
+ "subprocess call with shell=True), so metacharacters in the "
39
+ "arguments become commands. Pass an argument list with "
40
+ "shell=False and let the program, not a shell, run."),
41
+ ("CG005", "pickle-load", "medium",
42
+ "pickle.load/loads rebuilds objects from bytes and will run "
43
+ "whatever the bytes say. Never unpickle data that crossed a "
44
+ "trust boundary; use a data format (JSON) for anything "
45
+ "untrusted."),
46
+ ("CG006", "weak-hash", "medium",
47
+ "MD5 and SHA-1 are broken for security use: collisions are "
48
+ "cheap to manufacture. For integrity or password work use "
49
+ "SHA-256 or better (hashlib.sha256, or a password KDF such as "
50
+ "hashlib.pbkdf2_hmac)."),
51
+ ("CG007", "bare-except", "medium",
52
+ "A bare `except:` catches everything — including "
53
+ "KeyboardInterrupt, SystemExit, and the very bugs you are "
54
+ "hunting — and hides them all alike. Name the exceptions you "
55
+ "expect (`except Exception:` at the widest). This is the one "
56
+ "finding the static engine can rewrite deterministically."),
57
+ ("CG008", "swallowed-exception", "low",
58
+ "An except block whose whole body is `pass` (or `...`) makes "
59
+ "the failure vanish silently: the program continues as if "
60
+ "nothing happened, in a state nobody checked. Log it, count "
61
+ "it, or let it propagate — but decide on purpose."),
62
+ ("CG009", "todo-marker", "low",
63
+ "A TODO or FIXME comment is a promise the code has not kept "
64
+ "yet. Not a defect by itself — it is listed so the promise "
65
+ "stays visible instead of sinking into the file."),
66
+ ]
67
+
68
+
69
+ class Rule:
70
+ __slots__ = ("id", "name", "severity", "explanation")
71
+
72
+ def __init__(self, rid, name, severity, explanation):
73
+ self.id = rid
74
+ self.name = name
75
+ self.severity = severity
76
+ self.explanation = explanation
77
+
78
+
79
+ RULES = [Rule(*d) for d in _RULE_DEFS]
80
+ RULE_BY_ID = {r.id: r for r in RULES}
81
+
82
+
83
+ def find_rule(query):
84
+ """Look a rule up by id (CG002), exact name, or name fragment."""
85
+ q = str(query).strip()
86
+ if not q:
87
+ return None
88
+ if q.upper() in RULE_BY_ID:
89
+ return RULE_BY_ID[q.upper()]
90
+ ql = q.lower()
91
+ for r in RULES:
92
+ if r.name == ql:
93
+ return r
94
+ for r in RULES:
95
+ if ql in r.name:
96
+ return r
97
+ return None
98
+
99
+
100
+ # ---------------------------------------------------------------- scanning
101
+
102
+ _SECRET_NAME = re.compile(
103
+ r"(password|passwd|pwd|secret|api[_-]?key|access[_-]?token"
104
+ r"|auth[_-]?token|private[_-]?key)", re.IGNORECASE)
105
+ _TODO = re.compile(r"#\s*(TODO|FIXME)\b")
106
+ _BARE_EXCEPT = re.compile(r"^(\s*)except\s*:\s*(#.*)?$")
107
+
108
+ _KNOWN_MODULES = ("os", "subprocess", "pickle", "hashlib")
109
+
110
+
111
+ def _finding(path, line, rule, detail):
112
+ return {"path": str(path), "line": line, "rule": rule.id,
113
+ "name": rule.name, "severity": rule.severity,
114
+ "detail": detail}
115
+
116
+
117
+ def _is_secret_name(name):
118
+ return bool(_SECRET_NAME.search(name)) or name.lower() == "token" \
119
+ or name.lower().endswith("_token")
120
+
121
+
122
+ def _imports(tree):
123
+ """Map local names back to the modules they came from."""
124
+ modules = {} # local name -> top module ("pickle", "os", ...)
125
+ funcs = {} # local func name -> (module, attr)
126
+ for node in ast.walk(tree):
127
+ if isinstance(node, ast.Import):
128
+ for a in node.names:
129
+ top = a.name.split(".")[0]
130
+ modules[a.asname or top] = top
131
+ elif isinstance(node, ast.ImportFrom) and node.module:
132
+ top = node.module.split(".")[0]
133
+ for a in node.names:
134
+ funcs[a.asname or a.name] = (top, a.name)
135
+ return modules, funcs
136
+
137
+
138
+ def _call_target(func, modules, funcs):
139
+ """Resolve a call's func to (module, attr) where determinable."""
140
+ if isinstance(func, ast.Attribute):
141
+ base = func.value
142
+ if isinstance(base, ast.Name):
143
+ mod = modules.get(base.id, base.id)
144
+ return mod, func.attr
145
+ return None, func.attr
146
+ if isinstance(func, ast.Name):
147
+ if func.id in funcs:
148
+ return funcs[func.id]
149
+ return None, func.id
150
+ return None, None
151
+
152
+
153
+ def scan_source(source, path="<source>"):
154
+ """All findings in one Python source string, sorted by line."""
155
+ findings = []
156
+ lines = source.splitlines()
157
+
158
+ # text lane: markers live in comments, which the AST drops
159
+ for i, text in enumerate(lines, 1):
160
+ m = _TODO.search(text)
161
+ if m:
162
+ findings.append(_finding(
163
+ path, i, RULE_BY_ID["CG009"],
164
+ f"{m.group(1)} marker left in the code"))
165
+
166
+ try:
167
+ tree = ast.parse(source)
168
+ except SyntaxError as e:
169
+ findings.append(_finding(
170
+ path, e.lineno or 1, RULE_BY_ID["CG001"],
171
+ f"{e.msg} (line {e.lineno or 1})"))
172
+ findings.sort(key=lambda f: (f["line"], f["rule"]))
173
+ return findings
174
+
175
+ modules, funcs = _imports(tree)
176
+
177
+ for node in ast.walk(tree):
178
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
179
+ value = node.value
180
+ targets = node.targets if isinstance(node, ast.Assign) \
181
+ else [node.target]
182
+ if isinstance(value, ast.Constant) \
183
+ and isinstance(value.value, str) and value.value:
184
+ for t in targets:
185
+ if isinstance(t, ast.Name) and _is_secret_name(t.id):
186
+ findings.append(_finding(
187
+ path, node.lineno, RULE_BY_ID["CG002"],
188
+ f"string literal assigned to '{t.id}'"))
189
+ elif isinstance(node, ast.Dict):
190
+ for k, v in zip(node.keys, node.values):
191
+ if isinstance(k, ast.Constant) and isinstance(k.value, str) \
192
+ and _is_secret_name(k.value) \
193
+ and isinstance(v, ast.Constant) \
194
+ and isinstance(v.value, str) and v.value:
195
+ findings.append(_finding(
196
+ path, v.lineno, RULE_BY_ID["CG002"],
197
+ f"string literal under key '{k.value}'"))
198
+ elif isinstance(node, ast.Call):
199
+ mod, attr = _call_target(node.func, modules, funcs)
200
+ bare = node.func.id if isinstance(node.func, ast.Name) else None
201
+ if bare in ("eval", "exec"):
202
+ findings.append(_finding(
203
+ path, node.lineno, RULE_BY_ID["CG003"],
204
+ f"{bare}() executes a string as code"))
205
+ if mod == "os" and attr in ("system", "popen"):
206
+ findings.append(_finding(
207
+ path, node.lineno, RULE_BY_ID["CG004"],
208
+ f"os.{attr}() always runs through a shell"))
209
+ if mod == "subprocess":
210
+ for kw in node.keywords:
211
+ if kw.arg == "shell" \
212
+ and isinstance(kw.value, ast.Constant) \
213
+ and kw.value.value is True:
214
+ findings.append(_finding(
215
+ path, node.lineno, RULE_BY_ID["CG004"],
216
+ "subprocess call with shell=True"))
217
+ if mod == "pickle" and attr in ("load", "loads"):
218
+ findings.append(_finding(
219
+ path, node.lineno, RULE_BY_ID["CG005"],
220
+ f"pickle.{attr}() deserializes arbitrary objects"))
221
+ if mod == "hashlib" and attr in ("md5", "sha1"):
222
+ findings.append(_finding(
223
+ path, node.lineno, RULE_BY_ID["CG006"],
224
+ f"hashlib.{attr}() is a broken hash for security use"))
225
+ if mod == "hashlib" and attr == "new" and node.args:
226
+ first = node.args[0]
227
+ if isinstance(first, ast.Constant) \
228
+ and isinstance(first.value, str) \
229
+ and first.value.lower().replace("-", "") \
230
+ in ("md5", "sha1"):
231
+ findings.append(_finding(
232
+ path, node.lineno, RULE_BY_ID["CG006"],
233
+ f"hashlib.new({first.value!r}) is a broken hash "
234
+ "for security use"))
235
+ elif isinstance(node, ast.ExceptHandler):
236
+ if node.type is None:
237
+ findings.append(_finding(
238
+ path, node.lineno, RULE_BY_ID["CG007"],
239
+ "bare except: catches everything, names nothing"))
240
+ elif len(node.body) == 1 and (
241
+ isinstance(node.body[0], ast.Pass) or (
242
+ isinstance(node.body[0], ast.Expr)
243
+ and isinstance(node.body[0].value, ast.Constant)
244
+ and node.body[0].value.value is Ellipsis)):
245
+ findings.append(_finding(
246
+ path, node.lineno, RULE_BY_ID["CG008"],
247
+ "except block swallows the exception silently"))
248
+
249
+ findings.sort(key=lambda f: (f["line"], f["rule"]))
250
+ return findings
251
+
252
+
253
+ def scan_file(path):
254
+ """Findings for one file on disk. Raises OSError if unreadable."""
255
+ source = Path(path).read_text(encoding="utf-8", errors="ignore")
256
+ return scan_source(source, str(path))
257
+
258
+
259
+ def collect_files(target):
260
+ """The .py files a file-or-dir target names (model scan's own set)."""
261
+ p = Path(target)
262
+ if p.is_file():
263
+ return [p] if p.suffix == ".py" else []
264
+ if p.is_dir():
265
+ return sorted(f for f in p.rglob("*.py")
266
+ if "__pycache__" not in str(f)
267
+ and ".venv" not in str(f))
268
+ return []
269
+
270
+
271
+ def counts(findings):
272
+ out = {"high": 0, "medium": 0, "low": 0}
273
+ for f in findings:
274
+ out[f["severity"]] += 1
275
+ return out
276
+
277
+
278
+ # ---------------------------------------------------------------- fixing
279
+
280
+ FIXABLE_RULES = ("CG007",)
281
+
282
+
283
+ def apply_fixes(source, findings):
284
+ """Deterministically rewrite what has a defensible rewrite.
285
+
286
+ CG007 only: a line that is exactly a bare `except:` (plus optional
287
+ trailing comment) becomes `except Exception:` with the same
288
+ indentation. Line count never changes. Returns (new_source, n).
289
+ """
290
+ lines = source.splitlines(keepends=True)
291
+ applied = 0
292
+ for f in findings:
293
+ if f["rule"] not in FIXABLE_RULES:
294
+ continue
295
+ idx = f["line"] - 1
296
+ if not (0 <= idx < len(lines)):
297
+ continue
298
+ raw = lines[idx]
299
+ body = raw.rstrip("\r\n")
300
+ ending = raw[len(body):]
301
+ m = _BARE_EXCEPT.match(body)
302
+ if not m:
303
+ continue
304
+ comment = f" {m.group(2)}" if m.group(2) else ""
305
+ lines[idx] = f"{m.group(1)}except Exception:{comment}{ending}"
306
+ applied += 1
307
+ return "".join(lines), applied
@@ -0,0 +1 @@
1
+ """Shared helpers (keep minimal)."""
@@ -0,0 +1 @@
1
+ """debugwiz — part of Wizard Central Toolkit."""
@@ -0,0 +1,360 @@
1
+ """debugwiz — the diagnosis oracle. Ported from wizard_central's debug-wizard (with the locate-dispatch fix applied)."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .debug_wizard import (
6
+ DEBUG_LAW,
7
+ add_ignore,
8
+ audit_dir,
9
+ classify_file,
10
+ count_dir,
11
+ hotspot,
12
+ imports,
13
+ load_ignored,
14
+ locate,
15
+ names,
16
+ report_json,
17
+ signatures,
18
+ stack_digest,
19
+ stats_fleet,
20
+ suggest_fix,
21
+ timeline,
22
+ trace,
23
+ )
24
+
25
+ USAGE = """Debug Wizard — the diagnosis oracle
26
+ alias: debugwiz
27
+ usage: debug_wizard law print the DEBUG LAW alone
28
+ debug_wizard trace <file> traceback autopsy
29
+ debug_wizard locate <dir> <pattern> find files with error pattern
30
+ debug_wizard classify <file> error type breakdown
31
+ debug_wizard stack [file] stack digest of a traceback (file or stdin)
32
+ debug_wizard count <dir> errors by category
33
+ debug_wizard hotspot <dir> files with most errors
34
+ debug_wizard timeline <dir> error frequency by mtime
35
+ debug_wizard imports <file> missing import detection
36
+ debug_wizard names <file> undefined name detection
37
+ debug_wizard signatures <file> signature mismatch detection
38
+ debug_wizard suggest <file> heuristic fix suggestion
39
+ debug_wizard propose <file> stage fix via write_wizard
40
+ debug_wizard preview <file> show staged proposal
41
+ debug_wizard explain <file> human-readable explanation
42
+ debug_wizard audit <dir> full error census
43
+ debug_wizard report <dir> JSON report for CI/CD
44
+ debug_wizard log <dir> write error log (staged via write_wizard)
45
+ debug_wizard ignore <path> add to ignore list
46
+ debug_wizard ignored show ignored paths
47
+ debug_wizard stats error census of the current directory
48
+ debug_wizard versions law/version drift detection
49
+ exit codes: 0 ok | 1 failed | 2 out of scope"""
50
+
51
+ CMDS = ("law", "trace", "locate", "classify", "stack", "count", "hotspot",
52
+ "timeline", "imports", "names", "signatures", "suggest", "propose",
53
+ "preview", "explain", "audit", "report", "log", "ignore", "ignored",
54
+ "stats", "versions")
55
+
56
+
57
+ def _fix_target(p: Path) -> Path:
58
+ """Where a fix proposal for file p would land, if the Governor commits it."""
59
+ return Path.home() / ".debug-wizard" / f"fix_{p.stem}.txt"
60
+
61
+
62
+ def _stage_via_writewiz(target: Path, content: str):
63
+ """Stage content through writewiz, the fleet sibling in this same package.
64
+
65
+ Import lane first (writewiz ships inside this toolkit); fall back to
66
+ the resolved writewiz executable — the same two lanes the fleet
67
+ itself uses. Staging only: the commit keystroke stays the Governor's.
68
+ Returns (ok, detail).
69
+ """
70
+ try:
71
+ from wiz_central_toolkit.writewiz.write_wizard import propose as _propose
72
+ draft, _meta, t = _propose(str(target), content)
73
+ return True, f"{draft.name} -> {t} ({len(content)} chars quarantined)"
74
+ except ImportError:
75
+ pass
76
+ import shutil
77
+ import subprocess
78
+ exe = shutil.which("write_wizard") or shutil.which("writewiz")
79
+ if exe is None:
80
+ for home_name in ("write_wizard", "writewiz"):
81
+ home_exe = Path.home() / "bin" / home_name
82
+ if home_exe.exists():
83
+ exe = str(home_exe)
84
+ break
85
+ if exe is None:
86
+ return False, "write_wizard not found (package import or PATH)"
87
+ try:
88
+ r = subprocess.run([exe, "propose", str(target)], input=content,
89
+ capture_output=True, text=True, timeout=60)
90
+ except OSError as e:
91
+ return False, f"write_wizard unreachable: {e}"
92
+ if r.returncode != 0:
93
+ return False, (r.stdout + r.stderr).strip() or "write_wizard propose failed"
94
+ return True, (r.stdout.strip() or f"staged -> {target}")
95
+
96
+
97
+ def _preview_via_writewiz(target: Path):
98
+ """(report_lines, hard_fail) for the draft staged for target, or None."""
99
+ try:
100
+ from wiz_central_toolkit.writewiz import write_wizard as _ww
101
+ draft, _meta = _ww._draft_path(Path(target))
102
+ if not draft.exists():
103
+ return None
104
+ return _ww.gate_and_diff(draft, Path(target))
105
+ except ImportError:
106
+ pass
107
+ import shutil
108
+ import subprocess
109
+ exe = shutil.which("write_wizard") or shutil.which("writewiz")
110
+ if exe is None:
111
+ return None
112
+ try:
113
+ r = subprocess.run([exe, "preview", str(target)],
114
+ capture_output=True, text=True, timeout=60)
115
+ except OSError:
116
+ return None
117
+ out = (r.stdout + r.stderr).strip()
118
+ if "no quarantined draft" in out:
119
+ return None
120
+ return out.splitlines(), r.returncode != 0
121
+
122
+
123
+ def main(argv=None) -> int:
124
+ try:
125
+ return _dispatch(argv)
126
+ except Exception as e:
127
+ print(f"[FAIL] {type(e).__name__}: {e}")
128
+ return 1
129
+
130
+
131
+ def _dispatch(argv=None):
132
+ args = list(sys.argv[1:] if argv is None else argv)
133
+ if args and args[0] in ("-h", "--help", "help"):
134
+ print(USAGE)
135
+ return 0
136
+ if not args:
137
+ print(USAGE)
138
+ return 0
139
+ if args[0] not in CMDS:
140
+ print(USAGE)
141
+ return 2
142
+
143
+ cmd = args[0]
144
+
145
+ if cmd == "law":
146
+ print(DEBUG_LAW)
147
+ return 0
148
+ if cmd == "versions":
149
+ import hashlib
150
+ print("debug_law_sha256:", hashlib.sha256(DEBUG_LAW.encode()).hexdigest())
151
+ return 0
152
+ if cmd == "ignored":
153
+ ign = load_ignored()
154
+ if not ign:
155
+ print("(nothing ignored)")
156
+ return 0
157
+ for p in ign:
158
+ print(f"[IGNORED] {p}")
159
+ return 0
160
+ if cmd == "ignore":
161
+ if len(args) < 2:
162
+ return 2
163
+ r = add_ignore(args[1])
164
+ if r["ok"]:
165
+ print(f"[OK] ignored: {r['ignored']}")
166
+ return 0
167
+ print(f"[FAIL] {r['error']}")
168
+ return 1
169
+ if cmd == "stats":
170
+ for path, r in stats_fleet().items():
171
+ healthy = r.get("healthy", "?")
172
+ checked = r.get("files_checked", "?")
173
+ pct = r.get("health_pct", "?")
174
+ print(f"{path}: {healthy}/{checked} healthy ({pct}%)")
175
+ return 0
176
+
177
+ # --- locate: two args (dir, pattern) ---
178
+ if cmd == "locate":
179
+ if len(args) < 3:
180
+ print("[FAIL] locate requires a directory and a pattern")
181
+ return 2
182
+ d = Path(args[1]).expanduser()
183
+ pattern = args[2]
184
+ hits = locate(d, pattern)
185
+ if not hits:
186
+ print(f"(no files matching {pattern!r})")
187
+ return 0
188
+ for f in hits:
189
+ print(f"[MATCH] {f}")
190
+ return 1
191
+
192
+ # --- stack: a traceback digest; file argument or stdin ---
193
+ if cmd == "stack":
194
+ if len(args) >= 2 and args[1] != "-":
195
+ src = Path(args[1]).expanduser()
196
+ try:
197
+ text = src.read_text(encoding="utf-8", errors="replace")
198
+ except OSError as e:
199
+ print(f"[FAIL] cannot read {src}: {e}")
200
+ return 1
201
+ label = str(src)
202
+ else:
203
+ text = sys.stdin.read()
204
+ label = "stdin"
205
+ d = stack_digest(text)
206
+ if d is None:
207
+ print(f"[FAIL] no traceback found in {label}")
208
+ return 1
209
+ print(f"[OK] stack digest — {label}")
210
+ exc = d["type"] + (f": {d['message']}" if d["message"] else "")
211
+ print(f" exception: {exc}")
212
+ print(f" frames: {len(d['frames'])}")
213
+ for fr in d["frames"]:
214
+ print(f" File \"{fr['file']}\", line {fr['line']}, "
215
+ f"in {fr['func']} ({fr['home']})")
216
+ deep = d["deepest"]
217
+ print(f" deepest frame: File \"{deep['file']}\", "
218
+ f"line {deep['line']}, in {deep['func']}")
219
+ dp = d["deepest_project"]
220
+ if dp is not None:
221
+ print(f" deepest project frame: File \"{dp['file']}\", "
222
+ f"line {dp['line']}, in {dp['func']}")
223
+ else:
224
+ print(" deepest project frame: none — every frame is library code")
225
+ for (ff, ln, fn), c in d["repeated"]:
226
+ print(f" [RECURSION?] frame repeats {c} times: "
227
+ f"File \"{ff}\", line {ln}, in {fn}")
228
+ return 0
229
+
230
+ # --- path-taking commands ---
231
+ if len(args) < 2:
232
+ print(f"[FAIL] {cmd} requires a path")
233
+ return 2
234
+ p = Path(args[1]).expanduser()
235
+
236
+ if cmd == "trace":
237
+ r = trace(p)
238
+ if r["ok"]:
239
+ print(f"[OK] {p}: no errors")
240
+ return 0
241
+ tb = r.get("traceback")
242
+ if tb:
243
+ print(f"[TRACE] {p}: {len(tb['frames'])} frame(s)")
244
+ for fr in tb["frames"]:
245
+ print(f" File \"{fr['file']}\", line {fr['line']}, in {fr['func']}")
246
+ for e in r["errors"]:
247
+ print(f"[ERR] line {e['line']}: {e['type']} — {e['message']}")
248
+ return 1
249
+ if cmd == "classify":
250
+ r = classify_file(p)
251
+ print(f"[SUMMARY] {r['total']} errors")
252
+ for t, c in r["classification"].items():
253
+ print(f" {t}: {c}")
254
+ return 1 if r["total"] else 0
255
+ if cmd == "count":
256
+ r = count_dir(p)
257
+ print(f"files={r['files_checked']} total_errors={r['totals']}")
258
+ return 0
259
+ if cmd == "hotspot":
260
+ hits = hotspot(p)
261
+ if not hits:
262
+ print("(all files healthy)")
263
+ return 0
264
+ for f, cnt in hits:
265
+ print(f"[HOT] {cnt} errors {f}")
266
+ return 1 if hits else 0
267
+ if cmd == "timeline":
268
+ data = timeline(p)
269
+ for entry in data[:10]:
270
+ print(f"{entry['errors']} errors {entry['mtime']} {entry['file']}")
271
+ return 0
272
+ if cmd == "imports":
273
+ r = imports(p)
274
+ print(f"[IMPORTS] {p}")
275
+ if not r["likely_missing"]:
276
+ print("(no likely missing imports)")
277
+ return 0
278
+ for m in r["likely_missing"]:
279
+ print(f" ? {m}")
280
+ return 1 if r["likely_missing"] else 0
281
+ if cmd == "names":
282
+ r = names(p)
283
+ if not r:
284
+ print("(no undefined names)")
285
+ return 0
286
+ for n in r:
287
+ print(f" ? {n}")
288
+ return 1
289
+ if cmd == "signatures":
290
+ iss = signatures(p)
291
+ if not iss:
292
+ print("(no signature issues)")
293
+ return 0
294
+ for i in iss:
295
+ print(f"[WARN] line {i['line']}: {i['message']}")
296
+ return 1
297
+ if cmd == "suggest":
298
+ print(suggest_fix(p))
299
+ return 0
300
+ if cmd == "explain":
301
+ print(suggest_fix(p))
302
+ return 0
303
+ if cmd == "audit":
304
+ r = audit_dir(p)
305
+ print(f"[HEALTH] {r['healthy']}/{r['files_checked']} ({r['health_pct']}%)")
306
+ if r["unhealthy"]:
307
+ print(f"[FAIL] {r['unhealthy']} files with errors")
308
+ return 1
309
+ print("[OK] all files healthy")
310
+ return 0
311
+ if cmd == "report":
312
+ print(report_json(str(p)))
313
+ return 0
314
+ if cmd == "log":
315
+ r = audit_dir(p)
316
+ lines = [f"debug_wizard error log — {p}",
317
+ f"files checked: {r['files_checked']}",
318
+ f"healthy: {r['healthy']} unhealthy: {r['unhealthy']} "
319
+ f"total errors: {r['total_errors']}"]
320
+ for f, cnt in hotspot(p):
321
+ lines.append(f"[HOT] {cnt} errors {f}")
322
+ for e in trace(Path(f))["errors"]:
323
+ lines.append(f" line {e['line']}: {e['type']} — {e['message']}")
324
+ target = (p / "debug-error-log.txt") if p.is_dir() \
325
+ else p.with_name(p.stem + "-error-log.txt")
326
+ ok, detail = _stage_via_writewiz(target, "\n".join(lines) + "\n")
327
+ if not ok:
328
+ print(f"[FAIL] 'log' could not stage via write_wizard: {detail}")
329
+ return 1
330
+ print(f"[STAGED] error log for {p} — {detail}")
331
+ print(f"to write it: write_wizard commit {target} # your keystroke, Governor")
332
+ return 0
333
+ if cmd == "propose":
334
+ content = suggest_fix(p)
335
+ if not content.endswith("\n"):
336
+ content += "\n"
337
+ target = _fix_target(p)
338
+ ok, detail = _stage_via_writewiz(target, content)
339
+ if not ok:
340
+ print(f"[FAIL] propose could not stage via write_wizard: {detail}")
341
+ return 1
342
+ print(f"[STAGED] fix proposal for {p} — {detail}")
343
+ print(f"preview it: debug_wizard preview {p}")
344
+ return 0
345
+ if cmd == "preview":
346
+ got = _preview_via_writewiz(_fix_target(p))
347
+ if got is None:
348
+ print("(no staged proposal — use debugwiz propose first)")
349
+ return 0
350
+ report, hard_fail = got
351
+ for line in report:
352
+ print(line)
353
+ return 1 if hard_fail else 0
354
+
355
+ print("out of scope")
356
+ return 2
357
+
358
+
359
+ if __name__ == "__main__":
360
+ sys.exit(main())