deploy-guard-engine 0.1.0__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 (57) hide show
  1. deploy_guard_engine-0.1.0/.github/workflows/ci.yml +37 -0
  2. deploy_guard_engine-0.1.0/.github/workflows/publish.yml +24 -0
  3. deploy_guard_engine-0.1.0/.gitignore +19 -0
  4. deploy_guard_engine-0.1.0/CHANGELOG.md +52 -0
  5. deploy_guard_engine-0.1.0/LICENSE +21 -0
  6. deploy_guard_engine-0.1.0/PKG-INFO +163 -0
  7. deploy_guard_engine-0.1.0/README.md +130 -0
  8. deploy_guard_engine-0.1.0/docs/architecture.html +570 -0
  9. deploy_guard_engine-0.1.0/docs/deploy-guard-KT.pptx +0 -0
  10. deploy_guard_engine-0.1.0/examples/sample_service/__init__.py +1 -0
  11. deploy_guard_engine-0.1.0/examples/sample_service/broken.py +4 -0
  12. deploy_guard_engine-0.1.0/examples/sample_service/handlers.py +16 -0
  13. deploy_guard_engine-0.1.0/examples/sample_service/lookup.py +27 -0
  14. deploy_guard_engine-0.1.0/examples/sample_service/pricing.py +31 -0
  15. deploy_guard_engine-0.1.0/pyproject.toml +74 -0
  16. deploy_guard_engine-0.1.0/scripts/build-standalone.sh +25 -0
  17. deploy_guard_engine-0.1.0/scripts/make_kt_deck.py +403 -0
  18. deploy_guard_engine-0.1.0/src/deploy_guard/__init__.py +10 -0
  19. deploy_guard_engine-0.1.0/src/deploy_guard/__main__.py +4 -0
  20. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/__init__.py +39 -0
  21. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/callgraph.py +149 -0
  22. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/context.py +31 -0
  23. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/findings.py +333 -0
  24. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/nullability.py +461 -0
  25. deploy_guard_engine-0.1.0/src/deploy_guard/analysis/paths.py +219 -0
  26. deploy_guard_engine-0.1.0/src/deploy_guard/cli.py +190 -0
  27. deploy_guard_engine-0.1.0/src/deploy_guard/config.py +72 -0
  28. deploy_guard_engine-0.1.0/src/deploy_guard/engine.py +270 -0
  29. deploy_guard_engine-0.1.0/src/deploy_guard/explain/__init__.py +16 -0
  30. deploy_guard_engine-0.1.0/src/deploy_guard/explain/explainer.py +504 -0
  31. deploy_guard_engine-0.1.0/src/deploy_guard/explain/render.py +60 -0
  32. deploy_guard_engine-0.1.0/src/deploy_guard/explain/traceback_parse.py +89 -0
  33. deploy_guard_engine-0.1.0/src/deploy_guard/frontend/__init__.py +10 -0
  34. deploy_guard_engine-0.1.0/src/deploy_guard/frontend/python_cfg.py +332 -0
  35. deploy_guard_engine-0.1.0/src/deploy_guard/frontend/python_frontend.py +182 -0
  36. deploy_guard_engine-0.1.0/src/deploy_guard/generators/__init__.py +11 -0
  37. deploy_guard_engine-0.1.0/src/deploy_guard/generators/base.py +12 -0
  38. deploy_guard_engine-0.1.0/src/deploy_guard/generators/import_smoke.py +94 -0
  39. deploy_guard_engine-0.1.0/src/deploy_guard/ingest/__init__.py +5 -0
  40. deploy_guard_engine-0.1.0/src/deploy_guard/ingest/discover.py +166 -0
  41. deploy_guard_engine-0.1.0/src/deploy_guard/ir/__init__.py +24 -0
  42. deploy_guard_engine-0.1.0/src/deploy_guard/ir/cfg.py +114 -0
  43. deploy_guard_engine-0.1.0/src/deploy_guard/ir/model.py +128 -0
  44. deploy_guard_engine-0.1.0/src/deploy_guard/py.typed +0 -0
  45. deploy_guard_engine-0.1.0/src/deploy_guard/report/__init__.py +5 -0
  46. deploy_guard_engine-0.1.0/src/deploy_guard/report/render.py +262 -0
  47. deploy_guard_engine-0.1.0/src/deploy_guard/store.py +50 -0
  48. deploy_guard_engine-0.1.0/tests/__init__.py +0 -0
  49. deploy_guard_engine-0.1.0/tests/conftest.py +10 -0
  50. deploy_guard_engine-0.1.0/tests/helpers.py +25 -0
  51. deploy_guard_engine-0.1.0/tests/test_discover.py +62 -0
  52. deploy_guard_engine-0.1.0/tests/test_engine_and_cli.py +65 -0
  53. deploy_guard_engine-0.1.0/tests/test_explain.py +157 -0
  54. deploy_guard_engine-0.1.0/tests/test_interprocedural.py +190 -0
  55. deploy_guard_engine-0.1.0/tests/test_nullability.py +150 -0
  56. deploy_guard_engine-0.1.0/tests/test_paths_and_findings.py +121 -0
  57. deploy_guard_engine-0.1.0/tests/test_python_cfg.py +77 -0
@@ -0,0 +1,37 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: python -m pip install --upgrade pip
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check src
22
+ - run: pytest -q
23
+
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ needs: test
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.12"
32
+ - run: pip install build
33
+ - run: python -m build
34
+ - uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
@@ -0,0 +1,24 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI on a tagged release using Trusted Publishing (OIDC) -
4
+ # no API token needed. Configure the publisher at
5
+ # https://pypi.org/manage/project/deploy-guard/settings/publishing/
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ permissions:
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+ - run: pip install build
23
+ - run: python -m build
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ deploy_guard_env/
10
+ deployguard_env/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ .idea/
17
+ .vscode/
18
+ .deploy-guard/cache/
19
+ .deploy-guard/*.log
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.1.0] - 2026-09-01
7
+
8
+ First public release. Reworked from an internal prototype ("deployguard").
9
+
10
+ ### Added
11
+ - **Interprocedural call graph** (`deploy_guard.analysis.callgraph`) - best-effort
12
+ resolution of intra-project calls by qualified name, bare name, and
13
+ `Class.method` suffix.
14
+ - **Caller-aware `inconsistent-return`** - a value/None function is only `review`
15
+ when an in-project caller dereferences or returns the result without a guard;
16
+ otherwise it drops to a `note`. Kills the copy-pasted-helper false-positive
17
+ flood.
18
+ - **Interprocedural nullability** - a call that resolves to a project function
19
+ which can return `None` now makes the result nullable, so
20
+ `x = compute(); x.attr` is flagged when `compute()` is a local function.
21
+ - **`explain` walks the whole call chain** - every project frame in the
22
+ traceback is analysed, not just the deepest.
23
+ - **Configuration file** - `[tool.deploy-guard]` in the scanned project's
24
+ `pyproject.toml` (Python 3.11+ for TOML reading). CLI flags override.
25
+ - Packaging: `LICENSE` (MIT), trove classifiers, `dg` short alias, this
26
+ changelog, a standalone single-file build.
27
+
28
+ ### Changed
29
+ - Renamed distribution to **`deploy-guard`** (import package `deploy_guard`);
30
+ `deployguard` was already taken on PyPI.
31
+ - README leads with `explain`; `scan` is positioned as a supporting gate, with
32
+ a pointer to ruff + mypy for general linting.
33
+
34
+ ### Carried over from the prototype
35
+ - Python frontend, CFG builder (`if`/`while`/`for`/`with`/`try`/`match` + async),
36
+ path enumeration -> behavior spec.
37
+ - Flow-sensitive nullability with `is None` / truthiness / `isinstance` guards,
38
+ early-return guards, short-circuit narrowing in `and`/`or`/ternary, `.get()`
39
+ and `re.match/search` sources, `Optional[...]` params.
40
+ - Findings: `none-dereference`, `swallowed-exception`, `unreachable-code`,
41
+ `bare-except`, `mutable-default-arg`, `invalid-escape`, `path-explosion`.
42
+ - Import-smoke test generator.
43
+ - `explain` traceback parser with dedicated handlers for AttributeError,
44
+ TypeError, KeyError, IndexError, ZeroDivisionError, UnboundLocalError,
45
+ NameError, ValueError; the "checks in the wrong order" special case.
46
+ - Text / Markdown / JSON output, duplicate-finding collapse, `--disable`.
47
+
48
+ ### Known limitations
49
+ - Python only (a Java frontend would reuse the analysis unchanged).
50
+ - Call resolution is name-based, not full import resolution - ambiguous names
51
+ stay unresolved.
52
+ - No Z3-backed concrete repro inputs yet.
@@ -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.
@@ -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* &mdash; 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 &mdash; **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 &mdash; 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 &mdash; 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,130 @@
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: dg_financial_profile/financial_main.py:231 create_json
40
+ 231 | if profile_df.empty or profile_df is None:
41
+
42
+ Why:
43
+ - `profile_df` can be None here - the default passed to .get() is None.
44
+ - It reaches this line when: overall_financial_dict
45
+ - This line does check `profile_df is None`, but only after dereferencing it -
46
+ and/or evaluate left to right, so the attribute access raises first.
47
+
48
+ Call chain (from the traceback):
49
+ multiprocessing_script:164 lambda_handler
50
+ └─ financial_main:231 create_json <- raised
51
+
52
+ Matches scan finding: none-dereference (block) at financial_main.py:231
53
+
54
+ Fix: reorder the check so `profile_df is None` comes first.
55
+ ```
56
+
57
+ Dedicated explanations for `AttributeError` / `TypeError` (None), `KeyError`,
58
+ `IndexError`, `ZeroDivisionError`, `UnboundLocalError`, `NameError`, and
59
+ `int()` / `float()` `ValueError`. Anything else falls back to the call chain
60
+ + the branch conditions that reach the line. The call chain is analysed
61
+ *interprocedurally* &mdash; each project frame is checked, and a `None` that
62
+ originates in an argument is traced back to the caller that passed it.
63
+
64
+ ## Scan a project
65
+
66
+ ```bash
67
+ deploy-guard scan ./service # report, never fails
68
+ deploy-guard scan ./service --behavior # + the when-X-returns-Y table
69
+ deploy-guard check ./service --fail-on review # gate: exit 1 on findings
70
+ deploy-guard scan ./service --report report.json
71
+ ```
72
+
73
+ | Rule | Severity | Catches |
74
+ |---|---|---|
75
+ | `none-dereference` | block / review | attr/subscript on a possibly-`None` value (interprocedural: a call to a project function that can return `None` counts) |
76
+ | `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 |
77
+ | `swallowed-exception` | review | `except …: pass` |
78
+ | `unreachable-code` | warn | code after an always-diverting branch |
79
+ | `bare-except` | warn | `except:` with no type |
80
+ | `mutable-default-arg` | warn | `def f(x=[])` |
81
+ | `invalid-escape` | note | `"\d"` in a non-raw string |
82
+ | `path-explosion` | note | too-branchy function (detection still complete) |
83
+
84
+ Notes are collapsed to a one-line count; `--notes` lists them. Identical
85
+ findings from copy-pasted functions fold into one; `--no-collapse` expands.
86
+
87
+ ## Configuration
88
+
89
+ A `[tool.deploy-guard]` table in the **scanned project's** `pyproject.toml`
90
+ (Python 3.11+ for TOML reading):
91
+
92
+ ```toml
93
+ [tool.deploy-guard]
94
+ disable = ["bare-except"]
95
+ exclude = ["vendor", "migrations"]
96
+ fail-on = "review"
97
+ include-tests = false
98
+ ```
99
+
100
+ CLI flags override the file.
101
+
102
+ ## How it works
103
+
104
+ ```
105
+ discover -> lower to IR -> CFG per function -> call graph
106
+ -> behavior spec (path conditions -> returns/raises)
107
+ -> nullability data-flow (merges None facts at every branch join; interprocedural)
108
+ -> findings | explain (traceback -> the above, focused on one failure)
109
+ ```
110
+
111
+ The nullability pass is a forward fixpoint over the CFG &mdash; it merges
112
+ facts at branch joins, so it covers 100% of a function regardless of how
113
+ branchy it is, in roughly linear time.
114
+
115
+ `scan` and `explain` only **read** your code &mdash; never import or run it.
116
+
117
+ ## Develop
118
+
119
+ ```bash
120
+ pip install -e ".[dev]"
121
+ pytest
122
+ ruff check src
123
+ ```
124
+
125
+ ## What changed from the prototype
126
+
127
+ See [CHANGELOG.md](CHANGELOG.md). Headline: an interprocedural call graph, so
128
+ `explain` traces a `None` across frames and `inconsistent-return` no longer
129
+ fires on copy-pasted helpers whose callers all guard the result; plus real
130
+ packaging and a config file.