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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Text, Markdown and JSON renderers for a scan result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from deploy_guard.engine import ScanResult
|
|
10
|
+
|
|
11
|
+
_SEV_LABEL = {"block": "BLOCK ", "review": "REVIEW", "warn": "WARN "}
|
|
12
|
+
_SEV_MARK = {"block": "●", "review": "◐", "warn": "○"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rel(result: ScanResult, path: str) -> str:
|
|
16
|
+
try:
|
|
17
|
+
return os.path.relpath(path, result.project.root)
|
|
18
|
+
except ValueError:
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
# Plain text (terminal)
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def render_text(
|
|
27
|
+
result: ScanResult, *, show_behavior: bool = False, show_notes: bool = False
|
|
28
|
+
) -> str:
|
|
29
|
+
p = result.project
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
lines.append(f"Deployment Guard - scan of {p.root}")
|
|
32
|
+
langs = ", ".join(sorted(p.languages)) or "none detected"
|
|
33
|
+
builds = ", ".join(p.build_systems) or "unknown"
|
|
34
|
+
lines.append(f" languages: {langs} build: {builds}")
|
|
35
|
+
lines.append(
|
|
36
|
+
f" modules: {len(p.modules)} functions analysed: {len(result.functions)}"
|
|
37
|
+
)
|
|
38
|
+
for note in p.notes:
|
|
39
|
+
lines.append(f" note: {note}")
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
counts = result.counts_by_severity()
|
|
43
|
+
summary = (
|
|
44
|
+
f"Findings: {counts['block']} block {counts['review']} review "
|
|
45
|
+
f"{counts['warn']} warn"
|
|
46
|
+
)
|
|
47
|
+
if counts["note"]:
|
|
48
|
+
summary += f" ({counts['note']} note)"
|
|
49
|
+
lines.append(summary)
|
|
50
|
+
if result.collapsed_count:
|
|
51
|
+
lines.append(
|
|
52
|
+
f" ({result.collapsed_count} duplicate finding(s) from copy-pasted "
|
|
53
|
+
"functions folded in - see [same finding in N copies ...]; --no-collapse to expand)"
|
|
54
|
+
)
|
|
55
|
+
if result.disabled_rules:
|
|
56
|
+
lines.append(f" [disabled rules: {', '.join(sorted(result.disabled_rules))}]")
|
|
57
|
+
lines.append("")
|
|
58
|
+
|
|
59
|
+
for f in result.findings:
|
|
60
|
+
loc = f"{_rel(result, f.file)}:{f.lineno}"
|
|
61
|
+
lines.append(f" {_SEV_MARK[f.severity]} {_SEV_LABEL[f.severity]} {f.rule}")
|
|
62
|
+
lines.append(f" {loc} in {f.qualname}")
|
|
63
|
+
lines.append(f" {f.message}")
|
|
64
|
+
if f.detail:
|
|
65
|
+
lines.append(f" {f.detail}")
|
|
66
|
+
lines.append("")
|
|
67
|
+
|
|
68
|
+
notes = result.notes
|
|
69
|
+
if notes:
|
|
70
|
+
by_rule: dict[str, int] = {}
|
|
71
|
+
for n in notes:
|
|
72
|
+
by_rule[n.rule] = by_rule.get(n.rule, 0) + 1
|
|
73
|
+
breakdown = ", ".join(f"{v}x {k}" for k, v in sorted(by_rule.items()))
|
|
74
|
+
if show_notes:
|
|
75
|
+
lines.append(f"Notes ({len(notes)}: {breakdown}):")
|
|
76
|
+
for n in notes:
|
|
77
|
+
lines.append(
|
|
78
|
+
f" · {_rel(result, n.file)}:{n.lineno} {n.rule} - {n.message}"
|
|
79
|
+
)
|
|
80
|
+
lines.append("")
|
|
81
|
+
else:
|
|
82
|
+
lines.append(f"Notes: {len(notes)} ({breakdown}) - run with --notes to list")
|
|
83
|
+
lines.append("")
|
|
84
|
+
|
|
85
|
+
analysis_errors = [fr for fr in result.functions if fr.error]
|
|
86
|
+
if analysis_errors:
|
|
87
|
+
lines.append(f"Analysis errors ({len(analysis_errors)}):")
|
|
88
|
+
for fr in analysis_errors:
|
|
89
|
+
lines.append(f" {fr.fn.qualname}: {fr.error}")
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
if result.parse_errors:
|
|
93
|
+
lines.append(f"Unparseable modules ({len(result.parse_errors)}):")
|
|
94
|
+
for name, err in result.parse_errors:
|
|
95
|
+
lines.append(f" {name}: {err}")
|
|
96
|
+
lines.append("")
|
|
97
|
+
|
|
98
|
+
if show_behavior:
|
|
99
|
+
lines.append("Behavior spec:")
|
|
100
|
+
lines.extend(_behavior_block(result, indent=" "))
|
|
101
|
+
lines.append("")
|
|
102
|
+
|
|
103
|
+
if result.generated:
|
|
104
|
+
lines.append("Generated tests (use --write-tests to save):")
|
|
105
|
+
for g in result.generated:
|
|
106
|
+
lines.append(f" .deploy-guard/generated/{g.relpath} [{g.kind}]")
|
|
107
|
+
lines.append("")
|
|
108
|
+
|
|
109
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _behavior_block(result: ScanResult, *, indent: str) -> list[str]:
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
for fr in result.functions:
|
|
115
|
+
if fr.error or not fr.spec.entries:
|
|
116
|
+
continue
|
|
117
|
+
out.append(f"{indent}{fr.fn.qualname}")
|
|
118
|
+
for e in fr.spec.entries:
|
|
119
|
+
when = " and ".join(e.when) if e.when else "always"
|
|
120
|
+
if e.outcome == "returns":
|
|
121
|
+
what = f"returns {e.value}"
|
|
122
|
+
elif e.outcome == "raises":
|
|
123
|
+
what = f"raises {e.value}"
|
|
124
|
+
elif e.outcome == "implicit-none":
|
|
125
|
+
what = "returns None (falls off the end)"
|
|
126
|
+
elif e.outcome == "truncated":
|
|
127
|
+
what = "... (path bound hit)"
|
|
128
|
+
else:
|
|
129
|
+
what = e.outcome
|
|
130
|
+
out.append(f"{indent} when {when}: {what}")
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------
|
|
135
|
+
# Markdown
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def render_markdown(result: ScanResult) -> str:
|
|
139
|
+
p = result.project
|
|
140
|
+
counts = result.counts_by_severity()
|
|
141
|
+
md: list[str] = []
|
|
142
|
+
md.append("# Deployment Guard - scan report")
|
|
143
|
+
md.append("")
|
|
144
|
+
md.append(f"- **Root:** `{p.root}`")
|
|
145
|
+
md.append(f"- **Languages:** {', '.join(sorted(p.languages)) or 'none detected'}")
|
|
146
|
+
md.append(f"- **Build systems:** {', '.join(p.build_systems) or 'unknown'}")
|
|
147
|
+
md.append(f"- **Modules:** {len(p.modules)} | **Functions analysed:** {len(result.functions)}")
|
|
148
|
+
finding_line = (
|
|
149
|
+
f"- **Findings:** {counts['block']} block, {counts['review']} review, "
|
|
150
|
+
f"{counts['warn']} warn"
|
|
151
|
+
)
|
|
152
|
+
if counts["note"]:
|
|
153
|
+
finding_line += f" ({counts['note']} note)"
|
|
154
|
+
md.append(finding_line)
|
|
155
|
+
if result.disabled_rules:
|
|
156
|
+
md.append(f"- **Disabled rules:** {', '.join(sorted(result.disabled_rules))}")
|
|
157
|
+
md.append("")
|
|
158
|
+
|
|
159
|
+
if result.findings:
|
|
160
|
+
md.append("## Findings")
|
|
161
|
+
md.append("")
|
|
162
|
+
md.append("| Severity | Rule | Location | Function | Detail |")
|
|
163
|
+
md.append("|---|---|---|---|---|")
|
|
164
|
+
for f in result.findings:
|
|
165
|
+
loc = f"{_rel(result, f.file)}:{f.lineno}"
|
|
166
|
+
detail = f.message + (f" _{f.detail}_" if f.detail else "")
|
|
167
|
+
md.append(
|
|
168
|
+
f"| {f.severity} | `{f.rule}` | `{loc}` | `{f.qualname}` | {detail} |"
|
|
169
|
+
)
|
|
170
|
+
md.append("")
|
|
171
|
+
|
|
172
|
+
if result.notes:
|
|
173
|
+
md.append("## Notes (informational, not gate findings)")
|
|
174
|
+
md.append("")
|
|
175
|
+
for n in result.notes:
|
|
176
|
+
md.append(
|
|
177
|
+
f"- `{n.rule}` `{_rel(result, n.file)}:{n.lineno}` `{n.qualname}` - {n.message}"
|
|
178
|
+
)
|
|
179
|
+
md.append("")
|
|
180
|
+
|
|
181
|
+
if result.parse_errors:
|
|
182
|
+
md.append("## Unparseable modules")
|
|
183
|
+
md.append("")
|
|
184
|
+
for name, err in result.parse_errors:
|
|
185
|
+
md.append(f"- `{name}` - {err}")
|
|
186
|
+
md.append("")
|
|
187
|
+
|
|
188
|
+
md.append("## Behavior spec")
|
|
189
|
+
md.append("")
|
|
190
|
+
for line in _behavior_block(result, indent=""):
|
|
191
|
+
if not line.startswith(" "):
|
|
192
|
+
md.append(f"### `{line}`")
|
|
193
|
+
else:
|
|
194
|
+
md.append(f"- {line.strip()}")
|
|
195
|
+
md.append("")
|
|
196
|
+
return "\n".join(md).rstrip() + "\n"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --------------------------------------------------------------------------
|
|
200
|
+
# JSON
|
|
201
|
+
# --------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def render_json(result: ScanResult) -> str:
|
|
204
|
+
p = result.project
|
|
205
|
+
payload = {
|
|
206
|
+
"root": str(p.root),
|
|
207
|
+
"languages": sorted(p.languages),
|
|
208
|
+
"build_systems": p.build_systems,
|
|
209
|
+
"modules": len(p.modules),
|
|
210
|
+
"functions_analysed": len(result.functions),
|
|
211
|
+
"project_notes": p.notes,
|
|
212
|
+
"disabled_rules": sorted(result.disabled_rules),
|
|
213
|
+
"counts": result.counts_by_severity(),
|
|
214
|
+
"findings": [
|
|
215
|
+
{
|
|
216
|
+
"rule": f.rule,
|
|
217
|
+
"severity": f.severity,
|
|
218
|
+
"message": f.message,
|
|
219
|
+
"detail": f.detail,
|
|
220
|
+
"qualname": f.qualname,
|
|
221
|
+
"file": _rel(result, f.file),
|
|
222
|
+
"lineno": f.lineno,
|
|
223
|
+
}
|
|
224
|
+
for f in result.findings
|
|
225
|
+
],
|
|
226
|
+
"notes": [
|
|
227
|
+
{
|
|
228
|
+
"rule": n.rule,
|
|
229
|
+
"message": n.message,
|
|
230
|
+
"qualname": n.qualname,
|
|
231
|
+
"file": _rel(result, n.file),
|
|
232
|
+
"lineno": n.lineno,
|
|
233
|
+
}
|
|
234
|
+
for n in result.notes
|
|
235
|
+
],
|
|
236
|
+
"parse_errors": [
|
|
237
|
+
{"module": name, "error": err} for name, err in result.parse_errors
|
|
238
|
+
],
|
|
239
|
+
"behavior": [
|
|
240
|
+
{
|
|
241
|
+
"qualname": fr.fn.qualname,
|
|
242
|
+
"truncated": fr.spec.truncated,
|
|
243
|
+
"paths": [
|
|
244
|
+
{
|
|
245
|
+
"when": e.when,
|
|
246
|
+
"outcome": e.outcome,
|
|
247
|
+
"value": e.value,
|
|
248
|
+
"lineno": e.lineno,
|
|
249
|
+
}
|
|
250
|
+
for e in fr.spec.entries
|
|
251
|
+
],
|
|
252
|
+
}
|
|
253
|
+
for fr in result.functions
|
|
254
|
+
if not fr.error
|
|
255
|
+
],
|
|
256
|
+
"generated": [g.relpath for g in result.generated],
|
|
257
|
+
}
|
|
258
|
+
return json.dumps(payload, indent=2)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _iter_lines(chunks: Iterable[str]) -> str: # pragma: no cover - helper
|
|
262
|
+
return "\n".join(chunks)
|
deploy_guard/store.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Management of the ``.deploy-guard/`` working directory.
|
|
2
|
+
|
|
3
|
+
Holds generated tests and (from M3) contract baselines and golden files.
|
|
4
|
+
Everything here is meant to be committed and reviewed in PRs like code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from deploy_guard.generators.base import GeneratedFile
|
|
12
|
+
|
|
13
|
+
STORE_DIRNAME = ".deploy-guard"
|
|
14
|
+
GENERATED_SUBDIR = "generated"
|
|
15
|
+
|
|
16
|
+
_GITIGNORE = """\
|
|
17
|
+
# Deployment Guard Engine - transient analysis artifacts.
|
|
18
|
+
# Generated tests and baselines ARE committed; caches are not.
|
|
19
|
+
cache/
|
|
20
|
+
*.log
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def store_dir(root: str | Path) -> Path:
|
|
25
|
+
return Path(root).resolve() / STORE_DIRNAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generated_dir(root: str | Path) -> Path:
|
|
29
|
+
return store_dir(root) / GENERATED_SUBDIR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_store(root: str | Path) -> Path:
|
|
33
|
+
d = store_dir(root)
|
|
34
|
+
(d / GENERATED_SUBDIR).mkdir(parents=True, exist_ok=True)
|
|
35
|
+
gi = d / ".gitignore"
|
|
36
|
+
if not gi.exists():
|
|
37
|
+
gi.write_text(_GITIGNORE, encoding="utf-8")
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_generated(root: str | Path, files: list[GeneratedFile]) -> list[Path]:
|
|
42
|
+
ensure_store(root)
|
|
43
|
+
out_dir = generated_dir(root)
|
|
44
|
+
written: list[Path] = []
|
|
45
|
+
for f in files:
|
|
46
|
+
target = out_dir / f.relpath
|
|
47
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
target.write_text(f.content, encoding="utf-8")
|
|
49
|
+
written.append(target)
|
|
50
|
+
return written
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deploy-guard-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static root-cause analysis for a Python stack trace - offline, no account. Plus a deployment-gate scanner.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sai55387/deploy-guard
|
|
6
|
+
Project-URL: Issues, https://github.com/sai55387/deploy-guard/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/sai55387/deploy-guard/blob/main/CHANGELOG.md
|
|
8
|
+
Author: sai55387
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ci,control-flow,debugging,deployment,incident,nullability,root-cause,stacktrace,static-analysis,traceback
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# deploy-guard
|
|
35
|
+
|
|
36
|
+
**Static root-cause analysis for a Python stack trace.** Paste a traceback,
|
|
37
|
+
point it at the repo, and get: which variable is `None` and *why*, the branch
|
|
38
|
+
path that reached the crash, where the bad value entered across the call
|
|
39
|
+
chain, and a concrete fix. Fully local — no server, no account, no
|
|
40
|
+
network. Zero dependencies. Python 3.10+.
|
|
41
|
+
|
|
42
|
+
It also ships a deployment-gate scanner (`scan` / `check`), but for
|
|
43
|
+
general-purpose linting you should run **[ruff](https://docs.astral.sh/ruff/)
|
|
44
|
+
+ [mypy](https://mypy-lang.org/)** — they are faster and deeper. What
|
|
45
|
+
deploy-guard does that they don't is turn *a traceback you already have* into
|
|
46
|
+
an explanation grounded in your code.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install deploy-guard-engine # or: pipx install deploy-guard-engine
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or run it with no install at all — a single ~120 KB file:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
./scripts/build-standalone.sh # -> dist/deploy-guard.pyz
|
|
58
|
+
python deploy-guard.pyz explain --project ./service < traceback.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Explain a traceback
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
deploy-guard explain --project ./service --file traceback.txt
|
|
65
|
+
# or pipe it
|
|
66
|
+
deploy-guard explain --project ./service < traceback.txt
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Explaining: AttributeError: 'NoneType' object has no attribute 'empty'
|
|
71
|
+
|
|
72
|
+
Crash site: dg_financial_profile/financial_main.py:231 create_json
|
|
73
|
+
231 | if profile_df.empty or profile_df is None:
|
|
74
|
+
|
|
75
|
+
Why:
|
|
76
|
+
- `profile_df` can be None here - the default passed to .get() is None.
|
|
77
|
+
- It reaches this line when: overall_financial_dict
|
|
78
|
+
- This line does check `profile_df is None`, but only after dereferencing it -
|
|
79
|
+
and/or evaluate left to right, so the attribute access raises first.
|
|
80
|
+
|
|
81
|
+
Call chain (from the traceback):
|
|
82
|
+
multiprocessing_script:164 lambda_handler
|
|
83
|
+
└─ financial_main:231 create_json <- raised
|
|
84
|
+
|
|
85
|
+
Matches scan finding: none-dereference (block) at financial_main.py:231
|
|
86
|
+
|
|
87
|
+
Fix: reorder the check so `profile_df is None` comes first.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Dedicated explanations for `AttributeError` / `TypeError` (None), `KeyError`,
|
|
91
|
+
`IndexError`, `ZeroDivisionError`, `UnboundLocalError`, `NameError`, and
|
|
92
|
+
`int()` / `float()` `ValueError`. Anything else falls back to the call chain
|
|
93
|
+
+ the branch conditions that reach the line. The call chain is analysed
|
|
94
|
+
*interprocedurally* — each project frame is checked, and a `None` that
|
|
95
|
+
originates in an argument is traced back to the caller that passed it.
|
|
96
|
+
|
|
97
|
+
## Scan a project
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
deploy-guard scan ./service # report, never fails
|
|
101
|
+
deploy-guard scan ./service --behavior # + the when-X-returns-Y table
|
|
102
|
+
deploy-guard check ./service --fail-on review # gate: exit 1 on findings
|
|
103
|
+
deploy-guard scan ./service --report report.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Rule | Severity | Catches |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `none-dereference` | block / review | attr/subscript on a possibly-`None` value (interprocedural: a call to a project function that can return `None` counts) |
|
|
109
|
+
| `inconsistent-return` | review / note | returns a value on some paths, `None` on others — **review only when an in-project caller uses the result without a guard**, otherwise a note |
|
|
110
|
+
| `swallowed-exception` | review | `except …: pass` |
|
|
111
|
+
| `unreachable-code` | warn | code after an always-diverting branch |
|
|
112
|
+
| `bare-except` | warn | `except:` with no type |
|
|
113
|
+
| `mutable-default-arg` | warn | `def f(x=[])` |
|
|
114
|
+
| `invalid-escape` | note | `"\d"` in a non-raw string |
|
|
115
|
+
| `path-explosion` | note | too-branchy function (detection still complete) |
|
|
116
|
+
|
|
117
|
+
Notes are collapsed to a one-line count; `--notes` lists them. Identical
|
|
118
|
+
findings from copy-pasted functions fold into one; `--no-collapse` expands.
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
A `[tool.deploy-guard]` table in the **scanned project's** `pyproject.toml`
|
|
123
|
+
(Python 3.11+ for TOML reading):
|
|
124
|
+
|
|
125
|
+
```toml
|
|
126
|
+
[tool.deploy-guard]
|
|
127
|
+
disable = ["bare-except"]
|
|
128
|
+
exclude = ["vendor", "migrations"]
|
|
129
|
+
fail-on = "review"
|
|
130
|
+
include-tests = false
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
CLI flags override the file.
|
|
134
|
+
|
|
135
|
+
## How it works
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
discover -> lower to IR -> CFG per function -> call graph
|
|
139
|
+
-> behavior spec (path conditions -> returns/raises)
|
|
140
|
+
-> nullability data-flow (merges None facts at every branch join; interprocedural)
|
|
141
|
+
-> findings | explain (traceback -> the above, focused on one failure)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The nullability pass is a forward fixpoint over the CFG — it merges
|
|
145
|
+
facts at branch joins, so it covers 100% of a function regardless of how
|
|
146
|
+
branchy it is, in roughly linear time.
|
|
147
|
+
|
|
148
|
+
`scan` and `explain` only **read** your code — never import or run it.
|
|
149
|
+
|
|
150
|
+
## Develop
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
pytest
|
|
155
|
+
ruff check src
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## What changed from the prototype
|
|
159
|
+
|
|
160
|
+
See [CHANGELOG.md](CHANGELOG.md). Headline: an interprocedural call graph, so
|
|
161
|
+
`explain` traces a `None` across frames and `inconsistent-return` no longer
|
|
162
|
+
fires on copy-pasted helpers whose callers all guard the result; plus real
|
|
163
|
+
packaging and a config file.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
deploy_guard/__init__.py,sha256=ZnpgqPXRdlEhr6-MW9oauCMFhnAi6LvV4L62zuPmmk4,299
|
|
2
|
+
deploy_guard/__main__.py,sha256=EpBj9gNzkz57SL1By2r7bQyQrK8hJzTiXgIVdjS6umc,91
|
|
3
|
+
deploy_guard/cli.py,sha256=Kdfc38f4m-OHyCbrIqbEjAUaswRHSxMrHcua2Oc4EnY,7288
|
|
4
|
+
deploy_guard/config.py,sha256=i3cq_P8cWKyfHt_sLYlr4_ONAHMEjQwuirPsfCKwNH0,2221
|
|
5
|
+
deploy_guard/engine.py,sha256=PCZFyGkhavDj9zjCiJ9i3BvIRD3oL1QyU6kX7z1vAnY,9567
|
|
6
|
+
deploy_guard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
deploy_guard/store.py,sha256=Uay0bRYRAPKhOVEejap4o7PVx00JWXK5J7M7ZtOhKtk,1367
|
|
8
|
+
deploy_guard/analysis/__init__.py,sha256=MBZ6qK7vPyivfM734ZuwhOenuUTmYl_KrMx99pt7rX0,1119
|
|
9
|
+
deploy_guard/analysis/callgraph.py,sha256=xg7GUZQniEIJHZyRvMYKDG86ee94Tuj183Y3eth1I8A,5078
|
|
10
|
+
deploy_guard/analysis/context.py,sha256=tmeV-yWWwlmQCdakpQ1VqenxN_0E-pklcLGLS5H-VjM,1182
|
|
11
|
+
deploy_guard/analysis/findings.py,sha256=fd7euStrpLJO49YKMsclvtp0Ale61ws3B3dlh6RafZM,11650
|
|
12
|
+
deploy_guard/analysis/nullability.py,sha256=vvYVp4fC3qAAY3sphmiGBm7pEryX1SslbRzs9S_a6UU,18082
|
|
13
|
+
deploy_guard/analysis/paths.py,sha256=9zl3Pcr3dPiscBB3LqdzRqZ0ZZvZmYvKDKoXBlv3qzU,6939
|
|
14
|
+
deploy_guard/explain/__init__.py,sha256=JRhvmlhCH8hzS3CNyMh1Oelcvt7YhG_VqVMO65SGtPQ,521
|
|
15
|
+
deploy_guard/explain/explainer.py,sha256=txzs0o5F7hlqqKcjwNnkFjEJjRpY9xF73VxI9TlwYN4,17682
|
|
16
|
+
deploy_guard/explain/render.py,sha256=lE5l3Y01IK3NTf8DFsEQDo1tXFuF9RDWfyyagJLZkNI,1766
|
|
17
|
+
deploy_guard/explain/traceback_parse.py,sha256=2_JyusIvHyBgM8JHeryokKwjGvMyLOj4xuTKA26Ak6Q,2496
|
|
18
|
+
deploy_guard/frontend/__init__.py,sha256=WfuzQpPNurN45Du2DXyPNHuM4dM8RSRc0jENUuZfQME,347
|
|
19
|
+
deploy_guard/frontend/python_cfg.py,sha256=tYKOGJ5oaPs7Frn_OQHMWfBOXwJ-pEuUPJaX3hnzYDU,12677
|
|
20
|
+
deploy_guard/frontend/python_frontend.py,sha256=4GLgzmI4Kc5fsHFSmSuqUHgaMYCIAi4xJ9_jPuNE_HI,5761
|
|
21
|
+
deploy_guard/generators/__init__.py,sha256=Da-ussjB1y1nN5U-HL-UwJ1V-SYwJcsfgM8JmmdAQtI,457
|
|
22
|
+
deploy_guard/generators/base.py,sha256=EKuB6MCEuzG8ri7EP_A2dxhSUN82LnYjkDxpqVwLP64,247
|
|
23
|
+
deploy_guard/generators/import_smoke.py,sha256=fJ2qjl170tlPLTc7RAUfuFb_OWT7hr2hKvSeGeUK5Ns,3071
|
|
24
|
+
deploy_guard/ingest/__init__.py,sha256=1HbCTyg5BaxrS4xLsCYgnSjC1OsZNYFIpgYYQBaA9k0,155
|
|
25
|
+
deploy_guard/ingest/discover.py,sha256=c_BwT67wu99Wbqh79qsrcT9VAwmxP2aWGNWAWbmdRHQ,5249
|
|
26
|
+
deploy_guard/ir/__init__.py,sha256=WrxIHAPxRwVvgWn5dCC3BIso7A1HEzT-L6hpMduFeYk,470
|
|
27
|
+
deploy_guard/ir/cfg.py,sha256=UuMgKhY5aDRL89j0wsLpJ7jG--KeFKgsp5jpBMxoW3Y,3778
|
|
28
|
+
deploy_guard/ir/model.py,sha256=Xa1r3QvMkMaoA0gJD28qvOUzo5lxf7YjDYXP9giWPXg,3980
|
|
29
|
+
deploy_guard/report/__init__.py,sha256=8Qe7ha2-GYiBc3JFuB16kHrgy09pExi6FyytKLu5X0o,228
|
|
30
|
+
deploy_guard/report/render.py,sha256=TVR537HN9VcWo800bbs9WJzVs7xvC6piUZbqZjZm85w,9121
|
|
31
|
+
deploy_guard_engine-0.1.0.dist-info/METADATA,sha256=zrsNu6ZN3dGScYjkY36lPT5-WKFn-T3o30OhcoQdtPE,6323
|
|
32
|
+
deploy_guard_engine-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
33
|
+
deploy_guard_engine-0.1.0.dist-info/entry_points.txt,sha256=bh2T76PdJzYqirWSMYzzSa4xbrV5AyySPxz3zsSUMf8,82
|
|
34
|
+
deploy_guard_engine-0.1.0.dist-info/licenses/LICENSE,sha256=3lCxvJJXjv3cG2n8Zw0LyBS1L6cfYcCgnh7MeZQBYsw,1065
|
|
35
|
+
deploy_guard_engine-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sai55387
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|