refactor(py): harden the unsafe soundness argument - #1344
Merged
Conversation
The module doc of big-code-analysis-py/src/node.rs is this workspace's
canonical justification for its only sanctioned `unsafe` block. Two
things weakened it, neither a live soundness bug.
The argument reasons about a *named* tree-sitter release — `Tree(NonNull
<ffi::TSTree>)`, `Node<'tree>(ffi::TSNode, PhantomData<&'tree ()>)`,
`Tree::edit(&mut self)`, `Send + Sync` — and the literal had drifted two
patch releases behind the pin before anyone noticed. It is correct today
only because the 0.26.12 bump updated it by hand, which is the step this
gate removes. `utils/check-safety-doc-pin.py` fails when a backticked
`=X.Y.Z` in that module doc is not the `[workspace.dependencies]` pin,
when the citation is dropped altogether (the version-free phrasing was
considered and declined: it removes the stale number by making the claim
unfalsifiable at read time), and when the requirement is not an exact
pin at all. The gate cannot verify the argument; it forces a diff on the
line at bump time, which is the prompt to re-read it. Wired into `make
lint` / `pre-commit` / `ci` beside check-versions, with a pre-commit hook
pair on Cargo.toml + node.rs, defensive twins in ci.yml, and 17 self-
tests — including both directions of the module-doc stop condition,
since a scanner that stops reading early reports a clean file.
`detach`'s precondition is that a node is stored alongside the `Py<PyAst>`
that keeps its tree alive, and three sites built `PyNode { ast, node }`
by struct literal. Field privacy does not constrain them: all three were
in the defining module. `PyNode` and `PyNodeWalk` now live in an inner
`owned` module, so the literal is unavailable to the `#[pymethods]`
blocks outside it and every handle comes from `wrap`, `rewrap`,
`rooted_at`, or `next_node`. `rooted_at` takes the `PyNode` rather than
an `(Ast, node)` pair, so a walk's cursor, seed and keep-alive all derive
from one already-valid handle. Verified by perturbation rather than a
test: reintroducing either literal is now E0451, and a runtime test would
pass vacuously because the mismatch is unconstructible from Python.
That last fact, and the rest of the adversarial-harness results from the
issue — no GC tracking, no Python-reachable constructor, the per-yield
refcount bump, the 3,000-cycle and 8-thread runs — are recorded in the
module doc, where an auditor of the `unsafe` block will find them.
The gate is recorded under `## [Unreleased]`, beside the sibling
`check-ruff-lockstep` entry. No `STABILITY.md` entry: no library
behaviour moves and `PyNode` is not part of the Rust public API.
Everything in `owned` except `wrap` is `pub(super)`. `wrap` is the one
entry point another module needs — `ast.rs` builds the root handle —
while `node` + `rewrap` together are the mispairing primitive
(`a.rewrap(py, b.node())` pairs one tree's node with another tree's
keep-alive), so keeping them file-local is what makes the boundary true
beyond this file.
Fixes #1057
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1057 (already closed on the tracker; this is the code).
Two hygiene items on
big-code-analysis-py/src/node.rs, the canonicalsoundness argument for the workspace's only sanctioned
unsafeblock.Neither was a live soundness bug — both weakened the argument's
durability.
1. The cited tree-sitter version is now gated, not remembered
The literal was already correct:
67eecdbf(the 0.26.11 → 0.26.12 bump)updated it by hand, which is exactly the step this issue exists to
remove. So the work is the gate.
utils/check-safety-doc-pin.pyfails when a backticked=X.Y.Zin thatmodule doc is not what
[workspace.dependencies]pins, when the citationis dropped altogether, or when the requirement is not an exact pin.
Dropping the literal in favour of a version-free phrase was considered
and declined in the issue: it removes the stale number by making the
claim unfalsifiable at read time. The gate cannot verify the argument —
it forces a diff on the line at bump time, which is the prompt to re-read
it, and the diagnostic names the four premises to re-check rather than
just telling you to edit the line.
Wired in beside
check-versions.py:make lint/pre-commit/ci, a.pre-commit-config.yamlhook pair firing onCargo.tomlandnode.rs,and defensive twins in the
lintjob ofci.yml.Watched failing on the real tree, not only on synthetic input — the
historical defect reproduces exactly:
Scope is one file, deliberately.
AGENTS.mdandutils/check-excluded-manifests.pyalso name the pin, but both sit inprose that cites grammar pins illustratively (
=0.23.5,=0.26.10),so a literal-scanning rule there would fail on versions that are supposed
to differ. That limit is stated in the script's docstring rather than
left for a reader to assume away.
17 self-tests, weighted toward the false-clean direction: both directions
of the module-doc stop condition (an inner attribute must not end the
block — everything after it would silently stop being gated; code must),
the manifest parser over string / table / caret / missing forms, and
main()over the clean, stale, citation-dropped and unreadable-inputbranches.
2. The Ast/Node pairing is structural
Field privacy alone changed nothing — all three
PyNode { ast, node }sites were inside the defining module.
PyNodeandPyNodeWalknow livein an inner
ownedmodule, so the struct literal is unavailable to the#[pymethods]blocks outside it. Every handle comes fromwrap,rewrap,PyNodeWalk::rooted_at, orPyNodeWalk::next_node.rooted_attakes thePyNoderather than an(Ast, node)pair, so awalk's cursor, seed and keep-alive all derive from one already-valid
handle — there is no argument to get wrong.
Everything except
wrapispub(super), notpub(crate):wrapis theone entry point another module needs (
ast.rsbuilds the root handle),while
node+rewraptogether are the mispairing primitive —a.rewrap(py, b.node())pairs one tree's node with another tree'skeep-alive. Keeping them file-local is what makes the boundary true
beyond this file.
PyO3 raised no objection:
#[pyclass]expands fine inside the innermodule and the
#[pymethods]blocks stay outside it. No fallback to amarker field or newtype was needed.
Verified by perturbation, per
.claude/rules/testing.md— not by aruntime test, which would pass vacuously because the mismatch is
unconstructible from Python. Reintroducing either literal outside the
boundary is now a compile error, where before it was the shipped code:
The issue's context section is preserved
The "what was checked and found sound" list now lives in the
node.rsmodule doc under
## What has been checked: the absent GC tracking, theunreachable Python constructor (which is why the pairing is enforced at
the Rust module boundary rather than by a runtime test), the per-yield
refcount bump, and the 3,000-cycle / 8-thread runs.
AGENTS.md'sunsafecarve-out now names theownedboundary and the gate too.Verification
make pre-commit→BCA_GATE: pass, with 5,364 Rust tests (10 skipped)and 363 Python tests (1 xfailed) — the same totals
maincarries. Nointegration snapshot moved: the working tree is free of
.snap.newandthe
big-code-analysis-outputsubmodule is clean at its recorded SHA.No
STABILITY.mdentry: no library behaviour moves andPyNodeis notpart of the Rust public API. The gate is recorded under
## [Unreleased], beside the siblingcheck-ruff-lockstepentry.Known gap, not fixed here
module_doc_linesskips only single-line#![…]inner attributes; amulti-line one's continuation ends the scan early, so a stale citation
below the cut would be invisible.
node.rshas no inner attributes atall today, and the fix needs a decision about
test_code_ends_the_block,which currently pins the break as intended behaviour. Filed as #1345.