Structural Change Analysis (Graph Diff)
June 25, 2026 · View on GitHub
Spec 21. Deterministic, offline, no API key. A structural complement to
git diff.
structural_diff answers what a change did structurally, not textually. The difference between:
- git diff — "these 40 lines changed"
- structural_diff — "this removed function
gamma, changed the signature ofalpha, and 5 ofalpha's callers in other files are now stale"
The second is a computed consequence — Layer 3 — that a review or refactor agent would otherwise re-derive by hand from a raw text diff.
Prior art is AST diffing (difftastic) — comparing syntax trees instead of lines. This goes one level up and diffs the graph (the call relationships), which is where the consequences of a change actually live.
What it reports
Between two states (working tree vs a ref, or two refs):
- added / removed functions — by stable node id.
- signature changes — same function, changed signature (
before→after). - stale callers — for each signature-changed or removed function, the callers in the canonical graph that are NOT part of this change — i.e. callers that may now be broken because the callee moved under them.
- rename/move candidates — a removed function and an added function with the same signature shape (same file → high confidence, different file → medium). Reported as both the remove+add and a rename candidate — never silently guessed.
- edge deltas — calls added/removed among the changed files.
Tool contract
// Working tree vs HEAD (the default)
{ "directory": "/abs/path" }
// vs another ref
{ "directory": "/abs/path", "baseRef": "main" }
// two committed refs
{ "directory": "/abs/path", "baseRef": "v1.0.0", "headRef": "v1.1.0" }
Output:
{
"base": "abc1234", "head": "working tree",
"changedFiles": [{ "path": "src/mod.ts", "status": "modified" }],
"summary": { "addedFunctions": 1, "removedFunctions": 2, "signatureChanges": 1,
"addedEdges": 3, "removedEdges": 1, "staleCallers": 5, "renameCandidates": 1 },
"added": [{ "name": "delta", "file": "src/mod.ts", "signature": "function delta(z: string): void" }],
"removed": [{ "name": "gamma", "file": "src/mod.ts", "staleCallers": [] }],
"signatureChanged": [
{ "name": "alpha", "file": "src/mod.ts",
"before": "function alpha(a: string): number",
"after": "function alpha(a: string, b: number): number",
"staleCallers": [{ "name": "consumer", "file": "src/other.ts" }] }
],
"renameCandidates": [
{ "from": { "name": "oldName", "file": "src/mod.ts" }, "to": { "name": "newName", "file": "src/mod.ts" },
"confidence": "high", "note": "…reported as both remove+add and this rename candidate — verify." }
],
"edges": { "added": [...], "removed": [...] },
"soundness": { "posture": "structural-complement-to-git-diff", "caveats": ["Rename/move detection is heuristic…", "…"] }
}
Footprint escape detection (opt-in)
Change
add-footprint-escape-detection(PARALLEL-WORK-COORDINATION proposal 3). The back-side safety net for parallel agents: the swarm planner (plan_parallel_work) emits a predicted write-footprint per task; this confirms, after the code is written, whether the diff stayed inside it. Stateless and advisory — OpenLore detects the escape; the harness/CI/human enforces.
Pass a task's declared write-footprint (the Footprint plan_parallel_work produces) as
declaredFootprint, and optionally the declared footprints of other in-flight tasks as
peerFootprints. The result then carries an additional escapeAnalysis block. With no
declaredFootprint, behavior is byte-identical to the above — the extension is additive and dormant.
{
"directory": "/abs/path",
"declaredFootprint": {
"taskId": "add-widget",
"writeSet": [{ "id": "src/widget.ts::render", "filePath": "src/widget.ts", "writeMode": "modify" }],
"readSet": ["src/util.ts::format"] // symbols the task declared it would only read
},
"peerFootprints": [
{ "taskId": "refactor-dispatch",
"writeSet": [{ "id": "src/dispatch.ts::dispatchTool", "filePath": "src/dispatch.ts", "writeMode": "append" }] }
]
}
escapeAnalysis output:
{
"declaredTaskId": "add-widget",
"summary": { "modifiedSymbols": 4, "escapes": 2, "outOfScopeWrites": 1, "readSetIntrusions": 1,
"scopeCreep": 0, "newlyOpenedConflicts": 1, "registryResolutions": 0, "misDeclaredAppends": 0 },
"escapes": [
{ "id": "src/dispatch.ts::dispatchTool", "classification": "out-of-scope-write", "editNature": "modifies-existing" },
{ "id": "src/util.ts::format", "classification": "read-set-intrusion", "editNature": "modifies-existing" }
],
"newlyOpenedConflicts": [
{ "symbol": "src/dispatch.ts::dispatchTool", "peerTaskId": "refactor-dispatch", "verdict": "WAW",
"reason": "Out-of-scope edit modifies existing code in \"dispatchTool\", which is in peer \"refactor-dispatch\"'s declared write-set — a freshly-created write-write conflict." }
],
"registryResolutions": [ /* { symbol, peerTaskId, reason } — a clean append into a peer's declared append */ ],
"misDeclaredAppends": [ /* { symbol } — declared `append`, but the diff modified existing code */ ],
"findings": [ { "code": "footprint-escape-new-conflict", "enforcementClass": "advisory", "subject": "…", "message": "…" } ],
"gated": false,
"disclosure": "Escape detection is structural: … cannot catch a purely semantic conflict …"
}
Escape classes (a symbol the diff modified that lies outside the declared write-set):
- out-of-scope-write — modified a symbol in a file the task never declared.
- read-set-intrusion — modified a symbol that was declared read-only.
- scope-creep-within-file — added/modified a new symbol inside a declared file (lower severity).
Conflict recomputation. An escaped symbol that lands in a peerFootprints write-set is reported
as a newly-opened write-write conflict naming that peer (distinct from any overlap the plan
already knew about). For a registration hot-spot (dispatchTool, a registry array), a pure-addition
into a peer's declared append is downgraded to resolved-by-merge; a modify of an existing member
is a real WAW; a seed declared append that actually modified existing code is flagged as a
mis-declared append.
Gating. Each finding (footprint-escape, footprint-escape-new-conflict, mis-declared-append)
is advisory by default; a repo MAY opt one into blocking via enforcement.policy
({ "footprint-escape-new-conflict": "blocking" }), and gated then reports whether any finding
resolved to blocking. structural_diff itself never blocks — enforcement and swarm re-planning are
the harness's job. Blocking findings are always retained in findings even past maxResults.
How it works — bounded, never mutating
- Two snapshots, one diff. Only the changed files are re-parsed: old content from
git show <base>:<file>, new content from the working tree (orgit show <headRef>:<file>). Two in-memory graphs are built from just those files — cheap, and the canonical graph is never touched. Untracked new files are folded in (their functions are all additions). - Node/edge/signature diff by stable id over the two snapshots.
- Stale callers come from the cached canonical graph (
getCallers, the spec'sbfsFromDB(changedCallee, 'backward', 1)), filtered to callers outside the changed set — the ones not updated alongside the callee.
Honest limits
- Rename/move is heuristic. A renamed function looks like delete + add; the signature-shape match is a hint, not a fact. Both interpretations are always reported.
- Cross-language signatures differ. Signature-change detection is limited to what the analyzer extracts per language.
- Edge deltas cover calls among/out of the changed files; calls into unchanged files are used only for the stale-caller lookup.
- Stale-caller analysis needs a current
analyze_codebase. Without the cached graph the diff still works, but stale callers are skipped and the response says so. - Escape detection is structural, not semantic. It catches out-of-scope writes, read-set
intrusions, and write-write conflicts an escape opens against a declared peer write-set. An
escape that breaks a shared invariant with no shared write or call edge (a purely semantic
conflict) can still slip through — the
disclosuresays so. Aresolved-by-mergeverdict confirms only this diff's edit; the peer's append is trusted from its declaration, so true non-overlap of two realized diffs needs the check run once per diff.
Implementation: structural-diff.ts.
Tested over a real temporary git repo with a v1→v2 change (added/removed/signature-changed
functions, the stale-caller set, rename candidates, untracked files) in
structural-diff.test.ts.