Semantic Diff

Why diffing trees beats diffing lines — and what that unlocks.

Every version control system in widespread use today answers the same question: "what changed between two versions of this file?" Git, Mercurial, SVN, Perforce — they all answer it by looking at lines of text. A line that exists in version A but not in version B is a deletion. A line that exists in B but not in A is an addition. Anything more subtle than that — a rename, a reformat, a reorder — is inferred later, if at all, by heuristics.

Aura answers the same question differently. It parses both versions into an abstract syntax tree using tree-sitter, then diffs the trees. The atomic unit of change is not a line — it is a node. A function. An expression. A type. A binding.

This is not a cosmetic improvement. It changes what a diff can see, what a merge can resolve, and what a pre-commit hook can enforce.

A text diff tells you which characters moved. A semantic diff tells you which ideas moved.

The three cases text diff gets wrong

Git's line-based diff fails predictably in three scenarios. Each of them is a daily occurrence in real codebases.

Reformatting

You run rustfmt or prettier on a file. No behavior changed. No function was added, removed, or altered in meaning. But the line-by-line view explodes into a sea of red and green.

// Before
fn calculate(x: i32, y: i32) -> i32 { x + y }

// After rustfmt
fn calculate(x: i32, y: i32) -> i32 {
    x + y
}

Git sees: one line deleted, three lines added. Aura sees: no change. The AST is identical — same function node, same parameters, same body expression.

Variable renames inside a function

You rename a local variable from usr to user for clarity. The function's signature is unchanged. Its behavior is unchanged.

// Before
function greet(usr: User) {
  console.log(`Hello, ${usr.name}`);
  return usr.id;
}

// After
function greet(user: User) {
  console.log(`Hello, ${user.name}`);
  return user.id;
}

Git's diff: three lines changed. Aura's diff: one rename of a binding, all references updated in lockstep. The reviewer sees a single, coherent refactor instead of a scatter of edits.

Function reordering

You move helper() above main() because you prefer the file organized that way. No code changed. Git shows the entire function deleted from one place and added to another.

Aura recognizes this as a reorder. The function node still exists with the same content hash. Its position in the file changed; its identity did not.

Git vs Aura, side by side

| Scenario | Git diff shows | Aura diff shows | |---|---|---| | Reformat with rustfmt | Every reformatted line as changed | No change | | Rename usruser in one function | N scattered line edits | One rename, N automatic references | | Move helper() above main() | Full delete + full add | One reorder | | Rename file auth.rslogin.rs | New file + deleted file | One move, history preserved | | Extract 10 lines into a new function | 10 deletions + a function add | One extract, cross-referenced | | Change i32 to i64 in a signature | One line diff | Type change propagated through the call graph |

The right column is what a careful human reviewer does in their head when reading the left column. Aura does it in the tool so the reviewer does not have to.

What semantic diff enables

Once the unit of change is a node, several things that are hard or impossible with text diffs become natural.

Rename-proof history. A function renamed from login() to authenticate() keeps the same content hash (modulo the identifier). Aura links the two and preserves the entire commit history through the rename. No git log --follow dance. See rename-proof identity.

Intent validation. The pre-commit hook can look at the AST delta — "you deleted two functions, added one, and modified three" — and compare that to your logged intent — "refactored retry logic to use exponential backoff." If the delta contradicts the intent, the commit is flagged. A text diff cannot do this; "you changed 47 lines" is too coarse to validate against a sentence of English.

Safer merges. When two branches both edit the same function, a text merge sees a line-by-line conflict. An AST merge can often resolve it — if one branch renamed a variable and the other added a null check, those changes are on different AST nodes and compose cleanly. See AST merge.

Surgical rewind. Because every function has a stable identity, Aura can revert a single function to its last safe state without touching anything else in the file. aura rewind authenticate is a one-node operation, not a cherry-pick dance.

The anatomy of a semantic diff

A semantic diff in Aura is a set of typed operations over AST nodes, not a sequence of line edits. At a conceptual level:

{
  "operations": [
    { "op": "modify", "kind": "function", "name": "retry_request",
      "body_hash_before": "a7c1...", "body_hash_after": "d92f..." },
    { "op": "rename", "kind": "function",
      "from": "login", "to": "authenticate" },
    { "op": "move", "kind": "function", "name": "helper",
      "from": "utils.rs", "to": "helpers/mod.rs" },
    { "op": "add", "kind": "function", "name": "validate_token" }
  ]
}

This is the representation a reviewer actually wants. It answers the review questions directly: what functions changed behavior, what got renamed, what moved, what is new. No mental translation from lines to meaning.

How Aura computes the diff

The high-level pipeline, kept abstract because the internals evolve:

  1. Parse each version with the appropriate tree-sitter grammar. Every supported language — Rust, TypeScript, Python, Go, and others — reduces to the same uniform AST interface.
  2. Normalize the tree. Whitespace, trivia, and formatting-only tokens are stripped from the comparison; they never contribute to identity.
  3. Assign content hashes to every named node — functions, classes, methods, top-level constants. See content-addressed logic.
  4. Run a tree edit-distance algorithm tuned for program structure. It produces node-level operations: add, delete, modify, rename, move, reorder.
  5. Attach semantic metadata — impacted callers, changed signatures, touched types — so downstream tools (review, merge, intent validation) have the information they need.

Steps 1–3 guarantee that cosmetic edits are invisible. Step 4 does the structural matching. Step 5 is what makes the result useful for humans and for the pre-commit hook.

A worked example

Consider this change on a Rust file:

// Before
pub fn process(data: &[u8]) -> Result<String> {
    let s = std::str::from_utf8(data)?;
    Ok(s.to_uppercase())
}

pub fn helper() -> i32 { 42 }

// After
pub fn helper() -> i32 { 42 }

pub fn process(input: &[u8]) -> Result<String> {
    let text = std::str::from_utf8(input)?;
    Ok(text.to_uppercase())
}

A line-based diff shows both functions as heavily modified. A semantic diff shows:

  • helper — reordered, no body change.
  • process — parameter renamed datainput, local renamed stext, behavior unchanged.

A reviewer can approve this in five seconds. A text diff forces them to re-read both functions to confirm they did not change.

What semantic diff does not try to do

Aura is not a formal verifier. Two functions with different ASTs may be behaviorally equivalent (x + 1 vs 1 + x), and Aura does not try to prove that. Conversely, two functions with identical ASTs are guaranteed to behave identically — a guarantee a text diff cannot offer.

Aura also does not replace your language's type checker or your tests. It tells you precisely what changed at the structural level; it is up to the compiler and the test suite to tell you whether those changes were correct. What Aura offers is a faithful, reviewable description of the change — one that matches the way programmers actually think about their code.

Why this is the foundation

Every other idea in Aura — function-level identity, intent tracking, AST merge, shadow branches, the pre-commit hook — rests on semantic diff. Once the diff knows what changed in the program (not in the text), everything downstream becomes tractable. You can validate intent because you can describe intent at the right granularity. You can rewind a function because you can identify a function across commits. You can merge cleanly because you can reason about changes as operations on a tree.

Line-based diffs were the right answer in 1972, when storage was tight and parsers were expensive. They are the wrong primitive for 2026, when every language has a fast incremental parser and every codebase has a thousand collaborators — human and machine.

For how Aura uses AST diffs to merge branches without textual conflicts, see AST merge. For the stable identifiers that make all of this coherent across commits, see function-level identity and content-addressed logic.