deploy-guard-engine 0.1.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.
@@ -0,0 +1,504 @@
1
+ """Turn a parsed traceback into an explanation, using the project's analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from deploy_guard.analysis.findings import Finding, analyze_function
11
+ from deploy_guard.analysis.nullability import (
12
+ NullabilityAnalysis,
13
+ _witness_conditions,
14
+ )
15
+ from deploy_guard.analysis.paths import behavior_spec
16
+ from deploy_guard.explain.traceback_parse import Frame, ParsedTraceback
17
+ from deploy_guard.frontend import build_cfg
18
+ from deploy_guard.ir.cfg import CFG, Terminator
19
+ from deploy_guard.ir.model import FunctionDef, Module, Project
20
+
21
+
22
+ @dataclass
23
+ class CrashSite:
24
+ module: Module
25
+ fn: FunctionDef | None
26
+ lineno: int
27
+ source: str
28
+ cfg: object = None # CFG, set during analysis
29
+ ctx: object = None # AnalysisContext, set during analysis
30
+
31
+
32
+ @dataclass
33
+ class Explanation:
34
+ exc_type: str
35
+ exc_message: str
36
+ site: CrashSite | None = None
37
+ why: list[str] = field(default_factory=list)
38
+ witness: list[str] = field(default_factory=list)
39
+ call_chain: list[str] = field(default_factory=list)
40
+ linked_finding: Finding | None = None
41
+ fix_hint: str | None = None
42
+ notes: list[str] = field(default_factory=list)
43
+ none_var: str | None = None # the variable found to be None, if any
44
+
45
+
46
+ def explain_traceback(tb: ParsedTraceback, project: Project, ctx=None) -> Explanation:
47
+ exp = Explanation(exc_type=tb.exc_type, exc_message=tb.exc_message)
48
+
49
+ if not tb.ok:
50
+ exp.notes.append(
51
+ "Could not find an exception line. Paste the whole traceback, "
52
+ "including the final `SomeError: message` line."
53
+ )
54
+ return exp
55
+
56
+ if ctx is None:
57
+ try:
58
+ from deploy_guard.engine import build_context
59
+
60
+ ctx = build_context(project)
61
+ except Exception: # pragma: no cover
62
+ ctx = None
63
+
64
+ resolved = [_resolve_frame(f, project) for f in tb.frames]
65
+ exp.call_chain = _format_chain(resolved)
66
+
67
+ site = None
68
+ for frame, module, fn in reversed(resolved):
69
+ if module is not None:
70
+ site = CrashSite(module=module, fn=fn, lineno=frame.lineno, source=frame.source)
71
+ break
72
+ exp.site = site
73
+
74
+ if site is None:
75
+ exp.notes.append(
76
+ "No frame in the traceback points into this project. Check --project, "
77
+ "or the crash is entirely inside a third-party library."
78
+ )
79
+ return exp
80
+
81
+ if site.fn is None or not isinstance(
82
+ site.fn.raw, (ast.FunctionDef, ast.AsyncFunctionDef)
83
+ ):
84
+ exp.notes.append(
85
+ f"Line {site.lineno} is not inside a top-level function the engine "
86
+ "models (module scope, or a function nested in another). Explanation "
87
+ "is limited to the traceback structure."
88
+ )
89
+ return exp
90
+
91
+ cfg = build_cfg(site.fn)
92
+ bid = _block_for_line(cfg, site.lineno)
93
+ if bid is not None:
94
+ exp.witness = _witness_conditions(cfg, bid)
95
+
96
+ site.cfg = cfg
97
+ site.ctx = ctx
98
+ handler = _HANDLERS.get(tb.exc_short, _explain_generic)
99
+ handler(exp, tb, site, cfg)
100
+
101
+ # Trace a None that came in as a parameter back to the caller frame.
102
+ _trace_none_across_frames(exp, tb, resolved, ctx)
103
+
104
+ # Link to a full-scan finding at the same spot.
105
+ try:
106
+ spec = behavior_spec(site.fn, cfg)
107
+ for fnd in analyze_function(site.fn, cfg, spec, ctx):
108
+ if abs(fnd.lineno - site.lineno) <= 1:
109
+ exp.linked_finding = fnd
110
+ break
111
+ except Exception: # pragma: no cover - never let linking break explain
112
+ pass
113
+
114
+ return exp
115
+
116
+
117
+ def _trace_none_across_frames(exp: Explanation, tb, resolved, ctx) -> None:
118
+ """If the crash variable is a parameter, name the caller frame's call site
119
+ and which argument it passed."""
120
+ site = exp.site
121
+ if site is None or site.fn is None:
122
+ return
123
+ var = exp.none_var
124
+ if var is None:
125
+ return
126
+ param_names = [p.name for p in site.fn.params]
127
+ if var not in param_names:
128
+ return
129
+ param_index = param_names.index(var)
130
+
131
+ # the frame directly above the crash frame in the traceback
132
+ project_frames = [(f, m, fn) for (f, m, fn) in resolved if m is not None]
133
+ if len(project_frames) < 2:
134
+ exp.why.append(
135
+ f"`{var}` is a parameter - it was already None when this function "
136
+ "was called. Check the call site one frame up."
137
+ )
138
+ return
139
+ caller_frame, _cm, caller_fn = project_frames[-2]
140
+ if caller_fn is None or ctx is None:
141
+ return
142
+ for site_call in ctx.callgraph.callees_of(caller_fn.qualname):
143
+ if site_call.callee_qualname != site.fn.qualname:
144
+ continue
145
+ call = site_call.node
146
+ arg_src = None
147
+ if param_index < len(call.args):
148
+ try:
149
+ arg_src = ast.unparse(call.args[param_index])
150
+ except Exception:
151
+ arg_src = None
152
+ else:
153
+ for kw in call.keywords:
154
+ if kw.arg == var:
155
+ try:
156
+ arg_src = ast.unparse(kw.value)
157
+ except Exception:
158
+ pass
159
+ loc = f"{caller_fn.qualname.rsplit('.', 1)[-1]}:{site_call.lineno}"
160
+ if arg_src:
161
+ exp.why.append(
162
+ f"`{var}` is a parameter; {loc} passes `{arg_src}` for it - "
163
+ "that expression is what can be None."
164
+ )
165
+ else:
166
+ exp.why.append(f"`{var}` is a parameter passed by {loc}.")
167
+ return
168
+
169
+
170
+ # --------------------------------------------------------------------------
171
+ # frame resolution
172
+ # --------------------------------------------------------------------------
173
+
174
+ def _resolve_frame(
175
+ frame: Frame, project: Project
176
+ ) -> tuple[Frame, Module | None, FunctionDef | None]:
177
+ fpath = Path(frame.file)
178
+ for m in project.modules:
179
+ try:
180
+ if m.path == fpath or m.path.resolve() == fpath.resolve():
181
+ return frame, m, _fn_at(m, frame.lineno)
182
+ except OSError:
183
+ pass
184
+
185
+ best: Module | None = None
186
+ best_score = 0
187
+ fparts = fpath.parts
188
+ for m in project.modules:
189
+ score = _common_suffix_len(fparts, m.path.parts)
190
+ if score > best_score:
191
+ best_score, best = score, m
192
+ if best is not None and best_score >= 1:
193
+ return frame, best, _fn_at(best, frame.lineno)
194
+ return frame, None, None
195
+
196
+
197
+ def _common_suffix_len(a: tuple[str, ...], b: tuple[str, ...]) -> int:
198
+ n = 0
199
+ for x, y in zip(reversed(a), reversed(b)):
200
+ if x != y:
201
+ break
202
+ n += 1
203
+ return n
204
+
205
+
206
+ def _fn_at(module: Module, lineno: int) -> FunctionDef | None:
207
+ candidates = [
208
+ fn
209
+ for fn in module.iter_functions()
210
+ if fn.span.lineno <= lineno <= max(fn.span.end_lineno, fn.span.lineno)
211
+ ]
212
+ if not candidates:
213
+ return None
214
+ return max(candidates, key=lambda fn: fn.span.lineno) # innermost
215
+
216
+
217
+ # --------------------------------------------------------------------------
218
+ # CFG helpers
219
+ # --------------------------------------------------------------------------
220
+
221
+ def _block_for_line(cfg: CFG, lineno: int) -> int | None:
222
+ exact = [
223
+ b.id
224
+ for b in cfg.iter_blocks()
225
+ if any(s.lineno == lineno for s in b.statements)
226
+ ]
227
+ if exact:
228
+ return exact[0]
229
+ # nearest statement at or before the line
230
+ best_id, best_line = None, -1
231
+ for b in cfg.iter_blocks():
232
+ for s in b.statements:
233
+ if s.lineno <= lineno and s.lineno > best_line:
234
+ best_id, best_line = b.id, s.lineno
235
+ return best_id
236
+
237
+
238
+ def _statements_on_line(cfg: CFG, lineno: int) -> list[ast.AST]:
239
+ return [
240
+ s.node
241
+ for b in cfg.iter_blocks()
242
+ for s in b.statements
243
+ if s.lineno == lineno
244
+ ]
245
+
246
+
247
+ def _names_subscripted_or_attr_on_line(cfg: CFG, lineno: int) -> list[str]:
248
+ out: list[str] = []
249
+ for node in _statements_on_line(cfg, lineno):
250
+ for sub in ast.walk(node):
251
+ if isinstance(sub, (ast.Attribute, ast.Subscript)) and isinstance(
252
+ sub.value, ast.Name
253
+ ):
254
+ if sub.value.id not in out:
255
+ out.append(sub.value.id)
256
+ return out
257
+
258
+
259
+ # --------------------------------------------------------------------------
260
+ # per-exception explanations
261
+ # --------------------------------------------------------------------------
262
+
263
+ def _explain_none(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
264
+ attr = None
265
+ m = re.search(r"has no attribute '([^']+)'", tb.exc_message)
266
+ if m:
267
+ attr = m.group(1)
268
+
269
+ hits = [
270
+ nf
271
+ for nf in NullabilityAnalysis(site.fn, cfg, site.ctx).find_none_derefs()
272
+ if abs(nf.lineno - site.lineno) <= 1
273
+ ]
274
+ if hits:
275
+ nf = hits[0]
276
+ exp.none_var = nf.var
277
+ exp.why.append(
278
+ f"`{nf.var}` can be None at line {site.lineno} - {nf.reason}."
279
+ )
280
+ if nf.witness:
281
+ exp.why.append("It reaches this line when: " + " and ".join(nf.witness))
282
+
283
+ if _none_check_is_after_deref(cfg, site.lineno, nf.var):
284
+ exp.why.append(
285
+ f"This line *does* check `{nf.var} is None`, but only after "
286
+ f"dereferencing it - `and`/`or` evaluate left to right, so the "
287
+ f"attribute access runs first and raises before the check."
288
+ )
289
+ exp.fix_hint = (
290
+ f"reorder the check so `{nf.var} is None` comes first: "
291
+ f"`if {nf.var} is None or {nf.var}...:`"
292
+ )
293
+ else:
294
+ exp.fix_hint = (
295
+ f"guard it: `if {nf.var} is not None:` before line {site.lineno}, "
296
+ f"or give it a safe default."
297
+ )
298
+ else:
299
+ cands = _names_subscripted_or_attr_on_line(cfg, site.lineno)
300
+ if cands:
301
+ exp.none_var = cands[0]
302
+ target = (
303
+ f"`{cands[0]}`" if cands else ("the value" + (f".{attr}" if attr else ""))
304
+ )
305
+ exp.why.append(
306
+ f"{target} is None at this line. The engine could not prove where the "
307
+ "None came from - it most likely is the return value of a call it "
308
+ "cannot see through (a library, or a function that returns None on "
309
+ "some path)."
310
+ )
311
+ exp.why.append(
312
+ "Check what feeds it: arguments passed in, and the result of the last "
313
+ "assignment before this line."
314
+ )
315
+ if cands:
316
+ exp.fix_hint = f"guard `{cands[0]}` before it is used on line {site.lineno}."
317
+
318
+
319
+ def _explain_key(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
320
+ key = tb.exc_message.strip().strip("'\"")
321
+ subs = []
322
+ for node in _statements_on_line(cfg, site.lineno):
323
+ for sub in ast.walk(node):
324
+ if isinstance(sub, ast.Subscript):
325
+ try:
326
+ subs.append(ast.unparse(sub))
327
+ except Exception:
328
+ pass
329
+ where = f" ({', '.join(subs)})" if subs else ""
330
+ exp.why.append(
331
+ f"A dictionary/mapping is indexed with a key that is not present: "
332
+ f"`{key}`{where}."
333
+ )
334
+ guarded = _line_has_guard_for_key(cfg, key)
335
+ if guarded:
336
+ exp.why.append(
337
+ f"Other paths in this function do check for `{key}` first "
338
+ f"({guarded}) - this path does not."
339
+ )
340
+ exp.fix_hint = (
341
+ f"use `.get({key!r})` and handle None, or check `{key!r} in <mapping>` first."
342
+ )
343
+
344
+
345
+ def _explain_index(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
346
+ exp.why.append(
347
+ "A sequence is indexed past its length on this path - the code does not "
348
+ "check that the item exists before subscripting it "
349
+ "(e.g. `re.search(...).group(1)`, `parts[1]`, `rows[0]`)."
350
+ )
351
+ exp.fix_hint = "check the length / truthiness of the sequence before indexing."
352
+
353
+
354
+ def _explain_zerodiv(
355
+ exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
356
+ ) -> None:
357
+ divisors = []
358
+ for node in _statements_on_line(cfg, site.lineno):
359
+ for sub in ast.walk(node):
360
+ if isinstance(sub, ast.BinOp) and isinstance(
361
+ sub.op, (ast.Div, ast.FloorDiv, ast.Mod)
362
+ ):
363
+ try:
364
+ divisors.append(ast.unparse(sub.right))
365
+ except Exception:
366
+ pass
367
+ where = f" - divisor: `{'`, `'.join(divisors)}`" if divisors else ""
368
+ exp.why.append(f"A division/modulo on this line has a zero right-hand side{where}.")
369
+ exp.fix_hint = "guard the divisor: `x / d if d else <fallback>`."
370
+
371
+
372
+ def _explain_unbound(
373
+ exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
374
+ ) -> None:
375
+ m = re.search(r"variable '([^']+)'", tb.exc_message) or re.search(
376
+ r"name '([^']+)'", tb.exc_message
377
+ )
378
+ var = m.group(1) if m else "the variable"
379
+ exp.why.append(
380
+ f"`{var}` is only assigned on some branches of this function, and this "
381
+ "path reaches its use without assigning it."
382
+ )
383
+ if exp.witness:
384
+ exp.why.append("The path that skips the assignment: " + " and ".join(exp.witness))
385
+ exp.fix_hint = f"initialise `{var}` once at the top of the function."
386
+
387
+
388
+ def _explain_value(
389
+ exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
390
+ ) -> None:
391
+ if "invalid literal for int()" in tb.exc_message or "could not convert" in tb.exc_message:
392
+ conv = []
393
+ for node in _statements_on_line(cfg, site.lineno):
394
+ for sub in ast.walk(node):
395
+ if (
396
+ isinstance(sub, ast.Call)
397
+ and isinstance(sub.func, ast.Name)
398
+ and sub.func.id in ("int", "float")
399
+ ):
400
+ try:
401
+ conv.append(ast.unparse(sub))
402
+ except Exception:
403
+ pass
404
+ where = f" ({', '.join(conv)})" if conv else ""
405
+ exp.why.append(
406
+ f"A numeric conversion{where} is given a string the code never "
407
+ "validates - it can contain non-numeric characters."
408
+ )
409
+ exp.fix_hint = "validate with a regex / try-except before converting."
410
+ else:
411
+ _explain_generic(exp, tb, site, cfg)
412
+
413
+
414
+ def _explain_generic(
415
+ exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
416
+ ) -> None:
417
+ raises = []
418
+ for b in cfg.iter_blocks():
419
+ if b.terminator == Terminator.RAISE and b.term_node is not None:
420
+ try:
421
+ raises.append((b.first_line, ast.unparse(b.term_node)))
422
+ except Exception:
423
+ pass
424
+ if raises:
425
+ exp.why.append(
426
+ "This function raises explicitly at: "
427
+ + "; ".join(f"line {ln}: {src}" for ln, src in sorted(raises) if ln)
428
+ )
429
+ exp.why.append(
430
+ f"No dedicated explainer for {tb.exc_type} yet - use the call chain above "
431
+ "and the branch conditions to trace which inputs reach line "
432
+ f"{site.lineno}."
433
+ )
434
+
435
+
436
+ def _none_check_is_after_deref(cfg: CFG, lineno: int, var: str) -> bool:
437
+ """True if a statement on ``lineno`` is a BoolOp where an operand
438
+ dereferences ``var`` and a *later* operand tests ``var is [not] None``."""
439
+ for node in _statements_on_line(cfg, lineno):
440
+ for boolop in ast.walk(node):
441
+ if not isinstance(boolop, ast.BoolOp):
442
+ continue
443
+ saw_deref = False
444
+ for operand in boolop.values:
445
+ derefs_here = any(
446
+ isinstance(s, (ast.Attribute, ast.Subscript))
447
+ and isinstance(s.value, ast.Name)
448
+ and s.value.id == var
449
+ for s in ast.walk(operand)
450
+ )
451
+ is_none_test = any(
452
+ isinstance(s, ast.Compare)
453
+ and isinstance(s.left, ast.Name)
454
+ and s.left.id == var
455
+ and any(isinstance(o, (ast.Is, ast.IsNot)) for o in s.ops)
456
+ for s in ast.walk(operand)
457
+ )
458
+ if derefs_here:
459
+ saw_deref = True
460
+ elif is_none_test and saw_deref:
461
+ return True
462
+ return False
463
+
464
+
465
+ def _line_has_guard_for_key(cfg: CFG, key: str) -> str | None:
466
+ for b in cfg.iter_blocks():
467
+ for e in b.succ:
468
+ if not e.guard_src:
469
+ continue
470
+ if f"'{key}'" in e.guard_src or f'"{key}"' in e.guard_src:
471
+ if " in " in e.guard_src or ".get(" in e.guard_src:
472
+ return e.guard_src
473
+ return None
474
+
475
+
476
+ _HANDLERS = {
477
+ "AttributeError": _explain_none,
478
+ "TypeError": _explain_none, # 'NoneType' object is not subscriptable/callable/iterable
479
+ "KeyError": _explain_key,
480
+ "IndexError": _explain_index,
481
+ "ZeroDivisionError": _explain_zerodiv,
482
+ "UnboundLocalError": _explain_unbound,
483
+ "NameError": _explain_unbound,
484
+ "ValueError": _explain_value,
485
+ }
486
+
487
+
488
+ # --------------------------------------------------------------------------
489
+ # call-chain formatting
490
+ # --------------------------------------------------------------------------
491
+
492
+ def _format_chain(
493
+ resolved: list[tuple[Frame, Module | None, FunctionDef | None]]
494
+ ) -> list[str]:
495
+ out: list[str] = []
496
+ for frame, module, _fn in resolved:
497
+ loc = (
498
+ f"{module.dotted_name}"
499
+ if module is not None
500
+ else Path(frame.file).name
501
+ )
502
+ marker = "" if module is not None else " (not in project)"
503
+ out.append(f"{loc}:{frame.lineno} {frame.func}{marker}")
504
+ return out
@@ -0,0 +1,60 @@
1
+ """Render an :class:`Explanation` as terminal text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from deploy_guard.explain.explainer import Explanation
6
+
7
+
8
+ def render_explanation(exp: Explanation) -> str:
9
+ L: list[str] = []
10
+ exc = exp.exc_type + (f": {exp.exc_message}" if exp.exc_message else "")
11
+ L.append(f"Explaining: {exc}")
12
+ L.append("")
13
+
14
+ if exp.site is not None:
15
+ s = exp.site
16
+ where = s.fn.qualname if s.fn is not None else f"{s.module.dotted_name} (module scope)"
17
+ L.append(f"Crash site: {s.module.path}:{s.lineno}")
18
+ L.append(f" in {where}")
19
+ if s.source:
20
+ L.append(f" {s.lineno} | {s.source}")
21
+ L.append("")
22
+
23
+ if exp.why:
24
+ L.append("Why:")
25
+ for line in exp.why:
26
+ L.append(f" - {line}")
27
+ L.append("")
28
+
29
+ if exp.witness:
30
+ L.append("Reached when:")
31
+ L.append(" " + " and ".join(exp.witness))
32
+ L.append("")
33
+
34
+ if exp.call_chain:
35
+ L.append("Call chain (from the traceback):")
36
+ for i, frame in enumerate(exp.call_chain):
37
+ prefix = " " + (" " * i) + ("└─ " if i else "")
38
+ tail = " <- raised here" if i == len(exp.call_chain) - 1 else ""
39
+ L.append(f"{prefix}{frame}{tail}")
40
+ L.append("")
41
+
42
+ if exp.linked_finding:
43
+ f = exp.linked_finding
44
+ L.append(
45
+ f"Matches scan finding: {f.rule} ({f.severity}) at "
46
+ f"{f.file}:{f.lineno}"
47
+ )
48
+ L.append("")
49
+
50
+ if exp.fix_hint:
51
+ L.append(f"Fix: {exp.fix_hint}")
52
+ L.append("")
53
+
54
+ if exp.notes:
55
+ L.append("Notes:")
56
+ for n in exp.notes:
57
+ L.append(f" - {n}")
58
+ L.append("")
59
+
60
+ return "\n".join(L).rstrip() + "\n"
@@ -0,0 +1,89 @@
1
+ """Parse a pasted CPython traceback into frames + the exception that escaped."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ _FRAME_RE = re.compile(
9
+ r'^\s*File "(?P<file>.+?)", line (?P<line>\d+), in (?P<func>.+?)\s*$'
10
+ )
11
+ # ``ExceptionType`` or ``pkg.mod.ExceptionType: message``
12
+ _EXC_RE = re.compile(r"^(?P<type>[A-Za-z_][\w.]*)(?::[ ]?(?P<msg>.*))?$")
13
+
14
+ _CHAIN_SEPARATORS = (
15
+ "During handling of the above exception, another exception occurred:",
16
+ "The above exception was the direct cause of the following exception:",
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class Frame:
22
+ file: str
23
+ lineno: int
24
+ func: str
25
+ source: str = "" # the source line CPython printed under the frame, if any
26
+
27
+
28
+ @dataclass
29
+ class ParsedTraceback:
30
+ frames: list[Frame] = field(default_factory=list)
31
+ exc_type: str = "" # possibly dotted, as printed
32
+ exc_message: str = ""
33
+ raw: str = ""
34
+
35
+ @property
36
+ def exc_short(self) -> str:
37
+ return self.exc_type.rsplit(".", 1)[-1]
38
+
39
+ @property
40
+ def ok(self) -> bool:
41
+ return bool(self.exc_type)
42
+
43
+
44
+ def parse_traceback(text: str) -> ParsedTraceback:
45
+ # A chained traceback prints several blocks; the last one is what escaped.
46
+ for sep in _CHAIN_SEPARATORS:
47
+ if sep in text:
48
+ text = text.split(sep)[-1]
49
+
50
+ lines = text.splitlines()
51
+ frames: list[Frame] = []
52
+
53
+ i, n = 0, len(lines)
54
+ while i < n:
55
+ m = _FRAME_RE.match(lines[i])
56
+ if m:
57
+ source = ""
58
+ nxt = lines[i + 1] if i + 1 < n else ""
59
+ if (
60
+ nxt.strip()
61
+ and not _FRAME_RE.match(nxt)
62
+ and (nxt.startswith(" ") or nxt.startswith("\t"))
63
+ ):
64
+ source = nxt.strip()
65
+ i += 1
66
+ frames.append(
67
+ Frame(
68
+ file=m.group("file"),
69
+ lineno=int(m.group("line")),
70
+ func=m.group("func"),
71
+ source=source,
72
+ )
73
+ )
74
+ i += 1
75
+
76
+ exc_type, exc_message = "", ""
77
+ for ln in reversed(lines):
78
+ s = ln.rstrip()
79
+ if not s or s[0].isspace() or s.startswith("Traceback"):
80
+ continue
81
+ m = _EXC_RE.match(s)
82
+ if m:
83
+ exc_type = m.group("type")
84
+ exc_message = (m.group("msg") or "").strip()
85
+ break
86
+
87
+ return ParsedTraceback(
88
+ frames=frames, exc_type=exc_type, exc_message=exc_message, raw=text
89
+ )
@@ -0,0 +1,10 @@
1
+ """Language frontends: source -> :mod:`deploy_guard.ir`.
2
+
3
+ M1 ships the Python frontend. Java (via a bundled JavaParser fat-jar) lands
4
+ in M4 and reuses every analysis pass unchanged.
5
+ """
6
+
7
+ from deploy_guard.frontend.python_cfg import build_cfg
8
+ from deploy_guard.frontend.python_frontend import lower_python
9
+
10
+ __all__ = ["lower_python", "build_cfg"]