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.
deploy_guard/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """``deploy-guard`` command-line interface.
2
+
3
+ deploy-guard scan [path] analyse a tree, report findings + behavior spec
4
+ deploy-guard check [path] same, but exit non-zero on gate-blocking findings
5
+ deploy-guard explain explain a pasted traceback using the analysis
6
+ deploy-guard version
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from deploy_guard import __version__
16
+ from deploy_guard.analysis.paths import DEFAULT_LIMIT, DEFAULT_UNROLL
17
+ from deploy_guard.config import load_config
18
+ from deploy_guard.engine import scan
19
+ from deploy_guard.explain import explain_traceback, parse_traceback, render_explanation
20
+ from deploy_guard.frontend import lower_python
21
+ from deploy_guard.ingest import discover
22
+ from deploy_guard.report import render_json, render_markdown, render_text
23
+ from deploy_guard.store import write_generated
24
+
25
+ _FAIL_ON_CHOICES = ("block", "review", "warn", "none")
26
+ _EXIT_OK = 0
27
+ _EXIT_FINDINGS = 1
28
+ _EXIT_USAGE = 2
29
+
30
+
31
+ def _add_common(sp: argparse.ArgumentParser) -> None:
32
+ sp.add_argument("path", nargs="?", default=".", help="directory to scan (default: .)")
33
+ sp.add_argument("--include-tests", action="store_true", help="analyse test files too")
34
+ sp.add_argument("--path-limit", type=int, default=DEFAULT_LIMIT,
35
+ help=f"max paths per function (default: {DEFAULT_LIMIT})")
36
+ sp.add_argument("--unroll", type=int, default=DEFAULT_UNROLL,
37
+ help=f"max visits to a block on one path (default: {DEFAULT_UNROLL})")
38
+ sp.add_argument("--format", choices=("text", "markdown", "json"), default="text")
39
+ sp.add_argument("--behavior", action="store_true",
40
+ help="include the per-function behavior spec (text format)")
41
+ sp.add_argument("--notes", action="store_true",
42
+ help="list informational notes (e.g. path-explosion) instead of just counting them")
43
+ sp.add_argument("--disable", metavar="RULES", default="",
44
+ help="comma-separated rule names to suppress, e.g. --disable path-explosion,bare-except")
45
+ sp.add_argument("--no-collapse", action="store_true",
46
+ help="do not fold duplicate findings from copy-pasted functions")
47
+ sp.add_argument("--write-tests", action="store_true",
48
+ help="write generated tests into .deploy-guard/generated/")
49
+ sp.add_argument("--report", metavar="FILE",
50
+ help="also write the report (markdown or json by extension) to FILE")
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(prog="deploy-guard", description=__doc__)
55
+ parser.add_argument("--version", action="version", version=f"deploy-guard {__version__}")
56
+ sub = parser.add_subparsers(dest="command")
57
+
58
+ p_scan = sub.add_parser("scan", help="analyse and report (never fails the build)")
59
+ _add_common(p_scan)
60
+ p_scan.add_argument("--fail-on", choices=_FAIL_ON_CHOICES, default=None)
61
+
62
+ p_check = sub.add_parser("check", help="deployment gate - exit non-zero on blockers")
63
+ _add_common(p_check)
64
+ p_check.add_argument("--fail-on", choices=_FAIL_ON_CHOICES, default=None)
65
+
66
+ p_explain = sub.add_parser(
67
+ "explain",
68
+ help="explain a pasted traceback using the project's analysis",
69
+ )
70
+ p_explain.add_argument("--project", default=".", help="project root (default: .)")
71
+ p_explain.add_argument(
72
+ "--file", metavar="FILE", help="read the traceback from FILE instead of stdin"
73
+ )
74
+
75
+ sub.add_parser("version", help="print version")
76
+ return parser
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ parser = build_parser()
81
+ args = parser.parse_args(argv)
82
+
83
+ if args.command in (None, "version"):
84
+ if args.command is None:
85
+ parser.print_help()
86
+ return _EXIT_USAGE
87
+ print(f"deploy-guard {__version__}")
88
+ return _EXIT_OK
89
+
90
+ if args.command == "explain":
91
+ return _run_explain(args)
92
+
93
+ root = Path(args.path).resolve()
94
+ if not root.is_dir():
95
+ print(f"deploy-guard: not a directory: {root}", file=sys.stderr)
96
+ return _EXIT_USAGE
97
+
98
+ cli_disable = {r.strip() for r in args.disable.split(",") if r.strip()}
99
+ cfg = load_config(root).merged_with_cli(
100
+ disable=cli_disable,
101
+ fail_on=args.fail_on, # None unless passed on the CLI
102
+ include_tests=True if args.include_tests else None,
103
+ )
104
+ subcommand_default = "block" if args.command == "check" else "none"
105
+ effective_fail_on = cfg.fail_on or subcommand_default
106
+
107
+ if cfg.source is not None:
108
+ print(f"(config: {cfg.source})")
109
+ result = scan(
110
+ root,
111
+ include_tests=bool(cfg.include_tests),
112
+ path_limit=args.path_limit,
113
+ unroll=args.unroll,
114
+ disable=cfg.disable,
115
+ exclude=cfg.exclude,
116
+ )
117
+ result.collapse = not args.no_collapse
118
+
119
+ if args.format == "json":
120
+ rendered = render_json(result)
121
+ elif args.format == "markdown":
122
+ rendered = render_markdown(result)
123
+ else:
124
+ rendered = render_text(
125
+ result, show_behavior=args.behavior, show_notes=args.notes
126
+ )
127
+ print(rendered)
128
+
129
+ if args.report:
130
+ report_path = Path(args.report)
131
+ text = render_json(result) if report_path.suffix == ".json" else render_markdown(result)
132
+ report_path.write_text(text, encoding="utf-8")
133
+ print(f"report written to {report_path}")
134
+
135
+ if args.write_tests and result.generated:
136
+ written = write_generated(root, result.generated)
137
+ for w in written:
138
+ print(f"wrote {w}")
139
+
140
+ if args.command == "check":
141
+ print()
142
+ print("note: check scans the whole tree; diff-scoped gating is on the roadmap.")
143
+
144
+ if effective_fail_on != "none":
145
+ blockers = result.findings_at_or_above(effective_fail_on)
146
+ if blockers:
147
+ print(
148
+ f"\n{len(blockers)} finding(s) at or above '{effective_fail_on}' - "
149
+ "deployment gate: FAIL",
150
+ file=sys.stderr,
151
+ )
152
+ return _EXIT_FINDINGS
153
+
154
+ return _EXIT_OK
155
+
156
+
157
+ def _run_explain(args: argparse.Namespace) -> int:
158
+ project_root = Path(args.project).resolve()
159
+ if not project_root.is_dir():
160
+ print(f"deploy-guard: not a directory: {project_root}", file=sys.stderr)
161
+ return _EXIT_USAGE
162
+
163
+ if args.file:
164
+ text = Path(args.file).read_text(encoding="utf-8", errors="replace")
165
+ elif not sys.stdin.isatty():
166
+ text = sys.stdin.read()
167
+ else:
168
+ print(
169
+ "deploy-guard explain: paste a traceback on stdin, or use --file FILE\n"
170
+ " e.g. deploy-guard explain --project . < traceback.txt",
171
+ file=sys.stderr,
172
+ )
173
+ return _EXIT_USAGE
174
+
175
+ if not text.strip():
176
+ print("deploy-guard explain: no traceback text provided", file=sys.stderr)
177
+ return _EXIT_USAGE
178
+
179
+ cfg = load_config(project_root)
180
+ project = lower_python(
181
+ discover(project_root, include_tests=bool(cfg.include_tests), extra_exclude=cfg.exclude)
182
+ )
183
+ tb = parse_traceback(text)
184
+ explanation = explain_traceback(tb, project)
185
+ print(render_explanation(explanation))
186
+ return _EXIT_OK
187
+
188
+
189
+ if __name__ == "__main__": # pragma: no cover
190
+ raise SystemExit(main())
deploy_guard/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Load ``[tool.deploy-guard]`` from a scanned project's ``pyproject.toml``.
2
+
3
+ TOML reading needs the standard-library ``tomllib`` (Python 3.11+). On 3.10
4
+ there is no config file support; CLI flags still work.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ try: # Python 3.11+
13
+ import tomllib as _toml
14
+ except ModuleNotFoundError: # pragma: no cover - 3.10
15
+ _toml = None
16
+
17
+ _VALID_FAIL_ON = {"block", "review", "warn", "none"}
18
+
19
+
20
+ @dataclass
21
+ class Config:
22
+ disable: set[str] = field(default_factory=set)
23
+ exclude: list[str] = field(default_factory=list)
24
+ fail_on: str | None = None
25
+ include_tests: bool | None = None
26
+ source: Path | None = None # the file it came from, if any
27
+
28
+ def merged_with_cli(
29
+ self,
30
+ *,
31
+ disable: set[str] | None = None,
32
+ fail_on: str | None = None,
33
+ include_tests: bool | None = None,
34
+ ) -> Config:
35
+ """CLI values win over the file."""
36
+ return Config(
37
+ disable=self.disable | (disable or set()),
38
+ exclude=list(self.exclude),
39
+ fail_on=fail_on or self.fail_on,
40
+ include_tests=include_tests if include_tests is not None else self.include_tests,
41
+ source=self.source,
42
+ )
43
+
44
+
45
+ def load_config(project_root: str | Path) -> Config:
46
+ root = Path(project_root)
47
+ pyproject = root / "pyproject.toml"
48
+ if _toml is None or not pyproject.is_file():
49
+ return Config()
50
+ try:
51
+ data = _toml.loads(pyproject.read_text(encoding="utf-8"))
52
+ except Exception:
53
+ return Config()
54
+ table = data.get("tool", {}).get("deploy-guard", {})
55
+ if not isinstance(table, dict):
56
+ return Config()
57
+
58
+ fail_on = table.get("fail-on")
59
+ if fail_on not in _VALID_FAIL_ON:
60
+ fail_on = None
61
+
62
+ return Config(
63
+ disable={str(r) for r in table.get("disable", []) if isinstance(r, str)},
64
+ exclude=[str(d) for d in table.get("exclude", []) if isinstance(d, str)],
65
+ fail_on=fail_on,
66
+ include_tests=_as_bool(table.get("include-tests")),
67
+ source=pyproject,
68
+ )
69
+
70
+
71
+ def _as_bool(value: object) -> bool | None:
72
+ return value if isinstance(value, bool) else None
deploy_guard/engine.py ADDED
@@ -0,0 +1,270 @@
1
+ """The scan pipeline: directory in, analysis + generated tests out.
2
+
3
+ discover -> lower (Python) -> per function: CFG -> behavior spec -> findings
4
+ -> generators (import smoke)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import hashlib
11
+ from dataclasses import dataclass, field, replace
12
+ from pathlib import Path
13
+
14
+ from deploy_guard.analysis import findings as _findings_mod
15
+ from deploy_guard.analysis.callgraph import build_call_graph
16
+ from deploy_guard.analysis.context import AnalysisContext
17
+ from deploy_guard.analysis.findings import Finding, analyze_function
18
+ from deploy_guard.analysis.paths import (
19
+ DEFAULT_LIMIT,
20
+ DEFAULT_UNROLL,
21
+ BehaviorSpec,
22
+ behavior_spec,
23
+ )
24
+ from deploy_guard.frontend import build_cfg, lower_python
25
+ from deploy_guard.generators import GeneratedFile, generate_import_smoke
26
+ from deploy_guard.ingest import discover
27
+ from deploy_guard.ir.model import FunctionDef, Module, Project
28
+
29
+ _SEVERITY_ORDER = {"block": 0, "review": 1, "warn": 2, "note": 3}
30
+ # Severities that count as gate findings; "note" is informational only.
31
+ GATE_SEVERITIES = ("block", "review", "warn")
32
+
33
+
34
+ @dataclass
35
+ class FunctionReport:
36
+ module: Module
37
+ fn: FunctionDef
38
+ spec: BehaviorSpec
39
+ findings: list[Finding] = field(default_factory=list)
40
+ cfg_block_count: int = 0
41
+ cfg_notes: list[str] = field(default_factory=list)
42
+ error: str | None = None
43
+
44
+
45
+ @dataclass
46
+ class ScanResult:
47
+ project: Project
48
+ functions: list[FunctionReport] = field(default_factory=list)
49
+ generated: list[GeneratedFile] = field(default_factory=list)
50
+ parse_errors: list[tuple[str, str]] = field(default_factory=list)
51
+ disabled_rules: frozenset[str] = frozenset()
52
+ extra_findings: list[Finding] = field(default_factory=list)
53
+ context: AnalysisContext | None = None
54
+ collapse: bool = True
55
+
56
+ @property
57
+ def _all(self) -> list[Finding]:
58
+ flat = [f for fr in self.functions for f in fr.findings] + list(self.extra_findings)
59
+ flat.sort(key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.file, f.lineno))
60
+ return flat
61
+
62
+ @property
63
+ def findings(self) -> list[Finding]:
64
+ """Gate findings (block/review/warn), copy-paste duplicates collapsed."""
65
+ gate = [f for f in self._all if f.severity in GATE_SEVERITIES]
66
+ return _collapse_duplicates(gate, self.project.root) if self.collapse else gate
67
+
68
+ @property
69
+ def raw_findings(self) -> list[Finding]:
70
+ """Every gate finding, nothing collapsed."""
71
+ return [f for f in self._all if f.severity in GATE_SEVERITIES]
72
+
73
+ @property
74
+ def notes(self) -> list[Finding]:
75
+ return [f for f in self._all if f.severity == "note"]
76
+
77
+ def findings_at_or_above(self, threshold: str) -> list[Finding]:
78
+ cap = _SEVERITY_ORDER.get(threshold, -1)
79
+ return [f for f in self.findings if _SEVERITY_ORDER.get(f.severity, 9) <= cap]
80
+
81
+ def counts_by_severity(self) -> dict[str, int]:
82
+ out = {s: 0 for s in _SEVERITY_ORDER}
83
+ for f in [*self.findings, *self.notes]:
84
+ out[f.severity] = out.get(f.severity, 0) + 1
85
+ return out
86
+
87
+ @property
88
+ def collapsed_count(self) -> int:
89
+ """How many duplicate findings were folded away."""
90
+ return len(self.raw_findings) - len(self.findings)
91
+
92
+
93
+ def scan(
94
+ root: str | Path,
95
+ *,
96
+ include_tests: bool = False,
97
+ path_limit: int = DEFAULT_LIMIT,
98
+ unroll: int = DEFAULT_UNROLL,
99
+ disable: frozenset[str] | set[str] | None = None,
100
+ exclude: list[str] | None = None,
101
+ ) -> ScanResult:
102
+ disabled = frozenset(disable or ())
103
+ project = lower_python(
104
+ discover(root, include_tests=include_tests, extra_exclude=exclude)
105
+ )
106
+ result = ScanResult(project=project, disabled_rules=disabled)
107
+
108
+ ctx = build_context(project, path_limit=path_limit, unroll=unroll)
109
+ result.context = ctx
110
+
111
+ for module in project.modules:
112
+ if module.parse_error:
113
+ result.parse_errors.append(
114
+ (module.dotted_name or str(module.path), module.parse_error)
115
+ )
116
+ continue
117
+ result.extra_findings.extend(_syntax_warning_findings(module))
118
+ for fn in module.iter_functions():
119
+ fr = _analyze_one(module, fn, ctx=ctx, path_limit=path_limit, unroll=unroll)
120
+ if disabled:
121
+ fr.findings = [f for f in fr.findings if f.rule not in disabled]
122
+ result.functions.append(fr)
123
+
124
+ if disabled:
125
+ result.extra_findings = [
126
+ f for f in result.extra_findings if f.rule not in disabled
127
+ ]
128
+
129
+ if project.modules:
130
+ result.generated.append(generate_import_smoke(project))
131
+
132
+ return result
133
+
134
+
135
+ def build_context(
136
+ project: Project, *, path_limit: int = DEFAULT_LIMIT, unroll: int = DEFAULT_UNROLL
137
+ ) -> AnalysisContext:
138
+ """First pass: call graph + every function's return shape, so the second
139
+ pass can reason across function boundaries."""
140
+ cg = build_call_graph(project)
141
+ ctx = AnalysisContext(callgraph=cg)
142
+ for _module, fn in project.iter_functions():
143
+ try:
144
+ cfg = build_cfg(fn)
145
+ except Exception:
146
+ continue
147
+ value_return, none_exit, _ = _findings_mod._return_shape(cfg)
148
+ if none_exit and not _findings_mod._declares_optional(fn.returns):
149
+ ctx.returns_none.add(fn.qualname)
150
+ if value_return:
151
+ ctx.returns_value.add(fn.qualname)
152
+ ctx.specs[fn.qualname] = behavior_spec(fn, cfg, limit=path_limit, unroll=unroll)
153
+ return ctx
154
+
155
+
156
+ def _syntax_warning_findings(module: Module) -> list[Finding]:
157
+ out: list[Finding] = []
158
+ for lineno, message in module.syntax_warnings:
159
+ is_escape = "escape sequence" in message
160
+ rule = "invalid-escape" if is_escape else "syntax-warning"
161
+ # Works today, breaks in a future Python -> informational note, not a gate finding.
162
+ severity = "note" if is_escape else "warn"
163
+ msg = (
164
+ "invalid escape sequence in a non-raw string - works today but is "
165
+ "deprecated and will become a SyntaxError in a future Python; use a "
166
+ "raw string r\"...\""
167
+ if is_escape
168
+ else f"Python SyntaxWarning: {message}"
169
+ )
170
+ out.append(
171
+ Finding(
172
+ rule=rule,
173
+ severity=severity,
174
+ message=msg,
175
+ qualname=module.dotted_name,
176
+ file=str(module.path),
177
+ lineno=lineno,
178
+ detail=message,
179
+ )
180
+ )
181
+ return out
182
+
183
+
184
+ def _collapse_duplicates(findings: list[Finding], root: Path) -> list[Finding]:
185
+ """Fold findings that are the same finding in a copy-pasted function into
186
+ one, listing the other locations in its detail."""
187
+ groups: dict[tuple[str, str], list[Finding]] = {}
188
+ order: list[tuple[str, str]] = []
189
+ passthrough: list[Finding] = []
190
+ for f in findings:
191
+ if not f.group_key:
192
+ passthrough.append(f)
193
+ continue
194
+ key = (f.rule, f.group_key)
195
+ if key not in groups:
196
+ groups[key] = []
197
+ order.append(key)
198
+ groups[key].append(f)
199
+
200
+ collapsed: list[Finding] = list(passthrough)
201
+ for key in order:
202
+ members = groups[key]
203
+ head = members[0]
204
+ if len(members) > 1:
205
+ others = sorted({_rel(root, m.file) for m in members if m is not head})
206
+ shown = ", ".join(others[:6]) + ("…" if len(others) > 6 else "")
207
+ extra = (
208
+ f"this function is copy-pasted; same finding in {len(members)} "
209
+ f"places total (also: {shown})"
210
+ )
211
+ new_detail = f"{head.detail} [{extra}]" if head.detail else f"[{extra}]"
212
+ head = replace(head, detail=new_detail) # never mutate the original
213
+ collapsed.append(head)
214
+
215
+ collapsed.sort(key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.file, f.lineno))
216
+ return collapsed
217
+
218
+
219
+ def _rel(root: Path, path: str) -> str:
220
+ try:
221
+ return str(Path(path).resolve().relative_to(Path(root).resolve()))
222
+ except ValueError:
223
+ return path
224
+
225
+
226
+ def _analyze_one(
227
+ module: Module,
228
+ fn: FunctionDef,
229
+ *,
230
+ ctx: AnalysisContext | None = None,
231
+ path_limit: int,
232
+ unroll: int,
233
+ ) -> FunctionReport:
234
+ try:
235
+ cfg = build_cfg(fn)
236
+ spec = behavior_spec(fn, cfg, limit=path_limit, unroll=unroll)
237
+ findings = analyze_function(fn, cfg, spec, ctx)
238
+ body_hash = _body_hash(fn)
239
+ for f in findings:
240
+ f.group_key = f"{body_hash}:{f.rule}:{f.lineno - fn.span.lineno}"
241
+ return FunctionReport(
242
+ module=module,
243
+ fn=fn,
244
+ spec=spec,
245
+ findings=findings,
246
+ cfg_block_count=len(cfg.blocks),
247
+ cfg_notes=cfg.notes,
248
+ )
249
+ except Exception as exc: # analysis must never crash the whole scan
250
+ return FunctionReport(
251
+ module=module,
252
+ fn=fn,
253
+ spec=BehaviorSpec(qualname=fn.qualname),
254
+ error=f"{type(exc).__name__}: {exc}",
255
+ )
256
+
257
+
258
+ def _body_hash(fn: FunctionDef) -> str:
259
+ """Structural hash of the function body, ignoring names/lines, so that
260
+ copy-pasted helpers hash identically."""
261
+ node = fn.raw
262
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
263
+ return ""
264
+ try:
265
+ dump = ast.dump(
266
+ ast.Module(body=list(node.body), type_ignores=[]), include_attributes=False
267
+ )
268
+ except Exception:
269
+ return ""
270
+ return hashlib.blake2b(dump.encode(), digest_size=8).hexdigest()
@@ -0,0 +1,16 @@
1
+ """Reverse mode: take a production traceback and explain why it happened,
2
+ using the same CFG / nullability / behavior-spec analysis the scanner uses.
3
+ """
4
+
5
+ from deploy_guard.explain.explainer import Explanation, explain_traceback
6
+ from deploy_guard.explain.render import render_explanation
7
+ from deploy_guard.explain.traceback_parse import Frame, ParsedTraceback, parse_traceback
8
+
9
+ __all__ = [
10
+ "parse_traceback",
11
+ "ParsedTraceback",
12
+ "Frame",
13
+ "explain_traceback",
14
+ "Explanation",
15
+ "render_explanation",
16
+ ]