deploy-guard-engine 0.1.2__tar.gz → 0.1.4__tar.gz
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_engine-0.1.4/PKG-INFO +169 -0
- deploy_guard_engine-0.1.4/README.md +135 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/pyproject.toml +1 -1
- deploy_guard_engine-0.1.4/src/deploy_guard/__init__.py +10 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/__main__.py +4 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/__init__.py +39 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/callgraph.py +149 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/context.py +31 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/findings.py +333 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/nullability.py +461 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/analysis/paths.py +219 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/cli.py +190 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/config.py +72 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/engine.py +270 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/explain/__init__.py +16 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/explain/explainer.py +504 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/explain/render.py +60 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/explain/traceback_parse.py +89 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/frontend/__init__.py +10 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/frontend/python_cfg.py +332 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/frontend/python_frontend.py +182 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/generators/__init__.py +11 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/generators/base.py +12 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/generators/import_smoke.py +94 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/ingest/__init__.py +5 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/ingest/discover.py +166 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/ir/__init__.py +24 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/ir/cfg.py +114 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/ir/model.py +128 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/report/__init__.py +5 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/report/render.py +262 -0
- deploy_guard_engine-0.1.4/src/deploy_guard/store.py +50 -0
- deploy_guard_engine-0.1.4/src/deploy_guard_engine.egg-info/PKG-INFO +169 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/SOURCES.txt +8 -1
- deploy_guard_engine-0.1.4/tests/test_discover.py +62 -0
- deploy_guard_engine-0.1.4/tests/test_engine_and_cli.py +65 -0
- deploy_guard_engine-0.1.4/tests/test_explain.py +157 -0
- deploy_guard_engine-0.1.4/tests/test_interprocedural.py +190 -0
- deploy_guard_engine-0.1.4/tests/test_nullability.py +150 -0
- deploy_guard_engine-0.1.4/tests/test_paths_and_findings.py +121 -0
- deploy_guard_engine-0.1.4/tests/test_python_cfg.py +77 -0
- deploy_guard_engine-0.1.2/PKG-INFO +0 -37
- deploy_guard_engine-0.1.2/README.md +0 -3
- deploy_guard_engine-0.1.2/src/deploy_guard/__init__.py +0 -3
- deploy_guard_engine-0.1.2/src/deploy_guard/__main__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/callgraph.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/context.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/findings.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/nullability.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/analysis/paths.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/cli.py +0 -43
- deploy_guard_engine-0.1.2/src/deploy_guard/config.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/engine.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/explain/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/explain/explainer.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/explain/render.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/explain/traceback_parse.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/frontend/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/frontend/python_cfg.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/frontend/python_frontend.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/generators/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/generators/base.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/generators/import_smoke.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/ingest/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/ingest/discover.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/ir/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/ir/cfg.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/ir/model.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/report/__init__.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/report/render.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard/store.py +0 -1
- deploy_guard_engine-0.1.2/src/deploy_guard_engine.egg-info/PKG-INFO +0 -37
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/LICENSE +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/setup.cfg +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard/py.typed +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/dependency_links.txt +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/entry_points.txt +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/requires.txt +0 -0
- {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deploy-guard-engine
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Static root-cause analysis for a Python stack trace - offline, no account. Plus a deployment-gate scanner.
|
|
5
|
+
Author: sai55387
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Dineshsai7/deploy-guard-engine
|
|
8
|
+
Project-URL: Issues, https://github.com/Dineshsai7/deploy-guard-engine/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Dineshsai7/deploy-guard-engine/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: traceback,stacktrace,root-cause,debugging,static-analysis,control-flow,nullability,deployment,ci,incident
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# deploy-guard
|
|
36
|
+
|
|
37
|
+
**Static root-cause analysis for a Python stack trace.** Paste a traceback,
|
|
38
|
+
point it at the repo, and get: which variable is `None` and *why*, the branch
|
|
39
|
+
path that reached the crash, where the bad value entered across the call
|
|
40
|
+
chain, and a concrete fix. Fully local — no server, no account, no
|
|
41
|
+
network. Zero dependencies. Python 3.10+.
|
|
42
|
+
|
|
43
|
+
It also ships a deployment-gate scanner (`scan` / `check`), but for
|
|
44
|
+
general-purpose linting you should run **[ruff](https://docs.astral.sh/ruff/)
|
|
45
|
+
+ [mypy](https://mypy-lang.org/)** — they are faster and deeper. What
|
|
46
|
+
deploy-guard does that they don't is turn *a traceback you already have* into
|
|
47
|
+
an explanation grounded in your code.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install deploy-guard-engine # or: pipx install deploy-guard-engine
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or run it with no install at all — a single ~120 KB file:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
./scripts/build-standalone.sh # -> dist/deploy-guard.pyz
|
|
59
|
+
python deploy-guard.pyz explain --project ./service < traceback.txt
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Explain a traceback
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
deploy-guard explain --project ./service --file traceback.txt
|
|
66
|
+
# or pipe it
|
|
67
|
+
deploy-guard explain --project ./service < traceback.txt
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Explaining: AttributeError: 'NoneType' object has no attribute 'empty'
|
|
72
|
+
|
|
73
|
+
Crash site: project_folder/file_name.py:231 — create_json
|
|
74
|
+
|
|
75
|
+
231 | if _df.empty or _df is None:
|
|
76
|
+
|
|
77
|
+
Why:
|
|
78
|
+
|
|
79
|
+
_df can be None because the default value passed to .get() is None.
|
|
80
|
+
|
|
81
|
+
It reaches this line when processing report_dict.
|
|
82
|
+
|
|
83
|
+
Although the condition checks _df is None, Python evaluates the or expression from left to right.
|
|
84
|
+
|
|
85
|
+
Therefore, _df.empty is evaluated first. If _df is None, this raises an AttributeError before the None check is reached.
|
|
86
|
+
|
|
87
|
+
Call chain (from the traceback):
|
|
88
|
+
|
|
89
|
+
project_file:164 function_name
|
|
90
|
+
└─ file_locator.py:231 create_json <- exception raised
|
|
91
|
+
|
|
92
|
+
Scan finding:
|
|
93
|
+
none-dereference (block) at file_locator.py:231
|
|
94
|
+
|
|
95
|
+
Fix:
|
|
96
|
+
Reorder the condition so _df is None is checked first:
|
|
97
|
+
|
|
98
|
+
if _df is None or _df.empty:
|
|
99
|
+
|
|
100
|
+
This ensures _df.empty is only accessed when _df is not None.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Dedicated explanations for `AttributeError` / `TypeError` (None), `KeyError`,
|
|
104
|
+
`IndexError`, `ZeroDivisionError`, `UnboundLocalError`, `NameError`, and
|
|
105
|
+
`int()` / `float()` `ValueError`. Anything else falls back to the call chain
|
|
106
|
+
+ the branch conditions that reach the line. The call chain is analysed
|
|
107
|
+
*interprocedurally* — each project frame is checked, and a `None` that
|
|
108
|
+
originates in an argument is traced back to the caller that passed it.
|
|
109
|
+
|
|
110
|
+
## Scan a project
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
deploy-guard scan ./service # report, never fails
|
|
114
|
+
deploy-guard scan ./service --behavior # + the when-X-returns-Y table
|
|
115
|
+
deploy-guard check ./service --fail-on review # gate: exit 1 on findings
|
|
116
|
+
deploy-guard scan ./service --report report.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Rule | Severity | Catches |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `none-dereference` | block / review | attr/subscript on a possibly-`None` value (interprocedural: a call to a project function that can return `None` counts) |
|
|
122
|
+
| `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 |
|
|
123
|
+
| `swallowed-exception` | review | `except …: pass` |
|
|
124
|
+
| `unreachable-code` | warn | code after an always-diverting branch |
|
|
125
|
+
| `bare-except` | warn | `except:` with no type |
|
|
126
|
+
| `mutable-default-arg` | warn | `def f(x=[])` |
|
|
127
|
+
| `invalid-escape` | note | `"\d"` in a non-raw string |
|
|
128
|
+
| `path-explosion` | note | too-branchy function (detection still complete) |
|
|
129
|
+
|
|
130
|
+
Notes are collapsed to a one-line count; `--notes` lists them. Identical
|
|
131
|
+
findings from copy-pasted functions fold into one; `--no-collapse` expands.
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
A `[tool.deploy-guard]` table in the **scanned project's** `pyproject.toml`
|
|
136
|
+
(Python 3.11+ for TOML reading):
|
|
137
|
+
|
|
138
|
+
```toml
|
|
139
|
+
[tool.deploy-guard]
|
|
140
|
+
disable = ["bare-except"]
|
|
141
|
+
exclude = ["vendor", "migrations"]
|
|
142
|
+
fail-on = "review"
|
|
143
|
+
include-tests = false
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
CLI flags override the file.
|
|
147
|
+
|
|
148
|
+
## How it works
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
discover -> lower to IR -> CFG per function -> call graph
|
|
152
|
+
-> behavior spec (path conditions -> returns/raises)
|
|
153
|
+
-> nullability data-flow (merges None facts at every branch join; interprocedural)
|
|
154
|
+
-> findings | explain (traceback -> the above, focused on one failure)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The nullability pass is a forward fixpoint over the CFG — it merges
|
|
158
|
+
facts at branch joins, so it covers 100% of a function regardless of how
|
|
159
|
+
branchy it is, in roughly linear time.
|
|
160
|
+
|
|
161
|
+
`scan` and `explain` only **read** your code — never import or run it.
|
|
162
|
+
|
|
163
|
+
## Develop
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install -e ".[dev]"
|
|
167
|
+
pytest
|
|
168
|
+
ruff check src
|
|
169
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# deploy-guard
|
|
2
|
+
|
|
3
|
+
**Static root-cause analysis for a Python stack trace.** Paste a traceback,
|
|
4
|
+
point it at the repo, and get: which variable is `None` and *why*, the branch
|
|
5
|
+
path that reached the crash, where the bad value entered across the call
|
|
6
|
+
chain, and a concrete fix. Fully local — no server, no account, no
|
|
7
|
+
network. Zero dependencies. Python 3.10+.
|
|
8
|
+
|
|
9
|
+
It also ships a deployment-gate scanner (`scan` / `check`), but for
|
|
10
|
+
general-purpose linting you should run **[ruff](https://docs.astral.sh/ruff/)
|
|
11
|
+
+ [mypy](https://mypy-lang.org/)** — they are faster and deeper. What
|
|
12
|
+
deploy-guard does that they don't is turn *a traceback you already have* into
|
|
13
|
+
an explanation grounded in your code.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install deploy-guard-engine # or: pipx install deploy-guard-engine
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or run it with no install at all — a single ~120 KB file:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
./scripts/build-standalone.sh # -> dist/deploy-guard.pyz
|
|
25
|
+
python deploy-guard.pyz explain --project ./service < traceback.txt
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Explain a traceback
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
deploy-guard explain --project ./service --file traceback.txt
|
|
32
|
+
# or pipe it
|
|
33
|
+
deploy-guard explain --project ./service < traceback.txt
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Explaining: AttributeError: 'NoneType' object has no attribute 'empty'
|
|
38
|
+
|
|
39
|
+
Crash site: project_folder/file_name.py:231 — create_json
|
|
40
|
+
|
|
41
|
+
231 | if _df.empty or _df is None:
|
|
42
|
+
|
|
43
|
+
Why:
|
|
44
|
+
|
|
45
|
+
_df can be None because the default value passed to .get() is None.
|
|
46
|
+
|
|
47
|
+
It reaches this line when processing report_dict.
|
|
48
|
+
|
|
49
|
+
Although the condition checks _df is None, Python evaluates the or expression from left to right.
|
|
50
|
+
|
|
51
|
+
Therefore, _df.empty is evaluated first. If _df is None, this raises an AttributeError before the None check is reached.
|
|
52
|
+
|
|
53
|
+
Call chain (from the traceback):
|
|
54
|
+
|
|
55
|
+
project_file:164 function_name
|
|
56
|
+
└─ file_locator.py:231 create_json <- exception raised
|
|
57
|
+
|
|
58
|
+
Scan finding:
|
|
59
|
+
none-dereference (block) at file_locator.py:231
|
|
60
|
+
|
|
61
|
+
Fix:
|
|
62
|
+
Reorder the condition so _df is None is checked first:
|
|
63
|
+
|
|
64
|
+
if _df is None or _df.empty:
|
|
65
|
+
|
|
66
|
+
This ensures _df.empty is only accessed when _df is not None.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Dedicated explanations for `AttributeError` / `TypeError` (None), `KeyError`,
|
|
70
|
+
`IndexError`, `ZeroDivisionError`, `UnboundLocalError`, `NameError`, and
|
|
71
|
+
`int()` / `float()` `ValueError`. Anything else falls back to the call chain
|
|
72
|
+
+ the branch conditions that reach the line. The call chain is analysed
|
|
73
|
+
*interprocedurally* — each project frame is checked, and a `None` that
|
|
74
|
+
originates in an argument is traced back to the caller that passed it.
|
|
75
|
+
|
|
76
|
+
## Scan a project
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
deploy-guard scan ./service # report, never fails
|
|
80
|
+
deploy-guard scan ./service --behavior # + the when-X-returns-Y table
|
|
81
|
+
deploy-guard check ./service --fail-on review # gate: exit 1 on findings
|
|
82
|
+
deploy-guard scan ./service --report report.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Rule | Severity | Catches |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `none-dereference` | block / review | attr/subscript on a possibly-`None` value (interprocedural: a call to a project function that can return `None` counts) |
|
|
88
|
+
| `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 |
|
|
89
|
+
| `swallowed-exception` | review | `except …: pass` |
|
|
90
|
+
| `unreachable-code` | warn | code after an always-diverting branch |
|
|
91
|
+
| `bare-except` | warn | `except:` with no type |
|
|
92
|
+
| `mutable-default-arg` | warn | `def f(x=[])` |
|
|
93
|
+
| `invalid-escape` | note | `"\d"` in a non-raw string |
|
|
94
|
+
| `path-explosion` | note | too-branchy function (detection still complete) |
|
|
95
|
+
|
|
96
|
+
Notes are collapsed to a one-line count; `--notes` lists them. Identical
|
|
97
|
+
findings from copy-pasted functions fold into one; `--no-collapse` expands.
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
A `[tool.deploy-guard]` table in the **scanned project's** `pyproject.toml`
|
|
102
|
+
(Python 3.11+ for TOML reading):
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[tool.deploy-guard]
|
|
106
|
+
disable = ["bare-except"]
|
|
107
|
+
exclude = ["vendor", "migrations"]
|
|
108
|
+
fail-on = "review"
|
|
109
|
+
include-tests = false
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
CLI flags override the file.
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
discover -> lower to IR -> CFG per function -> call graph
|
|
118
|
+
-> behavior spec (path conditions -> returns/raises)
|
|
119
|
+
-> nullability data-flow (merges None facts at every branch join; interprocedural)
|
|
120
|
+
-> findings | explain (traceback -> the above, focused on one failure)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The nullability pass is a forward fixpoint over the CFG — it merges
|
|
124
|
+
facts at branch joins, so it covers 100% of a function regardless of how
|
|
125
|
+
branchy it is, in roughly linear time.
|
|
126
|
+
|
|
127
|
+
`scan` and `explain` only **read** your code — never import or run it.
|
|
128
|
+
|
|
129
|
+
## Develop
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install -e ".[dev]"
|
|
133
|
+
pytest
|
|
134
|
+
ruff check src
|
|
135
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deploy-guard-engine"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "Static root-cause analysis for a Python stack trace - offline, no account. Plus a deployment-gate scanner."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Deployment Guard Engine.
|
|
2
|
+
|
|
3
|
+
A local engine that reads a Python or Java codebase, reasons about every
|
|
4
|
+
branch and return path, and blocks a deploy when the logic says production
|
|
5
|
+
would break.
|
|
6
|
+
|
|
7
|
+
The public entrypoint is the ``deploy_guard`` CLI (see :mod:`deploy_guard.cli`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.4"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Analysis passes over the shared IR / CFG.
|
|
2
|
+
|
|
3
|
+
* ``paths`` - path enumeration -> behavior spec
|
|
4
|
+
* ``nullability`` - flow-sensitive None analysis (merges at joins)
|
|
5
|
+
* ``callgraph`` - best-effort intra-project call graph
|
|
6
|
+
* ``context`` - whole-project context shared by the per-function passes
|
|
7
|
+
* ``findings`` - the rules, mapped onto the deployment gate
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from deploy_guard.analysis.callgraph import CallGraph, CallSite, build_call_graph
|
|
11
|
+
from deploy_guard.analysis.context import AnalysisContext
|
|
12
|
+
from deploy_guard.analysis.findings import Finding, analyze_function
|
|
13
|
+
from deploy_guard.analysis.nullability import NV, NullabilityAnalysis, NullFinding
|
|
14
|
+
from deploy_guard.analysis.paths import (
|
|
15
|
+
BehaviorEntry,
|
|
16
|
+
BehaviorSpec,
|
|
17
|
+
ExecPath,
|
|
18
|
+
PathSet,
|
|
19
|
+
behavior_spec,
|
|
20
|
+
enumerate_paths,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Finding",
|
|
25
|
+
"analyze_function",
|
|
26
|
+
"NullabilityAnalysis",
|
|
27
|
+
"NullFinding",
|
|
28
|
+
"NV",
|
|
29
|
+
"CallGraph",
|
|
30
|
+
"CallSite",
|
|
31
|
+
"build_call_graph",
|
|
32
|
+
"AnalysisContext",
|
|
33
|
+
"ExecPath",
|
|
34
|
+
"PathSet",
|
|
35
|
+
"BehaviorEntry",
|
|
36
|
+
"BehaviorSpec",
|
|
37
|
+
"enumerate_paths",
|
|
38
|
+
"behavior_spec",
|
|
39
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""A best-effort intra-project call graph.
|
|
2
|
+
|
|
3
|
+
Full import resolution is out of scope; instead every function is indexed by
|
|
4
|
+
its qualified name, its bare name, and its ``Class.method`` suffix, and call
|
|
5
|
+
sites are resolved against those indexes. A call resolves when exactly one
|
|
6
|
+
project function matches; otherwise it is kept as an unresolved name.
|
|
7
|
+
|
|
8
|
+
Two things depend on this:
|
|
9
|
+
|
|
10
|
+
* caller-aware ``inconsistent-return`` - only flag a value/None function when
|
|
11
|
+
a caller actually uses the result without a guard;
|
|
12
|
+
* ``explain`` - analyse every project frame in a traceback and trace where a
|
|
13
|
+
``None`` entered.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from deploy_guard.ir.model import FunctionDef, Project
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CallSite:
|
|
27
|
+
caller: str # qualname of the enclosing function
|
|
28
|
+
callee_name: str # the name as written (bare or dotted tail)
|
|
29
|
+
callee_qualname: str | None # resolved project function, or None
|
|
30
|
+
node: ast.Call
|
|
31
|
+
lineno: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CallGraph:
|
|
36
|
+
functions: dict[str, FunctionDef] = field(default_factory=dict)
|
|
37
|
+
_by_bare: dict[str, list[str]] = field(default_factory=dict)
|
|
38
|
+
_by_method: dict[str, list[str]] = field(default_factory=dict)
|
|
39
|
+
call_sites: list[CallSite] = field(default_factory=list)
|
|
40
|
+
_callers: dict[str, list[CallSite]] = field(default_factory=dict)
|
|
41
|
+
_callees: dict[str, list[CallSite]] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
# -- lookups --------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def function(self, qualname: str) -> FunctionDef | None:
|
|
46
|
+
return self.functions.get(qualname)
|
|
47
|
+
|
|
48
|
+
def resolve(self, name: str) -> FunctionDef | None:
|
|
49
|
+
if name in self.functions:
|
|
50
|
+
return self.functions[name]
|
|
51
|
+
bare = name.rsplit(".", 1)[-1]
|
|
52
|
+
hits = self._by_bare.get(bare) or []
|
|
53
|
+
if len(hits) == 1:
|
|
54
|
+
return self.functions[hits[0]]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def callers_of(self, qualname: str) -> list[CallSite]:
|
|
58
|
+
return list(self._callers.get(qualname, ()))
|
|
59
|
+
|
|
60
|
+
def callees_of(self, qualname: str) -> list[CallSite]:
|
|
61
|
+
return list(self._callees.get(qualname, ()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_call_graph(project: Project) -> CallGraph:
|
|
65
|
+
cg = CallGraph()
|
|
66
|
+
|
|
67
|
+
for _module, fn in project.iter_functions():
|
|
68
|
+
cg.functions[fn.qualname] = fn
|
|
69
|
+
bare = fn.name
|
|
70
|
+
cg._by_bare.setdefault(bare, []).append(fn.qualname)
|
|
71
|
+
if fn.is_method:
|
|
72
|
+
# Class.method (drop the module prefix)
|
|
73
|
+
parts = fn.qualname.split(".")
|
|
74
|
+
if len(parts) >= 2:
|
|
75
|
+
cg._by_method.setdefault(".".join(parts[-2:]), []).append(fn.qualname)
|
|
76
|
+
|
|
77
|
+
for _module, fn in project.iter_functions():
|
|
78
|
+
if not isinstance(fn.raw, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
79
|
+
continue
|
|
80
|
+
enclosing_class = _enclosing_class(fn.qualname)
|
|
81
|
+
for call in _iter_calls(fn.raw):
|
|
82
|
+
name = _callee_name(call.func)
|
|
83
|
+
if name is None:
|
|
84
|
+
continue
|
|
85
|
+
qn = _resolve_call(cg, name, call.func, enclosing_class)
|
|
86
|
+
site = CallSite(
|
|
87
|
+
caller=fn.qualname,
|
|
88
|
+
callee_name=name,
|
|
89
|
+
callee_qualname=qn,
|
|
90
|
+
node=call,
|
|
91
|
+
lineno=getattr(call, "lineno", fn.span.lineno),
|
|
92
|
+
)
|
|
93
|
+
cg.call_sites.append(site)
|
|
94
|
+
if qn is not None:
|
|
95
|
+
cg._callers.setdefault(qn, []).append(site)
|
|
96
|
+
cg._callees.setdefault(fn.qualname, []).append(site)
|
|
97
|
+
|
|
98
|
+
return cg
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _iter_calls(node: ast.AST) -> Iterator[ast.Call]:
|
|
104
|
+
for sub in ast.walk(node):
|
|
105
|
+
if isinstance(sub, ast.Call):
|
|
106
|
+
yield sub
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _callee_name(func: ast.AST) -> str | None:
|
|
110
|
+
if isinstance(func, ast.Name):
|
|
111
|
+
return func.id
|
|
112
|
+
if isinstance(func, ast.Attribute):
|
|
113
|
+
if isinstance(func.value, ast.Name):
|
|
114
|
+
return f"{func.value.id}.{func.attr}"
|
|
115
|
+
return func.attr
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _enclosing_class(qualname: str) -> str | None:
|
|
120
|
+
parts = qualname.split(".")
|
|
121
|
+
return parts[-2] if len(parts) >= 3 else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_call(
|
|
125
|
+
cg: CallGraph, name: str, func: ast.AST, enclosing_class: str | None
|
|
126
|
+
) -> str | None:
|
|
127
|
+
# self.method() / cls.method()
|
|
128
|
+
if (
|
|
129
|
+
isinstance(func, ast.Attribute)
|
|
130
|
+
and isinstance(func.value, ast.Name)
|
|
131
|
+
and func.value.id in ("self", "cls")
|
|
132
|
+
and enclosing_class
|
|
133
|
+
):
|
|
134
|
+
key = f"{enclosing_class}.{func.attr}"
|
|
135
|
+
hits = cg._by_method.get(key) or []
|
|
136
|
+
if len(hits) == 1:
|
|
137
|
+
return hits[0]
|
|
138
|
+
|
|
139
|
+
# module.func() or obj.method()
|
|
140
|
+
tail = name.rsplit(".", 1)[-1]
|
|
141
|
+
hits = cg._by_bare.get(tail) or []
|
|
142
|
+
if len(hits) == 1:
|
|
143
|
+
return hits[0]
|
|
144
|
+
if len(hits) > 1 and "." in name:
|
|
145
|
+
prefix = name.rsplit(".", 1)[0]
|
|
146
|
+
narrowed = [q for q in hits if f".{prefix}." in f".{q}." or q.startswith(prefix + ".")]
|
|
147
|
+
if len(narrowed) == 1:
|
|
148
|
+
return narrowed[0]
|
|
149
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Whole-project context shared by the per-function analyses.
|
|
2
|
+
|
|
3
|
+
Built once per scan, after every function's CFG and behavior spec exist, so
|
|
4
|
+
that a single function's analysis can ask questions about the rest of the
|
|
5
|
+
project (does this callee return None? who calls me and how?).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from deploy_guard.analysis.callgraph import CallGraph
|
|
13
|
+
from deploy_guard.analysis.paths import BehaviorSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AnalysisContext:
|
|
18
|
+
callgraph: CallGraph
|
|
19
|
+
specs: dict[str, BehaviorSpec] = field(default_factory=dict)
|
|
20
|
+
# qualnames of project functions that can return None on some path
|
|
21
|
+
returns_none: set[str] = field(default_factory=set)
|
|
22
|
+
# qualnames that return a non-None value on some path
|
|
23
|
+
returns_value: set[str] = field(default_factory=set)
|
|
24
|
+
|
|
25
|
+
def callee_can_be_none(self, name: str) -> str | None:
|
|
26
|
+
"""If ``name`` resolves to a project function that can return None,
|
|
27
|
+
return that function's qualname; else None."""
|
|
28
|
+
fn = self.callgraph.resolve(name)
|
|
29
|
+
if fn is not None and fn.qualname in self.returns_none:
|
|
30
|
+
return fn.qualname
|
|
31
|
+
return None
|