|
| 1 | +# Vendored, typed_ast-free Scalpel as the default L4 oracle |
| 2 | + |
| 3 | +- **Date:** 2026-07-22 |
| 4 | +- **Status:** Design approved; ready for an implementation plan |
| 5 | +- **Scope:** Make the Scalpel-backed points-to oracle (`ScalpelAliasOracle`) the |
| 6 | + shipping default for L4 dataflow on **every** supported Python, by vendoring a |
| 7 | + minimal, `typed_ast`-free slice of `python-scalpel` into the package. |
| 8 | + |
| 9 | +## Motivation |
| 10 | + |
| 11 | +`python-scalpel` is the primary L4 may-alias oracle, but it ships today as an |
| 12 | +**optional** extra with a type-based fallback — because `python-scalpel 1.0b0` |
| 13 | +hard-depends on **`typed_ast`**, an abandoned package whose last release |
| 14 | +(`1.5.5`) has no wheel for Python 3.12+ and fails to compile from source on a |
| 15 | +modern compiler. So on the interpreters most users (and this repo's own dev env) |
| 16 | +now run — 3.12, 3.13, 3.14 — scalpel cannot be installed at all, and L4 silently |
| 17 | +degrades to the coarser type-based oracle. |
| 18 | + |
| 19 | +We want scalpel to be the **default** L4 oracle everywhere. It cannot be a hard |
| 20 | +PyPI dependency (the `typed_ast` wall), and a Python-version cap to reach it |
| 21 | +(≤3.11) would drop the 3.12–3.14 support the analyzer just added. The way |
| 22 | +through is to **vendor** the small slice of scalpel we use, minus the one module |
| 23 | +that drags `typed_ast`. |
| 24 | + |
| 25 | +## Feasibility (verified, not assumed) |
| 26 | + |
| 27 | +- **`typed_ast` is spurious for our use.** It is imported in exactly one scalpel |
| 28 | + module — `scalpel/typeinfer/analysers.py` — which codeanalyzer never touches. |
| 29 | + The oracle uses only `scalpel.SSA.const` and `scalpel.cfg`. |
| 30 | +- **The slice runs without `typed_ast`.** On Python 3.12 with `typed_ast` *not* |
| 31 | + installed, `cfg.CFGBuilder` + `SSA.const.SSA` import and `compute_SSA` returns |
| 32 | + the exact SSA/const facts the oracle consumes (verified: a copy chain yields |
| 33 | + `('b',0), ('c',0), ('<ret>',0)`). |
| 34 | +- **Exact closure = 9 modules**, provably free of `typeinfer`/`typed_ast` |
| 35 | + (checked via `sys.modules` after importing the two entry points): |
| 36 | + `scalpel/__init__.py`, `SSA/{__init__,const}.py`, |
| 37 | + `cfg/{__init__,builder,model}.py`, `core/{__init__,func_call_visitor,vars_visitor}.py`. |
| 38 | +- **Third-party imports touched:** `astor` (used at runtime — `SSA/const.py:191` |
| 39 | + `astor.to_source`), `graphviz` (imported at module load in `cfg/model.py` but |
| 40 | + only used by the unused `build_visual()`), `networkx` (already a dependency). |
| 41 | +- **License = Apache-2.0** (confirmed on `SMAT-Lab/Scalpel` and in the wheel's |
| 42 | + `LICENSE`) — vendorable with attribution, same family as the already-bundled |
| 43 | + PyCG. |
| 44 | +- **Upstream is abandoned** (`1.0b0`, frozen) — the usual "vendored copy drifts |
| 45 | + from upstream" cost does not apply. |
| 46 | + |
| 47 | +## Goals |
| 48 | + |
| 49 | +- `ScalpelAliasOracle` is the **default** L4 oracle on Python 3.9–3.14+, with no |
| 50 | + external `python-scalpel`/`typed_ast` dependency and no `pip install` breakage. |
| 51 | +- The type-based oracle (`TypeBasedAliasOracle`) is retained purely as the |
| 52 | + runtime safety net (per-callable build failure, per-query unresolved path). |
| 53 | +- Behavior for users who already had the `[scalpel]` extra on ≤3.11 is unchanged |
| 54 | + (same code). Behavior on 3.12+ *improves* (real points-to instead of the |
| 55 | + type-based over-approximation). |
| 56 | + |
| 57 | +## Non-goals |
| 58 | + |
| 59 | +- No change to the L4 *schema* (`prov` values `ssa`/`points-to` unchanged; the |
| 60 | + DDG shape is the same). |
| 61 | +- No `requires-python` change — stays `>=3.9`. |
| 62 | +- No vendoring of scalpel's `typeinfer`, `call_graph`, `pycg`, `import_graph`, |
| 63 | + `scope_graph`, or `dataflow` packages — only the 9-module `SSA`/`cfg`/`core` |
| 64 | + closure the oracle actually loads. |
| 65 | +- No change to `defuse.py`'s two-rule DDG contract or the `k_limit` machinery. |
| 66 | + |
| 67 | +## Design |
| 68 | + |
| 69 | +### The vendored package: `codeanalyzer/dataflow/scalpel/` |
| 70 | + |
| 71 | +A normal package (no `_vendor/` layer), mirroring upstream's layout so scalpel's |
| 72 | +relative imports (`from ..core.vars_visitor import get_vars`) resolve within |
| 73 | +`codeanalyzer.dataflow.scalpel`: |
| 74 | + |
| 75 | +``` |
| 76 | +codeanalyzer/dataflow/scalpel/ |
| 77 | + __init__.py |
| 78 | + SSA/__init__.py, const.py |
| 79 | + cfg/__init__.py, builder.py, model.py |
| 80 | + core/__init__.py, func_call_visitor.py, vars_visitor.py |
| 81 | + LICENSE # upstream Apache-2.0, copied verbatim |
| 82 | + README.md # provenance + the one patch (see below) |
| 83 | +``` |
| 84 | + |
| 85 | +The `scalpel/` wrapper name is kept (rather than flattening `SSA`/`cfg`/`core` |
| 86 | +into `dataflow/`) because `codeanalyzer/dataflow/cfg.py` already exists and |
| 87 | +scalpel ships its own `cfg/` package — the wrapper namespaces them apart. |
| 88 | + |
| 89 | +The 9 files are copied **verbatim** from `python-scalpel 1.0b0`, with **exactly |
| 90 | +one patch**: |
| 91 | + |
| 92 | +- `cfg/model.py`: the module-load `import graphviz as gv` (line 12) is moved to a |
| 93 | + lazy import *inside* the unused `build_visual()` method. This removes `graphviz` |
| 94 | + as a runtime dependency (we never render). No other line changes; the |
| 95 | + SSA/CFG-computation code paths are byte-identical to upstream. |
| 96 | + |
| 97 | +### Attribution (Apache-2.0) |
| 98 | + |
| 99 | +- `codeanalyzer/dataflow/scalpel/LICENSE` — upstream's Apache-2.0 license, copied. |
| 100 | +- `codeanalyzer/dataflow/scalpel/README.md` — records: source repo |
| 101 | + (`SMAT-Lab/Scalpel`), the pinned version (`1.0b0`), the exact 9-file list, the |
| 102 | + single `graphviz`-lazy patch, and that `typeinfer` (the sole `typed_ast` user) |
| 103 | + was deliberately excluded. |
| 104 | +- The repo's top-level `NOTICE` gains a one-line entry crediting vendored Scalpel |
| 105 | + (Apache-2.0), alongside the existing attributions. |
| 106 | + |
| 107 | +### Dependencies (`pyproject.toml`) |
| 108 | + |
| 109 | +- **Add** `astor` to core `dependencies` (genuine runtime dep; pure-Python; |
| 110 | + installs on every platform/Python). |
| 111 | +- **Remove** the `[project.optional-dependencies].scalpel` extra (scalpel is now |
| 112 | + built in). |
| 113 | +- `networkx` unchanged (already core); `graphviz` and `typed_ast` are **not** |
| 114 | + dependencies. |
| 115 | +- `requires-python` stays `>=3.9`. |
| 116 | + |
| 117 | +### Oracle rewiring: `codeanalyzer/dataflow/scalpel_oracle.py` |
| 118 | + |
| 119 | +- `ScalpelAliasOracle.from_function` imports from the vendored path: |
| 120 | + `from codeanalyzer.dataflow.scalpel.SSA.const import SSA` and |
| 121 | + `from codeanalyzer.dataflow.scalpel.cfg import CFGBuilder`. |
| 122 | +- `make_alias_oracle`: the `ImportError` ("python-scalpel not installed") branch |
| 123 | + becomes dead — the vendored import cannot be absent — and is **removed**. The |
| 124 | + per-callable **build-failure** `except` (returns the type-based fallback) and |
| 125 | + the per-query unresolved-path fallback inside `may_alias` both **stay**, so the |
| 126 | + total, never-raises contract is preserved. `ScalpelAliasOracle` is now the |
| 127 | + default the selector returns. |
| 128 | +- Docstrings/comments updated to drop the "optional / not installed" framing. |
| 129 | + |
| 130 | +### Behavior change |
| 131 | + |
| 132 | +On Python 3.12+/3.14 — and for anyone who never installed the old `[scalpel]` |
| 133 | +extra — L4's `prov:["points-to"]` DDG edges were previously derived from the |
| 134 | +*type-based* over-approximation; they now become **scalpel-precise** (a tighter |
| 135 | +subset). The `prov:["ssa"]` edges (the L3 subset) are unchanged, so the |
| 136 | +**monotonicity invariant `L3 ⊆ L4` still holds** (the ssa set is untouched; |
| 137 | +points-to is an additive overlay). Users who already had the `[scalpel]` extra on |
| 138 | +≤3.11 see no change. This L4 precision improvement is recorded in `CHANGELOG.md`. |
| 139 | + |
| 140 | +### Docs |
| 141 | + |
| 142 | +`CLAUDE.md` and `.claude/SCHEMA_DECISIONS.md` are updated to record: scalpel is |
| 143 | +now **vendored** (`codeanalyzer/dataflow/scalpel/`, `typed_ast`-free) and the |
| 144 | +**shipping default** L4 oracle on all supported Python — superseding the earlier |
| 145 | +"the type-based oracle is the sanctioned fallback, not the shipping default" |
| 146 | +statement (it is now the runtime safety net, not the default). The Stage-0 |
| 147 | +`SCHEMA_DECISIONS` entry gains a follow-up noting the `typed_ast` wall on 3.12+ |
| 148 | +that forced vendoring. |
| 149 | + |
| 150 | +## Testing plan |
| 151 | + |
| 152 | +1. **Import hygiene.** `import codeanalyzer.dataflow.scalpel` and building the |
| 153 | + oracle succeed with no external `scalpel`/`typed_ast`/`graphviz` installed; |
| 154 | + assert `typeinfer` is never imported (scan `sys.modules` after building the |
| 155 | + oracle). |
| 156 | +2. **`typed_ast`-free property gate.** Assert `typed_ast` is not importable in |
| 157 | + the environment, yet `make_alias_oracle` builds a `ScalpelAliasOracle` and |
| 158 | + `may_alias` returns verdicts on a copy-chain fixture — the whole point, |
| 159 | + encoded as a regression gate. |
| 160 | +3. **Vendored-fidelity check.** On Python ≤3.11 (where pip `python-scalpel` |
| 161 | + installs), compare the vendored `compute_SSA`/CFG output against the |
| 162 | + pip-installed scalpel on a set of fixtures — proves the copy is faithful. |
| 163 | + `skipif` the pip package can't install (3.12+); the copy is verbatim, so this |
| 164 | + is a belt-and-suspenders check. |
| 165 | +4. **L4 regression (the live acceptance test).** `test_v2_l4.py`, |
| 166 | + `test_v2_l4_summary.py`, `test_dataflow_sdg.py`, `test_dataflow_defuse.py` now |
| 167 | + exercise the **scalpel** path by default — the first time scalpel-L4 runs on |
| 168 | + the 3.14 dev env. They must stay green and now genuinely test scalpel-derived |
| 169 | + points-to rather than the fallback. |
| 170 | +5. **Determinism.** Run L4 twice on a fixture and assert identical output (the |
| 171 | + scalpel SSA feeds `may_alias`; the `#99` `PYTHONHASHSEED=0` pin + the oracle's |
| 172 | + access-path normalization must keep it stable). |
| 173 | + |
| 174 | +## Risks and rollback |
| 175 | + |
| 176 | +- **Incomplete closure** — a missed transitive import would surface as an |
| 177 | + `ImportError` at oracle build, which the per-callable fallback swallows into a |
| 178 | + silent type-based degrade. Mitigation: the import-hygiene test (1) builds the |
| 179 | + oracle and asserts it is a `ScalpelAliasOracle`, so a broken closure fails |
| 180 | + loudly rather than degrading silently. |
| 181 | +- **Vendored-copy divergence from upstream behavior** — mitigated by the verbatim |
| 182 | + copy (only the `graphviz` import patched) and the fidelity check (3). |
| 183 | +- **L4 output change on 3.12+** is intended, documented in the CHANGELOG, and |
| 184 | + bounded by the preserved monotonicity invariant. |
| 185 | +- **Rollback** is a single revert: delete `codeanalyzer/dataflow/scalpel/`, |
| 186 | + restore the `[scalpel]` extra + the `make_alias_oracle` ImportError branch, and |
| 187 | + drop `astor`. No schema or id changes to unwind. |
| 188 | + |
| 189 | +## Out of scope |
| 190 | + |
| 191 | +- Replacing `astor.to_source` with stdlib `ast.unparse` to drop the `astor` dep |
| 192 | + (a behavior-risking change to vendored SSA logic — deferred; `astor` is tiny |
| 193 | + and universal). |
| 194 | +- A scalpel-installed CI lane / capturing the L4 (`a4`) equivalence goldens from |
| 195 | + the separate analysis-pipeline branch — that work belongs to that branch once |
| 196 | + it merges; here scalpel simply becomes available for it. |
| 197 | +- Vendoring or using scalpel's type-inference (`typeinfer`) for the type-guided |
| 198 | + alias branch — the type-based oracle already covers that fallback. |
0 commit comments