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.
Files changed (80) hide show
  1. deploy_guard_engine-0.1.4/PKG-INFO +169 -0
  2. deploy_guard_engine-0.1.4/README.md +135 -0
  3. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/pyproject.toml +1 -1
  4. deploy_guard_engine-0.1.4/src/deploy_guard/__init__.py +10 -0
  5. deploy_guard_engine-0.1.4/src/deploy_guard/__main__.py +4 -0
  6. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/__init__.py +39 -0
  7. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/callgraph.py +149 -0
  8. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/context.py +31 -0
  9. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/findings.py +333 -0
  10. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/nullability.py +461 -0
  11. deploy_guard_engine-0.1.4/src/deploy_guard/analysis/paths.py +219 -0
  12. deploy_guard_engine-0.1.4/src/deploy_guard/cli.py +190 -0
  13. deploy_guard_engine-0.1.4/src/deploy_guard/config.py +72 -0
  14. deploy_guard_engine-0.1.4/src/deploy_guard/engine.py +270 -0
  15. deploy_guard_engine-0.1.4/src/deploy_guard/explain/__init__.py +16 -0
  16. deploy_guard_engine-0.1.4/src/deploy_guard/explain/explainer.py +504 -0
  17. deploy_guard_engine-0.1.4/src/deploy_guard/explain/render.py +60 -0
  18. deploy_guard_engine-0.1.4/src/deploy_guard/explain/traceback_parse.py +89 -0
  19. deploy_guard_engine-0.1.4/src/deploy_guard/frontend/__init__.py +10 -0
  20. deploy_guard_engine-0.1.4/src/deploy_guard/frontend/python_cfg.py +332 -0
  21. deploy_guard_engine-0.1.4/src/deploy_guard/frontend/python_frontend.py +182 -0
  22. deploy_guard_engine-0.1.4/src/deploy_guard/generators/__init__.py +11 -0
  23. deploy_guard_engine-0.1.4/src/deploy_guard/generators/base.py +12 -0
  24. deploy_guard_engine-0.1.4/src/deploy_guard/generators/import_smoke.py +94 -0
  25. deploy_guard_engine-0.1.4/src/deploy_guard/ingest/__init__.py +5 -0
  26. deploy_guard_engine-0.1.4/src/deploy_guard/ingest/discover.py +166 -0
  27. deploy_guard_engine-0.1.4/src/deploy_guard/ir/__init__.py +24 -0
  28. deploy_guard_engine-0.1.4/src/deploy_guard/ir/cfg.py +114 -0
  29. deploy_guard_engine-0.1.4/src/deploy_guard/ir/model.py +128 -0
  30. deploy_guard_engine-0.1.4/src/deploy_guard/report/__init__.py +5 -0
  31. deploy_guard_engine-0.1.4/src/deploy_guard/report/render.py +262 -0
  32. deploy_guard_engine-0.1.4/src/deploy_guard/store.py +50 -0
  33. deploy_guard_engine-0.1.4/src/deploy_guard_engine.egg-info/PKG-INFO +169 -0
  34. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/SOURCES.txt +8 -1
  35. deploy_guard_engine-0.1.4/tests/test_discover.py +62 -0
  36. deploy_guard_engine-0.1.4/tests/test_engine_and_cli.py +65 -0
  37. deploy_guard_engine-0.1.4/tests/test_explain.py +157 -0
  38. deploy_guard_engine-0.1.4/tests/test_interprocedural.py +190 -0
  39. deploy_guard_engine-0.1.4/tests/test_nullability.py +150 -0
  40. deploy_guard_engine-0.1.4/tests/test_paths_and_findings.py +121 -0
  41. deploy_guard_engine-0.1.4/tests/test_python_cfg.py +77 -0
  42. deploy_guard_engine-0.1.2/PKG-INFO +0 -37
  43. deploy_guard_engine-0.1.2/README.md +0 -3
  44. deploy_guard_engine-0.1.2/src/deploy_guard/__init__.py +0 -3
  45. deploy_guard_engine-0.1.2/src/deploy_guard/__main__.py +0 -1
  46. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/__init__.py +0 -1
  47. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/callgraph.py +0 -1
  48. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/context.py +0 -1
  49. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/findings.py +0 -1
  50. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/nullability.py +0 -1
  51. deploy_guard_engine-0.1.2/src/deploy_guard/analysis/paths.py +0 -1
  52. deploy_guard_engine-0.1.2/src/deploy_guard/cli.py +0 -43
  53. deploy_guard_engine-0.1.2/src/deploy_guard/config.py +0 -1
  54. deploy_guard_engine-0.1.2/src/deploy_guard/engine.py +0 -1
  55. deploy_guard_engine-0.1.2/src/deploy_guard/explain/__init__.py +0 -1
  56. deploy_guard_engine-0.1.2/src/deploy_guard/explain/explainer.py +0 -1
  57. deploy_guard_engine-0.1.2/src/deploy_guard/explain/render.py +0 -1
  58. deploy_guard_engine-0.1.2/src/deploy_guard/explain/traceback_parse.py +0 -1
  59. deploy_guard_engine-0.1.2/src/deploy_guard/frontend/__init__.py +0 -1
  60. deploy_guard_engine-0.1.2/src/deploy_guard/frontend/python_cfg.py +0 -1
  61. deploy_guard_engine-0.1.2/src/deploy_guard/frontend/python_frontend.py +0 -1
  62. deploy_guard_engine-0.1.2/src/deploy_guard/generators/__init__.py +0 -1
  63. deploy_guard_engine-0.1.2/src/deploy_guard/generators/base.py +0 -1
  64. deploy_guard_engine-0.1.2/src/deploy_guard/generators/import_smoke.py +0 -1
  65. deploy_guard_engine-0.1.2/src/deploy_guard/ingest/__init__.py +0 -1
  66. deploy_guard_engine-0.1.2/src/deploy_guard/ingest/discover.py +0 -1
  67. deploy_guard_engine-0.1.2/src/deploy_guard/ir/__init__.py +0 -1
  68. deploy_guard_engine-0.1.2/src/deploy_guard/ir/cfg.py +0 -1
  69. deploy_guard_engine-0.1.2/src/deploy_guard/ir/model.py +0 -1
  70. deploy_guard_engine-0.1.2/src/deploy_guard/report/__init__.py +0 -1
  71. deploy_guard_engine-0.1.2/src/deploy_guard/report/render.py +0 -1
  72. deploy_guard_engine-0.1.2/src/deploy_guard/store.py +0 -1
  73. deploy_guard_engine-0.1.2/src/deploy_guard_engine.egg-info/PKG-INFO +0 -37
  74. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/LICENSE +0 -0
  75. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/setup.cfg +0 -0
  76. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard/py.typed +0 -0
  77. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/dependency_links.txt +0 -0
  78. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/entry_points.txt +0 -0
  79. {deploy_guard_engine-0.1.2 → deploy_guard_engine-0.1.4}/src/deploy_guard_engine.egg-info/requires.txt +0 -0
  80. {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* &mdash; 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 &mdash; **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 &mdash; 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 &mdash; 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 &mdash; 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/)** &mdash; 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 &mdash; 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* &mdash; 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 &mdash; **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 &mdash; 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 &mdash; 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.2"
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,4 @@
1
+ from deploy_guard.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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