From f0f033b8be627f82f53fe13dbaba52e1d8f3df11 Mon Sep 17 00:00:00 2001 From: n1ckyb Date: Mon, 10 Aug 2026 00:46:53 +0100 Subject: [PATCH] fix(facts): carry the UAST structural facts across the Rust/Python boundary The cross-language structural facts (#9) were computed by the engine and thrown away before any caller saw them. uast.rs emits three facts for function entities - early_exit_count, negated_condition_count, has_guard_clause - and asserts them in 12 of its own Rust tests, all passing. NodeFacts carried no fields for any of them, so pydantic dropped them silently at the DTO boundary. Measured before this change, on a function with two early returns: early_exit_count = ABSENT These are the first facts derived from the PRUNED CANONICAL TREE rather than from a per-language extractor, so they are available for EVERY language rather than only the ones with bespoke support - exactly the facts a non-Python binding would rely on, and they reached nobody. Verified after the change, on a real guard clause: def send(msg): if not msg: negated_condition_count = 1 return None early_exit_count = 1 return deliver(msg) has_guard_clause = True has_guard_clause stays tri-state on purpose. The pruned tree can prove a guard is PRESENT but cannot prove one is ABSENT - statement order survives pruning, operators do not - so None means "not determinable here", never "no guard". Collapsing it to False would let a consumer draw a conclusion the evidence does not support. Adds Python acceptance tests, including one asserting the FIELDS EXIST on the model at all. The defect was never a wrong value; it was a missing field, and pydantic drops unknown keys without complaint. Proven to bite: removing the field fails all four, restoring it passes all four. Full suite: 2412 passed, 0 failed (2408 before, +4 new). The wider lesson is about the certified-path policy. Rust being authoritative does not help if the value never reaches the caller, and nothing on either side was watching the join - a Rust test proving a fact is COMPUTED and a Python test proving it is DELIVERED are different assertions. Co-Authored-By: Claude Opus 5 --- src/intentumdiff/core/models.py | 14 ++++ tests/unit/test_uast_structural_facts.py | 86 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/unit/test_uast_structural_facts.py diff --git a/src/intentumdiff/core/models.py b/src/intentumdiff/core/models.py index aea2aa9..b6e5d12 100644 --- a/src/intentumdiff/core/models.py +++ b/src/intentumdiff/core/models.py @@ -153,6 +153,20 @@ class NodeFacts(BaseModel, frozen=True): has_error_handling: bool | None = None #: The body raises/throws an exception. Flag only. throws: bool | None = None + #: Cross-language structural facts, derived from the pruned canonical tree (#9) rather + #: than from any one grammar — so they are available for EVERY language, not just the + #: ones with a bespoke fact extractor. + #: + #: These are counts and flags only, in keeping with the rest of NodeFacts: no source, no + #: identifiers, no literals. + #: + #: `has_guard_clause` is deliberately tri-state. The pruned tree can prove a guard is + #: present, but cannot prove one is absent — statement order survives pruning, operators + #: do not. So `None` means "not determinable here", NOT "no guard". Omitting beats + #: claiming; see uast.rs for the reasoning. + early_exit_count: int | None = None + negated_condition_count: int | None = None + has_guard_clause: bool | None = None #: The body mutates state — an augmented assignment or an assignment to an #: attribute/subscript (``self.x = …``, ``a[i] = …``). Flag only, no target name. mutates: bool | None = None diff --git a/tests/unit/test_uast_structural_facts.py b/tests/unit/test_uast_structural_facts.py new file mode 100644 index 0000000..0d0b3e5 --- /dev/null +++ b/tests/unit/test_uast_structural_facts.py @@ -0,0 +1,86 @@ +"""The cross-language structural facts (#9) survive the Rust -> Python boundary. + +These are derived from the PRUNED canonical tree rather than from any one grammar, so they +are available for every language rather than only the ones with a bespoke extractor. That +makes them the first facts a non-Python binding can rely on, which is why they are worth +pinning from this side as well as from Rust. + +WHY THESE EXIST: the Rust core computed all three and asserted them in 12 of its own tests, +and every one of those passed - while `NodeFacts` carried no fields for them, so pydantic +dropped them silently at the DTO boundary. A user of the Python API saw nothing. Rust being +the certified path does not help if the value never reaches the caller, and no test on either +side was watching the join. +""" + +from __future__ import annotations + +from intentumdiff.differ import SemanticDiffer +from intentumdiff.sources.string_source import StringSource + + +def _function_facts(old: str, new: str, filename: str = "m.py"): + """Facts on the function node of a rename, which puts the function itself in the diff.""" + diff = SemanticDiffer().diff(StringSource(old, new, filename=filename)) + for change in diff.changes: + node = change.new_node or change.old_node + if node is not None and node.facts is not None and "function" in node.node_type: + return node.facts.model_dump() + raise AssertionError(f"no function node with facts in: {[c.change_type for c in diff.changes]}") + + +def test_a_guard_clause_reaches_the_python_api(): + """The full shape: negated condition + early return = a guard clause.""" + facts = _function_facts( + "def send(msg):\n if not msg:\n return None\n return deliver(msg)\n", + "def transmit(msg):\n if not msg:\n return None\n return deliver(msg)\n", + ) + assert facts["has_guard_clause"] is True + assert facts["early_exit_count"] == 1 + assert facts["negated_condition_count"] == 1 + + +def test_early_exits_are_counted_without_a_guard(): + """Early exits are counted on their own merits. + + Two `return None`s behind non-negated conditions: the count is 2, and `has_guard_clause` + stays absent because there is no negated condition to make it a guard. Absent is the + honest answer here, not False - see the tri-state note below. + """ + facts = _function_facts( + "def check(x):\n if x < 0:\n return None\n" + " if x > 100:\n return None\n return x\n", + "def validate(x):\n if x < 0:\n return None\n" + " if x > 100:\n return None\n return x\n", + ) + assert facts["early_exit_count"] == 2 + assert facts["negated_condition_count"] is None + + +def test_absent_means_not_determinable_not_false(): + """`has_guard_clause` is tri-state, and the distinction is load-bearing. + + The pruned tree can prove a guard is PRESENT but cannot prove one is ABSENT - statement + order survives pruning, operators do not. So `None` means "not determinable here". + Collapsing it to False would let a consumer conclude "this function has no guard clause" + from evidence that does not support it, which is worse than saying nothing. + """ + facts = _function_facts( + "def add(a, b):\n return a + b\n", + "def total(a, b):\n return a + b\n", + ) + assert facts["has_guard_clause"] is None + assert facts["early_exit_count"] is None + + +def test_the_fields_exist_on_the_model_at_all(): + """The regression guard proper. + + The defect was not a wrong value - it was a MISSING FIELD. pydantic drops unknown keys + silently, so the facts arrived from Rust and evaporated. Asserting the fields exist on the + model catches a future removal or rename even if no fixture happens to populate them. + """ + from intentumdiff.core.models import NodeFacts + + fields = set(NodeFacts.model_fields) + for name in ("early_exit_count", "negated_condition_count", "has_guard_clause"): + assert name in fields, f"{name} missing from NodeFacts - Rust emits it, so it would be dropped"