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/__init__.py +10 -0
- deploy_guard/__main__.py +4 -0
- deploy_guard/analysis/__init__.py +39 -0
- deploy_guard/analysis/callgraph.py +149 -0
- deploy_guard/analysis/context.py +31 -0
- deploy_guard/analysis/findings.py +333 -0
- deploy_guard/analysis/nullability.py +461 -0
- deploy_guard/analysis/paths.py +219 -0
- deploy_guard/cli.py +190 -0
- deploy_guard/config.py +72 -0
- deploy_guard/engine.py +270 -0
- deploy_guard/explain/__init__.py +16 -0
- deploy_guard/explain/explainer.py +504 -0
- deploy_guard/explain/render.py +60 -0
- deploy_guard/explain/traceback_parse.py +89 -0
- deploy_guard/frontend/__init__.py +10 -0
- deploy_guard/frontend/python_cfg.py +332 -0
- deploy_guard/frontend/python_frontend.py +182 -0
- deploy_guard/generators/__init__.py +11 -0
- deploy_guard/generators/base.py +12 -0
- deploy_guard/generators/import_smoke.py +94 -0
- deploy_guard/ingest/__init__.py +5 -0
- deploy_guard/ingest/discover.py +166 -0
- deploy_guard/ir/__init__.py +24 -0
- deploy_guard/ir/cfg.py +114 -0
- deploy_guard/ir/model.py +128 -0
- deploy_guard/py.typed +0 -0
- deploy_guard/report/__init__.py +5 -0
- deploy_guard/report/render.py +262 -0
- deploy_guard/store.py +50 -0
- deploy_guard_engine-0.1.0.dist-info/METADATA +163 -0
- deploy_guard_engine-0.1.0.dist-info/RECORD +35 -0
- deploy_guard_engine-0.1.0.dist-info/WHEEL +4 -0
- deploy_guard_engine-0.1.0.dist-info/entry_points.txt +3 -0
- deploy_guard_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|