Skip to content

Diff Algorithm ​

Definition of the aburi diff algorithm, which compares two IRs (base / head) and produces a semantic diff.

References:


1. Purpose ​

The core feature of Aburi's primary use case: "reviewing PRs at semantic granularity". Instead of a line-based code diff, it reports what was added / removed / moved / changed at the Symbol level.

The reviewer's workflow becomes:

  • Scrolling through huge line diffs → eliminated
  • Symbols that are "move only" or "implementation refactor only" are collapsed → out of sight
  • Focus only on places where the "public contract / business logic / side effects" changed

2. Inputs and outputs ​

2.1 Inputs ​

Two IRs (aburi.ir.v1):

  • baseIR: comparison source
  • headIR: comparison target

Both must have matching schema versions (a mismatch is a fatal error).

2.2 Input supply methods ​

bash
aburi diff <base>..<head>             # specify git refs (both regenerated)
aburi diff --base ir-base.json --head ir-head.json  # supply existing IRs directly

For git refs, Aburi internally:

  1. Checks out the base ref and runs aburi scan (IR generation)
  2. Returns to the head ref (or uses a worktree in the first place)
  3. Compares the two IRs

Using a git worktree allows generating the base IR while keeping head intact, so this is the default approach.

2.3 Outputs ​

bash
<output-dir>/diff.json     # diff result (aburi.diff.v1.json schema)
<output-dir>/diff.md       # Markdown projection for pasting into PR comments
<output-dir>/diff.full.md  # diff.md without its size cap, only when --max-bytes shortened it

<output-dir> is --output-dir, then config.output.dir, then out — resolved against the working directory in every case. The filenames are the contract; the rest of this document writes them as out/diff.json / out/diff.md, which is the default run. diff.full.md exists only beside a diff.md whose size cap changed it, and every other run removes it (cli-spec.md §6.3).

A one-line summary goes to stdout; details are referred to the Markdown file.

3. Matching algorithm (5 stages) ​

Pairs up base/head Symbols.

input:  baseIR.symbols, headIR.symbols
output: matchedPairs[], remainingBase[], remainingHead[]

3.1 Stage 1: exact ID match ​

baseById = {s.id: s for s in baseIR.symbols}
headById = {s.id: s for s in headIR.symbols}

for id in keys(baseById) ∩ keys(headById):
  pair = (baseById[id], headById[id])
  matched.push({base: pair[0], head: pair[1], rationale: 'id-match'})
  remove from remainingBase / remainingHead

The most common case. Symbols with no file move and no rename are settled here.

3.2 Stage 2: git rename detection (only when git is available) ​

if git available and ref-based diff:
  renameMap = git diff --find-renames --name-status -z base..head
              → {oldpath: newpath}
  
  claimants = {}
  for sym in remainingBase:
    if sym.source.file not in renameMap: continue
    expectedId = sym.id with old path replaced by renameMap[sym.source.file]
    if expectedId in remainingHead:
      claimants[expectedId].append(sym)

  for (headId, bases) in claimants:
    pair the lowest base.id with headId, rationale: 'git-rename'
    remove from remaining

git's rename detection returns a physical-level mapping, making it the most reliable move-detection mechanism.

-z is part of the contract, not a detail of the invocation. Without it git separates the fields with a tab and renders a path outside printable ASCII the way core.quotePath asks — double-quoted and octal-escaped — so src/a b.ts and src/日本語.ts reach the reader in a shape that matches no sym.source.file, and the move they belong to degrades into the removed + added pair this stage exists to prevent. Under -z each field is NUL-terminated and git's path quoting is bypassed entirely; the record length is decided by the status, since R (rename) and C (copy) carry a second path and every other status carries one. Only R enters the map: a copy's source file is still there, so it is not a move.

The reader has two obligations beyond splitting. It normalizes each path to NFC, because sym.source.file is normalized (toRelativePosix) and the lookup here is a bare Map.get — a repository holding a path decomposed would otherwise build a map that cannot match anything. And it refuses a stream whose records it cannot delimit rather than returning the part it read: a desynced reader produces plausible pairs, and a wrong rename settles a move between two unrelated Symbols, which is worse for this stage than having no hint at all.

git can also succeed while declining to detect renames: over diff.renameLimit it exits 0, warns on stderr, and reports every move as a delete plus an add. That degradation is invisible in the records — they parse, the map is just empty — so the caller warns on any stderr from this invocation.

Two base files renamed onto one target predict the same head id, so the claimants are collected before one is chosen and the lowest base.id is the move source. §3.8 does not apply — there is no score to order by — but the reason for fixing the choice is the one it gives.

3.3 Stage 3: logic fingerprint match ​

For cases git rename could not catch, or when git is unavailable.

group both sides by (sym.kind, sym.fingerprint.logic) (excluding dropped)
  → a group is {bases, heads} that can only pair with each other

for each group:
  if the group's logic names nothing:
    # a value unrelated bodies share (fingerprint.md §4.8): no evidence of identity
    drop every base and head with nameEvidence(name) <= 1      # §3.4.3, inadmissible
  while bases and heads remain:
    if len(bases) == 1 and the group's logic names something:
      # identical logic is proof enough on its own; no similarity test
      pair with the head of highest nameSimilarity, ties to the lower head id
      rationale: 'logic-fingerprint'
      break
    candidates = [(b, h, nameSimilarity(b.name, h.name))
                  for b in bases for h in heads
                  if nameSimilarity >= 0.85]
    accepted = acceptInScoreOrder(candidates)     # §3.8
    if accepted is empty:
      break   # fall through to stage 4 with the group intact
    pair each, rationale: 'logic-fingerprint+name-disambiguation'
    remove them and repeat — a round that leaves one base makes it the lone candidate above

Matching logic fingerprint = same meaning. This catches a good share of "file move without rename" and "file move with a minor refactor".

Except where the logic names nothing. A rule's type and loopKind say what shape a body has, not what it does (fingerprint.md §4.8), so a Symbol with no effect and no rule carrying more — a class, an enum, a constructor, a body that only calls something or returns a literal, a body that is one for loop over calls, one try — shares its logic value with every unrelated body of that shape. That two Symbols share the hash is still true, and the group is keyed on it, but agreement on an axis that says nothing is no evidence that they are one Symbol, as sharing the dropped sentinel is none. Such a group therefore has no lone-candidate branch: every pair in it must reach the 0.85 name bar, and both names must say more than one word. That is the name half of what §3.4.3 skips in stage 4, for the same reason — the name is all there is to go on. The other half, h.signature === null, is not taken: every class carries a null signature, so it would refuse every class this group exists to pair. Without this, a deleted function paired with whatever head of its group was closest by name — an unrelated function, or, before the kind joined the key, an unrelated class added anywhere in the workspace — and --fail-on removed never saw the deletion.

A pair below the bar is now refused whether or not its base was the last one left in the group, where the lone branch used to take it. Which of several bases above the bar takes a shared head is still §3.8's call, so group membership still decides that much.

What this gives up. In such a group a move is settled by its name alone. A class that moved file without git rename information pairs when its name barely changed (similarity ≥ 0.85) and says two words or more: InvoiceRenderer that moved is a move, while Invoice that moved and InvoiceRenderer renamed to InvoicePrinter (similarity 1/3) are each added + removed. No later stage takes these back. Stage 4 skips a head with a null signature (§3.4.3), and every class, interface, type alias and enum carries one, as does a const that does not hold a function — DEFAULT_TIMEOUT → REQUEST_TIMEOUT goes the same way as the class rename. Stage 4.5 takes only dropped Symbols. A function or method has a signature and gets stage 4 under its own rules, which refuse Cls.getUser → Cls.fetchUser too (member similarity 1/3, under the 0.95 row): with a body that only calls something, that rename was one moved+changed through the lone branch and is added + removed now.

An owner and its members are paired independently. class Invoice { render() } moved file is added + removed while Invoice.render, whose name says two words, is moved. A renamed class whose methods pair through §3.4.6's gate was already reported the same way; nothing reconciles an owner with its members.

The kind is part of the group key for the reason it is part of stage 4's bucket key (§3.4.0): a function and a class are never one Symbol, whatever their bodies hash to. It also stops a Symbol whose kind changes while it moves file, on any logic fingerprint, non-empty included: a method extracted into a top-level function, or an enum rewritten as an as const object in another file, is added + removed, because stage 4's bucket carries the kind as well. Within one file the id survives the kind change and stage 1 pairs the two.

Every Symbol stage 3 does not pair is handed to stage 4, a group that missed the bar included. What stage 4 can do with it is stage 4's rule: it skips a head with a null signature, and a Symbol on either side whose name says one word (§3.4.3), so for those Symbols stage 3 was the last stage to read them.

Dropped symbols all share the fingerprint "000000000000", so they would collide massively under the same hash. Dropped-to-dropped matching is excluded from stages 3/4 and handled by a dedicated weak matcher (§3.4.5).

3.4 Stage 4: name + signature similarity (last resort) ​

# Bucket pre-filter (O(N) hash bucketing) suppresses K^2
buckets = group remainingBase by (kind, signatureNullness)
        → { (kind, sigNull): [base symbols] }
        skipping any base with nameEvidence(b.name) <= 1        # §3.4.3, inadmissible

candidates = []
for h in remainingHead:
  if h.signature === null: continue                       # §3.4.3, inadmissible
  if nameEvidence(h.name) <= 1: continue                  # §3.4.3, inadmissible
  bucket = buckets.get((h.kind, 'has-sig'))
  if !bucket: continue
  threshold = thresholdFor(h.name)                        # §3.4.3
  for b in bucket:
    if not ownersAreCompatible(b.name, h.name): continue  # §3.4.6, the owner gate
    score = 0.5 * memberSimilarity(b.name, h.name)        # §3.4.1, the last segment
          + 0.3 * signatureSimilarity(b.signature, h.signature)
          + 0.2 * 1.0                                     # §3.4.6, satisfied by the gate
    if score >= threshold:
      candidates.append((b, h, score))

for (b, h, score) in acceptInScoreOrder(candidates):      # §3.8
  pair, rationale: 'name-signature'

3.4.0 Bucket pre-filter (mandatory) ​

Stage 4 implemented as O(K^2) drops below practical speed at K=500 (heavy use of the Repository pattern / Zod schemas). As a bucket pre-filter, hash-partition by the combination (kind, signature === null ? 'no-sig' : 'has-sig') and evaluate each head only within its corresponding bucket.

That key alone is not a partition of anything. A directory rename with no git rename information — the §9.4 plugin-difference and §11.5 shallow-clone situations — leaves every method of the codebase in one bucket, so "a few dozen per bucket" describes the easy case rather than the hard one. Measured at 4000 symbols in one bucket, stage 4 took 64 s against §8.3's 2 s target when that was reported. Two later changes to the per-comparison cost — a per-pass token memo, and settling candidates in one sweep rather than per head — brought it to 14 s, which is what the table below measures against.

So within a bucket the bases are indexed by the tokens of their member names, and a head is offered only the bases sharing one:

for each base in the bucket:
  for each token of tokenize(lastSegment(base.name)):
    index[token].append(base)

for each head:
  candidates = the union of index[token] for the head's member tokens

This costs no recall. The composite is 0.5 * member + 0.3 * signature + 0.2, the lowest row of §3.4.3's table is 0.85, and the signature axis is worth at most 0.3 — so member >= 0.7 for any pairing that survives, whichever row applies. A Jaccard that high is a Jaccard above zero, and a Jaccard above zero is a shared token. Nothing outside the union could have paired.

Two details the rule needs:

  • A member name with no tokens is indexed under a key of its own. Foo.Bar. has an empty last segment and two tokens in its qualified name, so §3.4.3 admits it, and two such Symbols score 1.0 on an axis comparing two empty sets. Without a key they would be unreachable — a pairing lost to the index rather than to the score.
  • A head whose postings cover the bucket is walked over the bucket directly. A base is reached once per token it shares, so a head whose postings add up to at least the whole bucket is not being narrowed — only charged for the overlap: every Symbol named handleRequest puts the entire bucket under both of its tokens. This is a bound, not a promise of the cheaper branch, since the sum double-counts and a head can trip it while covering far less than the bucket. What it guarantees is that the index never costs more than not having one.

The member floor is read before §3.4.6's gate, and on a corpus of varied names that is worth as much as the index. The floor is one Jaccard over token sets the pass already holds; the gate splits both owners into segments, tokenises each and runs an augmenting-path matching. It is the only early exit — the gate short-circuits internally on identical owners and on first segments that cannot correspond, and hoisting either into the loop buys nothing, because the gate reaches them on the same two lookups.

The reverse order suits the opposite corpus, where the member names are identical and the owners differ: there the gate is the selective test and the floor refuses nothing. Neither order dominates, and this one favours the case the index cannot already help with.

Measured on a directory rename with an edited body, no git rename information, everything in one bucket:

beforeafter
4000 symbols, varied names14.0 s1.3 s
4000 symbols, every member name identical13.4 s13.5 s

The second row is the shape the index cannot help with — one token, shared by everything — and the point of the fallback is that it does not make it worse.

3.4.3 Thresholds by name token count ​

To avoid false positives on short names (1–2 token names like getUser vs getUsers), the threshold is adjusted by the token count of the head's last name segment.

The symbol kind does not enter into it. This section was headed "per-symbol-kind thresholds" and its pseudocode took a kind parameter that neither it nor the implementation ever read; §3.4.6 then quoted "for kind=method the threshold is 0.85" for a two-token name the table gives 0.95. Kind already does the work it can do in §3.4.0's bucket key, which stops a class body from being compared against a function at all.

thresholdFor(headName):
  tokenCount = tokenize(lastSegment(headName)).length
  if tokenCount <= 1:
    return 1.0                  # an exact match on both remaining axes
  if tokenCount == 2:
    return 0.95                 # strict (e.g. 'getUser' vs 'getUsers' scores 1/3 → does not pass)
  return 0.85                   # default

1.0 is the top of the scale and a reachable score: an identical member name and signature, past §3.4.6's gate, give 0.5 + 0.3 + 0.2, exactly 1 in IEEE 754. The comparison is score >= threshold, so the first row pairs an unchanged UserRepo.get that moved file with an edited body — which stage 3 does not catch. The owner need not be identical, only compatible, so UserRepo.get → UserRepos.get reaches it too. Making it > would empty the row rather than tighten it.

Symbols stage 4 does not read ​

Two kinds of Symbol are skipped before any threshold applies, because for them a high score means the evidence is missing rather than that the two names resemble each other. Neither is expressible as a threshold, and one of them wants a bar above the top of the scale:

if h.signature === null:            skip the head entirely, leave for added/removed
if nameEvidence(s.name) <= 1:       skip the Symbol, either side, leave for added/removed

A null signature makes signatureSimilarity(null, null) return 1.0 against every candidate. The bucket key partitions by signature nullness, so a signature-less head only ever sees signature-less candidates — there is nothing in its bucket it could legitimately pair with, and the rule needs no condition on them.

A one-token qualified name is the whole of what §3.4 has to read about that Symbol's identity. main supplies one word and an empty owner, so a top-level main(x: string): void scores 1.0 against any other top-level main(x: string): void and two unrelated CLI entry points are reported as one moved+changed — which --fail-on moved gates on. The first threshold row was written to prevent exactly this and could not, because 1.0 is the top of the scale.

The measure is over the whole qualified name — the member and its owner together, which is everything §3.4 knows a Symbol by — and not over the last segment alone, which is what thresholdFor reads. UserRepo.get supplies three and goes on pairing though its last segment supplies one; the threshold row that still governs it is the one above. Tokens are deduped (§3.4.1), so Main.main supplies one and is skipped: the formula cannot tell it from a bare main.

It is nameEvidence (§3.4.1) and not the distinct-token count. The two agree on a name whose words the tokeniser can find, and part company on a run it cannot segment, where the measure reads the run and not the token: ユーザー.取得 is two tokens and 1 1/6 words. The two rules in this section therefore read the same name differently on purpose: thresholdFor asks how coarse the Jaccard will be, which really is the token count, and this rule asks how much the name says, which is not.

The rule reads both sides, because the property belongs to a pairing rather than to one end of it.

It once read the head alone, on an arithmetic licence: a one-token name on either side capped the score at 0.5 * 0.5 + 0.3 + 0.2 = 0.75, under the table's lowest row, so a one-token base was unreachable without a check of its own. That held while the name axis was a Jaccard over the whole qualified name. §3.4.6's gate moved the axis to the last segment, and the ceiling went with it: Main.main is one deduped token, it clears the gate against Mains.main by inflection, and their member names are identical, so the pair scores 1.0. Reading both sides costs one test per Symbol and needs no licence.

What this gives up. A one-token name that moved file and changed body is now added + removed where it was one moved+changed. That band is narrow: stage 1 takes it if the id survives, stage 2 if git recorded the rename, stage 3 if the logic fingerprint is unchanged and names something (§3.3). What is left is a cross-file move git did not record, with an edited body — and for a name of one word, that pairing was never better than a guess.

The band was once much wider on codebases with non-Latin identifiers, because the rule read the distinct-token count and §3.4.1's tokeniser reads ユーザー情報を取得する as one token however much it says. That refused it on the same footing as main, where the proxy is simply wrong: two unrelated Symbols do not carry that name by coincidence. nameEvidence is what closed that, and it closes it for the reason rather than for the script — a name of one word is still refused whatever wrote it. главная and مستخدم alongside main; and, since the measure is a floor on words rather than a count of characters, メイン and 초기화 and ハンドラー alongside the same three.

What a morphemic name gets back is narrower than the rule's own band, and the reason is the threshold table rather than this rule. Such a name is one token, so thresholdFor hands it EXACT_MATCH_ONLY and only an identical signature past a compatible owner reaches 1.0: ユーザー情報を取得する moved file with an edited body is a pair, and the same move with one added input is not, where getUserInformation — two tokens in its last segment — has the 0.95 row to fall back on. Admissibility opens the door; the row still decides who comes through.

Stage 3 asks the same of a name only where the logic names nothing (§3.3). An identical logic fingerprint that names something — a guard, a returned expression, an effect — is proof on its own and does not ask the name to carry anything, so a main with such a body that moved file unchanged is still a move. A main whose body only calls something is added + removed: its fingerprint is shared with every other such body, and its name says one word.

3.4.4 Tuning via configuration ​

config.diff.nameSignatureThreshold (default: null = automatic, by the token count of §3.4.3's table) overriding the global threshold is planned — see the roadmap. Currently the threshold is automatic only.

3.4.5 Stage 4.5: dedicated weak matcher for dropped ​

Dropped symbols have zero fingerprints and cannot use stages 3/4. To catch dropped symbols that moved in environments without git rename, a lightweight dropped-only matcher runs after stage 4. Two signals remain: the trailing segment of the qualified name, and the file basename. Either one alone is enough to pair.

A signal counts only when it identifies a Symbol — when exactly one dropped base and one dropped head of that kind carry the key:

for keyOf in [ (kind, lastSegment(name)), (kind, basename(source.file)) ]:
  for key carried by exactly one dropped base AND exactly one dropped head:
    candidates.append((that base, that head))  # both keys may name the same pairing

for (b, h) in the largest subset of candidates that pairs each Symbol at most once:
  pair, rationale: 'dropped-weak-match'

As a result, a change such as "only renamed the directory of DTO files" is recorded as moved:10 rather than droppedAdded:10 / droppedRemoved:10.

Why identifying, and not just matching. A key several Symbols carry names a group, and a group is not a pairing. index.ts is the most common filename in a TypeScript monorepo, so a bare basename match paired every dropped Symbol of one kind under one with every other — at a score they all tied on, which left the choice among unrelated classes to the tie-break. Those pairings land in summary.moved, which --fail-on moved gates on, so the false-positive budget this section grants itself was being spent on the default case rather than an unusual one. Requiring the key to identify costs the pairings where the surviving signal was ambiguous, which are the ones with nothing to distinguish the candidates by — the fingerprint is zeroed, so there is no second opinion to consult.

The candidates carry no weight, so §3.8 does not apply. Every candidate here is worth the same: a pairing both halves identify cannot be contested — both keys are sole on both sides and point at each other, so neither Symbol appears in any other candidate — and the case that remains, one base offered different heads by the two halves, is one the 0.5-per-half scale scored equally anyway. §3.8's sweep settles conflicts by score, and its licence to be greedy is that it never passes over the best available pairing; with no score there is no best, and it would drop one identified pairing for another over nothing but the id it sorts under. Three identified pairings over four Symbols where two can hold is not a hypothetical.

So this stage takes a maximum matching. Each axis identifies a Symbol at most once, so a Symbol carries at most two candidates and the set is the union of two matchings — a disjoint union of simple paths and even cycles, where alternate candidates along each component are a maximum matching and walking from a fixed end makes the choice among them canonical. That is also what bounds the work: at most one pairing per identifying key, two axes, against the cross-product a shared basename used to produce.

"Exactly one" is counted over what reaches this stage, not over every dropped symbol in the Document. Stages 1 and 2 have already taken theirs, so a key they emptied out identifies again. That is the intended reading — the question is which leftover a key picks out, and the stage is handed the leftovers — but it is also the ordinary way a shared index.ts still pairs unrelated symbols: three dropped classes under one, two of them unchanged and matched by id, and the basename identifies the two that remain.

So there is still a false-positive risk — that case, and two unrelated classes that are genuinely the sole carriers of one basename — but dropped symbols are outside the IR's primary field of view, so the impact is small.

3.4.1 nameSimilarity ​

Token split (camelCase / snake_case / . / ::) → Jaccard similarity. Levenshtein is too sensitive and was rejected.

tokenize("InvoiceService.createInvoice") = ["invoice", "service", "create", "invoice"] (lowercase, dedupe)
jaccard(A, B) = |A ∩ B| / |A ∪ B|

The camel boundary is Unicode case. The split tests \p{Ll}, \p{Lu}/\p{Lt} and \p{Nd} rather than the ASCII ranges, so a hump is a hump in every cased script. What no case rule can find is a boundary in a script that has no case, so such a name comes back whole:

tokenize("получитьПользователя")  = ["получить", "пользователя"]   (2 — the hump registers)
tokenize("ユーザー情報を取得する") = ["ユーザー情報を取得する"]     (1)
tokenize("获取用户信息")           = ["获取用户信息"]               (1)
tokenize("مستخدم")                = ["مستخدم"]                     (1 — caseless, and one word)
tokenize("ユーザー.取得")          = ["ユーザー", "取得"]           (2 — a separator still splits)
tokenize("UserRepo.取得")          = ["user", "repo", "取得"]       (3 — the Latin half splits)

Jaccard is unharmed by this: two names that tokenise whole still score 1.0 against each other and 0 against anything else, which is the right answer for identical and for unrelated names alike. The count is likewise the right measure of the Jaccard's granularity, which is what §3.4.3's threshold table reads: one token against a name of n scores 0 or 1/n, so nothing but an identical single token reaches 1.0, and the composite tops out at 0.5 × 0.5 + 0.3 + 0.2 = 0.75 below that — under every row of the table. A one-token name is therefore all-or-nothing on its name axis whatever script wrote it, which is what the first row is calibrated to.

What the count is not is a measure of how much a name says. 获取用户信息 is one token and a phrase. §3.4.3's admissibility rule is the one place that asks that question, and it reads nameEvidence instead:

nameEvidence(qname) = (number of distinct tokens holding anything else)
                    + (distinct Han characters)      / 3
                    + (distinct kana and Hangul)     / 6

over the NFC form of the name; combining marks are not counted

A token of a script that writes its word boundaries is one word however long it runs, because length is not what makes a name coincidental: initialize is ten characters and one word, exactly as main is four and one. A run of a script that writes no boundary cannot be segmented, so it is counted instead, and the divisor is the longest a single word of that script runs. Han writes a morpheme per character and spells its longest single words in three — 初期化, 数据库, 处理器. Kana and Hangul write a syllable per character and take more: ハンドラー is one word in five, コンピュータ and 데이터베이스 one in six.

The quotient is therefore a floor on the number of words in the run, which is what the rule needs — it admits only when the run cannot be a single word. It is fractional where a run is: 取得 is two thirds of a word and getUser is exactly 2.

Script_Extensions rather than Script so that a character belonging to a run comes with it. U+30FC, the prolonged sound mark in ユーザー, is a modifier letter of script Common; read as foreign it would count as a word of its own, and ユーザー — three kana and two marks — would be admitted on the strength of them. Characters are counted once each, as tokens are, so 取得取得 says what 取得 says. NFC first, so a decomposed ガ is the one character it spells and Hangul jamo are the syllable they compose.

An alphabetic script with no case is deliberately not in either bucket. Its characters are letters rather than morphemes or syllables, so a run of them is one word and the token count was already right; a multi-word identifier in such a script writes a separator, and the tokeniser splits on it (مستخدم.احصل → 2).

nameEvidence("main")                = 1       nameEvidence("获取用户信息")           = 2
nameEvidence("initialize")          = 1       nameEvidence("ユーザー情報を取得する") = 2 1/3
nameEvidence("مستخدم")              = 1       nameEvidence("사용자정보조회")         = 1 1/6
nameEvidence("Main.main")           = 1       nameEvidence("получитьПользователя") = 2
nameEvidence("初期化")              = 1       nameEvidence("UserRepo.取得")          = 2 2/3
nameEvidence("ハンドラー")          = 5/6     nameEvidence("ユーザー.取得")          = 1 1/6

Six is the longest word the measure assumes, not the longest there is: アプリケーション is one word in eight and is admitted as though it were more. The constants sit where they do because a name of seven syllables is more often a phrase (사용자정보조회) than a loanword, and a wrong pairing costs less than the band of real moves a higher divisor would refuse.

3.4.6 The owner gate (R-8: avoiding same-name method collisions) ​

Two things must hold at once. A method whose class was renamed (UserRepo.getUser → UsersRepository.getUser) must pair, and a same-named method of a different class (AdminRepo.getUser) must not.

The owner is therefore a gate, not a term in the score: a pair whose owners are incompatible is never scored.

ownerOf(qname):
  if '::' in qname: return qname[:first index of '::']    # the Class:: portion of a static method
  if '.'  in qname: return qname[:last index of '.']      # the nested.namespace.Class portion
  return ''
  # 'Class::method' → 'Class'
  # 'A.B.C.method'  → 'A.B.C'
  # 'topLevel'      → ''   (empty)

ownersAreCompatible(baseName, headName):
  baseOwner = ownerOf(baseName)
  headOwner = ownerOf(headName)
  if baseOwner === '' && headOwner === '': return true    # both top-level: one shared scope
  if baseOwner === '' || headOwner === '': return false   # different depths
  if baseOwner === headOwner: return true                 # the common case

  # an owner is a path, so it corresponds segment by segment; within a segment every token on
  # each side needs a distinct partner on the other, under sameWord
  baseSegments = baseOwner.split('.')
  headSegments = headOwner.split('.')
  if len(baseSegments) != len(headSegments): return false
  return all(seg == cnt or perfectMatching(tokenize(seg), tokenize(cnt), sameWord)
             for seg, cnt in zip(baseSegments, headSegments))

sameWord(a, b):
  return a == b or inflectionOf(a, b)     # user/users, entity/entities. Nothing else.

As a result:

pairownersverdict
UserRepo.getUser vs UserRepos.getUserrepo -> reposcompatible, scored 1.0, pairs
EntityStore.findById vs EntitiesStore.findByIdentity -> entitiescompatible, pairs
Users.UserRepo.getUser vs Users.UserRepos.getUsersegment by segmentcompatible, pairs
UserRepo.getUser vs AdminRepo.getUseruser has no partnerrefused
RepoManager.load vs ReportManager.loadrepo is not report inflectedrefused

Why a gate and not a weight. The previous design added 0.2 * jaccardTokens(baseOwner, headOwner) to the score, and could satisfy neither requirement.

It could not pair the rename, because the owner was counted twice. §3.4.1's name axis is a Jaccard over the whole qualified name, so a renamed owner already depressed the name term, and the owner term then charged for the same difference again: UserRepo.getUser vs UsersRepository.getUser scored 0.5*0.4 + 0.3 + 0.2*0 = 0.5, not the 0.9 this section claimed. End to end, renaming a class and keeping three methods with edited bodies reported added: 3 / removed: 3.

And it could not refuse the collision, because a weight cannot outvote a perfect member name and signature. Reading the name axis on the last segment fixes the double count but inverts the ordering — AdminRepo shares the repo token where UsersRepository shares none:

pair, both .findByIdowner Jaccardw=0.2w=0.3threshold
UserRepo vs AdminRepo — must refuse0.3330.86670.80000.85
UserRepo to UsersRepository — must pair00.80000.70000.85
UserRepoService vs AdminRepoService — must refuse0.50.90000.85000.85

The collision outscores the rename at either weight, and the three-token collision passes at both. There is no weight at which "different class" reliably loses, because the quantity being weighed is not evidence of degree.

Past the gate there is no owner left to grade, so the owner axis is satisfied in full and the composite keeps the 0.5/0.3/0.2 shape §3.4.3's rows are calibrated against. Dropping the term and renormalising would move every threshold without changing what any of them means.

Why inflection and nothing looser. A prefix test is the obvious way to admit repo -> repository, and it cannot be made to work: it admits repo/report, cache/cached, con/controller — distinct classes, which is the collision this section exists to refuse. Nor is there a threshold to find, because the two populations interleave:

dicelevenshtein
accept UserRepo/UsersRepository0.5717
refuse RepoManager/ReportManager0.8182
accept Repo/Repository0.5006
refuse CacheStore/CachedStore0.8421

The renames to accept score lower than the collisions to refuse, on both measures. A length-growth rule fares no better: con -> controller grows by 7 and user -> users by 1, so anything admitting the second admits the first.

Inflection is not on that spectrum. It is a closed, mechanical relation between two spellings of one word, so it can be recognised rather than estimated, and it covers the rename a plain equality test misses most often — a class pluralised in place.

What the gate costs. Three things, all falling through to added + removed, which is R-8's preferred direction of error:

  • The abbreviation family. UserRepo -> UsersRepository, this section's original headline example, no longer clears the gate. That is the price of refusing repo/report, which no rule over the two strings alone can tell apart.
  • An added or dropped token. UserRepo and UserRepoV2 are two classes rather than one renamed; an added token is as much evidence of a sibling as of a rename.
  • A changed nesting depth. A rename changes what a class is called, not where it lives, so Services.UserRepo and UserRepo are different scopes.

The evidence that would settle the first is not in the strings. The owner is itself a Symbol in the IR, so "was this class paired as a rename" is a question the stage could ask rather than guess at — which would mean stage 4 consuming its own output, and is the direction a future revision should take rather than a looser string rule.

3.4.2 signatureSimilarity ​

null + null → 1.0 null + non-null or the reverse → 0.0 Both non-null →

  • match ratio of input types (order-sensitive)
  • match ratio of output types
  • set match ratio of throws (order-insensitive) averaged.

3.5 Stage 5: the remainder is added / removed / unknown ​

Symbols not paired by this point are finalized:

  • remainingHead → status: added
  • remainingBase → status: removed
  • …unless the other document never analysed the file the Symbol lives in, in which case the status is unknown (§3.5.1)

3.5.1 unknown: the other document could not look ​

A leftover Symbol whose source.file appears in the other document's stats.skippedFiles[] is not evidence of an addition or a deletion. That document did not read the file, so it cannot say whether the Symbol is there. The entry carries absentFrom (base or head — the document that lost the file, so head reads as "this may still exist" and base as "this may not be new") and the skip reason, which is what decides the reader's next move: parse-timeout depends on how loaded the machine was and usually clears on a re-run, while parse-failed, extraction-failed, over-size and unroutable describe the file and clear only when something is fixed.

Three properties this rests on:

  • Read after all five stages, never before. A Symbol that moved out of a lost file into a file the other document has is matched by stage 3 or 4, and that document holds real evidence for it — it is moved. Filtering the base list up front would throw that answer away.
  • Both directions. A file fine at head and withdrawn at base makes phantom added entries exactly as a file withdrawn at head makes phantom removed ones.
  • dropped leftovers keep their counters. They produce no symbols[] entry on either side today and nothing gates on them, so they stay in droppedAdded / droppedRemoved rather than becoming unknown entries where there were none.

When the other document omits stats.skippedFiles entirely — written before the field existed — nothing changes: the leftovers keep added / removed. The list cannot be inferred from totalFiles > parsedFiles without attaching the doubt to whichever Symbols happened to be missing, so aburi diff reports what it can see and warns on stderr that the check was unavailable (cli-spec.md §6.6).

dependencies[] gets the same treatment on its own terms — see §6.2.1. What stays outside both is a file both revisions skipped: neither document holds a Symbol or an edge from it, so there is no leftover to classify and the diff is silent about a file it never compared. That is a statement about the run rather than about any entry, and aburi diff makes it on stderr (cli-spec.md §6.6) and in the artifact, in notCompared[] (§6.3).

3.6 Handling dropped symbols ​

Symbols with dropped: true are excluded from matching in stages 3/4:

  • Their fingerprints are all zero, so they collide 100% in stage 3
  • Judging identity by name+signature alone carries too little information

Instead, the dropped-only weak matcher of stage 4.5 (§3.4.5) runs:

  1. If caught by stage 1 (exact ID match), settled
  2. Otherwise stage 4.5 attempts the lastSegment(name) + basename(file) weak match, on whichever of the two identifies a symbol (§3.4.5)
  3. If still unmatched, counted independently as "dropped disappeared/appeared" (aggregated in the "dropped + changes" section of the diff report)

Dropped symbols are outside the IR's primary field of view, so the false-positive risk of stage 4.5 is accepted.

3.7 Identity collisions (fail-fast) ​

The diff keys three collections by identity. Stage 1 pairs Symbols by id (§3.1) and stages 2 to 4.5 track the base Symbols they have consumed by id; §6.1 maps Components by id; §6.2 maps Dependencies by the (from, to, via) triple. Repeat any of them and the diff still produces an answer — with an entry left out, an entry classified twice, or a change the two revisions do not contain — and nothing downstream can tell that answer from the real one. ir-schema §14 #1, #2 and #13 forbid all three for that reason, and this section is where that reason lives: the code and its tests refer here rather than restating it.

At extraction time, the Aburi core stops with a fatal error on:

  • Multiple Symbols generated with the same ID (e.g., multiple export default function in the same file, or CJS module.exports.default coexisting with ESM export default)
  • Two or more <default> symbols detected within the same file
  • A dropped and a non-dropped symbol colliding on the same ID

The error message is Symbol ID collision at <file>: <id>, prompting the user to fix the offending location.

At the diff entry point, buildDiff checks the same three identities again before the first stage runs, because it is public API: an IR read off disk has been through the integrity checker, but one a caller assembled in memory has been through nothing. A collision raises DiffError with code ir-identity-collision, naming the side, the collection, the repeated value, and both positions.

The identity pass runs behind a shape gate, and both exist for the same reason. buildDiff calls checkDocumentShape — invariant #20, and only #20 — on each side first, raising ir-shape-invalid named by the record the breach sits in and the field inside it. Without it, every field the diff dereferences crashed with a TypeError naming neither: fingerprint and source during classification (§4), calls, decorators, effects and rules during the delta (§5), components[].roots in §6.1, and stats in the side view §6.2 builds, which reads stats.skippedFiles unconditionally. That enumeration is the shape of the class rather than the whole of it, and it is one change to the matcher away from being out of date — which is the argument for a gate that is not scoped to the fields the matcher happens to read. What the gate establishes is what the IR brand asserts, so a scope that moved with the matcher would leave a caller's Document conditionally valid. With it in place, the identity pass's own type guards have no live path, and DiffError carries the full violation array beside the message so a caller repairing a Document is not made to run the diff once per field.

The entry-point check stops at shape and identity rather than delegating to the whole integrity checker. Most of the remaining rules would make buildDiff refuse a Document over something that does not change its answer — #3 (a component reference that resolves), #7 (the effect vocabulary) and #15 (the callResolution census) are all read by no part of the diff. Note that this argument does not extend to every rule. #19 (Unicode normalisation) changes the answer, because matching compares raw strings and an NFD id on one side misses the NFC spelling of the same name on the other. #11 (array ordering) changes it too, through §5.2: an array delta pairs elements in array order, so when a body moves twenty lines, two same-typed rules written in the other order on one side report one of them as added and removed, where the canonical order reports nothing. Neither is enforced here because the diff has no standing to rewrite its inputs, not because they are harmless — and note that #11 no longer reaches matching, which §3.8 makes independent of array order.

3.8 Choosing among candidate pairings ​

Stages 3, 4 and 4.5 each produce a set of possible pairings and have to settle them. acceptInScoreOrder sorts the candidates and sweeps once, taking a pairing when neither side has been taken already:

sort by (score descending, base.id ascending, head.id ascending)
for each candidate:
  if base and head are both still free:
    accept it

Two properties follow, and neither held when each head chose its own best in turn.

The diff is a function of the two Documents, not of their array order. (base.id, head.id) is a total order on candidates of equal score, because ids are unique within a Document (ir-schema §14 #1) — which §3.7 has buildDiff establish before the first stage runs. Without it, equal scores resolve to whichever candidate was enumerated first, and permuting symbols[] changes the bytes of diff.json.

A better pairing is never passed over for a worse one. Choosing per head consumed a base the moment some head wanted it, so an earlier head could take the base that was a later head’s exact match — putting one name in the output as an addition and as the source of a move at the same time.

The sweep is greedy, not an optimal assignment: a pairing can still be stranded because both of its partners were taken by higher-scoring ones. That is a deliberate stop — the case that misleads a reader is the best available pairing being skipped, and a greedy sweep in score order never does that.

4. Status determination ​

For each matched pair:

droppedToggled = base.dropped != head.dropped     // §4.1
pathChanged    = base.source.file != head.source.file || base.id != head.id
apiChanged     = base.fingerprint.api    != head.fingerprint.api
logicChanged   = base.fingerprint.logic  != head.fingerprint.logic
syntaxChanged  = base.fingerprint.syntax != head.fingerprint.syntax

confidenceChanged = !head.dropped && base.confidence != head.confidence

contentChanged = apiChanged || logicChanged || syntaxChanged || confidenceChanged

if droppedToggled:
  status = "dropped-toggled"                       // takes precedence over all others
elif pathChanged && contentChanged:
  status = "moved+changed"
elif pathChanged:
  status = "moved"
elif contentChanged:
  status = "changed"
else:
  status = "unchanged"

unchanged is not included in the default output (only counted in the summary).

pathChanged covers an in-file rename as well as a relocation: any id mismatch in a matched pair means the identifier moved, which DF9 ("method rename (same file, same logic) → moved") relies on. Stage 1 pairs by exact id, so only a pair a later stage matched can take the id branch; a changed id no stage pairs is a removal and an addition.

confidence is compared on its own because no fingerprint reads it. The same code classified less surely — a framework role matched on the identifier alone where an import used to prove it — is something a reviewer has to see, and an unchanged pair is never reported, so a confidence change that moved no fingerprint would otherwise vanish. It counts like any other change of its status: toward summary.changed and --fail-on changed when the Symbol stayed put, toward summary.movedChanged and --fail-on moved+changed when it also moved. The blast radius can be wide. A plugin may decide confidence from a file-level signal (the Express plugin asks whether the file imports express), so deleting one import moves every Symbol that plugin classified in that file at once; and when the two IRs were read off disk rather than scanned in one run, they may come from different plugin versions, whose confidence rules differ for code nobody touched. --fail-on confidence-changed gates on this axis alone and takes a threshold (confidence-changed:>20); a gate that should ignore it writes the fingerprint axes (api-changed,logic-changed) instead of changed (cli-spec.md §6.7). A pair dropped on both sides is exempt and stays unchanged (DF13): a dropped Symbol is outside what the diff asks a reviewer to read. component is the contrast: it comes from Component.roots[] rather than from the code, so a re-rooted package changes it under every Symbol without anyone editing them, and it is recorded on the delta only.

4.1 Why the dropped-toggled status exists ​

The transition dropped: false → true (kept → dropped) or dropped: true → false (dropped → kept) is caused by changes to drop rules or plugin configuration:

  • Adding a stronger DTO-detection rule → many classes become dropped: true at once
  • Adding a framework plugin turns previously dropped symbols (no decorators) into boundaries → dropped: false

In this case the fingerprints inevitably all change (a..b → 0..0 or the reverse). Treating this as changed produces the classic false report: "a DTO rule change alone lists every DTO as api-changed in the API changes section (the highest-severity section)".

Treating dropped-toggled as an independent status means:

  • delta (apiChanged, etc.) is not computed (always treated as false)
  • The Markdown projection stores these in a dedicated collapsed section, "Drop rule changes"
  • --fail-on dropped-toggled can make it an explicit CI gate

4.2 Why the unknown status exists ​

The alternatives were both silence of a kind. Suppressing the entry hides a Symbol that may genuinely have been deleted; keeping removed and adding a flag beside it shows an API deletion to every reader that does not know to look for the flag — including --fail-on removed, which is the reason the phantom mattered. A status of its own makes --fail-on removed mean what it says and gives a strict pipeline --fail-on unknown to catch the case where the answer is missing on one side.

It does not catch the case where both sides lost the same file, and no status could. unknown is derived from the matcher's leftovers, and a file skipped in both revisions contributes Symbols to neither document, so there is no leftover to carry a status at all. That loss is reported at document level instead — see §6.3.

5. Delta computation (changed / moved+changed only) ​

Compute per-field "what" changed:

5.1 Fingerprint axes ​

delta.apiChanged    = base.fp.api    != head.fp.api
delta.logicChanged  = base.fp.logic  != head.fp.logic
delta.syntaxChanged = base.fp.syntax != head.fp.syntax

If all three axes changed: "implementation, contract, and meaning all changed".

5.2 Array diff (rules / effects / calls / decorators) ​

For each array, three kinds of delta are computed:

  • added[]: elements in head but not in base
  • removed[]: elements in base but not in head
  • modified[]: elements present in both but with differing content (e.g., a rule where only condition changed)

Element identity criteria — the key that makes two elements candidates; choosing among several candidates is §5.2.0:

  • Rule: (type); an edited one is paired within ±2 lines (§5.2.1)
  • Effect: (id, target), with no line window at all
  • Call: (target); an edited one is paired within the line window
  • Decorator: (name); an edited one is paired within the line window (§5.2.2)

An element whose content is unchanged has no line window in any array: it pairs with its counterpart however far the body moved (§5.2.0).

5.2.0 Choosing among elements that share a key ​

A key does not identify one element. A Symbol routinely holds two guard rules, two calls to one target, two @Get — so which base element a head element takes is a choice.

It is made in two passes, and each pass chooses a set of pairings rather than one at a time:

for admissible in [exact, near]:
  exact(b, h) = same key and content equal
  near(b, h)  = same key and |b.line - h.line| <= lineFuzz
  for each key, take the best set of admissible pairings over its elements still free, where
    - the pairings do not cross: i1 < i2 implies j1 < j2
    - more pairings beats fewer
    - among sets of equal size, less total |b.line - h.line| wins

unpaired head -> added
unpaired base -> removed
paired, contents differ -> modified   (the head element is the one emitted)

The first pass is what makes the answer readable. An element nothing touched has an exact counterpart, so the pass that only considers exact counterparts claims it before an edited or deleted neighbour can; whatever is left over is then paired by proximity, which is where a genuine edit lands.

Why the exact pass has no line window. Non-crossing, below, is what stops an element from pairing with one it was never beside. A window on the exact pass would add a refusal of exactly the case that pass exists for. A function that moved 14 lines down its file with nothing edited — one guard inserted above it is enough — would otherwise lose every pairing it had, and each call and rule would come back as one added and one removed, listed under both headings with only the line suffix to tell them apart. Distance stays in the pass as the tie-breaker of the score, which is where it ranks two otherwise equal sets. The window belongs to the second pass: once the contents disagree, proximity is the only evidence that two elements are one edited element rather than a deletion and an unrelated insertion that share a key.

The window did one other thing, and dropping it gives that up: it stopped a distant exact counterpart from outranking a near edit. With base guard@10 "!x" and head guard@11 "!y", guard@60 "!x", the exact pass pairs !x with its copy fifty lines down, so !y has nothing left to be an edit of and reads as added, where the window used to report !y modified and !x added. Likewise base !a@10, !b@20 against head !b@10, !c@20 reads as !a removed and !c added rather than two edits. Both readings are defensible, and this one is what the first pass promises: an element nothing touched is claimed by its own counterpart before an edited neighbour can take it. The trade is lopsided in its favour — a body that moved is the ordinary case, and a near edit beside a far identical copy the unusual one.

Why non-crossing, and only within a key. ir-schema §14 #11 orders these arrays by line, so i < j means element i sits above element j in the file. Two pairings of one key that crossed would have an element move above another of its key that it was below — not a line shift but a different element. Elements of different keys are never candidates for each other, so their relative order identifies nothing: a call to scan that moved below a call to mkdir is still the call to scan, and holding it to the order of every other call would report it as added and removed. The constraint is also what makes the best set reachable by a suffix recurrence rather than by a general assignment algorithm, and what settles ties: between two otherwise indistinguishable candidates the one that keeps the pairings in order is the one taken.

Why a set and not one element at a time. A greedy pass takes the pairing in front of it, and a nearer pairing can cost a farther one its only partner:

baseheadfirst key hitnearest line, greedythis rule
guard@1 "!user", guard@3 "!invoice"guard@3 "!invoice"removed and modified !invoiceremoved !userremoved !user
guard@1 "!a", guard@2 "!b"guard@2 "!a", guard@3 "!b"nothingmodified !a, modified !bnothing
guard@1 "!a", guard@2 "!b"guard@3 "!c", guard@4 "!d"modified !c, modified !dmodified !c, added !d, removed !amodified !c, modified !d

Row 1 is a deleted guard: taking the first key hit inside the window let the surviving guard claim the deleted one's slot, so an untouched element was reported as removed and modified at once, under contradictory buckets, and the element actually deleted appeared nowhere.

Row 2 is two guards shifted down a line together with nothing edited — the noise the exact pass exists to suppress, which pairing by proximity alone reintroduces.

Row 3 is the same shift where both guards were also edited, so the exact pass has nothing to claim and the whole block is the second pass's. Greedily, the first head element is nearest the second base element; claiming it strands the other head outside the window, and two edits come back as an edit, an add and a remove. Choosing the pair of pairings together costs more total movement and reports two edits, which is the right answer.

What is not promised. The result depends on the order of the arrays, and cannot not: it pairs by line, and #11 is what fixes that order. Reversing an array produces a different answer — and a non-canonical Document. §3.8 achieves order-independence for Symbol pairing because ids give it a total order that comes from content; §5.2 has no counterpart, and §3.7 records the distinction.

5.2.1 Rationale for line fuzz ​

Line fuzz decides one question: whether an edited element is the same element as the one it replaced. A guard whose condition changed from !user to !admin one line lower is an edit and reads as modified; the same pair forty lines apart is more likely a deletion and an unrelated insertion, and reads as removed and added. By default a difference of up to ±2 lines is tolerated for this; beyond that the two are treated as distinct elements.

It does not decide whether an unchanged element survived. That one is paired by the exact pass (§5.2.0), which has no window, so a body that moved any distance reports nothing for the rules, calls and decorators it kept.

The window defaults to 2 and is not an aburi.json setting, because it changes only how an edit reads, never which Symbols changed. A caller of @aburi/diff can pass another value through DeltaOptions.lineFuzz, whose docblock states what it accepts. 0 pairs an edit only with an element on the same line; it does not stop an unchanged element from pairing.

However, fingerprints themselves contain no line information (D4 §4), so line fuzz is for delta display only. It does not affect fingerprint equality checks.

5.2.2 Handling of arguments and receivers in Decorator deltas ​

Decorators are identified by (name), but differences in arguments and qualifier are shown in the delta:

Decorator delta (modified):
- @Post: arguments '/invoices' → '/invoices/v2'
- @UseGuards: arguments AuthGuard → AuthGuard,RoleGuard

arguments is combined with line fuzz for the modified determination:

  • Same name + within line fuzz + differing arguments → modified
  • Same name + within line fuzz + differing qualifier (the receiver: @nest.Post → @tsed.Post, or a receiver gained or lost) → modified. The api fingerprint hashes the normalized raw, which quotes the receiver, so a receiver edit moves that fingerprint, and a delta that ignored it would leave the move unexplained
  • An absent qualifier compares as absent. Two Documents written before the field existed report nothing, but a base stored by such a producer and diffed through --base against a head that carries the field reports each qualified decorator as modified, once. aburi diff <base>..<head> re-scans the base with the head's producer, so it never meets this case
  • raw itself is never compared: (name, qualifier, arguments) is its structured decomposition, and comparing the quoted text would report a reformat as an edit
  • boundary is not compared either: plugins derive it rather than the source writing it, so a flip would show as a modified decorator that reads the same on both sides. A boundary flip still moves the api fingerprint with nothing in the decorator delta to explain it; how to show it is a display question of its own
  • A same-name pair further apart than the line fuzz is not paired by the near pass, so a receiver edit that also moved that far reports as added + removed, as a changed argument list does
  • Same name + same arguments + same qualifier + only line differs, by any distance → implicitly the same (not shown in the delta)
5.2.3 modified determination for Component diffs ​

Components have no modified (only the three states added / removed / changed). Inside changed, delta fields indicate the differences via the booleans rootsChanged / publicApiChanged / frameworksChanged. For each field that actually changed, the full before/after arrays are emitted (no per-element modified diff — output is field-granular):

Component changed: billing
  roots: ['apps/billing'] → ['apps/billing', 'packages/billing-domain']
  frameworks: [] → ['nestjs']

5.3 signature delta ​

delta.signature = {
  inputs: { added: [...], removed: [...], modified: [...] },
  outputs: { added: [...], removed: [...] },
  throws:  { added: [...], removed: [...] },
  asyncChanged: bool,
  generatorChanged: bool,
  typeParametersChanged: bool
}

If both signatures are null → delta.signature = null.

5.4 component / visibility / confidence delta ​

delta.componentChanged = base.component != head.component
delta.visibilityChanged = base.visibility != head.visibility
delta.confidenceChanged = base.confidence != head.confidence

confidenceChanged is optional in the schema only so a diff written before it existed stays valid. A current writer always emits it, false included, so a reader can tell "unchanged" from "this writer could not say" (§10.1). The values themselves are on before and after.

A Component change means "responsibility relocation", so the diff report emphasizes it.

6. Component / Dependency diff ​

Not only Symbols but also Components / Dependencies are diffed, in separate arrays.

6.1 Component diff ​

componentDiff = {
  added:   [Component],         // head only
  removed: [Component],         // base only
  changed: [{
    before: Component,
    after:  Component,
    delta: { rootsChanged, publicApiChanged, frameworksChanged }
  }]
}

Component identity is id. Which fields make a Component changed and which fields the delta summarises are two different questions, and answering them with one list is a bug this document once invited: a Component is changed when any field differs, while delta names only the three axes a reviewer scans for architectural movement.

Reading the three booleans as the definition of change means a renamed component, an added language, or a new description produces componentsChanged: 0 and an empty changed[] — and with no entry there is not even a before/after pair for the Markdown layer to render. The delta needs no fourth boolean to fix that: changed[] already carries before and after, so a consumer renders a name change by comparing them, and an entry whose three booleans are all false is well-formed and means "something outside those axes moved".

Comparison is over the whole record, so a field added to v1 later counts without this section being revisited, and two spellings of "no value" are reduced to one before it: a description that is null compares equal to an absent key, and a publicApi / frameworks that is [] compares equal to an absent key.

Those two rules are scoped to the fields that license them, not to a class. description is Class A (ir-schema.md §1.1), where a reader MUST treat an absent key as null. publicApi and frameworks are Class B, whose own writer rule is "omitted when empty" — which is what makes [] a non-conforming spelling of absence. Class B does not say that in general: §1.1 is explicit that there "absent" and "empty" are different facts, with stats.lspEnrichment.hintsRejected as its own counterexample. So the one future shape that forces a revisit here is a Class B Component field whose presence is the information — dropping that by shape would swallow a real difference. Every other new field compares as written, which is the case that needs nothing.

6.2 Dependency diff ​

dependencyDiff = {
  added:   [Dependency],
  removed: [Dependency],
  unknown: [{ dependency, absentFrom, lostFiles }]   // §6.2.1
}

Dependency identity is judged by (from, to, via). Changes to direction / effect are treated as an added+removed pair (no modified needed).

6.2.1 unknown: the other document could not have held this edge ​

dependencies[] is projected from the resolved call graph, so a Symbol that never reached a document takes every edge it participated in with it. Comparing the two arrays by identity alone therefore reports those edges as deletions — the same confident-but-wrong report §3.5.1 exists to prevent, one array over, and the half that is easiest to miss is an edge whose other end survived: both revisions hold that Symbol, so the edge reads as a call the author removed.

An edge one document holds is unknown when the other document never analysed the file an endpoint of it lives in. absentFrom names that document, on the same reading as §3.5.1 (head means "this may still exist", base means "this may not be new"), and lostFiles[] names the files with the reason each was skipped.

Three properties, two of them differing from the Symbol side:

  • The endpoint's file comes from the document that holds the edge. An endpoint is matched against symbols[].source.file in that document, which is the same space stats.skippedFiles[].path is in and the same space §3.5.1 classifies Symbols by. Reading the file out of the id's path segment instead would be a second answer to the same question, and nothing in the schema forces the two to agree — a re-export or a generated file is where they come apart. Both sides read one DependencySideView per document, built once, so the agreement is a property of the wiring rather than a convention to be maintained.
  • lostFiles is a list, where SymbolUnknown carries one reason. An edge has two endpoints: they can sit in two files skipped for two different reasons, one of which says re-run and the other says fix something. An intra-file edge collapses to the single file it lost.
  • Component-level edges are never reclassified. A Component is an aggregate over roots and has no file to lose, so a component endpoint is simply absent from the lookup. Stated rather than left to be inferred, because the asymmetry is otherwise invisible.

A direction or effect flip stays an added + removed pair, and no loss check runs on it. The reason is not that neither document lost an endpoint file — nothing forbids a path from being both a symbols[].source.file and a skipped one, so that inference has no invariant behind it. It is that both documents hold the edge, so neither is silent about it, and unknown exists only to explain a silence.

An edge touching a Symbol that moved can be unknown while the Symbol itself is confidently moved. If S goes src/a.ts → src/b.ts and head skipped src/a.ts, stage 2/3/4 pairs the Symbol from real evidence on both sides, while the base-only edge naming ts:src/a.ts#S has an endpoint in a file head never read. Erring toward unknown there is correct — the head has no edge to compare against — and it is the one case where the Symbol and dependency sides report different confidence about the same entity. "Cannot disagree about which file went missing" is a claim about file identity, not about the two classifications matching.

dependencies.unknown[] and summary.depsUnknown are optional in the schema only so a diff written before they existed stays valid. A current writer always emits both, empty array and zero included — unlike the IR's stats.skippedFiles, there is no arithmetic elsewhere in the document that would let a reader tell "nothing was unknown" from "this writer could not say".

6.3 Files neither revision analysed ​

notCompared[] names every path both stats.skippedFiles lists hold, with the reason each scan gave. Sorted by path.

It exists because the Symbol level cannot express the loss. A file skipped on one side leaves Symbols on the other, and those become unknown (§3.5.1); a file skipped on both leaves nothing anywhere, so the document said nothing about it — and a diff that says nothing about a path is exactly what a diff that compared it and found it unchanged looks like. Most skip reasons are properties of the file rather than of the revision (a generated bundle over the size cap, a language no plugin claims, a file unparseable since before the branch was cut), so this is the ordinary case: a workspace with a standing blind spot got a clean-looking diff on every pull request while a whole directory sat outside the comparison.

The intersection, not the union. A one-sided loss is already reported as unknown, and listing it here as well would count one loss twice in two vocabularies that mean different things.

Both reasons, never one. They can differ — parse-timeout at the base and over-size at the head is one file that timed out once and is permanently too large — and the pair is what tells a reader whether a re-run is enough.

No summary counter. unknown and depsUnknown complete a census: they correct the counters beside them, which are undercounts by exactly that much. These files contributed no entry to any array on either side, so there is nothing to correct — the field scopes the document rather than qualifying a count. A gate written to mean "fail if this diff is incomplete" therefore sees neither this nor dependencies.unknown; --fail-on has no token for either (cli-spec.md §6.7).

A document that predates stats.skippedFiles produces nothing here. It reports totalFiles > parsedFiles and no list, so no path can be named; aburi diff warns about that state per side (cli-spec.md §6.6) and the artifact does not invent it.

7. Output formats ​

7.1 Diff result JSON (out/diff.json) ​

jsonc
{
  "$schema": "https://aburi.kage1020.com/schema/aburi.diff.v1.json",
  "generator": { "name": "aburi", "version": "1.0.0" },
  "base": {
    "ref": "main",                                   // git ref or "ir-base.json"
    "irSchema": "aburi.ir.v1.json"
  },
  "head": {
    "ref": "HEAD",
    "irSchema": "aburi.ir.v1.json"
  },
  "summary": {
    "added":           5,
    "removed":         3,
    "moved":           2,
    "movedChanged":    1,
    "changed":         12,
    "droppedToggled":  4,
    "unchanged":       142,
    "droppedAdded":    4,
    "droppedRemoved":  1,
    "componentsAdded":   1,
    "componentsRemoved": 0,
    "componentsChanged": 2,
    "depsAdded":     3,
    "depsRemoved":   1,
    "unknown":       2,
    "depsUnknown":   1
  },
  "symbols": [
    { "status": "added",   "symbol": { /* Symbol */ } },
    { "status": "removed", "symbol": { /* Symbol */ } },
    { "status": "moved",   "before": {...}, "after": {...}, "rationale": "git-rename" },
    { "status": "changed", "before": {...}, "after": {...}, "delta": { /* see §5 */ } },
    { "status": "moved+changed", "before": {...}, "after": {...}, "rationale": "...", "delta": {...} },
    { "status": "dropped-toggled", "before": {...}, "after": {...}, "direction": "to-dropped" | "to-kept" }
  ],
  "components": {
    "added":   [ /* Component[] */ ],
    "removed": [ /* Component[] */ ],
    "changed": [ /* {before, after, delta} */ ]
  },
  "dependencies": {
    "added":   [ /* Dependency[] */ ],
    "removed": [ /* Dependency[] */ ],
    "unknown": [ /* {dependency, absentFrom, lostFiles} — see §6.2.1 */ ]
  },
  "notCompared": [ /* {path, baseReason, headReason} — see §6.3 */ ]
}

status: "unchanged" is not included in the output (counted in the summary only).

7.2 Markdown projection (out/diff.md) ​

A format intended for pasting into PR comments:

md
# Aburi diff: main..HEAD

**Summary**: +5 added, -3 removed, ~12 changed, 2 moved, 1 moved+changed

## ⚠ API changes (review required)

### `InvoiceService.createInvoice` *(method)*
- **signature**: outputs `Promise<Invoice>` → `Promise<InvoiceWithReceipt>`
- decorator added: `@UseGuards(AuthGuard)`

## 🔧 Logic changes

### `RolesGuard.canActivate` *(method)*
- **effects added**:
  - `db.write: prisma.audit.create` (L75)
- **rules added**:
  - guard: `!user.verified` (L42)

## ➕ Added (5)

### `InvoiceService.refund` *(method)*
- boundary: `@Post('/refund')`
- effects: `db.write`, `event.publish`
- rules: guard, throw

## ➖ Removed (3)

### `ObsoleteController.endpoint` *(method)*
- was: `@Get('/old')`

## 🔀 Moved (no semantic change)

<details>
<summary>2 items (collapsed)</summary>

- `apps/billing/old.ts#X` → `apps/billing/new.ts#X` (git rename)
- `packages/util/a.ts#formatAmount` → `packages/util/b.ts#formatAmount` (logic fingerprint match)
</details>

## 🧱 Component changes

### added: `payments`
- roots: `apps/payments`
- public API: `apps/payments/src/routes/**`

## 🔗 Dependency changes

### added
- `billing` → `payments` (via: import)

Section order is high importance → low. markdown-projection.md §6.1 is the list the renderer and the size cap follow; this copy must match it:

  1. API changes (warning)
  2. Logic changes
  3. Slice View
  4. Added (new)
  5. Removed (deleted)
  6. Unknown
  7. Not compared
  8. Moved+Changed (moved and changed)
  9. Moved (no semantic change — collapsed)
  10. Component changes
  11. Dependency changes
  12. Dropped changes (collapsed)
  13. Confidence changes
  14. Syntax-only changes (collapsed)

Collapsed sections are visible at zero review cost.

8. Performance characteristics ​

8.1 Complexity ​

StageComplexity
Stage 1 (ID match)O(N) hash map lookup
Stage 2 (git rename)O(R) where R = renamed files
Stage 3 (logic fingerprint)O(N) grouping, then per group of B bases and H heads O(H) for the lone branch and O(B x H) per scored round; a group whose logic names nothing always scores (§3.3)
Stage 4 (name+signature)O(K x C) where K = remaining unmatched and C = bases sharing a member token (§3.4.0); O(K^2) when one token is shared by everything

K is usually < 100 (most symbols are settled in stage 1). Effectively O(N), i.e. linear.

8.2 Memory ​

Both base and head Symbols are held in memory. Under 100MB for a medium monorepo (10k Symbols). Large repositories (>100k) would require streaming, which is not yet supported.

Stage 4 additionally holds one record per candidate pairing (§3.8) — every (base, head) pair in a bucket that clears the head's threshold. That is O(1) in the ordinary case, where a threshold of 0.85 or above admits few pairs, and O(base × head) for a bucket whose members are near-identically named. The per-head loop it replaced held one record; the trade buys the guarantee that the best available pairing is never skipped, and §3.4.0's bucket pre-filter is what keeps the bound to a bucket rather than to the Document.

Stage 4.5 does not pay this. Its candidates are the keys that identify a pairing (§3.4.5), at most one each over two axes, so the list is bounded by the dropped Symbols themselves rather than by their pairs. The rule that bounds it is the same one that stops a shared index.ts from pairing unrelated Symbols: a key several Symbols carry identifies none of them.

8.3 Targets ​

For a medium monorepo (~1000 files, ~5000 Symbols): diff computation <2 seconds (excluding IR generation). Including IR generation: <30 seconds (including a full Workspace scan).

9. Edge cases ​

9.1 Schema version mismatch ​

base.$schema != head.$schema → fatal error. When a future v2 ships, an upgrader will be provided.

9.2 Empty base / empty head ​

  • Empty base → every head Symbol is added
  • Empty head → every base Symbol is removed
  • Both empty → empty diff

9.3 Component id collision ​

If base/head share the same Component id but with different roots:

  • Recorded as componentDiff.changed with delta.rootsChanged = true
  • If a Symbol moved to the new component, it is recorded with componentChanged = true

9.4 Differing plugin configurations ​

If base has the effects-prisma plugin enabled and head does not:

  • What was db.write in base remains as calls in head
  • The Symbol's logic fingerprint changes, so it appears as "changed"
  • There is no mechanism to tell directly from the Diff that the cause is a plugin configuration difference (a planned proposal records generator.plugins[] in the IR — see the roadmap)

9.5 Massive moved counts ​

When a directory rename turns every symbol into moved:

  • Aggregated in the summary (moved: 1234)
  • The Markdown displays them grouped as "inferred directory rename" (future feature; individual listing is acceptable today)

9.6 Failed dropped-to-dropped matches ​

Dropped symbols are tried in the order stage 1 (exact ID match) → stage 4.5 (weak matcher, §3.4.5). If they survive with the same ID they are treated as unchanged; if caught by stage 4.5 they become moved (rationale: dropped-weak-match); otherwise they are counted independently as droppedRemoved.

10. Verifiable properties ​

IDInputExpected
DF1Feed the same IR as base/head, with nothing in stats.skippedFilessummary all 0, changes/components/dependencies/notCompared all empty
DF1bFeed the same IR as base/head, with a file in stats.skippedFilessummary still all 0, but 1 entry in notCompared — the comparison covered less than the counts suggest
DF21 new Symbol in headadded: 1, 1 entry in the Markdown Added section
DF31 Symbol deleted from baseremoved: 1
DF4Only a rule's condition changedchanged: 1, delta.logicChanged: true, delta.apiChanged: false
DF5signature outputs changedchanged: 1, delta.apiChanged: true
DF6File rename (git rename detectable)moved: 1, rationale: "git-rename"
DF7File rename + rule addedmoved+changed: 1, rationale: "git-rename"
DF8File rename (no git) with a matching logic fp that names somethingmoved: 1, rationale: "logic-fingerprint"
DF9Method rename (same file, same logic)moved: 1, rationale: "name-signature" (same logic fp but different ID)
DF10Multiple Symbols in base/head share the same logic fpDisambiguated by name similarity, paired correctly
DF11Component added1 entry in components.added
DF12Dependency added1 entry in dependencies.added
DF13Dropped Symbol paired (same ID)Treated as unchanged
DF14Dropped Symbol disappeared (basename also changed)Counted in droppedRemoved
DF14bDropped Symbol moved to another directory (same basename)moved: 1, rationale: "dropped-weak-match"
DF15Schema version mismatchfatal error
DF16Same rule differing only in line (within ±2)no delta.rules.modified — the exact pass pairs it, at any lineFuzz including 0; DF17a is the same at any distance
DF17Rule of one type whose condition changed, with a large line difference (>2)delta as added + removed
DF17aSame-condition rule with a large line difference (>2) — a body that movedno delta.rules entry; the exact pass has no window (§5.2.0)
DF17bTwo same-type rules where one moved past the other by more than the windowthe one that crossed as added + removed; the other unchanged — non-crossing takes the nearer exact pairing, and the window then refuses the one it displaced. A crossing within the window is repaired by the second pass and reports nothing
DF18Only syntax changed (logic/api unchanged)changed, only delta.syntaxChanged true → in Markdown: "syntax-only, collapsed"
DF18aOnly confidence changed (high → medium), fingerprints equalchanged: 1, only delta.confidenceChanged true → in Markdown: the Confidence changes section
DF18bDF18a on a Symbol that also moved filemoved+changed: 1
DF18cDF18a on a pair dropped on both sidesunchanged, as DF13
DF19Two unrelated top-level main(x: string) in different filesadded: 1, removed: 1 — §3.4.3 does not read a name of one word, and §3.3 applies the same rule where the logic names nothing
DF19aA name of one word in any script — главная, مستخدم, initializeas DF19; the rule is about how much the name says, not which script says it
DF19bユーザー情報を取得する moved file with an edited bodymoved+changed: 1, rationale: "name-signature" — nameEvidence floors an unsegmentable run to the words it must hold (§3.4.1)
DF19cполучитьПользователя moved file with an edited bodyas DF19b, on its token count alone: the camel boundary is Unicode case, so the hump splits
DF19dA name of one word written without word boundaries — メイン, 초기화, ハンドラー, 取得as DF19; the floor puts each under a word, as main and handler are
DF19eユーザー情報を取得する moved file with an edited body and an added inputadded: 1, removed: 1 — one token in its last segment, so §3.4.3's first threshold row asks the full 1.0
DF19fA function whose body only calls something is deleted, and an unrelated class with one method is added elsewhereremoved: 1, added: 2 (the class and its method) — §3.3 never pairs across kinds
DF19gA class with one method moved file, without git rename information, and neither body names anything on the logic axismoved: 2 when the class name says two words or more; when it says one, the class is added: 1, removed: 1 and the method moved: 1 — §3.3 pairs an owner and its members independently
DF19hA function whose body only calls something, or is one for loop over calls, is deleted, and an unrelated function of the same shape is added elsewhere; both names say two words or moreremoved: 1, added: 1 — §3.3 has no lone-candidate branch where the logic names nothing
DF19iA function whose body returns a non-trivial expression moved file and was renamed, without git rename information — written as a concise arrow (export const isAdminUser = (u) => u.role === "admin") or with a block bodymoved+changed: 1, rationale: "logic-fingerprint", for both spellings — the arrow's expression is its return rule (lang-plugin.md LP19a), so its logic names something and §3.3's lone-candidate branch applies

10.1 Diff schema compatibility policy ​

The compatibility policy of aburi.diff.v1.json matches the IR schema (ir-schema.md §15). In particular, because the CI gate (aburi diff --fail-on) depends on it:

  • Adding a MatchRationale enum value is breaking (consumers' --fail-on settings depend on fixed values)
  • Adding a status enum value (added / removed / changed / moved / moved+changed / dropped-toggled) is breaking
  • Adding a summary field is non-breaking
  • Adding an optional SymbolDelta boolean is non-breaking, and the producer emits it unconditionally, false included, for the reason the next row gives
  • Adding an optional array is non-breaking on the same terms — to componentDiff / dependencyDiff or to the document itself — and carries one extra obligation: a reader must be able to tell "the writer had nothing to report" from "the writer predates the field". Where no arithmetic elsewhere in the document supplies that — which is everywhere in a diff — the producer emits the key unconditionally, empty included, and optionality in the schema covers only documents written before it existed. This is where the diff parts company with the IR, whose Class B fields (ir-schema.md §1.1) are omitted when empty: an IR reader can fall back on totalFiles - parsedFiles, and a diff reader has nothing to fall back on.

11. Design decisions ​

11.1 Why a 5-stage matching pipeline ​

A single mechanism (ID match only) produces massive false add/remove on file renames, method renames, and refactors. Applying multiple mechanisms sequentially, settling from the most trustworthy stage down, raises precision:

  1. ID match (definitive information)
  2. git rename (physically definitive)
  3. logic fingerprint match (semantic identity)
  4. name + signature similarity (heuristic)

The stage-4 threshold of 0.85 is provisional; it will be tuned on real projects.

11.2 Why dropped is excluded from stage 3 ​

Dropped symbols with fingerprint.logic = "000000000000" would "match" each other in bulk, producing meaningless pairs. Dropped symbols are settled first by stage 1 (exact ID match); the remainder is picked up cheaply by the dedicated weak matcher of stage 4.5 (lastSegment + basename, §3.4.5). Stage 4.5 runs with the false-positive risk priced in, under the premise that "dropped is outside the IR's primary field of view, so the impact is small" — but only on a key that identifies one symbol on each side, because a basename every symbol shares prices in no risk, it pairs at random.

11.3 Why line fuzz (±2) is used only for delta display ​

Including line numbers in fingerprint computation would change every logic FP the moment a single blank line is inserted. Fingerprints are kept line-free (fingerprint.md §4). However, listing rules that differ only in line as distinct items hurts delta readability. Lines therefore enter the delta only to pair an edited element with its predecessor (±2), and to rank equal sets of pairings (§5.2.0); both happen at display time only.

11.4 Why unchanged is excluded from the output ​

Emitting every unchanged Symbol would make the diff as large as the IR itself and impractical. Only a count is provided in the summary; for details, refer to the IR itself.

11.5 Why git is not required ​

In CI environments, git history can be shallow (shallow clone), so git rename detection is not always available. Making the fingerprint-based fallback (stage 3) mandatory keeps the diff working without git.

11.6 Purpose of collapsed Markdown sections ​

Symbols that are "move only" or "syntax only" need not be seen by reviewers. Collapsing them keeps PR comments readable while preserving all information. GitHub's <details> element is collapsed by default.

11.7 Why component / dependency diffs are separated from the Symbol diff ​

Component / Dependency changes are architecture-level changes. Mixing them with Symbol additions/removals hurts clarity. They are managed in separate top-level fields and given separate sections in the Markdown as well.

11.8 Why plugin-configuration-difference detection is not yet supported ​

If base and head have different plugin sets, the same source yields different IRs. This is a difference in "observation", not "meaning". The diff currently works under the premise of "trusting the IR"; plugin differences will be handled by a separate mechanism (recording generator.plugins[] in the IR). Introducing this today would require a schema change, so it is deferred — see the roadmap.

Released under the Apache License 2.0.