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,332 @@
1
+ """Build a control-flow graph from a Python function AST.
2
+
3
+ Supported structurally: sequential statements, ``if`` / ``elif`` / ``else``,
4
+ ``while`` / ``for`` (with ``else``), ``with``, ``try`` / ``except`` / ``else``
5
+ / ``finally``, ``match`` / ``case``, ``return``, ``raise``, ``break``,
6
+ ``continue``. Exceptions are modelled approximately: a ``try`` body is
7
+ assumed able to raise into any of its handlers at any point.
8
+
9
+ Anything not recognised is kept as an opaque statement in the current block,
10
+ so flow stays sound even when a construct is not modelled precisely.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+
17
+ from deploy_guard.ir.cfg import CFG, BasicBlock, Edge, EdgeKind, Statement, Terminator
18
+ from deploy_guard.ir.model import FunctionDef
19
+
20
+ _MAX_SRC = 120
21
+
22
+ _SIMPLE = (
23
+ ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Expr, ast.Pass,
24
+ ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Delete,
25
+ ast.Assert, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
26
+ )
27
+
28
+
29
+ def build_cfg(fn: FunctionDef) -> CFG:
30
+ node = fn.raw
31
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
32
+ raise TypeError(f"{fn.qualname}: no Python AST attached (raw={type(node)!r})")
33
+ return _Builder(fn.qualname).build(node.body)
34
+
35
+
36
+ def _src(node: ast.AST) -> str:
37
+ try:
38
+ text = ast.unparse(node)
39
+ except Exception: # pragma: no cover
40
+ return f"<{type(node).__name__}>"
41
+ text = " ".join(text.split())
42
+ return text if len(text) <= _MAX_SRC else text[: _MAX_SRC - 1] + "…"
43
+
44
+
45
+ class _Builder:
46
+ def __init__(self, qualname: str) -> None:
47
+ self.qualname = qualname
48
+ self.blocks: dict[int, BasicBlock] = {}
49
+ self._next = 0
50
+ self.entry_id = self._new("entry").id
51
+ self.exit_id = self._new("exit").id
52
+ self.loop_stack: list[tuple[int, int]] = [] # (header_id, exit_id)
53
+ self.exc_stack: list[list[int]] = [] # handler entry ids
54
+ self.notes: list[str] = []
55
+
56
+ # -- graph construction helpers ------------------------------------------
57
+
58
+ def _new(self, label: str = "") -> BasicBlock:
59
+ b = BasicBlock(id=self._next, label=label)
60
+ self.blocks[b.id] = b
61
+ self._next += 1
62
+ return b
63
+
64
+ def _stmt(self, block: BasicBlock, node: ast.AST, *, kind: str = "", src: str = "") -> None:
65
+ block.statements.append(
66
+ Statement(
67
+ node=node,
68
+ lineno=getattr(node, "lineno", 0) or 0,
69
+ kind=kind or type(node).__name__,
70
+ src=src or _src(node),
71
+ )
72
+ )
73
+
74
+ def _edge(
75
+ self,
76
+ src: BasicBlock,
77
+ target: int,
78
+ kind: EdgeKind = EdgeKind.SEQ,
79
+ *,
80
+ guard: ast.AST | None = None,
81
+ negated: bool = False,
82
+ ) -> None:
83
+ for e in src.succ:
84
+ if e.target == target and e.kind == kind and e.negated == negated:
85
+ return
86
+ src.succ.append(
87
+ Edge(
88
+ target=target,
89
+ kind=kind,
90
+ guard=guard,
91
+ guard_src=_src(guard) if guard is not None else None,
92
+ negated=negated,
93
+ )
94
+ )
95
+
96
+ # -- public entrypoint -------------------------------------------------
97
+
98
+ def build(self, body: list[ast.stmt]) -> CFG:
99
+ entry = self.blocks[self.entry_id]
100
+ tail = self._suite(body, entry)
101
+ if tail is not None:
102
+ tail.terminator = Terminator.IMPLICIT_RETURN
103
+ self._edge(tail, self.exit_id)
104
+ cfg = CFG(
105
+ func_qualname=self.qualname,
106
+ entry=self.entry_id,
107
+ exit=self.exit_id,
108
+ blocks=self.blocks,
109
+ notes=self.notes,
110
+ )
111
+ return cfg.finalize()
112
+
113
+ # -- suite / statement dispatch -------------------------------------------
114
+
115
+ def _suite(self, stmts: list[ast.stmt], block: BasicBlock | None) -> BasicBlock | None:
116
+ for stmt in stmts:
117
+ if block is None:
118
+ # Everything from here on is unreachable; keep it in a detached
119
+ # block so the dead-code detector can point at it.
120
+ block = self._new("unreachable")
121
+ block = self._stmt_dispatch(stmt, block)
122
+ return block
123
+
124
+ def _stmt_dispatch(self, stmt: ast.stmt, block: BasicBlock) -> BasicBlock | None:
125
+ method = getattr(self, f"_do_{type(stmt).__name__}", None)
126
+ if method is not None:
127
+ return method(stmt, block)
128
+ if isinstance(stmt, _SIMPLE):
129
+ self._stmt(block, stmt)
130
+ return block
131
+ # Unknown / unmodelled construct: keep it, stay sound.
132
+ self._stmt(block, stmt)
133
+ self.notes.append(f"unmodelled statement {type(stmt).__name__} at line {stmt.lineno}")
134
+ return block
135
+
136
+ # -- terminators -------------------------------------------------------
137
+
138
+ def _do_Return(self, stmt: ast.Return, block: BasicBlock) -> None:
139
+ self._stmt(block, stmt, kind="Return")
140
+ block.terminator = Terminator.RETURN
141
+ block.term_node = stmt
142
+ self._edge(block, self.exit_id)
143
+ return
144
+
145
+ def _do_Raise(self, stmt: ast.Raise, block: BasicBlock) -> None:
146
+ self._stmt(block, stmt, kind="Raise")
147
+ block.terminator = Terminator.RAISE
148
+ block.term_node = stmt
149
+ for handler_id in self._current_handlers():
150
+ self._edge(block, handler_id, EdgeKind.EXCEPTION)
151
+ return
152
+
153
+ def _do_Break(self, stmt: ast.Break, block: BasicBlock) -> None:
154
+ self._stmt(block, stmt, kind="Break")
155
+ block.terminator = Terminator.BREAK
156
+ if self.loop_stack:
157
+ self._edge(block, self.loop_stack[-1][1])
158
+ return
159
+
160
+ def _do_Continue(self, stmt: ast.Continue, block: BasicBlock) -> None:
161
+ self._stmt(block, stmt, kind="Continue")
162
+ block.terminator = Terminator.CONTINUE
163
+ if self.loop_stack:
164
+ self._edge(block, self.loop_stack[-1][0], EdgeKind.LOOP_BACK)
165
+ return
166
+
167
+ def _current_handlers(self) -> list[int]:
168
+ return self.exc_stack[-1] if self.exc_stack else []
169
+
170
+ # -- branches --------------------------------------------------------
171
+
172
+ def _do_If(self, stmt: ast.If, block: BasicBlock) -> BasicBlock | None:
173
+ self._stmt(block, stmt.test, kind="branch", src=f"if {_src(stmt.test)}")
174
+ join = self._new("if.join")
175
+ join_used = False
176
+
177
+ then_block = self._new("if.then")
178
+ self._edge(block, then_block.id, EdgeKind.TRUE, guard=stmt.test)
179
+ then_tail = self._suite(stmt.body, then_block)
180
+ if then_tail is not None:
181
+ self._edge(then_tail, join.id)
182
+ join_used = True
183
+
184
+ if stmt.orelse:
185
+ else_block = self._new("if.else")
186
+ self._edge(block, else_block.id, EdgeKind.FALSE, guard=stmt.test, negated=True)
187
+ else_tail = self._suite(stmt.orelse, else_block)
188
+ if else_tail is not None:
189
+ self._edge(else_tail, join.id)
190
+ join_used = True
191
+ else:
192
+ self._edge(block, join.id, EdgeKind.FALSE, guard=stmt.test, negated=True)
193
+ join_used = True
194
+
195
+ return join if join_used else None
196
+
197
+ def _do_Match(self, stmt: ast.Match, block: BasicBlock) -> BasicBlock | None:
198
+ self._stmt(block, stmt.subject, kind="match", src=f"match {_src(stmt.subject)}")
199
+ join = self._new("match.join")
200
+ join_used = False
201
+ has_catch_all = False
202
+
203
+ for case in stmt.cases:
204
+ case_block = self._new("match.case")
205
+ label = f"case {_src(case.pattern)}"
206
+ if case.guard is not None:
207
+ label += f" if {_src(case.guard)}"
208
+ self._edge(block, case_block.id, EdgeKind.MATCH, guard=case.pattern)
209
+ self._stmt(case_block, case.pattern, kind="case", src=label)
210
+ case_tail = self._suite(case.body, case_block)
211
+ if case_tail is not None:
212
+ self._edge(case_tail, join.id)
213
+ join_used = True
214
+ if (
215
+ isinstance(case.pattern, ast.MatchAs)
216
+ and case.pattern.pattern is None
217
+ and case.guard is None
218
+ ):
219
+ has_catch_all = True
220
+
221
+ if not has_catch_all:
222
+ self._edge(block, join.id)
223
+ join_used = True
224
+ return join if join_used else None
225
+
226
+ # -- loops ---------------------------------------------------------
227
+
228
+ def _do_While(self, stmt: ast.While, block: BasicBlock) -> BasicBlock | None:
229
+ return self._loop(stmt, block, header_src=f"while {_src(stmt.test)}", guard=stmt.test)
230
+
231
+ def _do_For(self, stmt: ast.For, block: BasicBlock) -> BasicBlock | None:
232
+ src = f"for {_src(stmt.target)} in {_src(stmt.iter)}"
233
+ return self._loop(stmt, block, header_src=src, guard=stmt.iter)
234
+
235
+ _do_AsyncFor = _do_For
236
+
237
+ def _loop(
238
+ self,
239
+ stmt: ast.While | ast.For | ast.AsyncFor,
240
+ block: BasicBlock,
241
+ *,
242
+ header_src: str,
243
+ guard: ast.AST,
244
+ ) -> BasicBlock | None:
245
+ header = self._new("loop.header")
246
+ self._stmt(header, guard, kind="loop", src=header_src)
247
+ self._edge(block, header.id)
248
+
249
+ exit_block = self._new("loop.exit")
250
+ body_block = self._new("loop.body")
251
+ # The loop guard is kept on the header statement for display, but it is
252
+ # not a per-path branch predicate the way an ``if`` test is, so the
253
+ # enter/exit edges carry no guard - they must not leak into path
254
+ # conditions as ``cond and not (cond)``.
255
+ self._edge(header, body_block.id, EdgeKind.TRUE)
256
+
257
+ false_target = exit_block.id
258
+ else_tail: BasicBlock | None = None
259
+ if stmt.orelse:
260
+ else_block = self._new("loop.else")
261
+ false_target = else_block.id
262
+ else_tail = self._suite(stmt.orelse, else_block)
263
+ self._edge(header, false_target, EdgeKind.FALSE)
264
+ if stmt.orelse and else_tail is not None:
265
+ self._edge(else_tail, exit_block.id)
266
+
267
+ self.loop_stack.append((header.id, exit_block.id))
268
+ body_tail = self._suite(stmt.body, body_block)
269
+ self.loop_stack.pop()
270
+ if body_tail is not None:
271
+ self._edge(body_tail, header.id, EdgeKind.LOOP_BACK)
272
+
273
+ return exit_block
274
+
275
+ # -- with / try --------------------------------------------------
276
+
277
+ def _do_With(self, stmt: ast.With, block: BasicBlock) -> BasicBlock | None:
278
+ items = ", ".join(_src(i.context_expr) for i in stmt.items)
279
+ self._stmt(block, stmt, kind="with", src=f"with {items}")
280
+ return self._suite(stmt.body, block)
281
+
282
+ _do_AsyncWith = _do_With
283
+
284
+ def _do_Try(self, stmt: ast.Try, block: BasicBlock) -> BasicBlock | None:
285
+ self._stmt(block, stmt, kind="try", src="try")
286
+ try_block = self._new("try.body")
287
+ self._edge(block, try_block.id)
288
+
289
+ handler_blocks = [self._new("try.handler") for _ in stmt.handlers]
290
+ for hb in handler_blocks:
291
+ self._edge(try_block, hb.id, EdgeKind.EXCEPTION)
292
+
293
+ self.exc_stack.append([hb.id for hb in handler_blocks])
294
+ try_tail = self._suite(stmt.body, try_block)
295
+ self.exc_stack.pop()
296
+
297
+ # ``else`` runs only when the try body completed without raising.
298
+ else_tail = try_tail
299
+ if stmt.orelse and try_tail is not None:
300
+ else_block = self._new("try.else")
301
+ self._edge(try_tail, else_block.id)
302
+ else_tail = self._suite(stmt.orelse, else_block)
303
+
304
+ handler_tails: list[BasicBlock | None] = []
305
+ for handler, hb in zip(stmt.handlers, handler_blocks):
306
+ type_src = _src(handler.type) if handler.type is not None else ""
307
+ name = f" as {handler.name}" if handler.name else ""
308
+ self._stmt(hb, handler, kind="except", src=f"except {type_src}{name}".rstrip())
309
+ handler_tails.append(self._suite(handler.body, hb))
310
+
311
+ sources = [t for t in ([else_tail, *handler_tails]) if t is not None]
312
+
313
+ if stmt.finalbody:
314
+ fin_block = self._new("try.finally")
315
+ self._stmt(fin_block, stmt, kind="finally", src="finally")
316
+ for s in sources:
317
+ self._edge(s, fin_block.id)
318
+ if not sources:
319
+ # ``finally`` still runs on the exception-propagation path.
320
+ self._edge(try_block, fin_block.id, EdgeKind.EXCEPTION)
321
+ return self._suite(stmt.finalbody, fin_block)
322
+
323
+ if not sources:
324
+ return None
325
+ if len(sources) == 1:
326
+ return sources[0]
327
+ join = self._new("try.join")
328
+ for s in sources:
329
+ self._edge(s, join.id)
330
+ return join
331
+
332
+ _do_TryStar = _do_Try
@@ -0,0 +1,182 @@
1
+ """Lower Python source into the language-neutral IR.
2
+
3
+ Captures module-level functions and classes with their methods. Functions
4
+ nested inside other functions are not lowered in M1 (noted per module); the
5
+ CFG builder still handles them structurally when they appear inside a body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import warnings
12
+ from pathlib import Path
13
+
14
+ from deploy_guard.ir.model import (
15
+ ClassDef,
16
+ FunctionDef,
17
+ Module,
18
+ Parameter,
19
+ ParamKind,
20
+ Project,
21
+ SourceSpan,
22
+ )
23
+
24
+ _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
25
+
26
+
27
+ def lower_python(project: Project) -> Project:
28
+ """Fill in functions/classes/imports for every Python module in place."""
29
+ for module in project.modules:
30
+ if module.parse_error or module.path.suffix != ".py":
31
+ continue
32
+ _lower_module(module)
33
+ return project
34
+
35
+
36
+ def _lower_module(module: Module) -> None:
37
+ # Parsing quietly captures SyntaxWarnings (invalid escape sequences,
38
+ # ``is`` with a literal, ...) so they become findings instead of noise on
39
+ # stderr.
40
+ with warnings.catch_warnings(record=True) as caught:
41
+ warnings.simplefilter("always")
42
+ try:
43
+ tree = ast.parse(module.source, filename=str(module.path))
44
+ except SyntaxError as exc:
45
+ module.parse_error = f"SyntaxError: {exc.msg} (line {exc.lineno})"
46
+ return
47
+ module.syntax_warnings = [
48
+ (getattr(w.message, "lineno", None) or w.lineno or 0, str(w.message))
49
+ for w in caught
50
+ if issubclass(w.category, SyntaxWarning)
51
+ ]
52
+
53
+ for node in tree.body:
54
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
55
+ module.imports.extend(_import_names(node))
56
+ elif isinstance(node, _FUNC_NODES):
57
+ module.functions.append(
58
+ _lower_function(node, module.path, prefix=module.dotted_name)
59
+ )
60
+ elif isinstance(node, ast.ClassDef):
61
+ module.classes.append(_lower_class(node, module.path, module.dotted_name))
62
+
63
+
64
+ def _import_names(node: ast.Import | ast.ImportFrom) -> list[str]:
65
+ if isinstance(node, ast.Import):
66
+ return [alias.name for alias in node.names]
67
+ if node.module and node.level == 0:
68
+ return [node.module]
69
+ return []
70
+
71
+
72
+ def _lower_class(node: ast.ClassDef, file: Path, prefix: str) -> ClassDef:
73
+ qualname = f"{prefix}.{node.name}" if prefix else node.name
74
+ cls = ClassDef(
75
+ name=node.name,
76
+ qualname=qualname,
77
+ span=_span(node, file),
78
+ bases=[_unparse(b) for b in node.bases],
79
+ decorators=[_unparse(d) for d in node.decorator_list],
80
+ docstring=ast.get_docstring(node),
81
+ )
82
+ for child in node.body:
83
+ if isinstance(child, _FUNC_NODES):
84
+ cls.methods.append(
85
+ _lower_function(child, file, prefix=qualname, is_method=True)
86
+ )
87
+ return cls
88
+
89
+
90
+ def _lower_function(
91
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
92
+ file: Path,
93
+ *,
94
+ prefix: str,
95
+ is_method: bool = False,
96
+ ) -> FunctionDef:
97
+ qualname = f"{prefix}.{node.name}" if prefix else node.name
98
+ return FunctionDef(
99
+ name=node.name,
100
+ qualname=qualname,
101
+ span=_span(node, file),
102
+ params=_params(node.args),
103
+ returns=_unparse(node.returns) if node.returns is not None else None,
104
+ decorators=[_unparse(d) for d in node.decorator_list],
105
+ docstring=ast.get_docstring(node),
106
+ is_async=isinstance(node, ast.AsyncFunctionDef),
107
+ is_method=is_method,
108
+ raw=node,
109
+ )
110
+
111
+
112
+ def _params(args: ast.arguments) -> list[Parameter]:
113
+ out: list[Parameter] = []
114
+
115
+ positional = list(args.posonlyargs) + list(args.args)
116
+ # ``defaults`` covers the *last* len(defaults) positional params.
117
+ n_defaults = len(args.defaults)
118
+ first_with_default = len(positional) - n_defaults
119
+ posonly_count = len(args.posonlyargs)
120
+
121
+ for i, arg in enumerate(positional):
122
+ kind = (
123
+ ParamKind.POSITIONAL_ONLY
124
+ if i < posonly_count
125
+ else ParamKind.POSITIONAL_OR_KEYWORD
126
+ )
127
+ default = None
128
+ if i >= first_with_default:
129
+ default = _unparse(args.defaults[i - first_with_default])
130
+ out.append(Parameter(name=arg.arg, kind=kind, annotation=_ann(arg), default=default))
131
+
132
+ if args.vararg:
133
+ out.append(
134
+ Parameter(
135
+ name=args.vararg.arg,
136
+ kind=ParamKind.VAR_POSITIONAL,
137
+ annotation=_ann(args.vararg),
138
+ )
139
+ )
140
+
141
+ for arg, default_node in zip(args.kwonlyargs, args.kw_defaults):
142
+ out.append(
143
+ Parameter(
144
+ name=arg.arg,
145
+ kind=ParamKind.KEYWORD_ONLY,
146
+ annotation=_ann(arg),
147
+ default=_unparse(default_node) if default_node is not None else None,
148
+ )
149
+ )
150
+
151
+ if args.kwarg:
152
+ out.append(
153
+ Parameter(
154
+ name=args.kwarg.arg,
155
+ kind=ParamKind.VAR_KEYWORD,
156
+ annotation=_ann(args.kwarg),
157
+ )
158
+ )
159
+ return out
160
+
161
+
162
+ def _ann(arg: ast.arg) -> str | None:
163
+ return _unparse(arg.annotation) if arg.annotation is not None else None
164
+
165
+
166
+ def _span(node: ast.AST, file: Path) -> SourceSpan:
167
+ return SourceSpan(
168
+ file=file,
169
+ lineno=getattr(node, "lineno", 0) or 0,
170
+ end_lineno=getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0,
171
+ col=getattr(node, "col_offset", 0) or 0,
172
+ end_col=getattr(node, "end_col_offset", 0) or 0,
173
+ )
174
+
175
+
176
+ def _unparse(node: ast.AST | None) -> str:
177
+ if node is None:
178
+ return ""
179
+ try:
180
+ return ast.unparse(node)
181
+ except Exception: # pragma: no cover - unparse is robust on parsed trees
182
+ return "<expr>"
@@ -0,0 +1,11 @@
1
+ """Test generators: turn the analysis into runnable tests.
2
+
3
+ M1 ships the import-smoke generator - the cheapest, highest-value check for
4
+ "the deployment will not even start". Characterization, property, endpoint,
5
+ config and contract generators follow in later milestones.
6
+ """
7
+
8
+ from deploy_guard.generators.base import GeneratedFile
9
+ from deploy_guard.generators.import_smoke import generate_import_smoke
10
+
11
+ __all__ = ["GeneratedFile", "generate_import_smoke"]
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class GeneratedFile:
8
+ """A generated test file, addressed relative to ``.deploy-guard/generated/``."""
9
+
10
+ relpath: str
11
+ content: str
12
+ kind: str = "test"
@@ -0,0 +1,94 @@
1
+ """Import-smoke generator.
2
+
3
+ Emits a single pytest module that imports every discovered product module by
4
+ its dotted name. A broken import, a missing dependency, a module-level
5
+ exception or a circular import all surface as a failing parametrised case -
6
+ which is exactly the class of bug that takes a deployment down before it
7
+ serves a request.
8
+
9
+ The generated file is written to ``.deploy-guard/generated/`` and finds the
10
+ project root by walking up to the ``.deploy-guard`` directory, so it stays
11
+ correct no matter where pytest is invoked from or how deep the tree is.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from deploy_guard.generators.base import GeneratedFile
19
+ from deploy_guard.ir.model import Project
20
+
21
+ _TEMPLATE = '''\
22
+ # ---------------------------------------------------------------------------
23
+ # Generated by Deployment Guard Engine - import smoke test.
24
+ # Do not edit by hand; re-run `deploy-guard scan --write-tests` to refresh.
25
+ # ---------------------------------------------------------------------------
26
+ import importlib
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import pytest
31
+
32
+ _HERE = Path(__file__).resolve()
33
+ # .../<root>/.deploy-guard/generated/test_import_smoke.py -> <root>
34
+ _PROJECT_ROOT = next(
35
+ (p.parent for p in _HERE.parents if p.name == ".deploy-guard"),
36
+ _HERE.parents[2],
37
+ )
38
+ _PACKAGE_ROOTS = {roots!r}
39
+ for _rel in _PACKAGE_ROOTS:
40
+ _abs = str((_PROJECT_ROOT / _rel).resolve())
41
+ if _abs not in sys.path:
42
+ sys.path.insert(0, _abs)
43
+
44
+ IMPORTABLE_MODULES = {modules!r}
45
+ {broken_block}
46
+
47
+ @pytest.mark.parametrize(
48
+ "module_name",
49
+ IMPORTABLE_MODULES
50
+ or [pytest.param(None, marks=pytest.mark.skip(reason="no modules discovered"))],
51
+ )
52
+ def test_module_imports_cleanly(module_name):
53
+ """Importing the module must not raise: no import errors, no crash at import time."""
54
+ importlib.import_module(module_name)
55
+ '''
56
+
57
+ _BROKEN_BLOCK = '''\
58
+ UNPARSEABLE_MODULES = {broken!r}
59
+
60
+
61
+ @pytest.mark.parametrize("module_name, error", UNPARSEABLE_MODULES)
62
+ def test_module_parses(module_name, error):
63
+ """A module the engine cannot parse cannot be reasoned about or shipped safely."""
64
+ pytest.fail(f"{{module_name}}: {{error}}")
65
+ '''
66
+
67
+
68
+ def generate_import_smoke(project: Project) -> GeneratedFile:
69
+ roots = sorted({_rel_root(project, str(p)) for p in project.package_roots}) or ["."]
70
+
71
+ modules = sorted(
72
+ {m.dotted_name for m in project.modules if not m.parse_error and m.dotted_name}
73
+ )
74
+ broken = sorted(
75
+ (m.dotted_name or str(m.path), m.parse_error or "unknown parse error")
76
+ for m in project.modules
77
+ if m.parse_error
78
+ )
79
+
80
+ broken_block = _BROKEN_BLOCK.format(broken=broken) if broken else ""
81
+ content = _TEMPLATE.format(roots=roots, modules=modules, broken_block=broken_block)
82
+ return GeneratedFile(
83
+ relpath="test_import_smoke.py", content=content, kind="import-smoke"
84
+ )
85
+
86
+
87
+ def _rel_root(project: Project, path: str) -> str:
88
+ try:
89
+ return os.path.relpath(path, project.root)
90
+ except ValueError:
91
+ return path
92
+
93
+
94
+ __all__ = ["generate_import_smoke"]
@@ -0,0 +1,5 @@
1
+ """Project ingestion: walk a directory and produce a :class:`Project` shell."""
2
+
3
+ from deploy_guard.ingest.discover import discover
4
+
5
+ __all__ = ["discover"]