Engineering

Semantic diff: what changes beyond the changed lines

8 min read
Abstract visualization of semantic code diff analysis beyond line changes

A pull request arrives with a four-line diff. The changed function looks fine in isolation. Nobody flags it. It merges. Three days later an on-call engineer is debugging a null pointer exception in a service that was never touched in that PR. The root cause: the renamed parameter broke a caller that relied on positional argument matching in a dynamically-dispatched call three layers up.

This is not a rare edge case. It is the normal failure mode of line-based diff review. Git shows you what changed. It does not show you what those changes mean to the rest of the program.

What line diff actually gives you

A standard unified diff computes the minimal edit distance between two file versions. It is a text-level operation. It understands nothing about identifiers, scopes, type contracts, or call graphs. Two changes that look identical in a diff can have completely different semantic consequences depending on where in the AST the changed lines live.

Consider a rename. A diff shows -user_id replaced with +userId. That is a one-line change. But whether that rename is safe depends on whether every reference site was updated consistently, whether any serialization format depends on the old field name, whether reflection or dynamic dispatch touches the identifier, and whether any test fixtures encode the old string. None of that is visible in the diff.

Or consider a signature change that widens a parameter type from int to number. In TypeScript, this is a two-token change. In the diff it looks trivially safe. But downstream callers that pass values assuming integer-only arithmetic may now silently receive floats, and the behavior diverges only in a specific numeric range that the test suite never exercises.

What semantic diff requires

To reason about impact beyond the changed lines, you need a representation of the program that encodes its structure, not just its text. That means constructing or querying an abstract syntax tree for the changed code, then tracing outward through call graph edges to find what else might be affected.

The minimal version of this is: parse the changed function, identify what its public interface is (parameter names, types, return type, side effects if analyzable), compare that to what the pre-change interface was, and then walk the call graph to find every direct caller. A deeper version follows the transitive closure of that call graph, pruning branches where the interface change is demonstrably contained.

This is computationally heavier than diffing text. It also requires language-specific tooling. A TypeScript AST is not interchangeable with a Python AST or a Go one. You cannot build this once and apply it universally without per-language investment.

When we built PRCheck, this was the design decision we argued about most. The easy path was pattern matching on diff text with some heuristics layered on top. It is fast to implement, easy to explain, and works for a large class of trivially obvious issues. The harder path was per-language AST parsing with call graph traversal. It took longer to build and requires more compute per PR. We chose the harder path because the class of bugs that matter most are the ones that text matching misses entirely.

The call graph walk in practice

Take a concrete scenario. A team at a mid-size SaaS company maintains a Python monorepo where a shared utility module is imported by about 40 service files. A developer modifies format_currency(amount, currency) to add a required third parameter: locale. The diff is three lines. The function body change looks innocuous. But PRCheck's call graph traversal finds 12 call sites across 7 service files that pass only two arguments. Six of those will raise TypeError at runtime.

A line-based review would have caught this only if the reviewer happened to know that this utility was widely imported, and happened to manually grep for every call site. That is the kind of institutional knowledge that lives in senior engineers' heads, not in a diff viewer.

The traversal itself follows import edges and function call edges. For each changed function, we extract its pre- and post-change signatures. We then search the indexed call graph for references to that function's fully-qualified name. Call sites where the argument count or type no longer matches the new signature surface as findings attached to the specific lines in the calling files, not just the changed file.

Where the analysis has real limits

We are not claiming this analysis is complete. It is not, and it is worth being direct about where it falls short.

Dynamic dispatch is the biggest gap. When a function is called through an interface, a protocol, or a dependency injection container, static call graph analysis may not trace the edge at all. The call site exists at runtime but is not visible as a direct reference in the source. This is a fundamental limitation of static analysis and is not unique to our approach; every SAST tool has the same boundary.

Monorepos where dependencies are not fully indexed produce incomplete traversals. If a service in the same repo imports a function but has never been parsed into the call graph (because it was added after the last full index, or because language support for it is partial), the traversal will miss it. We surface a low-confidence warning in these cases rather than a false negative silence, but the engineer still needs to consider unindexed areas.

Cross-repository calls are currently out of scope. If your change modifies a function that is consumed by an external package your team publishes, PRCheck will not trace into the downstream consumers. The boundary is the repository.

These are genuine constraints. We think the right response to them is to be explicit in the findings themselves: flag which edges were followed, which were not, and why.

What the reviewer sees

The output of the semantic diff analysis is not a wall of findings attached to the changed lines. It is a structured impact summary. For each changed function, the review comment shows the interface delta (what changed about the signature or return contract), the set of affected call sites with file and line references, and a severity assessment based on whether the call sites pass arguments that are provably incompatible, potentially incompatible, or unaffected.

The reviewer's job shifts from "scan every line in this diff and hope I remember all the places this is called" to "evaluate whether this impact set is acceptable and whether the caller updates are complete." That is a more tractable cognitive task. The reviewer can focus judgment on whether the design choice is correct, not on whether they caught all the mechanical consequences.

For the Python monorepo example above, the review comment would list the 12 call sites, mark 6 as definite errors (too few arguments), mark 4 as requiring inspection (two-argument calls where the second argument looks like it might be serving as the locale), and mark 2 as safe (already updated in the same PR). The developer can address the findings before the first human reviewer even opens the diff.

The asymmetry between writing and reading code

Writing a function change is fast. Understanding its full impact is slow. That asymmetry is structural in how code gets written: you are focused on making the change work in the context you can see, not on auditing every consumer.

Semantic diff analysis does not make the reviewer smarter. It makes the reviewer's attention cheaper to direct. The engineer who changed the function may have a complete mental model of the impact; the reviewer reviewing it two hours later in a different context does not. Surfacing the call graph impact at review time means the reviewer's first comment is specific rather than aspirational ("have you checked all callers?" is not a review, it is a request to do the analysis that should have already been done).

We are a small team and we have been using PRCheck on our own codebase since early builds. The category of bugs it reliably catches early is exactly this one: interface changes with incomplete propagation. Not because our engineers are careless, but because a three-person team cannot hold the full call graph in memory across 40k lines of code while also shipping features.

Catch issues before code ships

PRCheck reviews every pull request the moment it opens. Start in two minutes.